diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/music.py | 207 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 192 |
2 files changed, 209 insertions, 190 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/music.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/music.py new file mode 100644 index 0000000..acd4187 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/music.py @@ -0,0 +1,207 @@ +"""What the node does for the Music app: tags and cover art, and a transcode +for the formats no browser plays.""" + +import logging +from pathlib import Path + +import blake3 +from meshbay_common import MNP_VERSION +from meshbay_common.protocol import MNP + +from meshbay_node.roots import off_disk +from meshbay_node.transport.webrtc.disk import _locate +from meshbay_node.transport.webrtc.media_tools import _transcode_audio_to_aac + +log = logging.getLogger("meshbay_node.transport.webrtc_server") + + +# Extensions no mainstream browser's <audio> element decodes natively, no +# matter how well-tagged (enrich_audio.py's problem) — the Music app's own +# analogue of media_probe's BROWSER_INCOMPATIBLE_VIDEO_CODECS, keyed by extension +# rather than a probed codec name since these two are a red flag on their +# own, not something that varies by how the file happens to be encoded +# inside. +BROWSER_INCOMPATIBLE_AUDIO_EXTS = frozenset({".wma", ".mpc"}) + + +class MusicMixin: + @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_audio_transcode_request(self, msg: dict) -> None: + """ + docs/MESHBAY_DESIGN.md §9.8's one exception to "no node-side transcode pool": + WMA and Musepack tag/cover fine (enrich_audio.py) but decode in no + mainstream browser's <audio> element at all. Transcoded to AAC/M4A + once and cached under its own content hash — same "computed once, + reused forever" shape as `_fetch_and_cache_poster`/`_cover`, + served back to the client through the ordinary file_req/chunk path + (`_try_serve_thumbnail`, generalized to multi-chunk for this) rather + than a new download mechanism. + """ + ctx = self._group_ctx() + file_id = msg.get("file_id", "") + entry = ctx["index"].get_entry(file_id) + if not entry: + self._send({"type": "error", "detail": "File not found"}) + return + + # The gate `BROWSER_INCOMPATIBLE_AUDIO_EXTS` exists for, applied where it + # costs something. Nothing on the node read it: the player asks for these + # two extensions and no others, and `music-player.js` described itself as + # "kept in sync with the node's" constant — so the whole restriction lived + # in the caller, and a member's own message is not the caller. + # + # What that let through: this converts a *whole file* and holds a + # transcode slot shared with video streaming while it runs. Pointed at a + # two-hour film it spends minutes of the operator's CPU and a slot every + # other viewer is queued behind. `AUDIO_TRANSCODE_MAX_BYTES` catches the + # result, after the work; only this catches the work. + if Path(entry.name).suffix.lower() not in BROWSER_INCOMPATIBLE_AUDIO_EXTS: + self._send({ + "type": "error", + "detail": "This file does not need transcoding — play it directly.", + "code": "transcode_not_applicable", + }) + return + file_path, refusal = await off_disk(ctx["roots"], _locate, ctx["roots"], entry) + if refusal is not None: + self._send({"type": "error", "detail": refusal}) + return + + media_cache = self._ctx.get("media_cache") + if media_cache is None: + self._send({"type": "error", "detail": "Transcoding unavailable"}) + return + + synthetic_id = f"audio_transcode:{entry.id}" + cached_hash = await media_cache.get_thumb_hash_by_file_id(synthetic_id) + if cached_hash is not None: + blob = await media_cache.get_thumb(cached_hash) + if blob is not None: + self._send({"type": MNP.AUDIO_TRANSCODE_RESP, "v": MNP_VERSION, + "file_id": file_id, "hash": cached_hash, + "size": len(blob), "mime": "audio/mp4"}) + return + # Cached hash but the blob itself was pruned: fall through and + # transcode again below, same as a cold cache. + + sem = self._transcode_semaphore() + if sem.locked() and sem._value <= 0: + self._send({"type": "error", "detail": "Server busy, retry shortly"}) + return + async with sem: + try: + blob = await _transcode_audio_to_aac(file_path) + except Exception as e: + log.warning("Audio transcode failed for %s: %s", entry.id[:12], e) + self._send({"type": "error", "detail": f"Transcode failed: {e}"}) + return + + transcode_hash = blake3.blake3(blob).hexdigest() + await media_cache.put_thumb(transcode_hash, synthetic_id, blob) + self._audit("audio_transcode", entry.name) + self._send({"type": MNP.AUDIO_TRANSCODE_RESP, "v": MNP_VERSION, + "file_id": file_id, "hash": transcode_hash, + "size": len(blob), "mime": "audio/mp4"}) + + async def _do_music_meta_request(self, msg: dict) -> None: + """ + docs/MESHBAY_DESIGN.md §9.8: MusicBrainz metadata for one track, resolved + from the group's index by its content id. 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. + + Keyed by `file_id` (the entry's own content hash), not `path`: found + live (2026-08-25) — `IndexEntry.path` is the *folder* a file is in + (indexer.py's `_virtual_dir`), so any two tracks in the same folder + (routinely true — an album is one folder, many tracks) shared the + same `.path`, and looking a track up by it silently resolved to + whichever entry happened to be first in the index. Three unrelated + albums showed the same wrong cover before this fix, all sharing one + folder with the track that legitimately matched it. + """ + file_id = msg.get("file_id") + log.debug("music_meta_req file_id=%r", file_id) + if not isinstance(file_id, str) or not file_id: + self._send({"type": "error", "detail": "Missing file_id"}) + return + ctx = self._group_ctx() + entry = ctx["index"].get_entry(file_id) + 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, + "file_id": file_id, "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, + "file_id": file_id, "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 file_id=%r: replying mbid=%s cover=%s", + file_id, mbid, cover_thumb_hash) + + self._send({ + "type": MNP.MUSIC_META_RESP, "v": MNP_VERSION, "file_id": file_id, + "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), + }) 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 53840f6..5c4d76f 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -146,6 +146,7 @@ from meshbay_node.roots import ( ) from meshbay_node.roster import KIND_ACCOUNT, KIND_LINK from meshbay_node.transfers import TransferSlots +from meshbay_node.transport.webrtc.apps.music import MusicMixin from meshbay_node.transport.webrtc.apps.subtitles import SubtitlesMixin from meshbay_node.transport.webrtc.channel import ( _REPLY_TO, @@ -158,7 +159,6 @@ from meshbay_node.transport.webrtc.disk import _locate from meshbay_node.transport.webrtc.limits import CHUNK_SIZE, MAX_MSG from meshbay_node.transport.webrtc.media_tools import ( _seek_lands_at, - _transcode_audio_to_aac, ) from meshbay_node.transport.wire import index_sync_message @@ -319,13 +319,6 @@ UNAUTHENTICATED_SESSION_TIMEOUT = 60 # seconds # so the operator sets `max_concurrent_streams` under [node] in node.toml. This # value applies when they have said nothing. MAX_CONCURRENT_TRANSCODES = 8 -# Extensions no mainstream browser's <audio> element decodes natively, no -# matter how well-tagged (enrich_audio.py's problem) — the Music app's own -# analogue of BROWSER_INCOMPATIBLE_VIDEO_CODECS above, keyed by extension -# rather than a probed codec name since these two are a red flag on their -# own, not something that varies by how the file happens to be encoded -# inside. -BROWSER_INCOMPATIBLE_AUDIO_EXTS = frozenset({".wma", ".mpc"}) # Bundle fetches are served in the pre-proof window (C4). Bounded and audited # until the native client removes remote keypair bundles entirely. MAX_PRE_PROOF_FETCHES = 4 @@ -377,7 +370,7 @@ _WEBRTC_TRACE = os.environ.get("MESHBAY_WEBRTC_TRACE") == "1" _WEBRTC_TRACE_INTERVAL_S = 30.0 -class WebRTCPeerSession(SubtitlesMixin): +class WebRTCPeerSession(MusicMixin, SubtitlesMixin): """One WebRTC peer connection, handling MNP over a DataChannel.""" def __init__(self, pc: RTCPeerConnection, node_ctx: dict, peer_id: str = ""): @@ -3977,187 +3970,6 @@ class WebRTCPeerSession(SubtitlesMixin): 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_audio_transcode_request(self, msg: dict) -> None: - """ - docs/MESHBAY_DESIGN.md §9.8's one exception to "no node-side transcode pool": - WMA and Musepack tag/cover fine (enrich_audio.py) but decode in no - mainstream browser's <audio> element at all. Transcoded to AAC/M4A - once and cached under its own content hash — same "computed once, - reused forever" shape as `_fetch_and_cache_poster`/`_cover` above, - served back to the client through the ordinary file_req/chunk path - (`_try_serve_thumbnail`, generalized to multi-chunk for this) rather - than a new download mechanism. - """ - ctx = self._group_ctx() - file_id = msg.get("file_id", "") - entry = ctx["index"].get_entry(file_id) - if not entry: - self._send({"type": "error", "detail": "File not found"}) - return - - # The gate `BROWSER_INCOMPATIBLE_AUDIO_EXTS` exists for, applied where it - # costs something. Nothing on the node read it: the player asks for these - # two extensions and no others, and `music-player.js` described itself as - # "kept in sync with the node's" constant — so the whole restriction lived - # in the caller, and a member's own message is not the caller. - # - # What that let through: this converts a *whole file* and holds a - # transcode slot shared with video streaming while it runs. Pointed at a - # two-hour film it spends minutes of the operator's CPU and a slot every - # other viewer is queued behind. `AUDIO_TRANSCODE_MAX_BYTES` catches the - # result, after the work; only this catches the work. - if Path(entry.name).suffix.lower() not in BROWSER_INCOMPATIBLE_AUDIO_EXTS: - self._send({ - "type": "error", - "detail": "This file does not need transcoding — play it directly.", - "code": "transcode_not_applicable", - }) - return - file_path, refusal = await off_disk(ctx["roots"], _locate, ctx["roots"], entry) - if refusal is not None: - self._send({"type": "error", "detail": refusal}) - return - - media_cache = self._ctx.get("media_cache") - if media_cache is None: - self._send({"type": "error", "detail": "Transcoding unavailable"}) - return - - synthetic_id = f"audio_transcode:{entry.id}" - cached_hash = await media_cache.get_thumb_hash_by_file_id(synthetic_id) - if cached_hash is not None: - blob = await media_cache.get_thumb(cached_hash) - if blob is not None: - self._send({"type": MNP.AUDIO_TRANSCODE_RESP, "v": MNP_VERSION, - "file_id": file_id, "hash": cached_hash, - "size": len(blob), "mime": "audio/mp4"}) - return - # Cached hash but the blob itself was pruned: fall through and - # transcode again below, same as a cold cache. - - sem = self._transcode_semaphore() - if sem.locked() and sem._value <= 0: - self._send({"type": "error", "detail": "Server busy, retry shortly"}) - return - async with sem: - try: - blob = await _transcode_audio_to_aac(file_path) - except Exception as e: - log.warning("Audio transcode failed for %s: %s", entry.id[:12], e) - self._send({"type": "error", "detail": f"Transcode failed: {e}"}) - return - - transcode_hash = blake3.blake3(blob).hexdigest() - await media_cache.put_thumb(transcode_hash, synthetic_id, blob) - self._audit("audio_transcode", entry.name) - self._send({"type": MNP.AUDIO_TRANSCODE_RESP, "v": MNP_VERSION, - "file_id": file_id, "hash": transcode_hash, - "size": len(blob), "mime": "audio/mp4"}) - - async def _do_music_meta_request(self, msg: dict) -> None: - """ - docs/MESHBAY_DESIGN.md §9.8: MusicBrainz metadata for one track, resolved - from the group's index by its content id. 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. - - Keyed by `file_id` (the entry's own content hash), not `path`: found - live (2026-08-25) — `IndexEntry.path` is the *folder* a file is in - (indexer.py's `_virtual_dir`), so any two tracks in the same folder - (routinely true — an album is one folder, many tracks) shared the - same `.path`, and looking a track up by it silently resolved to - whichever entry happened to be first in the index. Three unrelated - albums showed the same wrong cover before this fix, all sharing one - folder with the track that legitimately matched it. - """ - file_id = msg.get("file_id") - log.debug("music_meta_req file_id=%r", file_id) - if not isinstance(file_id, str) or not file_id: - self._send({"type": "error", "detail": "Missing file_id"}) - return - ctx = self._group_ctx() - entry = ctx["index"].get_entry(file_id) - 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, - "file_id": file_id, "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, - "file_id": file_id, "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 file_id=%r: replying mbid=%s cover=%s", - file_id, mbid, cover_thumb_hash) - - self._send({ - "type": MNP.MUSIC_META_RESP, "v": MNP_VERSION, "file_id": file_id, - "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/MESHBAY_DESIGN.md §9.7: TMDB metadata for one file, resolved from |