Add beets_service, spotify_client, status_service
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.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import sqlite3
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
# Columns actually used by the app today. beets' `items` table has many more;
|
||||
# add columns here as new features need them rather than SELECT *.
|
||||
_ITEM_COLUMNS = ["id", "path", "title", "artist", "albumartist", "genres", "grouping", "isrc", "added", "format"]
|
||||
|
||||
|
||||
def db_exists() -> bool:
|
||||
return settings.beets_db_path.exists()
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
# mode=ro: alembic never writes through this connection. Mutations (tag
|
||||
# edits, imports) go through the `beet` CLI or beets.library.Library
|
||||
# in-process -- see services/library_edit.py (manual-fix feature).
|
||||
conn = sqlite3.connect(f"file:{settings.beets_db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _decode_path(value) -> str:
|
||||
"""beets stores `path` as a BLOB (raw filesystem bytes), not TEXT."""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return value or ""
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row) -> dict:
|
||||
d = dict(row)
|
||||
if "path" in d:
|
||||
d["path"] = _decode_path(d["path"])
|
||||
return d
|
||||
|
||||
|
||||
def query_items(grouping: str | None = None) -> list[dict]:
|
||||
"""All items, optionally filtered to one playlist's grouping tag."""
|
||||
if not db_exists():
|
||||
return []
|
||||
cols = ", ".join(_ITEM_COLUMNS)
|
||||
conn = _connect()
|
||||
try:
|
||||
if grouping is not None:
|
||||
cur = conn.execute(f"SELECT {cols} FROM items WHERE grouping = ?", (grouping,))
|
||||
else:
|
||||
cur = conn.execute(f"SELECT {cols} FROM items")
|
||||
return [_row_to_dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_item(item_id: int) -> dict | None:
|
||||
if not db_exists():
|
||||
return None
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.execute("SELECT * FROM items WHERE id = ?", (item_id,))
|
||||
row = cur.fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def stats() -> dict:
|
||||
"""Cheap summary for the dashboard and for migration-verification
|
||||
(compare against `beet stats` during Stage 0 cutover)."""
|
||||
if not db_exists():
|
||||
return {"db_exists": False, "total_tracks": 0, "groupings": []}
|
||||
conn = _connect()
|
||||
try:
|
||||
total = conn.execute("SELECT COUNT(*) FROM items").fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT grouping FROM items WHERE grouping IS NOT NULL AND grouping != ''"
|
||||
).fetchall()
|
||||
return {
|
||||
"db_exists": True,
|
||||
"total_tracks": total,
|
||||
"groupings": sorted(r[0] for r in rows),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,82 @@
|
||||
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
|
||||
@@ -0,0 +1,121 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services import beets_service, spotify_client
|
||||
from app.settings import settings
|
||||
|
||||
IN_LIBRARY = "IN_LIBRARY"
|
||||
QUARANTINED = "QUARANTINED"
|
||||
DOWNLOADED_PENDING_IMPORT = "DOWNLOADED_PENDING_IMPORT"
|
||||
NOT_YET_ATTEMPTED = "NOT_YET_ATTEMPTED"
|
||||
|
||||
ALL_STATUSES = [IN_LIBRARY, QUARANTINED, DOWNLOADED_PENDING_IMPORT, NOT_YET_ATTEMPTED]
|
||||
|
||||
# ATTEMPTED_NO_MATCH (a track sldl searched for and found nothing) is
|
||||
# deliberately not implemented: the only signal for it is grepping sldl's
|
||||
# per-run text logs for "no results" lines, which the migration plan flags
|
||||
# as best-effort/unreliable rather than authoritative. Tracks that were
|
||||
# searched-and-missed and tracks that haven't been searched yet both show
|
||||
# as NOT_YET_ATTEMPTED for now.
|
||||
|
||||
_STRIP_PARENS_RE = re.compile(r"\s*\([^)]*\)")
|
||||
_STRIP_BRACKETS_RE = re.compile(r"\s*\[[^\]]*\]")
|
||||
_FEAT_RE = re.compile(r"\s*(feat\.?|ft\.?)\s.*", re.IGNORECASE)
|
||||
_PUNCT_RE = re.compile(r"[\s\-_,;:&'\"!?.()\[\]/]+")
|
||||
|
||||
|
||||
def _normalize(s: str) -> str:
|
||||
s = (s or "").lower()
|
||||
s = _STRIP_PARENS_RE.sub("", s)
|
||||
s = _STRIP_BRACKETS_RE.sub("", s)
|
||||
s = _FEAT_RE.sub("", s)
|
||||
s = _PUNCT_RE.sub("", s)
|
||||
return s
|
||||
|
||||
|
||||
def _artist_title_key(artist: str, title: str) -> str:
|
||||
return f"{_normalize(artist)}|{_normalize(title)}"
|
||||
|
||||
|
||||
def _key_from_filename_stem(stem: str) -> str | None:
|
||||
"""'Artist - Title' -> matching key. sldl and quarantined files both
|
||||
follow this convention (sldl's name-format template; quarantined files
|
||||
are untagged sldl downloads, so the filename is usually all that's
|
||||
left to match on)."""
|
||||
if " - " not in stem:
|
||||
return None
|
||||
artist, title = stem.split(" - ", 1)
|
||||
return _artist_title_key(artist, title)
|
||||
|
||||
|
||||
def _sldl_index(playlist_name: str) -> set[str]:
|
||||
m3u8_path = settings.music_data_dir / "sldl-dropbox" / playlist_name / "_sldl.m3u8"
|
||||
if not m3u8_path.exists():
|
||||
return set()
|
||||
keys = set()
|
||||
for line in m3u8_path.read_text(errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key = _key_from_filename_stem(Path(line).stem)
|
||||
if key:
|
||||
keys.add(key)
|
||||
return keys
|
||||
|
||||
|
||||
def _quarantine_index() -> set[str]:
|
||||
quarantine_dir = settings.music_data_dir / "Songs" / "untagged"
|
||||
if not quarantine_dir.exists():
|
||||
return set()
|
||||
keys = set()
|
||||
for f in quarantine_dir.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
key = _key_from_filename_stem(f.stem)
|
||||
if key:
|
||||
keys.add(key)
|
||||
return keys
|
||||
|
||||
|
||||
def _beets_index(playlist_name: str) -> dict[str, dict]:
|
||||
index: dict[str, dict] = {}
|
||||
for item in beets_service.query_items(grouping=playlist_name):
|
||||
key = _artist_title_key(item.get("artist", ""), item.get("title", ""))
|
||||
index[key] = item
|
||||
if item.get("isrc"):
|
||||
index[f"isrc:{item['isrc']}"] = item
|
||||
return index
|
||||
|
||||
|
||||
def playlist_status(db: Session, playlist_name: str, spotify_url: str) -> dict:
|
||||
"""Reconcile one playlist's Spotify tracklist against beets (in-library),
|
||||
sldl's per-playlist index (downloaded, not yet imported), and the
|
||||
quarantine dir (downloaded, missing tags). Returns
|
||||
{"tracks": [{...track, "status": ...}], "counts": {status: count}}."""
|
||||
spotify_tracks = spotify_client.get_playlist_tracks(db, spotify_url)
|
||||
beets_index = _beets_index(playlist_name)
|
||||
sldl_index = _sldl_index(playlist_name)
|
||||
quarantine_index = _quarantine_index()
|
||||
|
||||
counts = {status: 0 for status in ALL_STATUSES}
|
||||
tracks = []
|
||||
|
||||
for track in spotify_tracks:
|
||||
key = _artist_title_key(track["artist"], track["title"])
|
||||
isrc_key = f"isrc:{track['isrc']}" if track.get("isrc") else None
|
||||
|
||||
if (isrc_key and isrc_key in beets_index) or key in beets_index:
|
||||
status = IN_LIBRARY
|
||||
elif key in quarantine_index:
|
||||
status = QUARANTINED
|
||||
elif key in sldl_index:
|
||||
status = DOWNLOADED_PENDING_IMPORT
|
||||
else:
|
||||
status = NOT_YET_ATTEMPTED
|
||||
|
||||
tracks.append({**track, "status": status})
|
||||
counts[status] += 1
|
||||
|
||||
return {"tracks": tracks, "counts": counts}
|
||||
Reference in New Issue
Block a user