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:
@@ -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())
|
||||
Reference in New Issue
Block a user