Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ecff44811 |
@@ -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.2
|
||||
docker pull git.kretzer.club/andrew/alembic:0.6.3
|
||||
```
|
||||
|
||||
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.
|
||||
That is the whole install. You do not need to download the source or build anything. The `0.6.3` 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.2
|
||||
image: git.kretzer.club/andrew/alembic:0.6.3
|
||||
container_name: alembic
|
||||
ports:
|
||||
- "8420:8420"
|
||||
@@ -211,6 +211,8 @@ Steps:
|
||||
|
||||
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.
|
||||
|
||||
One more limit that comes with a new Spotify app: Spotify only returns a playlist's contents to the account that owns it (or collaborates on it), even after you connect. So sync playlists that belong to the account you connect, and if someone shares a playlist with you, save your own copy of it first (or have them add you as a collaborator).
|
||||
|
||||
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.
|
||||
@@ -290,11 +292,13 @@ 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 `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:
|
||||
**A playlist's job log shows `Spotify returned 403 loading this playlist`, `Spotify returned this playlist without its contents`, or `0 visible tracks` even though you've connected Spotify.** (On versions before 0.6.2 these showed up as `sldl crashed (exit 134)` mentioning a 403 Forbidden; on 0.6.2 a playlist you don't own could finish "successfully" while downloading nothing.)
|
||||
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).
|
||||
1. **The playlist isn't owned by the connected account.** For Spotify apps created after the 2025/2026 API change, Spotify only returns a playlist's contents to its owner or a collaborator on it. Being public is NOT enough. Fix: recreate the playlist under the account you connected via **Connect Spotify**, or have the owner make it collaborative and add you.
|
||||
2. **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.
|
||||
|
||||
(If your Spotify app is old enough to be grandfathered, both public playlists and your own keep working like before.)
|
||||
|
||||
Either way, re-run the playlist after fixing it.
|
||||
|
||||
|
||||
@@ -14,12 +14,16 @@ 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."""
|
||||
wrong credentials, or the connected account can't see it. (The other
|
||||
can't-read case, contents hidden for playlists the account doesn't own,
|
||||
arrives as a RuntimeError from spotify_client with its own message.)"""
|
||||
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."
|
||||
"Settings then Credentials, and make sure the account connected via "
|
||||
"Connect Spotify can see this playlist. For newly created Spotify "
|
||||
"apps the connected account must own the playlist or be a "
|
||||
"collaborator on it."
|
||||
)
|
||||
return str(exc)
|
||||
|
||||
|
||||
@@ -77,18 +77,34 @@ def get_playlist_tracks(db: Session, playlist_url: str) -> list[dict]:
|
||||
|
||||
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.
|
||||
# February 2026 API changes. The response shape ALSO changed, but only for
|
||||
# apps on the new behavior: each entry's payload moved from "track" to
|
||||
# "item" (tracks.tracks.track -> items.items.item). Extended Quota Mode
|
||||
# (grandfathered) apps keep the old "track" key, so parse both. No
|
||||
# `fields` filter: it selects by key name, so on the renamed shape a
|
||||
# track(...) filter silently returns empty pages -- exactly the failure
|
||||
# we're avoiding.
|
||||
url = f"{API_BASE}/playlists/{playlist_id}/items"
|
||||
params = {"limit": 100, "fields": "items(track(name,artists(name),external_ids)),next"}
|
||||
params = {"limit": 100}
|
||||
|
||||
while url:
|
||||
resp = httpx.get(url, params=params, headers=headers, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
for item in data.get("items", []):
|
||||
track = item.get("track")
|
||||
if "items" not in data:
|
||||
# New-behavior apps get metadata only (no items field at all) for
|
||||
# playlists the connected account doesn't own or collaborate on --
|
||||
# public is no longer sufficient. Same message style the playlist
|
||||
# page shows for a 403.
|
||||
raise RuntimeError(
|
||||
"Spotify returned this playlist without its contents. For newly "
|
||||
"created Spotify apps, the account connected via Connect Spotify "
|
||||
"must own the playlist (or be a collaborator on it) -- ask the "
|
||||
"owner to share it as collaborative, or recreate it under the "
|
||||
"connected account."
|
||||
)
|
||||
for item in data["items"]:
|
||||
track = item.get("track") or item.get("item")
|
||||
if not track:
|
||||
continue # local files / removed tracks show up as null
|
||||
artists = track.get("artists") or []
|
||||
|
||||
@@ -84,6 +84,18 @@ if ! SPOTIFY_CLIENT_ID="$SPOTIFY_CLIENT_ID" SPOTIFY_CLIENT_SECRET="$SPOTIFY_CLIE
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Surface the fetched count in the timestamped summary. Zero tracks from a
|
||||
# successful fetch is almost never a truly empty playlist -- it usually means
|
||||
# Spotify hid the contents from the connected account (see the pointed errors
|
||||
# in spotify-playlist-csv.py), so call it out instead of letting sldl no-op
|
||||
# with a clean exit 0.
|
||||
TRACK_COUNT=$(($(wc -l < "$CSV_FILE") - 1))
|
||||
if [[ "$TRACK_COUNT" -le 0 ]]; then
|
||||
log "WARNING: Spotify returned 0 visible tracks for this playlist -- nothing to download. If the playlist isn't actually empty, the connected Spotify account can't see its contents (it must own or collaborate on the playlist for newly created Spotify apps)."
|
||||
else
|
||||
log "Fetched $TRACK_COUNT track(s) from Spotify"
|
||||
fi
|
||||
|
||||
# ==== Run sldl (vendored binary, subprocess of this container) ====
|
||||
log "Running sldl for: $PLAYLIST_NAME"
|
||||
|
||||
|
||||
@@ -45,10 +45,12 @@ 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}/items"
|
||||
"?limit=100&fields=items(track(name,artists(name),album(name),duration_ms,is_local)),next"
|
||||
)
|
||||
# No `fields` filter: Spotify's February 2026 changes renamed each entry's
|
||||
# payload from "track" to "item" for apps on the new behavior (extended
|
||||
# quota / grandfathered apps keep "track"), and a fields filter selects by
|
||||
# key name -- filtering on track(...) silently returns EMPTY pages on the
|
||||
# renamed shape. Fetch unfiltered and parse both key names below.
|
||||
next_url = f"{API}/playlists/{pid}/items?limit=100"
|
||||
while next_url:
|
||||
req = urllib.request.Request(next_url, headers={"Authorization": f"Bearer {token}"})
|
||||
try:
|
||||
@@ -56,18 +58,26 @@ def fetch_playlist(url: str, token: str) -> list[dict]:
|
||||
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."
|
||||
"Spotify account can't see it: it must own the playlist, be a "
|
||||
"collaborator on it, or (for grandfathered apps) the playlist "
|
||||
"must be Public. Check which account is connected via "
|
||||
"/connect/spotify."
|
||||
)
|
||||
raise
|
||||
for item in data.get("items", []):
|
||||
t = item.get("track")
|
||||
if "items" not in data:
|
||||
# New-behavior apps get metadata only (no items field) for
|
||||
# playlists the connected account doesn't own or collaborate on.
|
||||
sys.exit(
|
||||
"Spotify returned this playlist without its contents. For newly "
|
||||
"created Spotify apps, the connected account must OWN the playlist "
|
||||
"or be a collaborator on it (public is no longer enough). Ask the "
|
||||
"owner to make it collaborative and add you, or recreate the "
|
||||
"playlist under the connected account."
|
||||
)
|
||||
for item in data["items"]:
|
||||
t = item.get("track") or item.get("item")
|
||||
if not t or t.get("is_local"):
|
||||
continue
|
||||
# All artists, comma-joined -- matches what sldl's own Spotify
|
||||
|
||||
@@ -56,8 +56,11 @@ def fetch_playlist(url: str, token: str) -> list[dict]:
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
data = json.loads(r.read())
|
||||
for item in data["items"]:
|
||||
t = item.get("track")
|
||||
# Entries carry "track" (grandfathered apps) or "item" (apps on the
|
||||
# February 2026 renamed shape); items is absent entirely when the
|
||||
# connected account can't read the playlist's contents.
|
||||
for item in data.get("items", []):
|
||||
t = item.get("track") or item.get("item")
|
||||
if not t or t.get("is_local"):
|
||||
continue
|
||||
tracks.append(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""get_playlist_tracks must parse BOTH /items response shapes: entries keyed
|
||||
"track" (grandfathered / extended-quota apps) and "item" (apps on Spotify's
|
||||
February 2026 renamed shape), and must fail loudly, not return [], when
|
||||
Spotify withholds a playlist's contents (no items field at all)."""
|
||||
import pytest
|
||||
|
||||
from app.services import spotify_client
|
||||
|
||||
URL = "https://open.spotify.com/playlist/XYZ123"
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def _fake_api(monkeypatch, pages):
|
||||
it = iter(pages)
|
||||
monkeypatch.setattr(spotify_client, "_get_token", lambda db: "tok")
|
||||
monkeypatch.setattr(
|
||||
spotify_client.httpx,
|
||||
"get",
|
||||
lambda url, params=None, headers=None, timeout=None: _Resp(next(it)),
|
||||
)
|
||||
|
||||
|
||||
def _track(name, artist, isrc=None):
|
||||
return {
|
||||
"name": name,
|
||||
"artists": [{"name": artist}],
|
||||
"external_ids": {"isrc": isrc} if isrc else {},
|
||||
}
|
||||
|
||||
|
||||
def test_parses_legacy_track_shape(monkeypatch):
|
||||
_fake_api(monkeypatch, [{"items": [{"track": _track("Sandstorm", "Darude", "FIDAR9900011")}], "next": None}])
|
||||
tracks = spotify_client.get_playlist_tracks(None, URL)
|
||||
assert tracks == [{"artist": "Darude", "title": "Sandstorm", "isrc": "FIDAR9900011"}]
|
||||
|
||||
|
||||
def test_parses_renamed_item_shape(monkeypatch):
|
||||
# February 2026 shape: payload under "item", not "track"
|
||||
_fake_api(monkeypatch, [{"items": [{"item": _track("Sandstorm", "Darude")}], "next": None}])
|
||||
tracks = spotify_client.get_playlist_tracks(None, URL)
|
||||
assert tracks == [{"artist": "Darude", "title": "Sandstorm", "isrc": None}]
|
||||
|
||||
|
||||
def test_missing_items_field_raises_not_empty(monkeypatch):
|
||||
# metadata-only response (playlist not owned by the connected account on a
|
||||
# new app) must be an error the UI can show, never a silent empty list
|
||||
_fake_api(monkeypatch, [{"name": "DJ-USB", "public": True}])
|
||||
with pytest.raises(RuntimeError, match="without its contents"):
|
||||
spotify_client.get_playlist_tracks(None, URL)
|
||||
|
||||
|
||||
def test_skips_null_entries_and_paginates(monkeypatch):
|
||||
_fake_api(
|
||||
monkeypatch,
|
||||
[
|
||||
{"items": [{"track": None}, {"track": _track("A", "X")}], "next": "page2"},
|
||||
{"items": [{"item": _track("B", "Y")}], "next": None},
|
||||
],
|
||||
)
|
||||
tracks = spotify_client.get_playlist_tracks(None, URL)
|
||||
assert [t["title"] for t in tracks] == ["A", "B"]
|
||||
Reference in New Issue
Block a user