0.6.2: Bypass sldl's Spotify client -- fetch the playlist ourselves, feed sldl a CSV

sldl's vendored Spotify client still calls GET /playlists/{id}/tracks, which
Spotify removed in its February 2026 API changes. Grandfathered apps still get
a pass; apps created after the change get a hard 403 there no matter how they
authenticate (client-credentials or a correctly-scoped OAuth user token), so
sldl can never load a playlist for a new app regardless of what we hand it.

Instead of depending on sldl's Spotify client at all, run-playlist.sh now reads
the playlist itself via the still-working /items endpoint (new
spotify-playlist-csv.py, creds sourced from _spotify.env) and invokes sldl with
the CSV + --input-type csv, which override the conf's input lines while -c still
supplies Soulseek login, paths, and quality settings (verified live). The same
CSV format upgrade-mp3-to-flac.sh already feeds sldl. All artists are
comma-joined in the CSV so multi-artist tracks search no worse than before, and
a 403/404 on the fetch prints the account-visibility hint instead of a bare
traceback.

Since sldl no longer talks to Spotify, playlist .confs no longer carry
spotify-id/spotify-secret/spotify-refresh: _template.conf drops them,
render_playlist_confs stops injecting them, and a Spotify credential save no
longer re-renders confs (only _spotify.env). The retag step in run-playlist.sh
reuses the sourced _spotify.env creds instead of scraping the conf lines that
no longer exist. Confs are also re-rendered once at app startup so
already-deployed confs converge on upgrade (and shed the stale secret lines)
without waiting for a playlist or credential change. Playlist delete now also
removes the generated .csv.

Tests updated: conf rendering must NOT contain Spotify creds but must keep the
input URL line; new coverage for _spotify.env rendering (quoting, refresh-token
presence/blank, 0600).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
andrew
2026-07-15 08:48:30 -06:00
parent 9976c430aa
commit 3fbf580b88
8 changed files with 245 additions and 86 deletions
+114
View File
@@ -0,0 +1,114 @@
#!/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 = []
next_url = (
f"{API}/playlists/{pid}/items"
"?limit=100&fields=items(track(name,artists(name),album(name),duration_ms,is_local)),next"
)
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):
# 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."
)
raise
for item in data.get("items", []):
t = item.get("track")
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())