Files
alembic/app/services/beets_service.py
T
andrew fe98afaaf4 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.
2026-07-08 13:40:10 -06:00

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()