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/daemon.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/daemon.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 110 |
1 files changed, 106 insertions, 4 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 7691467..913ad68 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -47,8 +47,10 @@ from meshbay_node.roots import RootSet, RootError, entry_abs_path from meshbay_node.hub_client import HubClient, HubConfig from meshbay_node.indexer import DirectoryIndexer, IndexCache, GroupIndex from meshbay_node.indexer.enrich import Enricher +from meshbay_node.indexer.enrich_audio import AudioEnricher from meshbay_node.media_cache import MediaCache from meshbay_node.tmdb import TmdbClient +from meshbay_node.musicbrainz import MusicBrainzClient from meshbay_node.keystore import create_keystore, load_keystore, load_or_create_keystore from meshbay_node.roster import Roster from meshbay_node.transport import ( @@ -144,11 +146,15 @@ class NodeDaemon: self._media_cache: MediaCache | None = None self._enricher: Enricher | None = None self._tmdb_client: TmdbClient | None = None + self._audio_enricher: AudioEnricher | None = None + self._musicbrainz_client: MusicBrainzClient | None = None # A file id attempted at most once per daemon run, success or # failure — a persistently unprobeable file (corrupt, still being # written) does not get re-queued on every coalesced broadcast. A # restart retries everything, matching the "disposable, rebuildable" # stance the rest of this cache takes (docs/mediacenter.md §1/§2). + # Shared across the video and audio enrichment paths — content- + # addressed ids never collide between the two. self._enriched_attempted: set[str] = set() self._roster: Roster | None = None self._indexers: list[DirectoryIndexer] = [] @@ -340,6 +346,10 @@ class NodeDaemon: # shape as video_root above. "tmdb_enabled": await self._roster.tmdb_enabled( group_cfg.id) if self._roster else True, + # Music app equivalent of tmdb_enabled — per-group from + # the start (docs/musicbay.md §6). + "musicbrainz_enabled": await self._roster.musicbrainz_enabled( + group_cfg.id) if self._roster else True, } if not groups_ctx: @@ -380,6 +390,15 @@ class NodeDaemon: tmdb_token, tmdb_language = await self._roster.tmdb_config() self._state["tmdb_token_customized"] = bool(tmdb_token) self._state["tmdb_language"] = tmdb_language or "" + + # 6c. Music app (docs/musicbay.md) — same media_cache.db, its own + # enricher (mutagen, not ffmpeg) and its own MusicBrainz client. + # No token to read here (§3.1): only a contact string, and unset + # simply means the client makes no calls (musicbrainz.py). + self._audio_enricher = AudioEnricher(self._media_cache) + self._musicbrainz_client = MusicBrainzClient(roster=self._roster) + musicbrainz_contact = await self._roster.musicbrainz_contact() + self._state["musicbrainz_contact_configured"] = bool(musicbrainz_contact) log.info("Media cache opened: %s", media_cache_db) # 5. Denylist @@ -411,6 +430,7 @@ class NodeDaemon: self._webrtc._ctx["bundle_store"] = self._bundle_store self._webrtc._ctx["media_cache"] = self._media_cache self._webrtc._ctx["tmdb_client"] = self._tmdb_client + self._webrtc._ctx["musicbrainz_client"] = self._musicbrainz_client self._webrtc._ctx["sk_x25519_raw"] = sk_x_raw self._webrtc._ctx["pk_x25519_raw"] = pk_x_raw self._webrtc._ctx["pk_x25519_b64"] = keys.pk_x25519_b64 @@ -521,6 +541,7 @@ class NodeDaemon: self._state["hub"] = hub self._state["reload_fn"] = self._reload_config self._state["enrich_video_root_fn"] = self._enrich_video_root_now + self._state["enrich_music_now_fn"] = self._enrich_music_now # Rotating a key has to reach every transport holding a copy of it, # and clearing the denylist has to reach the one the handshake # consults — so both are published rather than reachable only @@ -732,6 +753,9 @@ class NodeDaemon: "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) if self._roster else True), + "musicbrainz_enabled": ( + await self._roster.musicbrainz_enabled(group_cfg.id) + if self._roster else True), "chat_store": store, } groups_ctx[group_cfg.id] = new_ctx @@ -964,6 +988,12 @@ class NodeDaemon: # INDEX_DELTA update (_on_enriched below). new_entries = delta.additions if delta is not None else list(idx.entries) asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries)) + # Music app (docs/musicbay.md §6): same shape, gated on the group's + # enabled_apps rather than a root (Music has no video_root analogue — + # see musicbay.md §2.1's note on why that scoping wasn't carried + # over). Tag/cover extraction is local and cheap either way; the gate + # exists to not do it at all for a group that never turned Music on. + asyncio.ensure_future(self._enrich_new_audio_entries(indexer, new_entries)) # A rename/move changes the very filename (or season folder) that # §3.3/§3.4's title-parse read display_title/season/episode from, @@ -976,11 +1006,15 @@ class NodeDaemon: if delta is not None and delta.updates and previous is not None: asyncio.ensure_future( self._reenrich_renamed_video_entries(indexer, delta.updates, previous)) + asyncio.ensure_future( + self._reenrich_renamed_audio_entries(indexer, delta.updates, previous)) - # Videos app: a file that leaves the index also loses its thumbnail - # and file->tmdb mapping — the "real deletion obligation" docs/ - # mediacenter.md §2/§8 calls out explicitly rather than leaving - # implicit. tmdb_meta rows are left alone (§2: shared across files). + # Videos/Music apps: a file that leaves the index also loses its + # thumbnail/cover and file->tmdb/file->mbid mapping — the "real + # deletion obligation" docs/mediacenter.md §2/§8 calls out + # explicitly rather than leaving implicit (docs/musicbay.md §6 + # follows the same rule). tmdb_meta/mbid_meta rows are left alone + # (§2: shared across files). if delta is not None and delta.deletions and self._media_cache: for file_id in delta.deletions: asyncio.ensure_future(self._media_cache.prune_file(file_id)) @@ -1110,6 +1144,71 @@ class NodeDaemon: self._enriched_attempted.discard(entry.id) await self._enrich_new_video_entries(indexer, updates) + async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None: + """ + Music app (docs/musicbay.md §2.1, §6): fire (never await further) + tag/cover enrichment for unattempted audio entries, gated on the + group having "music" in its enabled_apps — there is no video_root + analogue for Music (musicbay.md §2.1 deliberately didn't add one: + tag reads are free/local, unlike ffprobe+ffmpeg thumbnailing, so the + cost this gate protects against is smaller, and most personal MP3 + libraries want their whole shared tree available rather than one + scoped subfolder). `_enriched_attempted` is shared with the video + path — content-addressed ids never collide across the two. + """ + if not self._audio_enricher or not self._roster: + return + enabled_apps = await self._roster.enabled_apps(indexer.group_id) + if "music" not in enabled_apps: + return + for entry in entries: + if entry.type != "audio" or entry.id in self._enriched_attempted: + continue + file_path = entry_abs_path(indexer.roots, entry) + if not file_path or not file_path.exists(): + continue + self._enriched_attempted.add(entry.id) + + async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None: + await self._on_enriched(_indexer, file_id, fields) + + self._audio_enricher.spawn(entry, file_path, on_done) + + async def _enrich_music_now(self, group_id: str) -> None: + """ + Music app: sweep a group's existing index right after "music" is + added to its enabled_apps (ops.set_enabled_apps) — the ordinary path + above only ever looks at entries new since the last broadcast, so a + library that was already sitting there before Music was turned on + would otherwise never get enriched. Mirrors + `_enrich_video_root_now`, triggered from a different setting because + Music has no root of its own to key off. + """ + indexer = self._state.get("indexers", {}).get(group_id) + if not indexer: + return + await self._enrich_new_audio_entries(indexer, list(indexer.index.entries)) + + async def _reenrich_renamed_audio_entries( + self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex, + ) -> None: + """ + Music app equivalent of `_reenrich_renamed_video_entries` — a rename + can change the filename-parse fallback (title/track_no) even though + embedded tags, when present, are unaffected. Re-running the whole + pass on a rename is redundant work for a tagged file and a real fix + for an untagged one, and renames are rare enough not to need a + cheaper, tags-only special case. + """ + for entry in updates: + if entry.type != "audio": + continue + old = previous.get_entry(entry.id) + if old is None or (old.name == entry.name and old.path == entry.path): + continue + self._enriched_attempted.discard(entry.id) + await self._enrich_new_audio_entries(indexer, updates) + async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None: """ Merge enrichment fields into the live index and re-trigger a @@ -1174,6 +1273,9 @@ class NodeDaemon: if self._tmdb_client: await self._tmdb_client.close() + if self._musicbrainz_client: + await self._musicbrainz_client.close() + if self._media_cache: await self._media_cache.close() |