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.6 KiB
Python
83 lines
2.6 KiB
Python
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()
|