diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 17:12:36 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 17:12:36 +0200 |
| commit | 941d1a135dd7b03834576855e8e9fdaa24c4e406 (patch) | |
| tree | 5d7c27d45a3f1e77320e089f6a4522b8383cbd45 /packages/meshbay-node/src/meshbay_node/media_cache.py | |
| parent | 16bc07acf053d7d14f8182f5523da1d179154a15 (diff) | |
| download | meshbay-941d1a135dd7b03834576855e8e9fdaa24c4e406.tar.gz | |
feat(node): Music app node-side — indexing, MusicBrainz enrichment, protocol
Implements the node half of docs/musicbay.md against MNP 0.8:
- IndexEntry gains artist/album/track_no (reuses duration/thumb_hash/
display_title, already generic). New musicbrainz_config/_enabled and
music_meta_req/_resp message pairs, mirroring the TMDB shape.
- title_parse.parse_track_filename: track-number-prefix + title parsing,
fallback-only (embedded tags are the primary source, unlike Videos).
- indexer.enrich_audio.AudioEnricher: mutagen-based tag/embedded-cover
extraction through its own bounded pool (asyncio.to_thread, no
subprocess — no ffmpeg-shaped deadlock risk). Gated on "music" in a
group's enabled_apps rather than a video_root-style scoped folder.
- musicbrainz.py: MusicBrainzClient — no API key (unlike TMDB), just a
self-imposed ~1 req/s pace and a configurable, non-default User-Agent
contact string; inert (no calls at all) when no contact is configured,
never sends an unidentified client.
- media_cache.py: file_mbid/mbid_meta tables alongside the existing TMDB
ones, cover art reusing the thumbs table via a synthetic
musicbrainz:{mbid} id, pruned on file deletion.
- roster.py/ops.py/webrtc_server.py: musicbrainz_contact (node-wide) and
musicbrainz_enabled (per-group, from the start) as signed operator
settings, ALLOWED_APPS gains "music", _do_music_meta_request resolves
and caches a release-level MusicBrainz match per (artist, album).
- daemon.py: AudioEnricher/MusicBrainzClient wired alongside the video
ones; a group's existing library is swept when "music" is newly
enabled (no video_root equivalent — see musicbay.md §2.1).
41 new tests (musicbrainz.py against a mocked transport, admin-op policy
for both new settings, media_cache round-trip/pruning, enrich_audio
end-to-end against real ffmpeg-generated MP3s). Full suite (common +
node + hub): 1116 passed, no regressions.
Client-side (music-app.js, persistent player bar) not started yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/media_cache.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/media_cache.py | 77 |
1 files changed, 68 insertions, 9 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py index 6daae3f..47032b8 100644 --- a/packages/meshbay-node/src/meshbay_node/media_cache.py +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -1,11 +1,17 @@ """ -MeshBay Node — TMDB metadata and thumbnail cache for the Videos group app. +MeshBay Node — TMDB/MusicBrainz metadata and thumbnail cache, shared by the +Videos and Music group apps. Node-wide (not per-group, `data_dir/media_cache.db`), same rationale as -`tmdb_enabled`/`tmdb_api_token` living in `group_settings` under the -`group_id=""` sentinel (docs/mediacenter.md §5.5): TMDB is one operator's -budget and credential, and a thumbnail is the same bytes regardless of which -group happens to share the file. +`tmdb_enabled`/`tmdb_api_token` (and `musicbrainz_enabled`/ +`musicbrainz_contact`, docs/musicbay.md §6) living in `group_settings` under +the `group_id=""` sentinel (docs/mediacenter.md §5.5): the credential/budget +is one operator's, and a thumbnail or cover image is the same bytes +regardless of which group happens to share the file. The `file_mbid`/ +`mbid_meta` tables below are the Music app's equivalent of `file_tmdb`/ +`tmdb_meta`, sharing the same `thumbs` table for cover art (a MusicBrainz +release's cover is cached under a synthetic `musicbrainz:{mbid}` file_id, +the same trick `_fetch_and_cache_poster` uses for a TMDB poster_path). Disposable and rebuildable, like the rest of the file index (§1, §2) — never a second identity for a file. Every row here is keyed off a value the node @@ -48,15 +54,28 @@ CREATE TABLE IF NOT EXISTS season_meta ( fetched_at REAL NOT NULL, PRIMARY KEY (tmdb_id, season) ); +CREATE TABLE IF NOT EXISTS file_mbid ( + file_id TEXT PRIMARY KEY, + mbid TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS mbid_meta ( + mbid TEXT PRIMARY KEY, + json TEXT NOT NULL, + fetched_at REAL NOT NULL +); """ # TMDB overviews/ratings do drift; a file's own resolved tmdb_id does not # need re-checking on this schedule, only the metadata blob (§5.4, V3). TMDB_META_TTL_SECS = 30 * 86400 +# Same default as TMDB (docs/musicbay.md §6) — MusicBrainz release data is +# not expected to drift faster; revisit if that proves wrong in practice. +MUSICBRAINZ_META_TTL_SECS = 30 * 86400 + class MediaCache: - """Async SQLite cache for TMDB lookups and generated thumbnails.""" + """Async SQLite cache for TMDB/MusicBrainz lookups and generated thumbnails/cover art.""" def __init__(self, db_path: Path): self._db_path = db_path @@ -144,6 +163,44 @@ class MediaCache: ) await self._db.commit() + # ── file -> musicbrainz release id mapping (Music app) ─────────────────── + + async def get_file_mbid(self, file_id: str) -> str | None: + async with self._db.execute( + "SELECT mbid FROM file_mbid WHERE file_id = ?", (file_id,), + ) as cur: + row = await cur.fetchone() + return row[0] if row else None + + async def set_file_mbid(self, file_id: str, mbid: str) -> None: + await self._db.execute( + "INSERT OR REPLACE INTO file_mbid (file_id, mbid) VALUES (?, ?)", + (file_id, mbid), + ) + await self._db.commit() + + # ── musicbrainz release id -> metadata json (Music app) ─────────────────── + + async def get_mbid_meta(self, mbid: str) -> dict | None: + """Returns None on a miss or on an entry older than MUSICBRAINZ_META_TTL_SECS.""" + async with self._db.execute( + "SELECT json, fetched_at FROM mbid_meta WHERE mbid = ?", (mbid,), + ) as cur: + row = await cur.fetchone() + if not row: + return None + raw_json, fetched_at = row + if time.time() - fetched_at > MUSICBRAINZ_META_TTL_SECS: + return None + return json.loads(raw_json) + + async def set_mbid_meta(self, mbid: str, meta: dict) -> None: + await self._db.execute( + "INSERT OR REPLACE INTO mbid_meta (mbid, json, fetched_at) VALUES (?, ?, ?)", + (mbid, json.dumps(meta), time.time()), + ) + await self._db.commit() + # ── thumbnails ──────────────────────────────────────────────────────────── async def get_thumb(self, thumb_hash: str) -> bytes | None: @@ -179,10 +236,12 @@ class MediaCache: async def prune_file(self, file_id: str) -> None: """ Called when a file leaves the index (deletion, unshared root). Removes - its thumbnail and its file->tmdb mapping. `tmdb_meta` rows are left - alone — they're keyed by tmdb_id, not file_id, and other files (other - episodes of the same show) may still reference the same entry. + its thumbnail and its file->tmdb/file->mbid mappings. `tmdb_meta`/ + `mbid_meta` rows are left alone — they're keyed by tmdb_id/mbid, not + file_id, and other files (other episodes of the same show, other + tracks of the same release) may still reference the same entry. """ await self._db.execute("DELETE FROM thumbs WHERE file_id = ?", (file_id,)) await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,)) + await self._db.execute("DELETE FROM file_mbid WHERE file_id = ?", (file_id,)) await self._db.commit() |