summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py218
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))