Milestone 5: MusicBrainz MBID canonicalizer
Give tracks a source-agnostic identity so the same song from different sources no longer replays in a loop. - Canonicalizer resolves (artist, title) to a MusicBrainz recording MBID (no API key; ~1 req/s, descriptive User-Agent, best-effort). Hits and confirmed misses are cached in SQLite; transient errors are not. - Track.key becomes mbid:<id> when resolved, else a normalized name:<artist>|<title> fallback — still source-agnostic. - Scheduler now owns the authoritative anti-repeat on the canonical key, canonicalizing the drawn track with a bounded retry; providers keep a cheap recent-locator filter to limit retries. - db: canonical_cache table, history.locator column with migration for existing databases, recent_locators(). - Canonicalization can be turned off via RADIEO_CANONICAL_ENABLED=0. Verified: MBID hit/cache/miss, cross-source key collapse, scheduler dodging a recent play, schema migration, and full stack (Navidrome + yt-dlp) with zero Python tracebacks and a valid 192 kbps MP3 stream. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8774f5c2a1
commit
7e0f08b863
11 changed files with 292 additions and 33 deletions
|
|
@ -1,13 +1,17 @@
|
|||
"""SQLite state: play history (anti-repeat + stats) and cache-file retention.
|
||||
"""SQLite state: play history, cache-file retention and MBID canonical cache.
|
||||
|
||||
Two concerns, two tables:
|
||||
Three concerns, three tables:
|
||||
|
||||
- ``history`` is append-only. It drives anti-repeat (recently played track
|
||||
keys) and survives cache eviction, so a track can stay "recently played" even
|
||||
after its file is deleted.
|
||||
- ``history`` is append-only. It drives anti-repeat (recently played canonical
|
||||
keys, and raw locators for the providers' cheap local filter) and survives
|
||||
cache eviction, so a track can stay "recently played" even after its file is
|
||||
deleted.
|
||||
- ``cache_files`` tracks downloaded files so we can keep only the N most
|
||||
recently *played* ones (LRU retention). Files not yet played are never
|
||||
evicted.
|
||||
- ``canonical_cache`` memoizes ``(artist, title) -> MBID`` lookups (a NULL mbid
|
||||
means a confirmed no-match) so the Canonicalizer hits the network at most once
|
||||
per distinct track.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
|
@ -21,6 +25,7 @@ _SCHEMA = """
|
|||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY,
|
||||
track_key TEXT NOT NULL,
|
||||
locator TEXT,
|
||||
artist TEXT,
|
||||
title TEXT,
|
||||
origin TEXT,
|
||||
|
|
@ -33,6 +38,14 @@ CREATE TABLE IF NOT EXISTS cache_files (
|
|||
track_key TEXT,
|
||||
played_at REAL -- NULL until the file has been played
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS canonical_cache (
|
||||
artist_norm TEXT NOT NULL,
|
||||
title_norm TEXT NOT NULL,
|
||||
mbid TEXT, -- NULL means a confirmed no-match
|
||||
resolved_at REAL NOT NULL,
|
||||
PRIMARY KEY (artist_norm, title_norm)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -45,6 +58,16 @@ class Database:
|
|||
)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._migrate()
|
||||
|
||||
def _migrate(self) -> None:
|
||||
# Add columns introduced after a DB may have been created (milestone 5).
|
||||
cols = {
|
||||
r["name"]
|
||||
for r in self._conn.execute("PRAGMA table_info(history)").fetchall()
|
||||
}
|
||||
if "locator" not in cols:
|
||||
self._conn.execute("ALTER TABLE history ADD COLUMN locator TEXT")
|
||||
|
||||
# --- anti-repeat / history -------------------------------------------
|
||||
|
||||
|
|
@ -56,12 +79,56 @@ class Database:
|
|||
).fetchall()
|
||||
return {r["track_key"] for r in rows}
|
||||
|
||||
def recent_locators(self, limit: int) -> set[str]:
|
||||
"""Raw backend locators recently played (providers' cheap local filter)."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT locator FROM history WHERE locator IS NOT NULL"
|
||||
" ORDER BY played_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return {r["locator"] for r in rows}
|
||||
|
||||
def record_play(self, track: Track) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT INTO history (track_key, artist, title, origin, played_at)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(track.key, track.artist, track.title, track.origin, time.time()),
|
||||
"INSERT INTO history"
|
||||
" (track_key, locator, artist, title, origin, played_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
track.key,
|
||||
track.locator,
|
||||
track.artist,
|
||||
track.title,
|
||||
track.origin,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
|
||||
# --- MBID canonical cache --------------------------------------------
|
||||
|
||||
def get_canonical(self, artist_norm: str, title_norm: str):
|
||||
"""Return (cached: bool, mbid: str | None). ``cached`` False means the
|
||||
pair was never looked up; a cached row with mbid None is a known miss."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT mbid FROM canonical_cache"
|
||||
" WHERE artist_norm = ? AND title_norm = ?",
|
||||
(artist_norm, title_norm),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False, None
|
||||
return True, row["mbid"]
|
||||
|
||||
def put_canonical(
|
||||
self, artist_norm: str, title_norm: str, mbid: str | None
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO canonical_cache"
|
||||
" (artist_norm, title_norm, mbid, resolved_at)"
|
||||
" VALUES (?, ?, ?, ?)",
|
||||
(artist_norm, title_norm, mbid, time.time()),
|
||||
)
|
||||
|
||||
# --- cache-file retention --------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue