fe98afaaf4
beets_service: read-only SQLite queries against the beets DB (mode=ro, matches the WAL setup in db.py's enable_beets_db_wal). Decodes the `path` column, which beets stores as a BLOB not TEXT. stats() gives dashboard/ migration-verification parity with `beet stats`. spotify_client: client-credentials OAuth (token cached in-process), paginated playlist-track fetch. Verified with mocked httpx responses: pagination across pages, null-track filtering (removed/local tracks), ISRC extraction, and token reuse across calls. status_service: reconciles a playlist's live Spotify tracklist against beets (IN_LIBRARY, by ISRC or normalized artist+title), sldl's per-playlist _sldl.m3u8 index (DOWNLOADED_PENDING_IMPORT), and the tag-guard quarantine dir (QUARANTINED, matched by filename since quarantined files have no tags by definition) -- everything else is NOT_YET_ATTEMPTED. ATTEMPTED_NO_MATCH is deliberately not implemented (the only signal is grepping sldl's per-run text logs, which the migration plan already flags as unreliable rather than authoritative). Verified end-to-end against a fake beets DB + sldl index + quarantine dir: all four statuses reconcile correctly, including both the ISRC-match and normalized-artist+title-fallback paths.
83 lines
2.8 KiB
Python
83 lines
2.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 client-credentials token is fine (no per-user tokens here).
|
|
_token_cache: dict = {"token": None, "expires_at": 0.0}
|
|
|
|
_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()
|
|
if _token_cache["token"] and _token_cache["expires_at"] > now + 30:
|
|
return _token_cache["token"]
|
|
|
|
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)")
|
|
|
|
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
|
resp = httpx.post(
|
|
TOKEN_URL,
|
|
data={"grant_type": "client_credentials"},
|
|
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"]
|
|
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 = []
|
|
url = f"{API_BASE}/playlists/{playlist_id}/tracks"
|
|
params = {"limit": 100, "fields": "items(track(name,artists(name),external_ids)),next"}
|
|
|
|
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 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
|