diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 15:57:41 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 15:57:41 +0200 |
| commit | c5585beab3d6adefaa2ef9444946dd3816960a7c (patch) | |
| tree | a321e540c2db0458716d526e4a045fca123937b2 /packages/meshbay-node/src/meshbay_node/transport | |
| parent | 317f09328ed8bf20148b707470c9b0fe82e59575 (diff) | |
| download | meshbay-c5585beab3d6adefaa2ef9444946dd3816960a7c.tar.gz | |
fix(node,hub): HEVC transcode fallback, live-add progress, per-group TMDB toggle
Three bugs found live testing the Videos app against a real HEVC/EAC3 show,
plus a design change requested afterward:
- Streaming always did "-c:v copy", which faithfully reports a source's real
hev1 codec string but is unplayable in a browser with no HEVC decoder
(most Chrome/Linux builds). The node now transcodes to H264 whenever the
probed codec is browser-incompatible (media_probe.py's new
BROWSER_INCOMPATIBLE_VIDEO_CODECS), with a `transcode_incompatible_video`
node.toml opt-out for operators who know their viewers already decode it.
- Dropping a whole season into an already-watched folder gave no scanning
indicator and no progress bar: IndexProgress was only ever updated by the
two bulk scan paths, never by the real-time per-file watchdog path
(_schedule_update/_debounce/_update_entry). That path now accounts a
"burst" the same way, without double-counting a file rewritten mid-debounce.
- A stray literal "0" rendered in the video detail modal when there was no
TMDB match (`meta.confidence` is 0, and `0 && x` renders "0" in JSX/htm,
not nothing) — `confident` is now a real boolean.
- Whether TMDB is used at all moves from a node-wide setting to per-group
(OP_TMDB_ENABLED/tmdb_enabled/tmdb_enabled_ack, scoped like OP_VIDEO_ROOT):
an operator running a real media-library group alongside test/demo groups
on one node wants outbound TMDB traffic for the one that needs it, not all
of them. The custom API token and query language stay node-wide, one
shared credential/cache (tmdb_config/OP_TMDB_CONFIG, unchanged reasoning).
MNP_VERSION 0.6 -> 0.7, additive.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 155 |
1 files changed, 121 insertions, 34 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 f4d1dee..c35e3ea 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -66,6 +66,7 @@ from meshbay_common.adminop import ( OP_APPS_ENABLED, OP_SET_SCAN_SETTINGS, OP_TMDB_CONFIG, + OP_TMDB_ENABLED, OP_VIDEO_ROOT, OP_TMDB_OVERRIDE, OP_ROOT_ADD, @@ -96,7 +97,10 @@ from meshbay_node import ops # this module still refers to it as `_probe_video`. The implementation lives # in media_probe.py so the indexer package (imported just above) can call it # too, for index-time enrichment, without a circular import. -from meshbay_node.media_probe import probe_video as _probe_video +from meshbay_node.media_probe import ( + BROWSER_INCOMPATIBLE_VIDEO_CODECS, + probe_video as _probe_video, +) from meshbay_node.roots import ( RootSet, entry_abs_path, SAFE_UPLOAD_NAME, safe_subdir, _free_name, ) @@ -375,6 +379,8 @@ class WebRTCPeerSession: self._do_set_scan_settings(msg) elif mtype == MNP.TMDB_CONFIG: self._do_tmdb_config(msg) + elif mtype == MNP.TMDB_ENABLED: + self._do_tmdb_enabled(msg) elif mtype == MNP.VIDEO_ROOT: self._do_video_root(msg) elif mtype == MNP.MEDIA_META_REQ: @@ -638,13 +644,14 @@ class WebRTCPeerSession: # Which folder the Videos app treats as its entry point for # this group — "" means the whole group index. "video_root": self._group_ctx().get("video_root") or "", - # Node-wide (not per-group), same "read once, kept current by - # the signed op" shape — surfaced here rather than only via - # tmdb_config_ack so a client that connects after the operator - # already configured it does not have to wait for a live change - # to find out (docs/mediacenter.md §5.5). - "tmdb_enabled": bool( - self._ctx.get("daemon_state", {}).get("tmdb_enabled", True)), + # Per-group (2026-08-24 — used to be node-wide), same "read once, + # kept current in place by the signed op" shape as video_root + # above — surfaced here rather than only via tmdb_enabled_ack so + # a client that connects after the operator already configured + # it does not have to wait for a live change to find out. + "tmdb_enabled": bool(self._group_ctx().get("tmdb_enabled", True)), + # Token/language stay node-wide (one shared credential/cache) — + # via daemon_state, kept current by tmdb_config_ack. "tmdb_token_customized": bool( self._ctx.get("daemon_state", {}).get("tmdb_token_customized", False)), "tmdb_language": str( @@ -1664,19 +1671,15 @@ class WebRTCPeerSession: def _do_tmdb_config(self, msg: dict) -> None: """ - Turn TMDB lookups on/off node-wide, optionally set (or clear) a - custom API token, and optionally set the language TMDB is queried - in (e.g. "fr-FR") — one for the whole node, same reasoning as the - token: one shared cache, not a per-viewer request. Signed like the - rest: this turns on outbound third-party network traffic the node - did not have before the Videos app (docs/mediacenter.md §5.5, §8) - — an unsigned toggle would let any member turn on egress the - operator never agreed to. + Optionally set (or clear) a custom TMDB API token, and optionally set + the language TMDB is queried in (e.g. "fr-FR") — one for the whole + node, since both are one operator's shared credential/cache, not a + per-group concern (see _do_tmdb_enabled for the per-group on/off + switch). Signed like the rest: this changes outbound third-party + network traffic the node did not have before the Videos app + (docs/mediacenter.md §5.5, §8) — an unsigned change would let any + member alter egress the operator never agreed to. """ - enabled = msg.get("enabled") - if not isinstance(enabled, bool): - self._send({"type": "error", "detail": "Missing or invalid 'enabled'"}) - return token = msg.get("token") if token is not None and not isinstance(token, str): self._send({"type": "error", "detail": "Invalid 'token'"}) @@ -1693,11 +1696,10 @@ class WebRTCPeerSession: # in plaintext). The actual token travels only in `payload`, which # is node-side context, never re-sent or re-verified from the wire. # The language is not a secret, so it travels in the subject itself. - subject = (f"enabled={enabled},custom_token={'yes' if token else 'no'}," - f"language={language or 'default'}") + subject = f"custom_token={'yes' if token else 'no'},language={language or 'default'}" self._issue_admin_challenge( OP_TMDB_CONFIG, subject, - payload={"enabled": enabled, "token": token, "language": language}, + payload={"token": token, "language": language}, group_id="") async def _admin_exec_tmdb_config( @@ -1710,17 +1712,18 @@ class WebRTCPeerSession: p = pending.get("payload") or {} try: result = await self._run_op( - ops.set_tmdb_config, p.get("enabled", True), p.get("token"), p.get("language")) + ops.set_tmdb_config, p.get("token"), p.get("language")) except ops.OpError as e: self._send({"type": "error", "detail": e.message}) return self._audit("tmdb_config", pending["subject"]) # Node-wide setting: every connected peer in every group is told, not - # just this group's peers (unlike apps_enabled/member_upload). + # just this group's peers (unlike apps_enabled/member_upload/the + # per-group tmdb_enabled below). notice = { "type": MNP.TMDB_CONFIG_ACK, "v": MNP_VERSION, - "enabled": result["enabled"], "token_customized": result["token_customized"], + "token_customized": result["token_customized"], "language": result["language"], } for gctx in self._ctx.get("groups", {}).values(): @@ -1730,6 +1733,44 @@ class WebRTCPeerSession: except Exception: pass + def _do_tmdb_enabled(self, msg: dict) -> None: + """ + Whether TMDB lookups run for this group at all. Per-group, unlike + tmdb_config's token/language — see ops.set_tmdb_enabled. Signed like + video_root: it decides whether this group's members' Videos tab ever + makes outbound TMDB 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_TMDB_ENABLED, str(enabled)) + + async def _admin_exec_tmdb_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"tmdb_enabled:{pending['subject']}") + return + try: + await self._run_op(ops.set_tmdb_enabled, self._group_id or "", enabled) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("tmdb_enabled", pending["subject"]) + + notice = {"type": MNP.TMDB_ENABLED_ACK, "v": MNP_VERSION, "enabled": enabled} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + def _do_video_root(self, msg: dict) -> None: """ Which folder (possibly a subfolder of a shared root) the Videos app @@ -2405,7 +2446,11 @@ class WebRTCPeerSession: media_cache = self._ctx.get("media_cache") tmdb_client = self._ctx.get("tmdb_client") - if media_cache is None or tmdb_client is None: + # Per-group, not node-wide (docs/mediacenter.md §5.5, 2026-08-24): + # treated exactly like "no client configured" — same silent, no-error + # degradation, since a member's Videos tab already has to handle "no + # TMDB match" as the ordinary case. + if media_cache is None or tmdb_client is None or not ctx.get("tmdb_enabled", True): self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, "path": path, "confidence": 0}) return @@ -2476,7 +2521,10 @@ class WebRTCPeerSession: return media_cache = self._ctx.get("media_cache") tmdb_client = self._ctx.get("tmdb_client") - if media_cache is None or tmdb_client is None: + # Per-group, not node-wide (docs/mediacenter.md §5.5, 2026-08-24) — + # same silent zero-confidence degradation as "no client configured". + if (media_cache is None or tmdb_client is None + or not self._group_ctx().get("tmdb_enabled", True)): self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION, "tmdb_id": tmdb_id, "season": season, "confidence": 0}) return @@ -2524,7 +2572,12 @@ class WebRTCPeerSession: return media_cache = self._ctx.get("media_cache") tmdb_client = self._ctx.get("tmdb_client") - if media_cache is None or tmdb_client is None: + # Per-group, not node-wide (docs/mediacenter.md §5.5, 2026-08-24) — + # same silent empty-results degradation as "no client configured": + # a member with TMDB off for this group sees the same "type it in + # yourself" affordance either way, never an error. + if (media_cache is None or tmdb_client is None + or not self._group_ctx().get("tmdb_enabled", True)): self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION, "query": query, "media_type": media_type, "results": []}) return @@ -3202,6 +3255,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_TMDB_CONFIG: self._spawn( self._admin_exec_tmdb_config(pending, transcript, sig_bytes)) + elif pending["op"] == OP_TMDB_ENABLED: + self._spawn( + self._admin_exec_tmdb_enabled(pending, transcript, sig_bytes)) elif pending["op"] == OP_VIDEO_ROOT: self._spawn( self._admin_exec_video_root(pending, transcript, sig_bytes)) @@ -3479,7 +3535,8 @@ class WebRTCPeerSession: file_hash = bytes.fromhex(entry.id) try: - codec_str, duration, has_audio, _width, _height = await _probe_video(str(file_path)) + codec_str, duration, has_audio, _width, _height, raw_video_codec = \ + await _probe_video(str(file_path)) except Exception as e: self._send({"type": "error", "detail": f"Probe failed: {e}"}) return @@ -3508,16 +3565,42 @@ class WebRTCPeerSession: # what every streaming player does, and why the client is told the # value used rather than left to assume its own. seek_args = ["-ss", f"{start:.3f}"] if start > 0 else [] - # Video is always copied — re-encoding it is the expensive thing this - # pipeline exists to avoid, and H264/HEVC/VP9/AV1 already decode fine - # in-browser. Audio is always transcoded to AAC, never copied — see + # Video is copied whenever the browser can decode it directly — + # re-encoding it is the expensive thing this pipeline exists to avoid, + # and H264/VP9/AV1 already decode fine in-browser. HEVC is the one + # exception (BROWSER_INCOMPATIBLE_VIDEO_CODECS, media_probe.py): found + # live, a real HEVC/EAC3 WEB-DL reported "Codec not supported for + # streaming" from MediaSource.isTypeSupported even though ffprobe/VLC + # play it fine — Chrome has no HEVC decoder on most non-Apple + # platforms. The operator can turn this fallback off (node.toml + # transcode_incompatible_video = false) for a client fleet they know + # already decodes HEVC, since it is real CPU cost, unlike the copy + # path. Audio is always transcoded to AAC, never copied — see # _probe_video for why "copy" there is not an option, not even for a # codec that sounds close enough (plain AC-3 has the same in-browser # decode problem as E-AC-3, just without ffmpeg also refusing to mux # it). Transcoding audio is cheap; it does not change the cost model # the transcode-slot semaphore is sized around. + transcode_video = ( + raw_video_codec in BROWSER_INCOMPATIBLE_VIDEO_CODECS + and self._ctx.get("transcode_incompatible_video", True) + ) map_args = ["-map", "0:v:0"] - codec_args = ["-c:v", "copy"] + if transcode_video: + # -pix_fmt yuv420p: a 10-bit or 4:4:4 HEVC source (common for HDR + # WEB-DLs) fails "-profile:v high" outright otherwise — libx264's + # High profile is 8-bit 4:2:0 only. Downsampling loses nothing a + # browser could show anyway (MSE/HTML5 video has no HDR path). + codec_args = ["-c:v", "libx264", "-pix_fmt", "yuv420p", + "-profile:v", "high", "-level", "4.1", + "-preset", "veryfast", "-crf", "21"] + # Must match "-profile:v high -level 4.1" byte-for-byte (avc1.<profile + # hex><constraint><level hex>) — the client checks this string with + # MediaSource.isTypeSupported before trusting a single byte of the + # stream, so a mismatch here fails exactly the check this exists to pass. + codec_str = "avc1.640029,mp4a.40.2" if has_audio else "avc1.640029" + else: + codec_args = ["-c:v", "copy"] if has_audio: map_args += ["-map", "0:a:0"] # Downmixed to stereo: a WEB-DL's 5.1 track becomes 6-channel AAC @@ -3750,6 +3833,7 @@ class WebRTCTransport: denylist: Any | None = None, stun_servers: list[str] | None = None, max_concurrent_streams: int | None = None, + transcode_incompatible_video: bool = True, ): self._ctx: dict[str, Any] = { "sk_node": sk_node, @@ -3761,6 +3845,9 @@ class WebRTCTransport: # None means "the operator said nothing" — the default applies. It # is read once, when the first stream builds the semaphore. "max_concurrent_streams": max_concurrent_streams, + # Operator opt-out (node.toml) for the HEVC-etc. transcode + # fallback in _stream_video_inner — real CPU cost, unlike copy. + "transcode_incompatible_video": transcode_incompatible_video, } if groups: self._ctx["groups"] = groups |