8ecff44811
Spotify's February 2026 changes did not just move /playlists/{id}/tracks to
/items: for apps on the new behavior the per-entry payload key was renamed
from "track" to "item" (tracks.tracks.track -> items.items.item). Extended
Quota Mode (grandfathered) apps keep the old key, which is why this never
reproduced locally. Every parser in alembic read only "track", so on a new
app each entry looked like a null/local track and was silently skipped: the
CSV came out header-only, sldl no-opped with exit 0, and the playlist page
showed an empty tracklist. Confirmed live on a new app against a playlist
the connected account owns.
All /items consumers (spotify_client.py, spotify-playlist-csv.py,
spotify-retag.py) now parse both key names, and the fields query filter is
gone: it selects by key name, so filtering on track(...) is itself what
returned empty pages on the renamed shape.
Also per the migration guide, new apps only receive playlist contents for
playlists the connected account owns or collaborates on; other playlists
return metadata with no items field at all (public is no longer enough).
That case now raises a pointed error (UI and CSV fetch) instead of reading
as an empty playlist, run-playlist.sh logs the fetched track count and warns
loudly when it is zero, and the README guidance is updated to match.
New tests pin get_playlist_tracks against both response shapes, the
metadata-only error, and pagination.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
122 lines
4.8 KiB
Python
122 lines
4.8 KiB
Python
import base64
|
|
import re
|
|
import time
|
|
|
|
import httpx
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.services import credential_service
|
|
|
|
TOKEN_URL = "https://accounts.spotify.com/api/token"
|
|
API_BASE = "https://api.spotify.com/v1"
|
|
|
|
# Module-level cache: one alembic process, one Spotify app registration --
|
|
# a single shared token is fine (single-user app, no per-request identity).
|
|
# Keyed by mode too, so connecting/disconnecting an account (switching
|
|
# between a user token and a client-credentials token) can't serve a stale
|
|
# token minted under the other grant type.
|
|
_token_cache: dict = {"token": None, "expires_at": 0.0, "mode": None}
|
|
|
|
_PLAYLIST_ID_RE = re.compile(r"playlist/([A-Za-z0-9]+)")
|
|
|
|
|
|
def _extract_playlist_id(playlist_url: str) -> str:
|
|
m = _PLAYLIST_ID_RE.search(playlist_url)
|
|
if not m:
|
|
raise ValueError(f"not a Spotify playlist URL: {playlist_url}")
|
|
return m.group(1)
|
|
|
|
|
|
def _get_token(db: Session) -> str:
|
|
now = time.time()
|
|
creds = credential_service.get_scope(db, "spotify")
|
|
client_id = creds.get("client_id")
|
|
client_secret = creds.get("client_secret")
|
|
if not client_id or not client_secret:
|
|
raise RuntimeError("Spotify credentials not configured (settings/credentials)")
|
|
refresh_token = creds.get("refresh_token")
|
|
# A user token (from /connect/spotify) can read playlists on newly created
|
|
# Spotify apps; client-credentials can't (Spotify blocks GET /items for
|
|
# new apps without user auth). Prefer the user token whenever one is
|
|
# connected, falling back to client-credentials for grandfathered apps.
|
|
mode = "user" if refresh_token else "client_credentials"
|
|
|
|
if (
|
|
_token_cache["token"]
|
|
and _token_cache["mode"] == mode
|
|
and _token_cache["expires_at"] > now + 30
|
|
):
|
|
return _token_cache["token"]
|
|
|
|
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
|
grant_data = (
|
|
{"grant_type": "refresh_token", "refresh_token": refresh_token}
|
|
if refresh_token
|
|
else {"grant_type": "client_credentials"}
|
|
)
|
|
resp = httpx.post(
|
|
TOKEN_URL,
|
|
data=grant_data,
|
|
headers={"Authorization": f"Basic {basic}"},
|
|
timeout=15,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
_token_cache["token"] = data["access_token"]
|
|
_token_cache["expires_at"] = now + data["expires_in"]
|
|
_token_cache["mode"] = mode
|
|
return _token_cache["token"]
|
|
|
|
|
|
def get_playlist_tracks(db: Session, playlist_url: str) -> list[dict]:
|
|
"""Return [{"artist": ..., "title": ..., "isrc": ... | None}, ...] for
|
|
every track in the playlist, paginated 100/page."""
|
|
token = _get_token(db)
|
|
playlist_id = _extract_playlist_id(playlist_url)
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
tracks = []
|
|
# /items, not /tracks: Spotify removed GET /playlists/{id}/tracks in its
|
|
# 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}
|
|
|
|
while url:
|
|
resp = httpx.get(url, params=params, headers=headers, timeout=15)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
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 []
|
|
tracks.append(
|
|
{
|
|
"artist": artists[0]["name"] if artists else "",
|
|
"title": track.get("name", ""),
|
|
"isrc": (track.get("external_ids") or {}).get("isrc"),
|
|
}
|
|
)
|
|
url = data.get("next")
|
|
params = None # `next` is a full URL with its own query string
|
|
|
|
return tracks
|