0.6.3: Parse Spotify's renamed /items response shape (new apps get item, not track)

Spotify's February 2026 changes did not just move /playlists/{id}/tracks to
/items: for apps on the new behavior the per-entry payload key was renamed
from "track" to "item" (tracks.tracks.track -> items.items.item). Extended
Quota Mode (grandfathered) apps keep the old key, which is why this never
reproduced locally. Every parser in alembic read only "track", so on a new
app each entry looked like a null/local track and was silently skipped: the
CSV came out header-only, sldl no-opped with exit 0, and the playlist page
showed an empty tracklist. Confirmed live on a new app against a playlist
the connected account owns.

All /items consumers (spotify_client.py, spotify-playlist-csv.py,
spotify-retag.py) now parse both key names, and the fields query filter is
gone: it selects by key name, so filtering on track(...) is itself what
returned empty pages on the renamed shape.

Also per the migration guide, new apps only receive playlist contents for
playlists the connected account owns or collaborates on; other playlists
return metadata with no items field at all (public is no longer enough).
That case now raises a pointed error (UI and CSV fetch) instead of reading
as an empty playlist, run-playlist.sh logs the fetched track count and warns
loudly when it is zero, and the README guidance is updated to match.

New tests pin get_playlist_tracks against both response shapes, the
metadata-only error, and pagination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
andrew
2026-07-15 09:50:57 -06:00
parent 2e3210cc01
commit 8ecff44811
7 changed files with 150 additions and 30 deletions
+11 -7
View File
@@ -73,10 +73,10 @@ Optional, add these later if you want them:
Pull the prebuilt image onto your Docker host:
```bash
docker pull git.kretzer.club/andrew/alembic:0.6.2
docker pull git.kretzer.club/andrew/alembic:0.6.3
```
That is the whole install. You do not need to download the source or build anything. The `0.6.2` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
That is the whole install. You do not need to download the source or build anything. The `0.6.3` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
(If you would rather build it yourself from source, you can, but you do not need to.)
@@ -136,7 +136,7 @@ Create a file called `docker-compose.yml` on your server (put it wherever you ke
```yaml
services:
alembic:
image: git.kretzer.club/andrew/alembic:0.6.2
image: git.kretzer.club/andrew/alembic:0.6.3
container_name: alembic
ports:
- "8420:8420"
@@ -211,6 +211,8 @@ Steps:
That's it, done once. alembic stores the resulting OAuth refresh token (encrypted, same as your other credentials) and uses it to silently mint a fresh access token whenever it needs one — you never have to repeat this unless you disconnect or revoke access on Spotify's side.
One more limit that comes with a new Spotify app: Spotify only returns a playlist's contents to the account that owns it (or collaborates on it), even after you connect. So sync playlists that belong to the account you connect, and if someone shares a playlist with you, save your own copy of it first (or have them add you as a collaborator).
If you happen to be running a Spotify app created *before* the 2025/2026 change, plain Client ID/Secret still works and this step is optional — but connecting is harmless either way, so there's no reason not to.
> **If you connected Spotify before version 0.6.1:** click **Connect Spotify** again. Versions before 0.6.1 requested a narrower OAuth scope than playlist reads actually need, which could get you a token that refreshes fine but then gets 403 Forbidden loading a playlist (see Troubleshooting). Reconnecting grants the correct scope; your old connection doesn't upgrade itself.
@@ -290,11 +292,13 @@ Your Spotify app can't read the playlist with app-only (client-credentials) acce
The fix: go to **Settings → Credentials** and click **Connect Spotify** to complete the OAuth login (see "Connecting your Spotify account (OAuth)" above). This mints a user token that works regardless of when your app was created. If you haven't already, add the redirect URI `https://<your-host>/connect/spotify/callback` in your Spotify app's dashboard first, or the connect step itself will fail with a redirect_uri mismatch.
**A playlist's job log shows `Spotify returned 403 loading this playlist` even though you've connected Spotify.** (On versions before 0.6.2 the same problem showed up as `sldl crashed (exit 134)` mentioning a 403 Forbidden.)
Two possible causes, in order of likelihood:
**A playlist's job log shows `Spotify returned 403 loading this playlist`, `Spotify returned this playlist without its contents`, or `0 visible tracks` even though you've connected Spotify.** (On versions before 0.6.2 these showed up as `sldl crashed (exit 134)` mentioning a 403 Forbidden; on 0.6.2 a playlist you don't own could finish "successfully" while downloading nothing.)
Possible causes, in order of likelihood:
1. **You connected before version 0.6.1.** Earlier versions requested a narrower OAuth scope than playlist reads actually need, so the token refreshes fine but then gets 403 loading a playlist regardless of ownership. Fix: go to **Settings → Credentials** and click **Connect Spotify** again to grant the correct scope.
2. **The connected account genuinely can't see this playlist.** Once connected, playlist reads are scoped to what that account can actually see — its own playlists, ones it collaborates on, or ones marked Public — not any arbitrary playlist by ID the way app-only access could. Fix: make sure the playlist is owned by the connected account, or set it to Public on Spotify (Playlist → ⋯ → Make public).
1. **The playlist isn't owned by the connected account.** For Spotify apps created after the 2025/2026 API change, Spotify only returns a playlist's contents to its owner or a collaborator on it. Being public is NOT enough. Fix: recreate the playlist under the account you connected via **Connect Spotify**, or have the owner make it collaborative and add you.
2. **You connected before version 0.6.1.** Earlier versions requested a narrower OAuth scope than playlist reads actually need, so the token refreshes fine but then gets 403 loading a playlist regardless of ownership. Fix: go to **Settings → Credentials** and click **Connect Spotify** again to grant the correct scope.
(If your Spotify app is old enough to be grandfathered, both public playlists and your own keep working like before.)
Either way, re-run the playlist after fixing it.
+7 -3
View File
@@ -14,12 +14,16 @@ templates = Jinja2Templates(directory="app/templates")
def _friendly_status_error(exc: Exception) -> str:
"""Turn a raw Spotify API error into something a non-technical user can act
on. 403 here almost always means the Spotify app can't read the playlist:
either the credentials are wrong or the playlist isn't public."""
wrong credentials, or the connected account can't see it. (The other
can't-read case, contents hidden for playlists the account doesn't own,
arrives as a RuntimeError from spotify_client with its own message.)"""
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 403:
return (
"Spotify denied access (403). Check your Spotify credentials under "
"Settings then Credentials, and make sure the playlist is set to public "
"on Spotify -- alembic can't read private playlists."
"Settings then Credentials, and make sure the account connected via "
"Connect Spotify can see this playlist. For newly created Spotify "
"apps the connected account must own the playlist or be a "
"collaborator on it."
)
return str(exc)
+22 -6
View File
@@ -77,18 +77,34 @@ def get_playlist_tracks(db: Session, playlist_url: str) -> list[dict]:
tracks = []
# /items, not /tracks: Spotify removed GET /playlists/{id}/tracks in its
# February 2026 API changes in favor of /items (same response shape). New
# apps are 403'd on the old endpoint; grandfathered apps still tolerate it
# for now, but /items is the correct, future-proof one.
# February 2026 API changes. The response shape ALSO changed, but only for
# apps on the new behavior: each entry's payload moved from "track" to
# "item" (tracks.tracks.track -> items.items.item). Extended Quota Mode
# (grandfathered) apps keep the old "track" key, so parse both. No
# `fields` filter: it selects by key name, so on the renamed shape a
# track(...) filter silently returns empty pages -- exactly the failure
# we're avoiding.
url = f"{API_BASE}/playlists/{playlist_id}/items"
params = {"limit": 100, "fields": "items(track(name,artists(name),external_ids)),next"}
params = {"limit": 100}
while url:
resp = httpx.get(url, params=params, headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
for item in data.get("items", []):
track = item.get("track")
if "items" not in data:
# New-behavior apps get metadata only (no items field at all) for
# playlists the connected account doesn't own or collaborate on --
# public is no longer sufficient. Same message style the playlist
# page shows for a 403.
raise RuntimeError(
"Spotify returned this playlist without its contents. For newly "
"created Spotify apps, the account connected via Connect Spotify "
"must own the playlist (or be a collaborator on it) -- ask the "
"owner to share it as collaborative, or recreate it under the "
"connected account."
)
for item in data["items"]:
track = item.get("track") or item.get("item")
if not track:
continue # local files / removed tracks show up as null
artists = track.get("artists") or []
+12
View File
@@ -84,6 +84,18 @@ if ! SPOTIFY_CLIENT_ID="$SPOTIFY_CLIENT_ID" SPOTIFY_CLIENT_SECRET="$SPOTIFY_CLIE
exit 1
fi
# Surface the fetched count in the timestamped summary. Zero tracks from a
# successful fetch is almost never a truly empty playlist -- it usually means
# Spotify hid the contents from the connected account (see the pointed errors
# in spotify-playlist-csv.py), so call it out instead of letting sldl no-op
# with a clean exit 0.
TRACK_COUNT=$(($(wc -l < "$CSV_FILE") - 1))
if [[ "$TRACK_COUNT" -le 0 ]]; then
log "WARNING: Spotify returned 0 visible tracks for this playlist -- nothing to download. If the playlist isn't actually empty, the connected Spotify account can't see its contents (it must own or collaborate on the playlist for newly created Spotify apps)."
else
log "Fetched $TRACK_COUNT track(s) from Spotify"
fi
# ==== Run sldl (vendored binary, subprocess of this container) ====
log "Running sldl for: $PLAYLIST_NAME"
+22 -12
View File
@@ -45,10 +45,12 @@ def fetch_playlist(url: str, token: str) -> list[dict]:
sys.exit(f"Could not parse playlist ID from {url!r}")
pid = m.group(1)
tracks = []
next_url = (
f"{API}/playlists/{pid}/items"
"?limit=100&fields=items(track(name,artists(name),album(name),duration_ms,is_local)),next"
)
# No `fields` filter: Spotify's February 2026 changes renamed each entry's
# payload from "track" to "item" for apps on the new behavior (extended
# quota / grandfathered apps keep "track"), and a fields filter selects by
# key name -- filtering on track(...) silently returns EMPTY pages on the
# renamed shape. Fetch unfiltered and parse both key names below.
next_url = f"{API}/playlists/{pid}/items?limit=100"
while next_url:
req = urllib.request.Request(next_url, headers={"Authorization": f"Bearer {token}"})
try:
@@ -56,18 +58,26 @@ def fetch_playlist(url: str, token: str) -> list[dict]:
data = json.loads(r.read())
except urllib.error.HTTPError as e:
if e.code in (403, 404):
# The one non-bug cause of this: OAuth playlist reads are
# scoped to what the CONNECTED account can see (its own
# playlists, collaborations, or ones marked Public).
sys.exit(
f"Spotify returned {e.code} loading this playlist. The connected "
"Spotify account can't see it: make sure it's owned by, or "
"shared/followed by, whichever account is connected via "
"/connect/spotify, or set it to Public on Spotify."
"Spotify account can't see it: it must own the playlist, be a "
"collaborator on it, or (for grandfathered apps) the playlist "
"must be Public. Check which account is connected via "
"/connect/spotify."
)
raise
for item in data.get("items", []):
t = item.get("track")
if "items" not in data:
# New-behavior apps get metadata only (no items field) for
# playlists the connected account doesn't own or collaborate on.
sys.exit(
"Spotify returned this playlist without its contents. For newly "
"created Spotify apps, the connected account must OWN the playlist "
"or be a collaborator on it (public is no longer enough). Ask the "
"owner to make it collaborative and add you, or recreate the "
"playlist under the connected account."
)
for item in data["items"]:
t = item.get("track") or item.get("item")
if not t or t.get("is_local"):
continue
# All artists, comma-joined -- matches what sldl's own Spotify
+5 -2
View File
@@ -56,8 +56,11 @@ def fetch_playlist(url: str, token: str) -> list[dict]:
)
with urllib.request.urlopen(req, timeout=15) as r:
data = json.loads(r.read())
for item in data["items"]:
t = item.get("track")
# Entries carry "track" (grandfathered apps) or "item" (apps on the
# February 2026 renamed shape); items is absent entirely when the
# connected account can't read the playlist's contents.
for item in data.get("items", []):
t = item.get("track") or item.get("item")
if not t or t.get("is_local"):
continue
tracks.append(
+71
View File
@@ -0,0 +1,71 @@
"""get_playlist_tracks must parse BOTH /items response shapes: entries keyed
"track" (grandfathered / extended-quota apps) and "item" (apps on Spotify's
February 2026 renamed shape), and must fail loudly, not return [], when
Spotify withholds a playlist's contents (no items field at all)."""
import pytest
from app.services import spotify_client
URL = "https://open.spotify.com/playlist/XYZ123"
class _Resp:
def __init__(self, data):
self._data = data
def raise_for_status(self):
pass
def json(self):
return self._data
def _fake_api(monkeypatch, pages):
it = iter(pages)
monkeypatch.setattr(spotify_client, "_get_token", lambda db: "tok")
monkeypatch.setattr(
spotify_client.httpx,
"get",
lambda url, params=None, headers=None, timeout=None: _Resp(next(it)),
)
def _track(name, artist, isrc=None):
return {
"name": name,
"artists": [{"name": artist}],
"external_ids": {"isrc": isrc} if isrc else {},
}
def test_parses_legacy_track_shape(monkeypatch):
_fake_api(monkeypatch, [{"items": [{"track": _track("Sandstorm", "Darude", "FIDAR9900011")}], "next": None}])
tracks = spotify_client.get_playlist_tracks(None, URL)
assert tracks == [{"artist": "Darude", "title": "Sandstorm", "isrc": "FIDAR9900011"}]
def test_parses_renamed_item_shape(monkeypatch):
# February 2026 shape: payload under "item", not "track"
_fake_api(monkeypatch, [{"items": [{"item": _track("Sandstorm", "Darude")}], "next": None}])
tracks = spotify_client.get_playlist_tracks(None, URL)
assert tracks == [{"artist": "Darude", "title": "Sandstorm", "isrc": None}]
def test_missing_items_field_raises_not_empty(monkeypatch):
# metadata-only response (playlist not owned by the connected account on a
# new app) must be an error the UI can show, never a silent empty list
_fake_api(monkeypatch, [{"name": "DJ-USB", "public": True}])
with pytest.raises(RuntimeError, match="without its contents"):
spotify_client.get_playlist_tracks(None, URL)
def test_skips_null_entries_and_paginates(monkeypatch):
_fake_api(
monkeypatch,
[
{"items": [{"track": None}, {"track": _track("A", "X")}], "next": "page2"},
{"items": [{"item": _track("B", "Y")}], "next": None},
],
)
tracks = spotify_client.get_playlist_tracks(None, URL)
assert [t["title"] for t in tracks] == ["A", "B"]