4 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
andrew 9976c430aa 0.6.1: Fix Spotify OAuth scope missing user-library-read
connect.py's OAuth scope request (playlist-read-private
playlist-read-collaborative) didn't match what sldl's own built-in login
flow requests (same two, plus user-library-read). The narrower scope alembic
granted let sldl refresh a valid access token, but Spotify then returned 403
Forbidden loading the playlist regardless of ownership, which sldl turns
into an unhandled-exception crash (exit 134) instead of a clean scope error.
Found by reproducing a friend's crash: his playlist was his own, connected
via his own OAuth, correct redirect URI -- ruling out the ownership/
visibility explanation and pointing at the scope mismatch instead.

Also:
- run-playlist.sh: detect exit 134 specifically and log a pointed hint
  (distinguishing the Forbidden-loading-playlist case from any other
  unhandled sldl exception) instead of just "sldl finished with exit code
  134" with no context.
- README: document that anyone who connected Spotify before this version
  needs to click Connect Spotify again -- existing refresh tokens carry the
  old, narrower scope and don't upgrade themselves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 08:07:30 -06:00
andrew 29d6249e88 README: make the Spotify OAuth requirement explicit, not a footnote
Previously described the Connect Spotify step in soft "one click, optional
either way" language and never used the word OAuth at all, even though it's
now a required step for any Spotify app created after Spotify's 2025/2026
API change. Rewrote "What you need before you start", the credentials
table, and the connect section to say plainly that keys alone no longer
work and walk through the OAuth login as a required numbered step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 11:25:05 -06:00
9 changed files with 279 additions and 80 deletions
+29 -9
View File
@@ -56,7 +56,7 @@ Before you begin, make sure you have:
1. **A server that runs Docker**, with `docker compose` available. Any Linux box, NAS, or mini PC works.
2. **A music server that reads a plain folder of tagged files**, such as [Navidrome](https://www.navidrome.org/). alembic builds your library on disk; Navidrome (or similar) is what you actually listen with.
3. **A Soulseek account.** This is how tracks get downloaded. Sign up free at [soulseek.com](https://www.soulseek.com/).
4. **A Spotify Developer app.** alembic reads your playlists' track lists from Spotify (it does not download from Spotify itself, only Soulseek). Create one free at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) for the Client ID and Secret. Because of a 2025/2026 Spotify API change, a brand-new app can no longer read playlists with just those two values — you also need to **connect your Spotify account** (one click in Settings → Credentials, standard Spotify login) the first time. This is a one-time step; see the credentials section below.
4. **A Spotify Developer app, plus an OAuth login (not just keys).** alembic reads your playlists' track lists from Spotify (it does not download from Spotify itself, only Soulseek). Create an app free at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) to get a Client ID and Secret — but for any app created since Spotify's 2025/2026 API change, those two values alone (plain "client-credentials" auth) are no longer enough to read playlists. You additionally need to complete a one-time **OAuth login** — alembic's **Connect Spotify** button, which redirects you to Spotify to log in and grants alembic a user token — the first time you set things up. Plan on doing both steps; see the credentials section below for exactly how.
5. **A login provider that speaks OpenID Connect (OIDC).** alembic doesn't have its own username/password login, it delegates to something you already trust. [Pocket ID](https://github.com/pocket-id/pocket-id) is a small self-hosted option built exactly for this, but Authentik, Keycloak, Authelia, or any other OIDC provider works too.
Optional, add these later if you want them:
@@ -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"
@@ -191,7 +191,7 @@ Spotify, Soulseek, and Navidrome are marked **Required** — every playlist sync
| Service | What to enter | Where to get it |
|---|---|---|
| **Spotify** *(required)* | Client ID, Client Secret, then **Connect Spotify** | Reads your playlists' track lists. Create an app at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) for the Client ID/Secret, save them, then click **Connect Spotify** and log in with your Spotify account — required for any app created after Spotify's 2025/2026 API change (see Troubleshooting below). |
| **Spotify** *(required)* | Client ID, Client Secret, then **Connect Spotify** (OAuth login) | Reads your playlists' track lists. Create an app at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) for the Client ID/Secret, save them, then click **Connect Spotify** to complete an OAuth login with your Spotify account — required for any app created after Spotify's 2025/2026 API change, since keys alone (client-credentials) are no longer sufficient (see Troubleshooting below). |
| **Soulseek** *(required)* | Username, Password | Your normal Soulseek login. |
| **Navidrome** *(required)* | Base URL, admin username, admin password | The address of your Navidrome server and an admin account on it. Used so alembic can trigger a library rescan after downloads. |
| **Bandcamp** *(optional)* | Username, format preference, cookies | Export your Bandcamp cookies while logged into bandcamp.com in your browser, using an extension like "Get cookies.txt LOCALLY", and paste the contents in. Format preference is a space-separated list like `flac mp3-320`. |
@@ -199,9 +199,21 @@ Spotify, Soulseek, and Navidrome are marked **Required** — every playlist sync
| **AzuraCast** *(optional)* | Base URL, API key | Only relevant if you also run an AzuraCast radio station off the same library. It's not a buy-link source itself (that's Bandcamp/Qobuz, written straight to the file); this just lets alembic tell AzuraCast to immediately pick up a tag change instead of waiting for its own periodic scan. Base URL is your AzuraCast instance's address, e.g. `https://radio.example.com`. |
| **Telegram** *(optional)* | Bot token, chat ID | Create a bot via [@BotFather](https://t.me/BotFather) if you want the daily status digest sent to a chat. |
### Connecting your Spotify account
### Connecting your Spotify account (OAuth) — required for any new Spotify app
After saving your Spotify Client ID and Secret, add this redirect URI in your Spotify app's dashboard settings (Developer Dashboard → your app → Settings → Redirect URIs): `https://<your-host>/connect/spotify/callback`. Then click **Connect Spotify** on the Credentials page and log in — this only needs to be done once. It's what lets alembic read playlists on an app created after Spotify's 2025/2026 restriction on app-only playlist access; grandfathered apps continue to work without it, but connecting is harmless either way.
Spotify Client ID/Secret alone ("client-credentials" auth) used to be enough for alembic to read your playlists. Since Spotify's 2025/2026 API change, that stopped working for any app created after the change — Spotify now requires a real user to authorize the app via OAuth before it will hand over playlist data at all. If your Spotify app is that new (almost certainly, if you just created one in the step above), you must complete this OAuth login or every playlist sync will fail with a 403/401 (see Troubleshooting).
Steps:
1. In your Spotify app's dashboard (Developer Dashboard → your app → Settings → Redirect URIs), add this redirect URI: `https://<your-host>/connect/spotify/callback`.
2. In alembic, go to **Settings → Credentials**, enter your Spotify Client ID and Secret, and save.
3. Click **Connect Spotify**. You'll be redirected to Spotify's own login page to authorize alembic, then bounced back to the Credentials page, which now shows **Connected**.
That's it, done once. alembic stores the resulting OAuth refresh token (encrypted, same as your other credentials) and uses it to silently mint a fresh access token whenever it needs one — you never have to repeat this unless you disconnect or revoke access on Spotify's side.
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 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
@@ -274,9 +286,17 @@ Double check the redirect URI registered with your OIDC provider exactly matches
Check that playlist's job history under **Settings → Jobs**, the log will usually show whether Soulseek couldn't find a track, or a credential is missing. The playlist's own page will also show everything as "Waiting to download" if nothing came through.
**The playlist page shows "Spotify denied access (403)" or a 401, or nothing downloads and the logs mention one of those.**
Your Spotify app can't read the playlist with app-only (client-credentials) access alone. Spotify closed that off for apps created after its 2025/2026 API change, on both the old `/tracks` endpoint (403) and the current `/items` endpoint (401 "valid user authentication required") — this affects even public playlists. The "extended quota mode" that would lift it is granted only to organizations, not individuals, since May 2025.
Your Spotify app can't read the playlist with app-only (client-credentials) access alone — you haven't completed the OAuth login yet. Spotify closed off key-only access for apps created after its 2025/2026 API change, on both the old `/tracks` endpoint (403) and the current `/items` endpoint (401 "valid user authentication required") — this affects even public playlists. The "extended quota mode" that would lift it is granted only to organizations, not individuals, since May 2025.
The fix: go to **Settings → Credentials** and click **Connect Spotify** (see "Connecting your Spotify account" 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.
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 `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 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.
**Dedup found something that isn't actually a duplicate.**
Use the "Keep both" button on that pair. alembic will remember your decision and won't flag that exact pair again.
+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)
+7 -1
View File
@@ -25,7 +25,13 @@ router = APIRouter(tags=["spotify-connect"])
AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
TOKEN_URL = "https://accounts.spotify.com/api/token"
SCOPES = "playlist-read-private playlist-read-collaborative"
# Matches exactly what sldl's own built-in OAuth login flow requests (seen in
# its printed authorize URL: user-library-read + these two) -- granting a
# narrower scope than sldl expects gets a token sldl can refresh fine but
# then gets 403 Forbidden from Spotify partway through loading a playlist,
# which sldl turns into an unhandled-exception crash (exit 134) rather than a
# clean scope error.
SCOPES = "playlist-read-private playlist-read-collaborative user-library-read"
def _callback_redirect_uri(request: Request) -> str:
+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
+51 -28
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,23 +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
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"
@@ -111,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):