#!/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 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]} ") 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())