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/transport | |
| 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/transport')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 218 |
1 files changed, 212 insertions, 6 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index c35e3ea..5da1b7e 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -69,6 +69,8 @@ from meshbay_common.adminop import ( OP_TMDB_ENABLED, OP_VIDEO_ROOT, OP_TMDB_OVERRIDE, + OP_MUSICBRAINZ_CONFIG, + OP_MUSICBRAINZ_ENABLED, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_GROUP_ATTACH, @@ -391,6 +393,12 @@ class WebRTCPeerSession: self._spawn(self._do_tmdb_search_request(msg)) elif mtype == MNP.TMDB_OVERRIDE: self._do_tmdb_override(msg) + elif mtype == MNP.MUSICBRAINZ_CONFIG: + self._do_musicbrainz_config(msg) + elif mtype == MNP.MUSICBRAINZ_ENABLED: + self._do_musicbrainz_enabled(msg) + elif mtype == MNP.MUSIC_META_REQ: + self._spawn(self._do_music_meta_request(msg)) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -656,6 +664,12 @@ class WebRTCPeerSession: self._ctx.get("daemon_state", {}).get("tmdb_token_customized", False)), "tmdb_language": str( self._ctx.get("daemon_state", {}).get("tmdb_language") or ""), + # Music app (docs/musicbay.md §6) — same shape as the TMDB + # fields above. No language field: MusicBrainz search doesn't + # take one the way TMDB does. + "musicbrainz_enabled": bool(self._group_ctx().get("musicbrainz_enabled", True)), + "musicbrainz_contact_configured": bool( + self._ctx.get("daemon_state", {}).get("musicbrainz_contact_configured", False)), # So a client that connects mid-scan shows the indexing state # immediately, instead of waiting for the next periodic # INDEX_PROGRESS push. Never a path or filename — see @@ -1612,13 +1626,14 @@ class WebRTCPeerSession: except Exception: pass - # Every "application" a group can show. Music, Photos join this set (and - # apps.js's registry, client-side) when they land; nothing else about + # Every "application" a group can show. Photos joins this set (and + # apps.js's registry, client-side) when it lands; nothing else about # this handler changes. DEFAULT_APPS (roster.py) deliberately does not - # include "video" — it is the first app with outbound third-party - # network calls (once TMDB is on), so an operator opts a group in - # explicitly rather than getting it for free (docs/mediacenter.md §5.6). - ALLOWED_APPS = frozenset({"chat", "files", "video"}) + # include "video" or "music" — both can make outbound third-party + # network calls (TMDB, MusicBrainz) once enabled, so an operator opts a + # group in explicitly rather than getting it for free + # (docs/mediacenter.md §5.6, docs/musicbay.md §4.4). + ALLOWED_APPS = frozenset({"chat", "files", "video", "music"}) def _do_apps_enabled(self, msg: dict) -> None: """ @@ -1822,6 +1837,97 @@ class WebRTCPeerSession: except Exception: pass + def _do_musicbrainz_config(self, msg: dict) -> None: + """ + Set (or clear) the node-wide MusicBrainz User-Agent contact string + (docs/musicbay.md §3.2). Unlike tmdb_config there is no token field — + MusicBrainz's read endpoints need no credential, only a descriptive + client identity. Signed like tmdb_config: this changes outbound + third-party network traffic the node did not have before the Music + app (§8) — an unsigned change would let any member alter egress the + operator never agreed to. + """ + contact = msg.get("contact") + if contact is not None and not isinstance(contact, str): + self._send({"type": "error", "detail": "Invalid 'contact'"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + # Not a secret (unlike tmdb_config's token) — a contact address is + # meant to be visible to whoever receives it (MusicBrainz), but it is + # still not committed to the audit log's subject line as free text: + # the same "yes/no configured" shape as tmdb_config keeps the audit + # log itself free of a personal address. + subject = f"contact_configured={'yes' if contact else 'no'}" + self._issue_admin_challenge( + OP_MUSICBRAINZ_CONFIG, subject, + payload={"contact": contact}, group_id="") + + async def _admin_exec_musicbrainz_config( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"musicbrainz_config:{pending['subject']}") + return + p = pending.get("payload") or {} + try: + result = await self._run_op(ops.set_musicbrainz_config, p.get("contact")) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("musicbrainz_config", pending["subject"]) + + notice = { + "type": MNP.MUSICBRAINZ_CONFIG_ACK, "v": MNP_VERSION, + "contact_configured": result["contact_configured"], + } + for gctx in self._ctx.get("groups", {}).values(): + for session in list(gctx.get("_peers", {}).values()): + try: + session._send(notice) + except Exception: + pass + + def _do_musicbrainz_enabled(self, msg: dict) -> None: + """ + Whether MusicBrainz lookups run for this group at all. Per-group + from the start (docs/musicbay.md §3.2/§6) — signed like + tmdb_enabled/video_root: it decides whether this group's members' + Music tab ever makes outbound MusicBrainz traffic. + """ + enabled = msg.get("enabled") + if not isinstance(enabled, bool): + self._send({"type": "error", "detail": "Missing or invalid 'enabled'"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_MUSICBRAINZ_ENABLED, str(enabled)) + + async def _admin_exec_musicbrainz_enabled( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + enabled = pending["subject"] == "True" + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"musicbrainz_enabled:{pending['subject']}") + return + try: + await self._run_op(ops.set_musicbrainz_enabled, self._group_id or "", enabled) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("musicbrainz_enabled", pending["subject"]) + + notice = {"type": MNP.MUSICBRAINZ_ENABLED_ACK, "v": MNP_VERSION, "enabled": enabled} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + # Reconcile's backstop and the watchdog debounce (indexer.py # DirectoryIndexer) — how hard the node works on the operator's own # disk, not a member-facing permission. Signed for the same reason as @@ -2427,6 +2533,100 @@ class WebRTCPeerSession: await media_cache.put_thumb(thumb_hash, synthetic_id, content) return thumb_hash + @staticmethod + async def _fetch_and_cache_cover(media_cache, musicbrainz_client, mbid: str | None) -> str | None: + """ + Music app equivalent of `_fetch_and_cache_poster` — a release's + Cover Art Archive image, fetched once per mbid and cached under its + own blake3, addressed the same synthetic-id trick + (`musicbrainz:{mbid}`) so a second track of the same album never + re-downloads it. Most releases have no scan at all; that's a normal + outcome (None), not an error. + """ + if not mbid: + return None + synthetic_id = f"musicbrainz:{mbid}" + cached_hash = await media_cache.get_thumb_hash_by_file_id(synthetic_id) + if cached_hash is not None: + return cached_hash + content = await musicbrainz_client.fetch_cover_art(mbid) + if content is None: + return None + thumb_hash = blake3.blake3(content).hexdigest() + await media_cache.put_thumb(thumb_hash, synthetic_id, content) + return thumb_hash + + async def _do_music_meta_request(self, msg: dict) -> None: + """ + docs/musicbay.md §4.3: MusicBrainz metadata for one path, resolved + from the group's index. Album-level (release), the direct analogue + of Videos' show-level TMDB caching: one search per (artist, album) + pair serves cover art and canonical naming to every track of the + same release, keyed off the `artist`/`album` fields enrich_audio.py + already populated at index time (from tags, or the filename-parse + fallback) — never re-parsed here. + """ + path = msg.get("path") + log.debug("music_meta_req path=%r", path) + if not isinstance(path, str) or not path: + self._send({"type": "error", "detail": "Missing path"}) + return + ctx = self._group_ctx() + entry = ctx["index"].get_entry_by_path(path) + if not entry: + self._send({"type": "error", "detail": "File not found"}) + return + + media_cache = self._ctx.get("media_cache") + musicbrainz_client = self._ctx.get("musicbrainz_client") + # Same silent, no-error degradation as _do_media_meta_request: no + # client configured, MusicBrainz off for this group, or nothing to + # search with (no artist/album — an untagged, unparseable file) all + # look identical to the caller, which already has to handle "no + # match" as the ordinary case in flat mode. + if (media_cache is None or musicbrainz_client is None + or not ctx.get("musicbrainz_enabled", True) + or not entry.artist or not entry.album): + self._send({"type": MNP.MUSIC_META_RESP, "v": MNP_VERSION, + "path": path, "confidence": 0}) + return + + mbid = await media_cache.get_file_mbid(entry.id) + meta = await media_cache.get_mbid_meta(mbid) if mbid else None + + if meta is None: + result, ratio = await musicbrainz_client.search_release(entry.artist, entry.album) + if result is None or ratio < 0.6: + self._send({"type": MNP.MUSIC_META_RESP, "v": MNP_VERSION, + "path": path, "confidence": 0}) + return + mbid = result.get("id") + artist_credit = result.get("artist-credit") or [] + meta = { + "artist": artist_credit[0].get("name") if artist_credit else entry.artist, + "album": result.get("title"), + "release_date": result.get("date"), + "confidence": ratio, + } + await media_cache.set_file_mbid(entry.id, mbid) + await media_cache.set_mbid_meta(mbid, meta) + + cover_thumb_hash = await self._fetch_and_cache_cover( + media_cache, musicbrainz_client, mbid) + log.debug("music_meta_req path=%r: replying mbid=%s cover=%s", + path, mbid, cover_thumb_hash) + + self._send({ + "type": MNP.MUSIC_META_RESP, "v": MNP_VERSION, "path": path, + "mbid": mbid, + "artist": meta.get("artist"), + "album": meta.get("album"), + "title": entry.display_title, + "release_date": meta.get("release_date"), + "cover_thumb_hash": cover_thumb_hash, + "confidence": meta.get("confidence", 1.0), + }) + async def _do_media_meta_request(self, msg: dict) -> None: """ docs/mediacenter.md §5.4: TMDB metadata for one path, resolved from @@ -3264,6 +3464,12 @@ class WebRTCPeerSession: elif pending["op"] == OP_TMDB_OVERRIDE: self._spawn( self._admin_exec_tmdb_override(pending, transcript, sig_bytes)) + elif pending["op"] == OP_MUSICBRAINZ_CONFIG: + self._spawn( + self._admin_exec_musicbrainz_config(pending, transcript, sig_bytes)) + elif pending["op"] == OP_MUSICBRAINZ_ENABLED: + self._spawn( + self._admin_exec_musicbrainz_enabled(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_ADD: self._spawn( self._admin_exec_root_add(pending, transcript, sig_bytes)) |