Files
alembic/app/routers/playlists.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

114 lines
3.8 KiB
Python

import httpx
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import get_db
from app.security.deps import require_auth
from app.services import playlist_service, status_service
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:
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 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)
@router.get("")
async def playlists_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
playlists = playlist_service.list_all(db)
sync_times = {p.id: playlist_service.cron_to_time(p.cron_expr) for p in playlists}
return templates.TemplateResponse(
request, "playlists/index.html", {"playlists": playlists, "sync_times": sync_times}
)
@router.post("")
async def create_playlist(
name: str = Form(...),
spotify_url: str = Form(...),
sync_time: str = Form(""),
no_m3u: bool = Form(False),
user: dict = Depends(require_auth),
db=Depends(get_db),
):
try:
playlist_service.create(
db,
name=name,
spotify_url=spotify_url,
cron_expr=playlist_service.time_to_cron(sync_time),
no_m3u=no_m3u,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
return RedirectResponse(url="/playlists", status_code=303)
@router.get("/{playlist_id}")
async def playlist_detail(
request: Request, playlist_id: int, user: dict = Depends(require_auth), db=Depends(get_db)
):
playlist = playlist_service.get(db, playlist_id)
if playlist is None:
raise HTTPException(404, "no such playlist")
status = None
error = None
try:
status = status_service.playlist_status(db, playlist.name, playlist.spotify_url)
except Exception as exc:
error = _friendly_status_error(exc)
return templates.TemplateResponse(
request,
"playlists/detail.html",
{
"playlist": playlist,
"sync_time": playlist_service.cron_to_time(playlist.cron_expr),
"status": status,
"error": error,
},
)
@router.post("/{playlist_id}")
async def update_playlist(
playlist_id: int,
active: bool = Form(False),
sync_time: str = Form(""),
no_m3u: bool = Form(False),
notes: str = Form(""),
user: dict = Depends(require_auth),
db=Depends(get_db),
):
playlist_service.update(
db,
playlist_id,
active=active,
cron_expr=playlist_service.time_to_cron(sync_time),
no_m3u=no_m3u,
notes=notes or None,
)
return RedirectResponse(url=f"/playlists/{playlist_id}", status_code=303)
@router.post("/{playlist_id}/delete")
async def delete_playlist(playlist_id: int, user: dict = Depends(require_auth), db=Depends(get_db)):
playlist_service.delete(db, playlist_id)
return RedirectResponse(url="/playlists", status_code=303)