8ecff44811
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>
125 lines
5.3 KiB
Python
125 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
spotify-playlist-csv.py — dump a Spotify playlist to a CSV sldl can search from.
|
|
|
|
sldl's own vendored Spotify client still calls Spotify's GET /playlists/{id}/tracks
|
|
endpoint, which Spotify removed in its February 2026 API changes (see
|
|
https://developer.spotify.com/documentation/web-api/references/changes/february-2026)
|
|
in favor of /items. Grandfathered apps still get a pass on /tracks for now, but
|
|
apps created after that change get a hard 403 there regardless of auth method --
|
|
client-credentials, or a correctly-scoped, correctly-owned OAuth user token, none
|
|
of it matters, because the endpoint itself is gone for them. sldl has no separate
|
|
code path to fall back to /items, so it can never read a playlist for a new app
|
|
no matter what alembic hands it.
|
|
|
|
Rather than depend on sldl's Spotify client at all, this script reads the
|
|
playlist itself (via /items, which alembic's own code already migrated to) and
|
|
writes the same Artist,Title,Album,Length CSV format upgrade-mp3-to-flac.sh
|
|
already feeds to sldl via --input-type csv. run-playlist.sh runs this first and
|
|
then points sldl at the CSV instead of the playlist URL, sidestepping sldl's
|
|
Spotify client entirely -- for grandfathered and new apps alike.
|
|
|
|
Usage:
|
|
spotify-playlist-csv.py <playlist_url> <out_csv>
|
|
|
|
Credentials are read from env vars SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.
|
|
SPOTIFY_REFRESH_TOKEN, if set, mints a user token instead of client-credentials
|
|
(required for newly created Spotify apps -- see _spotify_auth.get_token).
|
|
"""
|
|
import csv
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from _spotify_auth import get_token
|
|
|
|
API = "https://api.spotify.com/v1"
|
|
|
|
|
|
def fetch_playlist(url: str, token: str) -> list[dict]:
|
|
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", url)
|
|
if not m:
|
|
sys.exit(f"Could not parse playlist ID from {url!r}")
|
|
pid = m.group(1)
|
|
tracks = []
|
|
# 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:
|
|
with urllib.request.urlopen(req, timeout=15) as r:
|
|
data = json.loads(r.read())
|
|
except urllib.error.HTTPError as e:
|
|
if e.code in (403, 404):
|
|
sys.exit(
|
|
f"Spotify returned {e.code} loading this playlist. The connected "
|
|
"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
|
|
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
|
|
# extractor fed its search, so multi-artist tracks match no worse
|
|
# than they did before the CSV detour.
|
|
artists = [a["name"] for a in (t.get("artists") or []) if a.get("name")]
|
|
tracks.append(
|
|
{
|
|
"artist": ", ".join(artists),
|
|
"title": t.get("name", ""),
|
|
"album": (t.get("album") or {}).get("name", ""),
|
|
"length": round((t.get("duration_ms") or 0) / 1000),
|
|
}
|
|
)
|
|
next_url = data.get("next")
|
|
return tracks
|
|
|
|
|
|
def main() -> int:
|
|
if len(sys.argv) != 3:
|
|
sys.exit(f"Usage: {sys.argv[0]} <playlist_url> <out_csv>")
|
|
url, out_path = sys.argv[1], sys.argv[2]
|
|
|
|
cid = os.environ.get("SPOTIFY_CLIENT_ID")
|
|
csec = os.environ.get("SPOTIFY_CLIENT_SECRET")
|
|
refresh = os.environ.get("SPOTIFY_REFRESH_TOKEN") or None
|
|
if not cid or not csec:
|
|
sys.exit("SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET must be set")
|
|
|
|
token = get_token(cid, csec, refresh)
|
|
tracks = fetch_playlist(url, token)
|
|
|
|
with open(out_path, "w", newline="") as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow(["Artist", "Title", "Album", "Length"])
|
|
for t in tracks:
|
|
writer.writerow([t["artist"], t["title"], t["album"], t["length"]])
|
|
|
|
print(f"[spotify-playlist-csv] wrote {len(tracks)} tracks to {out_path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|