2 Commits

Author SHA1 Message Date
andrew 2e3210cc01 README: point install and compose examples at 0.6.2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 08:54:17 -06:00
andrew 3fbf580b88 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>
2026-07-15 08:48:30 -06:00
8 changed files with 248 additions and 89 deletions
+6 -6
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
docker pull git.kretzer.club/andrew/alembic:0.6.2
```
That is the whole install. You do not need to download the source or build anything. The `0.6` 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.2` 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
image: git.kretzer.club/andrew/alembic:0.6.2
container_name: alembic
ports:
- "8420:8420"
@@ -213,7 +213,7 @@ That's it, done once. alembic stores the resulting OAuth refresh token (encrypte
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 sldl actually needs, 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.
> **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.
## Adding your first playlist
@@ -290,10 +290,10 @@ 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 `sldl crashed (exit 134)` mentioning a 403 Forbidden, even though you've connected Spotify.**
**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:
1. **You connected before version 0.6.1.** Earlier versions requested a narrower OAuth scope than sldl actually needs, 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.
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).
Either way, re-run the playlist after fixing it.
+20 -1
View File
@@ -8,7 +8,7 @@ from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from app.db import enable_beets_db_wal, init_db, mark_interrupted_runs
from app.db import SessionLocal, enable_beets_db_wal, init_db, mark_interrupted_runs
from app.routers import artist_casing, auth, connect, credentials, dashboard, dedup, genres, health, import_, jobs, library, playlists
from app.security.session import resolve_session_secret
from app.services import scheduler_service
@@ -48,12 +48,31 @@ def _warn_if_key_colocated_with_config() -> None:
)
def _render_confs_on_startup() -> None:
"""Re-render every playlist .conf from the current template once per boot,
so confs rendered by an older version converge on upgrade without waiting
for a playlist or credential change. Concretely: 0.6.2 dropped the Spotify
credential lines from confs (sldl no longer talks to Spotify), and this is
what scrubs those secrets from already-deployed confs. Best-effort -- a
render failure shouldn't stop the app from starting."""
from app.services import credential_service
db = SessionLocal()
try:
credential_service.render_playlist_confs(db)
except Exception:
log.exception("Startup playlist .conf render failed; continuing")
finally:
db.close()
@asynccontextmanager
async def lifespan(_app: FastAPI):
_warn_if_key_colocated_with_config()
init_db()
mark_interrupted_runs()
enable_beets_db_wal()
_render_confs_on_startup()
scheduler = scheduler_service.create_scheduler()
scheduler_service.register_all_jobs(scheduler)
+16 -18
View File
@@ -116,8 +116,10 @@ def get_scope(db: Session, scope: str) -> dict[str, str]:
_SAFE_CONF_NAME = re.compile(r"^[A-Za-z0-9 _-]{1,64}$")
# The credential lines the renderer fills in from the encrypted store. Everything
# else in a rendered .conf comes verbatim from _template.conf.
_CONF_CRED_KEYS = ("user", "pass", "spotify-id", "spotify-secret", "spotify-refresh")
# else in a rendered .conf comes verbatim from _template.conf. Spotify creds are
# NOT here: sldl never talks to Spotify itself (see _template.conf and
# run-playlist.sh) -- the scripts that do read them from _spotify.env instead.
_CONF_CRED_KEYS = ("user", "pass")
def _set_conf_field(text: str, key: str, value: str) -> str:
@@ -134,27 +136,24 @@ def _set_conf_field(text: str, key: str, value: str) -> str:
def render_playlist_confs(db: Session) -> None:
"""Render every playlist's sldl .conf directly from _template.conf, with
the path placeholders and the Soulseek/Spotify credentials substituted in
one pass, written 0600.
the path placeholders and the Soulseek credentials substituted in one
pass, written 0600. No Spotify credentials here -- sldl never talks to
Spotify itself (see _template.conf); those live only in _spotify.env.
This is the single source of .conf rendering. It replaces the older
DB -> playlists.json -> regen.sh -> regex-patch-back chain: the app owns
every input (playlists in its DB, credentials in its encrypted store), so
there is no reason to round-trip through an intermediate file and a
subprocess. Called on any playlist change (playlist_service) and on any
Spotify/Soulseek credential change (render_scope)."""
Soulseek credential change (render_scope)."""
from app.services import playlist_service
template = (settings.pipeline_dir / "configs" / "_template.conf").read_text()
dropbox_root = str(settings.music_data_dir / "sldl-dropbox")
spotify = get_scope(db, "spotify")
soulseek = get_scope(db, "soulseek")
creds = {
"user": soulseek.get("username", ""),
"pass": soulseek.get("password", ""),
"spotify-id": spotify.get("client_id", ""),
"spotify-secret": spotify.get("client_secret", ""),
"spotify-refresh": spotify.get("refresh_token", ""),
}
# Path placeholders are substituted as LITERAL text in a single left-to-right
# pass (never re-scanned), so a value can't be reinterpreted as another
@@ -193,13 +192,14 @@ def render_scope(db: Session, scope: str) -> None:
if scope == "spotify":
cid = values.get("client_id", "")
csec = values.get("client_secret", "")
# _spotify.env is read by the pipeline python scripts (spotify-genre,
# spotify-retag, fix-track-metadata). SPOTIFY_REFRESH_TOKEN is only set
# once an account is connected via /connect/spotify -- its presence is
# what switches every reader from client-credentials to a user token
# (see _spotify_auth.get_token). The rendered playlist .conf gets the
# same refresh token too (spotify-refresh, in render_playlist_confs),
# which is what lets sldl itself use a user token.
# _spotify.env is the ONLY place Spotify creds are rendered -- sldl
# itself never talks to Spotify (see _template.conf), so playlist
# .confs don't need them. Read by run-playlist.sh (to generate the
# search CSV) and the pipeline python scripts (spotify-retag,
# spotify-genre, fix-track-metadata). SPOTIFY_REFRESH_TOKEN is only
# set once an account is connected via /connect/spotify -- its
# presence is what switches every reader from client-credentials to
# a user token (see _spotify_auth.get_token).
_write_env_file(
settings.pipeline_config_dir / "_spotify.env",
{
@@ -208,8 +208,6 @@ def render_scope(db: Session, scope: str) -> None:
"SPOTIFY_REFRESH_TOKEN": values.get("refresh_token", ""),
},
)
# Re-render every playlist .conf so the new Spotify creds land in them.
render_playlist_confs(db)
elif scope == "soulseek":
render_playlist_confs(db)
+5 -1
View File
@@ -115,7 +115,8 @@ def update(db: Session, playlist_id: int, **fields) -> Playlist:
def delete(db: Session, playlist_id: int) -> None:
"""Remove the playlist from the DB and delete its rendered .conf file.
"""Remove the playlist from the DB and delete its rendered .conf and
generated search .csv files.
Does NOT touch anything already downloaded/imported for it — that's a
library decision, not a playlist-definition one."""
playlist = db.get(Playlist, playlist_id)
@@ -126,6 +127,9 @@ def delete(db: Session, playlist_id: int) -> None:
db.commit()
conf_path = settings.pipeline_config_dir / f"{name}.conf"
conf_path.unlink(missing_ok=True)
# The search CSV run-playlist.sh generates next to the conf each run.
csv_path = settings.pipeline_config_dir / f"{name}.csv"
csv_path.unlink(missing_ok=True)
from app.services import scheduler_service
+50 -41
View File
@@ -25,6 +25,8 @@ PLAYLIST_NAME="${1:?Usage: $0 [--no-m3u] <playlist_name>}"
# ==== Paths ====
CONFIG_FILE=${ALEMBIC_CONFIG_DIR:-/config}/pipeline/${PLAYLIST_NAME}.conf
SPOTIFY_ENV=${ALEMBIC_CONFIG_DIR:-/config}/pipeline/_spotify.env
CSV_FILE=${ALEMBIC_CONFIG_DIR:-/config}/pipeline/${PLAYLIST_NAME}.csv
DROPBOX=${MUSIC_DATA_DIR:-/data/music}/sldl-dropbox/${PLAYLIST_NAME}
PLAYLISTS_DIR=${MUSIC_DATA_DIR:-/data/music}/playlists
QUARANTINE_DIR=${MUSIC_DATA_DIR:-/data/music}/Songs/untagged
@@ -54,6 +56,34 @@ if [[ ! -f "$CONFIG_FILE" ]]; then
exit 1
fi
# ==== Read the playlist from Spotify into a CSV sldl can search from ====
# sldl's own vendored Spotify client still calls GET /playlists/{id}/tracks,
# which Spotify removed in its February 2026 API changes. Grandfathered apps
# still get a pass there; apps created after that change get a hard 403
# regardless of auth method (client-credentials, or even a correctly-scoped,
# correctly-owned OAuth user token -- confirmed live, exit 134 unhandled
# APIException: Forbidden from Extractors.Spotify.GetPlaylist). sldl has no
# fallback to the still-working /items endpoint, so it can never read a
# playlist for a new app no matter what alembic hands it. Reading the
# playlist ourselves (via /items) and handing sldl a plain CSV instead
# sidesteps sldl's Spotify client entirely -- works the same for
# grandfathered and new apps alike.
SPOTIFY_URL=$(sed -n 's/^input *= *//p' "$CONFIG_FILE" | tr -d ' ')
[[ -f "$SPOTIFY_ENV" ]] && source "$SPOTIFY_ENV"
if [[ -z "$SPOTIFY_URL" || -z "${SPOTIFY_CLIENT_ID:-}" || -z "${SPOTIFY_CLIENT_SECRET:-}" ]]; then
log "ERROR: missing playlist URL in $CONFIG_FILE or Spotify credentials in $SPOTIFY_ENV"
exit 1
fi
log "Fetching playlist from Spotify: $SPOTIFY_URL"
if ! SPOTIFY_CLIENT_ID="$SPOTIFY_CLIENT_ID" SPOTIFY_CLIENT_SECRET="$SPOTIFY_CLIENT_SECRET" \
SPOTIFY_REFRESH_TOKEN="${SPOTIFY_REFRESH_TOKEN:-}" \
python3 "${PIPELINE_DIR:-/app/pipeline}/lib/spotify-playlist-csv.py" "$SPOTIFY_URL" "$CSV_FILE" >> "$LOG" 2>&1; then
log "ERROR: failed to fetch playlist from Spotify — see $LOG"
exit 1
fi
# ==== Run sldl (vendored binary, subprocess of this container) ====
log "Running sldl for: $PLAYLIST_NAME"
@@ -62,37 +92,24 @@ log "Running sldl for: $PLAYLIST_NAME"
# download. A non-zero exit here is logged but not fatal.
#
# Hard timeout: without it a stuck sldl hangs FOREVER holding the shared lock,
# which silently skips every later-scheduled playlist for the night. (Seen
# 2026-06-18: a transient Spotify client-creds failure made sldl fall back to
# interactive OAuth — "manually open: https://accounts.spotify.com/..." — and
# it waited 7h on a browser callback that never comes. Fixed by always
# rendering `spotify-refresh` into the conf once a Spotify account is
# connected via /connect/spotify — sldl's own docs confirm supplying a
# refresh token skips the login-flow requirement entirely. This timeout stays
# as a backstop for any other cause of a stuck run.) timeout TERMs the run
# and KILLs after a grace period; there's no leftover container to clean up
# now that sldl is a plain subprocess instead of a docker-compose service.
# which silently skips every later-scheduled playlist for the night. timeout
# TERMs the run and KILLs after a grace period; there's no leftover container
# to clean up now that sldl is a plain subprocess instead of a docker-compose
# service.
#
# -c "$CONFIG_FILE" still supplies Soulseek login, download path, quality
# settings, port, etc. -- the CSV positional arg + --input-type csv override
# just the input source (confirmed: sldl ignores the conf's own `input =`/
# `input-type =` lines when both are given).
SLDL_TIMEOUT="${SLDL_TIMEOUT:-2700}" # 45 min; override via env
SLDL_EXIT=0
timeout --kill-after=30s "$SLDL_TIMEOUT" \
"$SLDL_BIN" -c "$CONFIG_FILE" >> "$LOG" 2>&1 \
"$SLDL_BIN" "$CSV_FILE" --input-type csv -c "$CONFIG_FILE" >> "$LOG" 2>&1 \
|| SLDL_EXIT=$?
if [[ "$SLDL_EXIT" -eq 124 || "$SLDL_EXIT" -eq 137 ]]; then
log "ERROR: sldl exceeded ${SLDL_TIMEOUT}s and was killed (likely a hang)"
elif [[ "$SLDL_EXIT" -eq 134 ]]; then
# sldl aborts (SIGABRT) on ANY unhandled .NET exception rather than failing
# cleanly. The one case we've actually seen in the wild: a valid, freshly-
# refreshed user token still gets 403 Forbidden loading the playlist,
# because Spotify scopes OAuth playlist reads to what the CONNECTED account
# can see (its own playlists, ones it collaborates on, or ones marked
# Public) -- unlike client-credentials, which could read any public
# playlist regardless of whose app it was. Give a pointed hint when that's
# the visible cause; otherwise just flag that sldl crashed outright.
if grep -q "APIException: Forbidden" "$LOG"; then
log "ERROR: sldl crashed (exit 134) -- Spotify returned 403 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."
else
log "ERROR: sldl crashed (exit 134, unhandled exception) -- see the log above for the exception detail"
fi
log "ERROR: sldl crashed (exit 134, unhandled exception) -- see the log above for the exception detail"
fi
log "sldl finished with exit code $SLDL_EXIT"
@@ -125,27 +142,19 @@ log "Cleaning up .incomplete files in dropbox"
find "$DROPBOX" -name "*.incomplete" -type f -delete 2>>"$LOG" || true
# ==== Rewrite tags from Spotify (source of truth for matched tracks) ====
# Reads Spotify creds + playlist URL from the same conf sldl used. For each file
# in the dropbox that matches a Spotify playlist track, overwrites ARTIST
# Uses the playlist URL and _spotify.env credentials already loaded for the
# CSV fetch above (confs no longer carry Spotify creds). For each file in the
# dropbox that matches a Spotify playlist track, overwrites ARTIST
# (semicolon-joined), ALBUMARTIST (primary), ALBUM, TITLE, TRACKNUMBER,
# DISCNUMBER, DATE. GROUPING is preserved. Files that don't match are left for
# the fallback step below.
SPOTIFY_URL=$(sed -n 's/^input *= *//p' "$CONFIG_FILE" | tr -d ' ')
SPOTIFY_CLIENT_ID=$(sed -n 's/^spotify-id *= *//p' "$CONFIG_FILE" | tr -d ' ')
SPOTIFY_CLIENT_SECRET=$(sed -n 's/^spotify-secret *= *//p' "$CONFIG_FILE" | tr -d ' ')
SPOTIFY_REFRESH_TOKEN=$(sed -n 's/^spotify-refresh *= *//p' "$CONFIG_FILE" | tr -d ' ')
if [[ -n "$SPOTIFY_URL" && -n "$SPOTIFY_CLIENT_ID" && -n "$SPOTIFY_CLIENT_SECRET" ]]; then
log "Rewriting tags from Spotify for files in $DROPBOX"
if SPOTIFY_CLIENT_ID="$SPOTIFY_CLIENT_ID" SPOTIFY_CLIENT_SECRET="$SPOTIFY_CLIENT_SECRET" \
SPOTIFY_REFRESH_TOKEN="$SPOTIFY_REFRESH_TOKEN" \
python3 ${PIPELINE_DIR:-/app/pipeline}/lib/spotify-retag.py "$SPOTIFY_URL" "$DROPBOX" >> "$LOG" 2>&1; then
log "Spotify retag complete"
else
log "WARNING: Spotify retag failed (exit $?) — continuing with sldl-supplied tags"
fi
log "Rewriting tags from Spotify for files in $DROPBOX"
if SPOTIFY_CLIENT_ID="$SPOTIFY_CLIENT_ID" SPOTIFY_CLIENT_SECRET="$SPOTIFY_CLIENT_SECRET" \
SPOTIFY_REFRESH_TOKEN="${SPOTIFY_REFRESH_TOKEN:-}" \
python3 ${PIPELINE_DIR:-/app/pipeline}/lib/spotify-retag.py "$SPOTIFY_URL" "$DROPBOX" >> "$LOG" 2>&1; then
log "Spotify retag complete"
else
log "Skipping Spotify retag — missing input/spotify-id/spotify-secret in $CONFIG_FILE"
log "WARNING: Spotify retag failed (exit $?) — continuing with sldl-supplied tags"
fi
# ==== Quarantine any files still missing essential tags ====
+7 -11
View File
@@ -14,19 +14,15 @@
user = SOULSEEK_USER
pass = SOULSEEK_PASS
# ==== Spotify API credentials ====
spotify-id = SPOTIFY_CLIENT_ID
spotify-secret = SPOTIFY_CLIENT_SECRET
# Set once an account is connected via /connect/spotify (blank otherwise).
# sldl's own docs confirm supplying a refresh token skips its interactive
# login-flow requirement entirely -- this is what lets newly created Spotify
# apps (which Spotify blocks from reading playlists with client-credentials
# alone) work without sldl ever trying to open a browser.
spotify-refresh = SPOTIFY_REFRESH_TOKEN
# ==== Input (Spotify playlist URL) ====
# sldl itself never reads Spotify at all -- its vendored Spotify client still
# calls GET /playlists/{id}/tracks, which Spotify removed in its February 2026
# API changes (new apps get a hard 403 there regardless of auth method; see
# spotify-playlist-csv.py). run-playlist.sh reads the playlist itself (via the
# still-working /items endpoint) and hands sldl a CSV via --input-type csv on
# the command line, which overrides this line entirely -- it's kept only so
# run-playlist.sh has somewhere to read the playlist URL from per-playlist.
input = SPOTIFY_URL
input-type = spotify
# ==== Output paths ====
# SLDL_DROPBOX_ROOT is substituted at render time from $MUSIC_DATA_DIR
+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())
+30 -11
View File
@@ -43,22 +43,41 @@ def test_render_playlist_confs_injects_creds_safely(db, config_dir):
text = conf.read_text()
assert "user = seek" in text
assert "pass = p'a$(id)ss" in text
assert "spotify-id = CID" in text
assert "spotify-secret = CSECRET" in text
# no account connected yet -- rendered blank, not left as the placeholder
assert "spotify-refresh = \n" in text
# run-playlist.sh scrapes the playlist URL from this line to build the CSV
assert "input = https://open.spotify.com/playlist/Z" in text
# Spotify creds must NOT be rendered into confs: sldl never talks to
# Spotify (run-playlist.sh feeds it a CSV); the creds live in _spotify.env
assert "spotify-id" not in text
assert "spotify-secret" not in text
assert "spotify-refresh" not in text
assert "CID" not in text
assert "CSECRET" not in text
# rendered config must be owner-only (holds the Soulseek password)
assert stat.S_IMODE(os.stat(conf).st_mode) == 0o600
def test_render_playlist_confs_includes_spotify_refresh_when_connected(db, config_dir):
cs.set_credentials(db, "spotify", {"client_id": "CID", "client_secret": "CSECRET", "refresh_token": "RTOK"})
cs.set_credentials(db, "soulseek", {"username": "seek", "password": "pw"})
pl.create(db, name="rtest2", spotify_url="https://open.spotify.com/playlist/Z")
def test_spotify_env_renders_creds_and_refresh_token(db, config_dir):
# _spotify.env is the ONLY rendered home of Spotify creds; run-playlist.sh
# sources it to fetch the playlist CSV, so values must be shell-quoted
cs.set_credentials(
db, "spotify", {"client_id": "CID", "client_secret": "CS'EC$(id)RET", "refresh_token": "RTOK"}
)
conf = config_dir / "pipeline" / "rtest2.conf"
text = conf.read_text()
assert "spotify-refresh = RTOK" in text
env = config_dir / "pipeline" / "_spotify.env"
text = env.read_text()
assert "SPOTIFY_CLIENT_ID=CID" in text
# shlex.quote keeps a metacharacter-laden secret a single literal value
assert "SPOTIFY_CLIENT_SECRET='CS'\"'\"'EC$(id)RET'" in text
assert "SPOTIFY_REFRESH_TOKEN=RTOK" in text
assert stat.S_IMODE(os.stat(env).st_mode) == 0o600
def test_spotify_env_refresh_token_blank_until_connected(db, config_dir):
cs.set_credentials(db, "spotify", {"client_id": "CID", "client_secret": "CSECRET"})
text = (config_dir / "pipeline" / "_spotify.env").read_text()
# rendered blank (''), which sources cleanly and stays falsy in bash
assert "SPOTIFY_REFRESH_TOKEN=''" in text
def test_azuracast_renders_and_clears_base_url(db, config_dir):