diff options
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)) |