Files
alembic/app/services/spotify_client.py
T
andrew 8ecff44811 0.6.3: Parse Spotify's renamed /items response shape (new apps get item, not track)
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>
2026-07-15 09:50:57 -06:00

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