diff options
Diffstat (limited to 'packages')
16 files changed, 413 insertions, 16 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index 3ed1ed2..ecf51fd 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -31,5 +31,11 @@ __version__ = "0.7.0" # `thumb_hash`/`display_title` rather than declaring new ones. Additive: # an older client simply doesn't render the new fields or send the new # messages. -MNP_VERSION = "0.8" +# 0.9: added `audio_transcode_req`/`_resp` — WMA and Musepack tag/cover +# fine but decode in no mainstream browser's <audio> element at all, so +# the node transcodes to AAC/M4A on request and caches the result the same +# way a TMDB poster or MusicBrainz cover is cached. Additive: an older +# client never sends the request, and simply can't play those two formats, +# same as before this existed. +MNP_VERSION = "0.9" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 3b5d428..8f29c38 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -113,6 +113,12 @@ class MNP: MUSICBRAINZ_ENABLED_ACK = "musicbrainz_enabled_ack" # node → this group: new enabled state MUSIC_META_REQ = "music_meta_req" # client → node: metadata for a path MUSIC_META_RESP = "music_meta_resp" # node → client: metadata (or none) + # A file whose format the browser's own <audio> element cannot decode at + # all (WMA, Musepack) — resolved to a cached, browser-playable AAC/M4A + # copy on request, the same "computed once, reused forever" shape as a + # TMDB poster or a MusicBrainz cover. + AUDIO_TRANSCODE_REQ = "audio_transcode_req" # client → node: transcode this file id + AUDIO_TRANSCODE_RESP = "audio_transcode_resp" # node → client: cache hash/size/mime # Device linking. A new device files a request bound to a code it displays; # an already-pinned device of the same account approves it. Neither the hub # nor the node can produce the countersignature. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 6df5a77..b05c8b1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -190,6 +190,7 @@ export default { }, 'music.err_transport': 'Transport nicht verbunden', 'music.err_playback': 'Wiedergabefehler', + 'music.err_transcode': 'Formatumwandlung fehlgeschlagen', 'music.player_play': 'Abspielen', 'music.player_pause': 'Pause', 'music.player_prev': 'Vorheriger', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 4c0db39..db7d048 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -188,6 +188,7 @@ export default { }, 'music.err_transport': 'Transport not connected', 'music.err_playback': 'Playback error', + 'music.err_transcode': 'Format conversion failed', 'music.player_play': 'Play', 'music.player_pause': 'Pause', 'music.player_prev': 'Previous', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 2d795fc..8f29908 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -188,6 +188,7 @@ export default { }, 'music.err_transport': 'Transporte no conectado', 'music.err_playback': 'Error de reproducción', + 'music.err_transcode': 'Error al convertir el formato', 'music.player_play': 'Reproducir', 'music.player_pause': 'Pausa', 'music.player_prev': 'Anterior', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 0ce4c53..5d83517 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -189,6 +189,7 @@ export default { }, 'music.err_transport': 'Transport non connecté', 'music.err_playback': 'Erreur de lecture', + 'music.err_transcode': 'Échec de la conversion du format', 'music.player_play': 'Lecture', 'music.player_pause': 'Pause', 'music.player_prev': 'Précédent', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 2ddea64..fb88cbe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -189,6 +189,7 @@ export default { }, 'music.err_transport': 'Trasporto non connesso', 'music.err_playback': 'Errore di riproduzione', + 'music.err_transcode': 'Conversione del formato non riuscita', 'music.player_play': 'Riproduci', 'music.player_pause': 'Pausa', 'music.player_prev': 'Precedente', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 82a8fc2..84e9eb5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -186,6 +186,7 @@ export default { }, 'music.err_transport': 'トランスポートが接続されていません', 'music.err_playback': '再生エラー', + 'music.err_transcode': 'フォーマット変換に失敗しました', 'music.player_play': '再生', 'music.player_pause': '一時停止', 'music.player_prev': '前へ', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index be0184a..06097e6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -190,6 +190,7 @@ export default { }, 'music.err_transport': 'Transport niet verbonden', 'music.err_playback': 'Afspeelfout', + 'music.err_transcode': 'Formaatconversie mislukt', 'music.player_play': 'Afspelen', 'music.player_pause': 'Pauzeren', 'music.player_prev': 'Vorige', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 0738cf5..e24fd1e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -199,6 +199,7 @@ export default { }, 'music.err_transport': 'Transport niepołączony', 'music.err_playback': 'Błąd odtwarzania', + 'music.err_transcode': 'Konwersja formatu nie powiodła się', 'music.player_play': 'Odtwórz', 'music.player_pause': 'Pauza', 'music.player_prev': 'Poprzedni', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 1874a5b..1a3069a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -190,6 +190,7 @@ export default { }, 'music.err_transport': 'Transporte não conectado', 'music.err_playback': 'Erro de reprodução', + 'music.err_transcode': 'Falha na conversão de formato', 'music.player_play': 'Reproduzir', 'music.player_pause': 'Pausar', 'music.player_prev': 'Anterior', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 08fd940..236747e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -183,6 +183,7 @@ export default { }, 'music.err_transport': '传输未连接', 'music.err_playback': '播放错误', + 'music.err_transcode': '格式转换失败', 'music.player_play': '播放', 'music.player_pause': '暂停', 'music.player_prev': '上一首', 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 690efc9..b75d73c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -16,11 +16,15 @@ import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; * calls `onPlayQueue(tracks, startIndex)`, threaded down from group-page.js, * to hand this component a new queue. * - * No streaming, no MSE, no node-side transcode pool: a track is a few - * megabytes, so it is downloaded and decrypted once through the same chunk - * pipeline Files already uses (file-utils.js's pipelinedDownload), then - * played from a blob URL — the deliberate simplification recorded in - * musicbay.md §2.2. + * No streaming, no MSE, no node-side transcode pool for the common case: a + * track is a few megabytes, so it is downloaded and decrypted once through + * the same chunk pipeline Files already uses (file-utils.js's + * pipelinedDownload), then played from a blob URL — the deliberate + * simplification recorded in musicbay.md §2.2. WMA and Musepack are the one + * exception (`NEEDS_TRANSCODE_RE` below): neither decodes in a browser's + * <audio> element at all, tagged correctly or not, so those two go through + * `transport.requestAudioTranscode` first — a node-side, cached-after-once + * AAC/M4A conversion — before the same download/blob path runs. */ const MIME_BY_EXT = { @@ -28,6 +32,11 @@ const MIME_BY_EXT = { wav: 'audio/wav', aac: 'audio/aac', m4a: 'audio/mp4', }; +// Kept in sync with the node's BROWSER_INCOMPATIBLE_AUDIO_EXTS +// (webrtc_server.py) — both name the same two formats no mainstream +// browser's <audio> element decodes natively. +const NEEDS_TRANSCODE_RE = /\.(wma|mpc)$/i; + function guessMime(name) { const ext = (name || '').split('.').pop().toLowerCase(); return MIME_BY_EXT[ext] || 'audio/mpeg'; @@ -186,10 +195,30 @@ function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) { if (cached) return cached.url; const transport = transportRef.current; if (!transport || !transport.connected) throw new Error(t('music.err_transport')); - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks); - const blob = new Blob(chunks, { type: guessMime(entry.name) }); + + let downloadId = entry.id; + let downloadSize = entry.size; + let mime = guessMime(entry.name); + if (NEEDS_TRANSCODE_RE.test(entry.name)) { + let info; + try { + info = await transport.requestAudioTranscode(entry.id); + } catch (err) { + throw new Error(t('music.err_transcode')); + } + downloadId = info.hash; + downloadSize = info.size; + mime = info.mime || 'audio/mp4'; + } + + const totalChunks = Math.ceil(downloadSize / CHUNK_SIZE); + const chunks = await pipelinedDownload(transport, gekRef.current, downloadId, totalChunks); + const blob = new Blob(chunks, { type: mime }); const url = URL.createObjectURL(blob); + // Keyed by the track's own id, not `downloadId` — the transcode cache + // hash is an implementation detail of getting there, and a second play + // of the same track must still hit this cache rather than re-requesting + // a transcode the node already ran once. blobCacheRef.current.set(entry.id, { url, order: blobInsertRef.current++ }); evictOldBlobs(); return url; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index e0e4a64..4f8e3b5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -620,6 +620,23 @@ class MeshBayTransport { } /** + * Server-side transcode of a Music-app file the browser's own <audio> + * element cannot decode at all (WMA, Musepack) into AAC/M4A. Returns + * `{ hash, size, mime }` — the *cache* hash to pull through the normal + * file_req/chunk path (fetchChunk/pipelinedDownload), not the file's own + * id, the same indirection already used for a TMDB poster or a + * MusicBrainz cover. Cached node-side after the first call, but ffmpeg + * still has to run at least once and a transcode slot can be busy, so + * this gets a longer timeout than the metadata lookups above. + */ + async requestAudioTranscode(fileId) { + const msg = await this._sendAndWait( + { type: 'audio_transcode_req', v: '0.9', file_id: fileId }, 120000); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** * Set/clear the node-wide MusicBrainz contact string — the User-Agent * identity MusicBrainz's usage policy asks for, not a credential (there * is none, docs/musicbay.md §3.1). Signed like setTmdbConfig: this turns @@ -1386,6 +1403,9 @@ class MeshBayTransport { // search box can have more than one of these in flight at once. : obj.type === 'season_meta_req' ? `season_meta:${obj.tmdb_id}:${obj.season}` : obj.type === 'tmdb_search_req' ? `tmdb_search:${obj.media_type}:${obj.query}` + // Same reordering hazard as media_meta_req: the player prefetches + // the next track while the current one may still be transcoding. + : obj.type === 'audio_transcode_req' ? `audio_transcode:${obj.file_id}` : null, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, @@ -1641,6 +1661,17 @@ class MeshBayTransport { return; } + // Same reasoning as music_meta_resp: keyed, not arrival-order — the + // player can have a transcode of the current track and a prefetch of + // the next one in flight together. + if (msg.type === 'audio_transcode_resp') { + const key = `audio_transcode:${msg.file_id}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + return; + } + // Same reasoning as media_meta_resp: keyed, not arrival-order, and // "nobody's waiting any more" must not fall through either. if (msg.type === 'season_meta_resp') { 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 5da1b7e..1629801 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -29,6 +29,7 @@ import hmac import logging import os import struct +import tempfile import time from pathlib import Path from typing import Any @@ -137,6 +138,18 @@ PRE_HANDSHAKE_MAX_MSG = 64 * 1024 # 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"}) +# A whole audio file is small enough to transcode in one shot rather than +# live-piped like video's fMP4 segments — a few seconds of ffmpeg at most, +# bounded generously so one slow/huge outlier can't pin a transcode slot +# (shared with video, MAX_CONCURRENT_TRANSCODES above) indefinitely. +AUDIO_TRANSCODE_TIMEOUT_SECS = 120 # 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 @@ -399,6 +412,8 @@ class WebRTCPeerSession: self._do_musicbrainz_enabled(msg) elif mtype == MNP.MUSIC_META_REQ: self._spawn(self._do_music_meta_request(msg)) + elif mtype == MNP.AUDIO_TRANSCODE_REQ: + self._spawn(self._do_audio_transcode_request(msg)) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -2439,18 +2454,26 @@ class WebRTCPeerSession: """ docs/mediacenter.md §5.3: a thumbnail is served through the same chunked file_req path as a real file, resolved against the media - cache instead of the index when the id doesn't match a file. Always - a single chunk in practice (a thumbnail-sized JPEG never approaches - CHUNK_SIZE) — a request for any chunk beyond 0 is just a miss. + cache instead of the index when the id doesn't match a file. + Sliced by `chunk_index` like a real file's chunks, not just handed + back whole: a thumbnail/poster/cover never approached CHUNK_SIZE so + this used to be equivalent to "only chunk 0 exists", but an audio + transcode result (docs/musicbay.md, the WMA/Musepack exception) is + cached in the same media_cache blob store and can be several MB — + genuinely multi-chunk, same as a file read straight off disk. """ media_cache = self._ctx.get("media_cache") - if media_cache is None or chunk_index != 0: + if media_cache is None: return None - jpeg = await media_cache.get_thumb(thumb_hash) - if jpeg is None: + blob = await media_cache.get_thumb(thumb_hash) + if blob is None: return None + start = chunk_index * CHUNK_SIZE + if start > len(blob) or (start == len(blob) and chunk_index != 0): + return None + piece = blob[start:start + CHUNK_SIZE] return _encrypt_chunk_bytes( - self._ctx["sk_node"], gek, jpeg, 0, + self._ctx["sk_node"], gek, piece, chunk_index, bytes.fromhex(thumb_hash), thumb_hash, ) @@ -2556,6 +2579,64 @@ class WebRTCPeerSession: await media_cache.put_thumb(thumb_hash, synthetic_id, content) return thumb_hash + async def _do_audio_transcode_request(self, msg: dict) -> None: + """ + docs/musicbay.md'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 + file_path = entry_abs_path(ctx["roots"], entry) + if not file_path.exists(): + self._send({"type": "error", "detail": "File not on disk"}) + 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/musicbay.md §4.3: MusicBrainz metadata for one path, resolved @@ -4018,6 +4099,47 @@ def _read_and_encrypt( return _encrypt_chunk_bytes(sk_node, gek, plaintext, chunk_index, file_hash, file_id) +async def _transcode_audio_to_aac(file_path: Path) -> bytes: + """ + One-shot, whole-file transcode to AAC in an M4A container — no live + piping, no seeking, unlike `_stream_video_inner`'s fMP4 segments: a + WMA/Musepack source here is a few MB at most, so there is nothing to + gain from streaming it and a real cost to the added complexity + (fragmented output needs `-movflags empty_moov` and its own + client-side reassembly). A plain temp file lets ffmpeg write a normal, + fully-seekable M4A container instead. `-vn` drops any attached-picture + "video" stream some taggers embed as cover art — without it, ffmpeg's + mp4 muxer has been seen treating that picture as a video track to + encode, which is not what this is for; cover art still comes from the + ordinary embedded/sibling-file path (enrich_audio.py), never from here. + """ + fd, tmp_name = tempfile.mkstemp(suffix=".m4a") + os.close(fd) + tmp_path = Path(tmp_name) + try: + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-i", str(file_path), + "-vn", "-c:a", "aac", "-ac", "2", "-b:a", "192k", + "-f", "ipod", str(tmp_path), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + try: + _, stderr = await asyncio.wait_for( + proc.communicate(), timeout=AUDIO_TRANSCODE_TIMEOUT_SECS) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise RuntimeError(f"ffmpeg timed out after {AUDIO_TRANSCODE_TIMEOUT_SECS}s") + if proc.returncode != 0: + raise RuntimeError( + f"ffmpeg exited {proc.returncode}: {stderr.decode(errors='replace')[:300]}") + return tmp_path.read_bytes() + finally: + tmp_path.unlink(missing_ok=True) + + class WebRTCTransport: """ Manages WebRTC peer connections for browser clients. diff --git a/packages/meshbay-node/tests/test_audio_transcode.py b/packages/meshbay-node/tests/test_audio_transcode.py new file mode 100644 index 0000000..725839b --- /dev/null +++ b/packages/meshbay-node/tests/test_audio_transcode.py @@ -0,0 +1,193 @@ +""" +Tests for the Music app's one exception to "no node-side transcode pool" +(docs/musicbay.md §2.2): WMA and Musepack tag/cover fine (enrich_audio.py) +but decode in no mainstream browser's <audio> element at all, so +`_do_audio_transcode_request` converts to AAC/M4A on request and caches the +result — served back through the ordinary file_req/chunk path, generalized +in `_try_serve_thumbnail` to handle more than one chunk. +""" + +import shutil +import subprocess +from pathlib import Path + +import blake3 +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.crypto import generate_gek +from meshbay_common.protocol import MNP, IndexEntry +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_server import WebRTCPeerSession + +from conftest import one_root + +_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +def _make_wma_clip(path: Path) -> None: + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-c:a", "wmav2", "-b:a", "64k", str(path)], + check=True, capture_output=True, + ) + + +def _session(tmp_path: Path, file_path: Path, gek: bytes, media_cache: MediaCache): + file_bytes = file_path.read_bytes() + file_id = blake3.blake3(file_bytes).hexdigest() + + sk_node = Ed25519PrivateKey.generate() + index = GroupIndex(group_id="g" * 32, sk_node=sk_node, gek=gek) + index.add_entry(IndexEntry( + id=file_id, name=file_path.name, path=file_path.parent.name, + size=len(file_bytes), type="audio", added_at=0)) + + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "roots": one_root(file_path.parent), + "index": index, + "gek": gek, + "sk_node": sk_node, + "media_cache": media_cache, + "max_concurrent_streams": 4, + } + session._group_id = None + session._user_id = "tester" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session, file_id + + +def _reassemble_file_chunks(sent: list[dict], gek: bytes, hash_hex: str) -> bytes: + file_hash = bytes.fromhex(hash_hex) + chunks = sorted( + (m for m in sent if m.get("type") == "file_chunk" and m.get("file_id") == hash_hex), + key=lambda m: m["chunk_index"]) + out = b"" + for m in chunks: + key = chunk_key_aes(gek, file_hash, m["chunk_index"]) + out += decrypt_chunk_aes(key, m["nonce"], m["ct"]) + return out + + +@pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed") +async def test_wma_transcodes_to_a_browser_playable_aac_file(tmp_path, media_cache): + clip = tmp_path / "clip.wma" + _make_wma_clip(clip) + gek = generate_gek() + session, file_id = _session(tmp_path, clip, gek, media_cache) + + await session._do_audio_transcode_request({"file_id": file_id}) + + resp = next(m for m in session.sent if m.get("type") == MNP.AUDIO_TRANSCODE_RESP) + assert resp["mime"] == "audio/mp4" + assert resp["size"] > 0 + transcode_hash = resp["hash"] + + # Pull it back exactly the way a client would: file_req/chunk, resolved + # against the media cache since this hash is not a real index entry. + await session._do_file_request({"file_id": transcode_hash, "chunk_index": 0}) + out_bytes = _reassemble_file_chunks(session.sent, gek, transcode_hash) + assert len(out_bytes) == resp["size"] + + out_path = tmp_path / "out.m4a" + out_path.write_bytes(out_bytes) + probe = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "stream=codec_name,codec_type", + "-of", "csv=p=0", str(out_path)], + check=True, capture_output=True, text=True) + rows = [line.split(",") for line in probe.stdout.strip().splitlines()] + codecs = {r[1]: r[0] for r in rows} + assert codecs.get("audio") == "aac", f"must be AAC, playable in a browser: {codecs}" + + +@pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed") +async def test_second_request_reuses_the_cached_transcode(tmp_path, media_cache, monkeypatch): + clip = tmp_path / "clip.wma" + _make_wma_clip(clip) + gek = generate_gek() + session, file_id = _session(tmp_path, clip, gek, media_cache) + + calls = {"n": 0} + real = wrs._transcode_audio_to_aac + + async def counting(path): + calls["n"] += 1 + return await real(path) + + monkeypatch.setattr(wrs, "_transcode_audio_to_aac", counting) + + await session._do_audio_transcode_request({"file_id": file_id}) + session.sent.clear() + await session._do_audio_transcode_request({"file_id": file_id}) + + resp = next(m for m in session.sent if m.get("type") == MNP.AUDIO_TRANSCODE_RESP) + assert resp["hash"] + assert calls["n"] == 1, "a second request for the same file must not re-run ffmpeg" + + +async def test_missing_file_id_is_an_error(tmp_path, media_cache): + gek = generate_gek() + sk_node = Ed25519PrivateKey.generate() + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "roots": one_root(tmp_path), "index": GroupIndex(group_id="g" * 32, sk_node=sk_node, gek=gek), + "gek": gek, "sk_node": sk_node, "media_cache": media_cache, + } + session._group_id = None + session.sent = [] + session._send = session.sent.append + + await session._do_audio_transcode_request({"file_id": "nonexistent"}) + + assert session.sent[-1]["type"] == "error" + + +async def test_multi_chunk_cached_blob_reassembles_correctly(tmp_path, media_cache): + """ + `_try_serve_thumbnail` used to assume a cached blob never exceeds one + chunk (true for a thumbnail, not true for a multi-MB audio transcode) — + this exercises the slicing directly, without needing ffmpeg at all: a + blob a little over two chunks, fetched chunk by chunk, must reassemble + to exactly the original bytes. + """ + import os + gek = generate_gek() + sk_node = Ed25519PrivateKey.generate() + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "roots": one_root(tmp_path), "index": GroupIndex(group_id="g" * 32, sk_node=sk_node, gek=gek), + "gek": gek, "sk_node": sk_node, "media_cache": media_cache, + } + session._group_id = None + session.sent = [] + session._send = session.sent.append + + blob = os.urandom(int(wrs.CHUNK_SIZE * 2.3)) + blob_hash = blake3.blake3(blob).hexdigest() + await media_cache.put_thumb(blob_hash, "synthetic:test", blob) + + total_chunks = -(-len(blob) // wrs.CHUNK_SIZE) + for i in range(total_chunks): + await session._do_file_request({"file_id": blob_hash, "chunk_index": i}) + # One past the end must be a clean miss, not a partial/garbage chunk. + await session._do_file_request({"file_id": blob_hash, "chunk_index": total_chunks}) + + chunk_msgs = [m for m in session.sent if m.get("type") == "file_chunk"] + assert len(chunk_msgs) == total_chunks + reassembled = _reassemble_file_chunks(session.sent, gek, blob_hash) + assert reassembled == blob |