From 97672ffdcd961459151bc376b5bda368f86c5a36 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 14 Jul 2026 10:12:42 -0600 Subject: [PATCH] 0.5.2: Run now for unscheduled playlists, Spotify /items migration, friendlier 403 - Run now (jobs.run_now): playlists without a daily sync time are never registered as scheduler jobs, so the old registered-job check 404'd. Fall back to running the playlist directly via pipeline_runner for any playlist: key that isn't registered. - Spotify removed GET /playlists/{id}/tracks in its February 2026 API changes in favor of /items (identical response shape). New apps are already 403'd on the old endpoint. Migrate both callers (app/services/spotify_client.py and pipeline/lib/spotify-retag.py) to /items. (The vendored sldl downloader uses its own bundled Spotify library and would need an upstream update when /tracks is fully removed.) - Friendlier on-screen error for a Spotify 403 (check credentials + playlist must be public) and a README troubleshooting entry covering the 403, the public-playlist requirement, and the org-only extended-quota reality. Co-Authored-By: Claude Opus 4.8 --- README.md | 14 ++++++++++---- app/routers/jobs.py | 27 +++++++++++++++++++++------ app/routers/playlists.py | 16 +++++++++++++++- app/services/spotify_client.py | 6 +++++- pipeline/lib/spotify-retag.py | 3 ++- 5 files changed, 53 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8236955..e85497b 100644 --- a/README.md +++ b/README.md @@ -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.5.1 +docker pull git.kretzer.club/andrew/alembic:0.5.2 ``` -That is the whole install. You do not need to download the source or build anything. The `0.5.1` 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.5.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.5.1 + image: git.kretzer.club/andrew/alembic:0.5.2 container_name: alembic ports: - "8420:8420" @@ -220,7 +220,7 @@ That's it. On its next scheduled run (or immediately, using the **Run now** butt ## Updating alembic -When a new version is released, change the version in your `docker-compose.yml` (for example `:0.5.1` to the new number), then pull and restart: +When a new version is released, change the version in your `docker-compose.yml` (for example `:0.5.2` to the new number), then pull and restart: ```bash docker compose pull @@ -269,6 +269,12 @@ Double check the redirect URI registered with your OIDC provider exactly matches **A playlist says it's synced but nothing downloaded.** 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 nothing downloads and the logs mention 403.** +Your Spotify app can't read the playlist. Two things to check: + +- **The playlist must be public.** alembic reads playlists with app-only (client-credentials) access, which can only see public playlists, never private ones. Set the playlist to public on Spotify. +- **Your Spotify app must be allowed to read playlists.** Spotify now restricts brand-new developer apps (in "development mode") from reading playlists they don't own, and since May 2025 the "extended quota mode" that lifts this is only granted to organizations, not individuals. If your own app gets 403 on a public playlist, the simplest fix is to use the Spotify Client ID and Secret from an app that already works (for example, one a friend running alembic set up before the change). Client credentials only read public catalog data, so sharing them exposes no account access, just the app's rate limit. + **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. diff --git a/app/routers/jobs.py b/app/routers/jobs.py index e022849..26d7b2f 100644 --- a/app/routers/jobs.py +++ b/app/routers/jobs.py @@ -6,7 +6,7 @@ from sqlalchemy import select from app.db import get_db from app.models import JobRun, ScheduledJob from app.security.deps import require_auth -from app.services import scheduler_service +from app.services import pipeline_runner, playlist_service, scheduler_service router = APIRouter(prefix="/settings/jobs", tags=["jobs"]) templates = Jinja2Templates(directory="app/templates") @@ -67,7 +67,9 @@ async def runs_table_partial(request: Request, user: dict = Depends(require_auth @router.post("/{job_key:path}/run") -async def run_now(job_key: str, background: BackgroundTasks, user: dict = Depends(require_auth)): +async def run_now( + job_key: str, background: BackgroundTasks, user: dict = Depends(require_auth), db=Depends(get_db) +): scheduler = scheduler_service.get_scheduler() if scheduler is None: raise HTTPException(503, "scheduler not running") @@ -75,10 +77,23 @@ async def run_now(job_key: str, background: BackgroundTasks, user: dict = Depend # background and redirect immediately. A playlist sync can take the better # part of an hour; awaiting it here would hang the browser/reverse proxy. # Progress shows up in the runs table below, which polls every few seconds. - if scheduler.get_job(job_key) is None: - raise HTTPException(404, f"no such registered job: {job_key}") - background.add_task(scheduler_service.trigger_now, scheduler, job_key) - return RedirectResponse(url="/settings/jobs?started=1", status_code=303) + if scheduler.get_job(job_key) is not None: + background.add_task(scheduler_service.trigger_now, scheduler, job_key) + return RedirectResponse(url="/settings/jobs?started=1", status_code=303) + + # Playlists with no cron_expr (or paused) are never registered with the + # scheduler by sync_playlist_jobs -- that's "unscheduled/manual-only" by + # design, not missing. Run it directly through pipeline_runner instead of + # requiring a scheduler job to exist, so "Run now" works for those too. + if job_key.startswith("playlist:"): + playlist = playlist_service.get_by_name(db, job_key.removeprefix("playlist:")) + if playlist is not None: + background.add_task( + pipeline_runner.run_playlist, playlist.name, playlist.no_m3u, "manual" + ) + return RedirectResponse(url="/settings/jobs?started=1", status_code=303) + + raise HTTPException(404, f"no such registered job: {job_key}") @router.post("/{job_key:path}/toggle") diff --git a/app/routers/playlists.py b/app/routers/playlists.py index 6f2ebbb..38eeaeb 100644 --- a/app/routers/playlists.py +++ b/app/routers/playlists.py @@ -1,3 +1,4 @@ +import httpx from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates @@ -10,6 +11,19 @@ router = APIRouter(prefix="/playlists", tags=["playlists"]) templates = Jinja2Templates(directory="app/templates") +def _friendly_status_error(exc: Exception) -> str: + """Turn a raw Spotify API error into something a non-technical user can act + on. 403 here almost always means the Spotify app can't read the playlist: + either the credentials are wrong or the playlist isn't public.""" + if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 403: + return ( + "Spotify denied access (403). Check your Spotify credentials under " + "Settings then Credentials, and make sure the playlist is set to public " + "on Spotify -- alembic can't read private playlists." + ) + return str(exc) + + @router.get("") async def playlists_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)): playlists = playlist_service.list_all(db) @@ -54,7 +68,7 @@ async def playlist_detail( try: status = status_service.playlist_status(db, playlist.name, playlist.spotify_url) except Exception as exc: - error = str(exc) + error = _friendly_status_error(exc) return templates.TemplateResponse( request, diff --git a/app/services/spotify_client.py b/app/services/spotify_client.py index 5ea0844..d33934e 100644 --- a/app/services/spotify_client.py +++ b/app/services/spotify_client.py @@ -57,7 +57,11 @@ def get_playlist_tracks(db: Session, playlist_url: str) -> list[dict]: headers = {"Authorization": f"Bearer {token}"} tracks = [] - url = f"{API_BASE}/playlists/{playlist_id}/tracks" + # /items, not /tracks: Spotify removed GET /playlists/{id}/tracks in its + # February 2026 API changes in favor of /items (same response shape). New + # apps are 403'd on the old endpoint; grandfathered apps still tolerate it + # for now, but /items is the correct, future-proof one. + url = f"{API_BASE}/playlists/{playlist_id}/items" params = {"limit": 100, "fields": "items(track(name,artists(name),external_ids)),next"} while url: diff --git a/pipeline/lib/spotify-retag.py b/pipeline/lib/spotify-retag.py index fb4fcd6..130ccb0 100755 --- a/pipeline/lib/spotify-retag.py +++ b/pipeline/lib/spotify-retag.py @@ -46,7 +46,8 @@ def fetch_playlist(url: str, token: str) -> list[dict]: sys.exit(f"Could not parse playlist ID from {url!r}") pid = m.group(1) tracks = [] - next_url = f"{API}/playlists/{pid}/tracks?limit=100" + # /items (not the removed /tracks) -- see February 2026 Spotify API changes. + next_url = f"{API}/playlists/{pid}/items?limit=100" while next_url: req = urllib.request.Request( next_url, headers={"Authorization": f"Bearer {token}"}