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