aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-24 01:52:51 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-24 16:45:37 +0200
commit57737c05fbf7c482ba436ca8001e7a4b3097b434 (patch)
tree68401879223e3d1e1ec14f02a2083961e0a37bb4
parent7216c1779a799652a8fb4d9301bbf05952b33661 (diff)
downloadmeshbay-57737c05fbf7c482ba436ca8001e7a4b3097b434.tar.gz
refactor(node): move the Music handlers out of webrtc_server
MusicMixin in transport/webrtc/apps/music.py: tags and cover art, and the audio transcode with the extension list that gates it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js2
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/music.py207
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py192
-rw-r--r--packages/meshbay-node/tests/test_audio_transcode.py5
4 files changed, 213 insertions, 193 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
index 5f2e33c..e8124fa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
@@ -37,7 +37,7 @@ const MIME_BY_EXT = {
};
// The same two formats as the node's BROWSER_INCOMPATIBLE_AUDIO_EXTS
-// (webrtc_server.py), which is the one that decides: the node refuses a
+// (transport/webrtc/apps/music.py), which is the one that decides: the node refuses a
// transcode request for anything else. This is here so the player does not
// ask for one it knows will be refused — not because it enforces the rule.
// It used to be the only thing that did, and a member's own message never
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
diff --git a/packages/meshbay-node/tests/test_audio_transcode.py b/packages/meshbay-node/tests/test_audio_transcode.py
index acb39b7..941ada2 100644
--- a/packages/meshbay-node/tests/test_audio_transcode.py
+++ b/packages/meshbay-node/tests/test_audio_transcode.py
@@ -20,6 +20,7 @@ from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
from meshbay_node.indexer.group_index import GroupIndex
from meshbay_node.media_cache import MediaCache
from meshbay_node.transport import webrtc_server as wrs
+from meshbay_node.transport.webrtc.apps import music
from meshbay_node.transport.webrtc_server import WebRTCPeerSession
from conftest import needs_subprocess, one_root
@@ -123,13 +124,13 @@ async def test_second_request_reuses_the_cached_transcode(tmp_path, media_cache,
session, file_id = _session(tmp_path, clip, gek, media_cache)
calls = {"n": 0}
- real = wrs._transcode_audio_to_aac
+ real = music._transcode_audio_to_aac
async def counting(path):
calls["n"] += 1
return await real(path)
- monkeypatch.setattr(wrs, "_transcode_audio_to_aac", counting)
+ monkeypatch.setattr(music, "_transcode_audio_to_aac", counting)
await session._do_audio_transcode_request({"file_id": file_id})
session.sent.clear()