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>
114 lines
3.8 KiB
Python
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)
|