aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 15:57:41 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 15:57:41 +0200
commitc5585beab3d6adefaa2ef9444946dd3816960a7c (patch)
treea321e540c2db0458716d526e4a045fca123937b2 /packages/meshbay-node/src
parent317f09328ed8bf20148b707470c9b0fe82e59575 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py15
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py25
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py108
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_probe.py30
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py43
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py54
-rw-r--r--packages/meshbay-node/src/meshbay_node/tmdb.py25
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py155
9 files changed, 342 insertions, 115 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index 8372e5c..e7f3116 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -46,6 +46,11 @@ device_request_ttl_minutes = 60
# lower it on a Pi.
max_concurrent_streams = 8
+# HEVC sources have no browser decoder on most platforms, so streaming one is
+# transcoded to H264 rather than the usual free copy — real CPU per viewer.
+# Set to false only if every viewer's client is known to decode HEVC itself.
+transcode_incompatible_video = true
+
# Browser and native clients reach this node over WebRTC DataChannel via hub
# signaling — no inbound port to open. QUIC is the optional direct path.
@@ -120,6 +125,14 @@ class NodeConfig:
# the node answers "server busy" — see MAX_CONCURRENT_TRANSCODES in
# transport/webrtc_server.py for what one costs.
max_concurrent_streams: int = 8
+ # HEVC (and any future codec in media_probe.py's
+ # BROWSER_INCOMPATIBLE_VIDEO_CODECS) has no decoder in most browsers, so
+ # streaming it needs a real re-encode to H264 rather than the usual free
+ # copy. On by default since the alternative is a hard "codec not
+ # supported" error; set to false if this node's viewers are all clients
+ # that already decode the source codec directly, since transcoding costs
+ # real CPU per concurrent viewer, unlike the copy path.
+ transcode_incompatible_video: bool = True
@dataclass
@@ -275,6 +288,8 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg.node.max_concurrent_streams = _positive(
nd.get("max_concurrent_streams", cfg.node.max_concurrent_streams),
cfg.node.max_concurrent_streams, "max_concurrent_streams")
+ cfg.node.transcode_incompatible_video = bool(
+ nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video))
# Multi-group: [[groups]] array
if "groups" in raw:
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index bf6bd64..7691467 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -334,6 +334,12 @@ class NodeDaemon:
# group — "" means the whole group index.
"video_root": await self._roster.video_root(
group_cfg.id) if self._roster else "",
+ # Whether TMDB lookups run for this group at all —
+ # 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.
+ "tmdb_enabled": await self._roster.tmdb_enabled(
+ group_cfg.id) if self._roster else True,
}
if not groups_ctx:
@@ -364,13 +370,14 @@ class NodeDaemon:
await self._media_cache.open()
self._enricher = Enricher(self._media_cache)
self._tmdb_client = TmdbClient(roster=self._roster)
- # Read once at load, like member_upload/enabled_apps — kept
- # current in place by ops.set_tmdb_config (the signed op),
- # exposed to every group's handshake ack via `daemon_state`
- # (already wired to self._webrtc._ctx below) since this is
- # node-wide, not per-group.
- tmdb_enabled, tmdb_token, tmdb_language = await self._roster.tmdb_config()
- self._state["tmdb_enabled"] = tmdb_enabled
+ # Token/language only — read once at load, kept current in place
+ # by ops.set_tmdb_config (the signed op), exposed to every
+ # group's handshake ack via `daemon_state` (already wired to
+ # self._webrtc._ctx below) since these stay node-wide, one
+ # shared credential/cache. Whether TMDB is used at all is now
+ # per-group instead — see each group's own "tmdb_enabled" in
+ # groups_ctx above.
+ tmdb_token, tmdb_language = await self._roster.tmdb_config()
self._state["tmdb_token_customized"] = bool(tmdb_token)
self._state["tmdb_language"] = tmdb_language or ""
log.info("Media cache opened: %s", media_cache_db)
@@ -390,6 +397,7 @@ class NodeDaemon:
groups=groups_ctx,
denylist=denylist,
max_concurrent_streams=self._config.node.max_concurrent_streams,
+ transcode_incompatible_video=self._config.node.transcode_incompatible_video,
)
# No global chat_store here: each group's store lives in
# groups_ctx[gid]["chat_store"] and is resolved per session via
@@ -721,6 +729,9 @@ class NodeDaemon:
"video_root": (
await self._roster.video_root(group_cfg.id)
if self._roster else ""),
+ "tmdb_enabled": (
+ await self._roster.tmdb_enabled(group_cfg.id)
+ if self._roster else True),
"chat_store": store,
}
groups_ctx[group_cfg.id] = new_ctx
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
index 784b2a3..4dddc27 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
@@ -131,7 +131,7 @@ class Enricher:
fields: dict = {}
duration: float | None = None
try:
- _codec, duration, _has_audio, width, height = await asyncio.wait_for(
+ _codec, duration, _has_audio, width, height, _raw = await asyncio.wait_for(
probe_video(str(file_path)), timeout=PROBE_TIMEOUT_SECS)
fields["duration"] = int(duration) if duration else None
fields["width"] = width
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index b479f7e..b643046 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -226,6 +226,18 @@ class DirectoryIndexer:
self._reconciler: asyncio.Task | None = None
self._pending_timers: dict[str, asyncio.TimerHandle] = {}
self.progress = IndexProgress()
+ # Real-time watchdog adds (_debounce/_update_entry below) previously
+ # never touched `progress` at all — a whole season dropped into an
+ # already-watched folder gave the operator no scanning indicator and
+ # no progress bar, files just appeared one at a time with no feedback
+ # (found live). `_burst_inflight` counts files currently scheduled or
+ # being hashed in the current burst; `scanning` only drops back to
+ # False once it reaches zero *and* no more timers are pending — a
+ # debounce timer firing is not the same as the hash it schedules
+ # having finished, and the whole point of a progress indicator is to
+ # stay up for exactly as long as the slow part (hashing) is running.
+ self._burst_inflight = 0
+ self._burst_sizes: dict[str, int] = {}
@property
def index(self) -> GroupIndex:
@@ -637,6 +649,28 @@ class DirectoryIndexer:
if old:
old.cancel()
+ # Progress accounting for the real-time path — see `_burst_inflight`'s
+ # docstring in __init__. Only on this key's *first* appearance in the
+ # current burst: a rapid re-trigger of the same path (several writes
+ # debounced together) cancels the old timer above and must not also
+ # double-count its size, so `_burst_sizes` (keyed the same as
+ # `_pending_timers`) is the source of truth for "already counted",
+ # not "does a timer currently exist for it" — a cancelled timer's
+ # bookkeeping still has to reach the `fire()` that actually runs.
+ if not deleted and key not in self._burst_sizes:
+ try:
+ size = file_path.stat().st_size
+ except OSError:
+ size = 0
+ if not self.progress.scanning:
+ self.progress.scanning = True
+ self.progress.scanned_bytes = 0
+ self.progress.total_bytes = 0
+ self.progress.total_bytes += size
+ self.progress.current_dir = file_path.parent.name
+ self._burst_sizes[key] = size
+ self._burst_inflight += 1
+
def fire() -> None:
self._pending_timers.pop(key, None)
asyncio.ensure_future(self._update_entry(file_path, deleted))
@@ -654,41 +688,55 @@ class DirectoryIndexer:
self._index.remove_entry(entry.id)
async def _update_entry(self, file_path: Path, deleted: bool) -> None:
- root = self._root_for(file_path)
- if root is None:
- return
+ try:
+ root = self._root_for(file_path)
+ if root is None:
+ return
- if deleted and not root.is_live():
- # The volume went away rather than the file. Freeze: mark the root
- # and touch nothing. Every other event for this root will arrive
- # here too and be dropped the same way, which is the intent — one
- # unplugged drive must not empty a library.
- if root.available:
- root.available = False
- self._index.roots = self.roots.describe()
- log.warning("Root %r disappeared — ignoring deletion events and "
- "freezing %d entries", root.name,
- len(self._entries_under(root)))
- self._index.version = int(time.time())
- if self.on_change:
- await self.on_change(self)
- return
+ if deleted and not root.is_live():
+ # The volume went away rather than the file. Freeze: mark the
+ # root and touch nothing. Every other event for this root
+ # will arrive here too and be dropped the same way, which is
+ # the intent — one unplugged drive must not empty a library.
+ if root.available:
+ root.available = False
+ self._index.roots = self.roots.describe()
+ log.warning("Root %r disappeared — ignoring deletion events and "
+ "freezing %d entries", root.name,
+ len(self._entries_under(root)))
+ self._index.version = int(time.time())
+ if self.on_change:
+ await self.on_change(self)
+ return
- if not root.available:
- return
+ if not root.available:
+ return
- self._remove_by_path(root, file_path)
+ self._remove_by_path(root, file_path)
- if not deleted:
- entry = await self._hash_or_cached(root, file_path)
- if entry:
- self._index.add_entry(entry)
- log.debug("Indexed: %s (%s, %d bytes)",
- file_path.name, entry.id[:8], entry.size)
+ if not deleted:
+ entry = await self._hash_or_cached(root, file_path)
+ if entry:
+ self._index.add_entry(entry)
+ log.debug("Indexed: %s (%s, %d bytes)",
+ file_path.name, entry.id[:8], entry.size)
- self._index.version = int(time.time())
- if self.on_change:
- await self.on_change(self)
+ self._index.version = int(time.time())
+ if self.on_change:
+ await self.on_change(self)
+ finally:
+ # Mirror image of the accounting in _debounce, run whichever way
+ # this method exits (including the several early returns above) —
+ # otherwise a frozen/unavailable root's files would leave
+ # `scanning` stuck True forever, the exact bug this is fixing but
+ # in the other direction.
+ size = self._burst_sizes.pop(str(file_path), None)
+ if size is not None:
+ self.progress.scanned_bytes += size
+ self._burst_inflight -= 1
+ if self._burst_inflight <= 0 and not self._pending_timers:
+ self.progress.scanning = False
+ self.progress.current_dir = ""
class _WatchdogHandler(FileSystemEventHandler):
diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py
index 8ad883d..6dd3baa 100644
--- a/packages/meshbay-node/src/meshbay_node/media_probe.py
+++ b/packages/meshbay-node/src/meshbay_node/media_probe.py
@@ -11,11 +11,22 @@ import json
_H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"}
+# Source video codecs whose MSE codec string is real but which no mainstream
+# browser can actually decode via MediaSource on most desktop platforms — HEVC
+# has no royalty-free decoder in Chrome/Firefox on Linux (and is spotty even
+# on platforms with one). Found live: a real HEVC/EAC3 WEB-DL reported
+# "Codec not supported for streaming: hev1.1.6.L93.B0,mp4a.40.2" from
+# MediaSource.isTypeSupported, even though ffprobe/VLC play it fine. VP9/AV1
+# are not in this set — those decode natively in every mainstream browser.
+BROWSER_INCOMPATIBLE_VIDEO_CODECS = frozenset({"hevc"})
-async def probe_video(path: str) -> tuple[str | None, float, bool, int | None, int | None]:
+
+async def probe_video(
+ path: str,
+) -> tuple[str | None, float, bool, int | None, int | None, str | None]:
"""
Probe video file with ffprobe, return (MSE codec string, duration,
- has_audio, width, height).
+ has_audio, width, height, raw video codec name).
The audio half of the codec string is always "mp4a.40.2" (AAC-LC) or
absent — never the source's real audio codec — because the streaming
@@ -24,8 +35,13 @@ async def probe_video(path: str) -> tuple[str | None, float, bool, int | None, i
that (AC-3, E-AC-3, DTS, ...) is at best silently unplayable and at
worst, for E-AC-3 at least, makes ffmpeg itself refuse to write the
fragmented MP4 header ("Cannot write moov atom before EAC3 packets
- parsed" — reproduced against a real 5.1 E-AC-3 WEB-DL). Video stays
- whatever it actually is: it is always copied, never transcoded.
+ parsed" — reproduced against a real 5.1 E-AC-3 WEB-DL). Video is copied
+ whenever the browser can decode it directly; the raw codec name is
+ returned alongside the MSE string so the caller can decide whether this
+ source needs a real re-encode instead (BROWSER_INCOMPATIBLE_VIDEO_CODECS
+ above) — the MSE string alone can't drive that decision, since it still
+ faithfully reports "hev1..." for a source this pipeline cannot actually
+ deliver copied.
width/height come from the same ffprobe call (one extra `-show_entries`
field, no second process spawn) — resolution is deliberately never
@@ -43,12 +59,14 @@ async def probe_video(path: str) -> tuple[str | None, float, bool, int | None, i
duration = float(info.get("format", {}).get("duration", 0))
v_codec = ""
+ raw_codec_name: str | None = None
has_audio = False
width: int | None = None
height: int | None = None
for s in info.get("streams", []):
if s.get("codec_type") == "video" and not v_codec:
cn = s.get("codec_name", "")
+ raw_codec_name = cn or None
if cn == "h264":
p = _H264_PROFILES.get(s.get("profile", "High"), "64")
lvl = int(s.get("level", 40))
@@ -65,6 +83,6 @@ async def probe_video(path: str) -> tuple[str | None, float, bool, int | None, i
has_audio = True
if not v_codec:
- return None, duration, has_audio, width, height
+ return None, duration, has_audio, width, height, raw_codec_name
codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec
- return codec, duration, has_audio, width, height
+ return codec, duration, has_audio, width, height, raw_codec_name
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 6848e3a..a232ba2 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -733,23 +733,23 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict:
# ── TMDB config (Videos app) ─────────────────────────────────────────────────
-async def set_tmdb_config(state: dict, enabled: bool, token: str | None = None,
+async def set_tmdb_config(state: dict, token: str | None = None,
language: str | None = None) -> dict:
"""
- Whether the node calls TMDB at all, whether it uses a custom API token
- instead of the shipped default, and in what language it queries TMDB
- (docs/mediacenter.md §5.5).
+ Whether the node uses a custom API token instead of the shipped default,
+ and in what language it queries TMDB (docs/mediacenter.md §5.5).
Node-wide (roster.py group_settings, group_id="") rather than per-group
- like set_member_upload/set_enabled_apps: TMDB is one operator's budget,
- one credential and one shared cache, not a per-group or per-viewer
- concern. `token=""` explicitly clears a previously-set custom token
- (reverts to the shipped default); `token=None` leaves whatever was
- there unchanged. Same discipline for `language`.
+ like set_member_upload/set_enabled_apps: the token and the shared-cache
+ language are one operator's budget and one credential, not a per-group
+ or per-viewer concern. Whether TMDB is used *at all* is the per-group
+ decision set_tmdb_enabled below makes instead. `token=""` explicitly
+ clears a previously-set custom token (reverts to the shipped default);
+ `token=None` leaves whatever was there unchanged. Same discipline for
+ `language`.
"""
roster = _roster(state)
- await roster.set_tmdb_config(enabled, token, language, set_by=state.get("node_user_id", ""))
- state["tmdb_enabled"] = enabled
+ await roster.set_tmdb_config(token, language, set_by=state.get("node_user_id", ""))
# `token=None` means "leave whatever was there" (§ set_tmdb_config's own
# docstring) — so the customized flag only changes when a value (a real
# token, or "" to clear one) was actually given.
@@ -757,15 +757,30 @@ async def set_tmdb_config(state: dict, enabled: bool, token: str | None = None,
state["tmdb_token_customized"] = bool(token)
if language is not None:
state["tmdb_language"] = language
- log.info("TMDB config: enabled=%s custom_token=%s language=%s",
- enabled, bool(token), language or state.get("tmdb_language", ""))
+ log.info("TMDB config: custom_token=%s language=%s",
+ bool(token), language or state.get("tmdb_language", ""))
return {
- "enabled": enabled,
"token_customized": state.get("tmdb_token_customized", False),
"language": state.get("tmdb_language", ""),
}
+async def set_tmdb_enabled(state: dict, group_id: str, enabled: bool) -> dict:
+ """
+ Whether TMDB lookups run for this group at all (docs/mediacenter.md
+ §5.5) — per-group, unlike set_tmdb_config above: an operator running a
+ real media library alongside test/demo groups on one node wants
+ outbound TMDB traffic (and API quota) spent for the one that needs it,
+ not all of them just because one process serves both.
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ await roster.set_tmdb_enabled(group_id, enabled, set_by=state.get("node_user_id", ""))
+ ctx["tmdb_enabled"] = enabled
+ log.info("TMDB enabled for group %s: %s", group_id[:8], enabled)
+ return {"enabled": enabled, "group_id": group_id}
+
+
async def set_video_root(state: dict, group_id: str, path: str) -> dict:
"""
Which folder (possibly a subfolder of a shared root) is the Videos app's
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 701462f..b4efb7c 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -599,13 +599,20 @@ class Roster:
json.dumps(sorted(apps)), set_by)
return apps
- # TMDB is one operator's budget and credential, not a per-group concern
- # (docs/mediacenter.md §5.5) — stored under the group_id="" sentinel,
- # the same precedent as `roster.get_member("", user_id)` authorizing the
- # operator node-wide (desktop-client-v1.md §6.3). Unset means "on, using
- # the shipped default token" — the same "absent means the old behaviour"
- # discipline member_upload/enabled_apps already follow.
- SETTING_TMDB_ENABLED = "tmdb_enabled"
+ # The TMDB credential and query language are one operator's budget, not a
+ # per-group concern (docs/mediacenter.md §5.5) — stored under the
+ # group_id="" sentinel, the same precedent as `roster.get_member("",
+ # user_id)` authorizing the operator node-wide (desktop-client-v1.md
+ # §6.3). Unset means "the shipped default token, TMDB's own default
+ # language" — the same "absent means the old behaviour" discipline
+ # member_upload/enabled_apps already follow.
+ #
+ # Whether TMDB is used *at all*, though, is per-group (moved off the
+ # node-wide sentinel below, 2026-08-24): an operator running a real media
+ # library alongside test/demo groups wants outbound TMDB traffic for the
+ # one group that needs it, not all of them just because one node process
+ # serves both. See SETTING_TMDB_ENABLED's own per-group methods further
+ # down, next to video_root.
SETTING_TMDB_TOKEN = "tmdb_api_token"
# A TMDB language tag (e.g. "fr-FR") — one for the whole node, same
# reasoning as the token: one shared cache, not a per-viewer request.
@@ -614,18 +621,14 @@ class Roster:
SETTING_TMDB_LANGUAGE = "tmdb_language"
NODE_WIDE_GROUP_ID = ""
- async def tmdb_config(self) -> tuple[bool, str | None, str | None]:
- """Returns (enabled, custom_token_or_None, language_or_None)."""
- enabled = (await self.get_setting(
- self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_ENABLED, "1")) != "0"
+ async def tmdb_config(self) -> tuple[str | None, str | None]:
+ """Returns (custom_token_or_None, language_or_None)."""
token = await self.get_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_TOKEN)
language = await self.get_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_LANGUAGE)
- return enabled, (token or None), (language or None)
+ return (token or None), (language or None)
- async def set_tmdb_config(self, enabled: bool, token: str | None = None,
+ async def set_tmdb_config(self, token: str | None = None,
language: str | None = None, set_by: str = "") -> None:
- await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_ENABLED,
- "1" if enabled else "0", set_by)
if token is not None:
await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_TOKEN,
token, set_by)
@@ -634,8 +637,9 @@ class Roster:
language, set_by)
# Which folder is the Videos app's entry point for this group — per-group
- # (unlike tmdb_config above), since different groups share different
- # trees. Empty/unset means the whole group index, exactly as today.
+ # (unlike the token/language above), since different groups share
+ # different trees. Empty/unset means the whole group index, exactly as
+ # today.
SETTING_VIDEO_ROOT = "video_root"
async def video_root(self, group_id: str) -> str:
@@ -645,6 +649,22 @@ class Roster:
await self.set_setting(group_id, self.SETTING_VIDEO_ROOT, path or "", set_by)
return path or ""
+ # Whether TMDB lookups run for this group at all — per-group, unlike the
+ # token/language above: one node process can share a real media library
+ # group and several test/demo groups, and outbound TMDB traffic (and API
+ # quota) for the demo groups is not something turning it on for the real
+ # one should imply. Unset means on, same "absent means the old
+ # behaviour" discipline as everything else here — a node that predates
+ # this setting keeps working exactly as before for every group.
+ SETTING_TMDB_ENABLED = "tmdb_enabled"
+
+ async def tmdb_enabled(self, group_id: str) -> bool:
+ return (await self.get_setting(group_id, self.SETTING_TMDB_ENABLED, "1")) != "0"
+
+ async def set_tmdb_enabled(self, group_id: str, enabled: bool, set_by: str = "") -> None:
+ await self.set_setting(group_id, self.SETTING_TMDB_ENABLED,
+ "1" if enabled else "0", set_by)
+
# How often the indexer's reconciliation backstop runs, and how long it
# waits after the last change on a file before hashing it. Unset means
# the indexer's own defaults — an existing group's behaviour must not
diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py
index 448a2b9..a530660 100644
--- a/packages/meshbay-node/src/meshbay_node/tmdb.py
+++ b/packages/meshbay-node/src/meshbay_node/tmdb.py
@@ -20,6 +20,12 @@ Results also come back in whatever language the operator configured
node, same reasoning as the token: one shared cache, not a per-viewer
request. Omitted entirely when unset, which lets TMDB fall back to its own
default (English) rather than this client guessing one.
+
+Whether TMDB is used *at all* is a **per-group** decision (roster.py's
+`tmdb_enabled(group_id)`, moved off the node-wide sentinel 2026-08-24) —
+this client has no group in scope, so that check happens once, in
+webrtc_server.py, before any of this client's methods are ever called for a
+given request. This client only resolves the shared credential/language.
"""
import difflib
@@ -80,17 +86,24 @@ class TmdbClient:
await self._client.aclose()
async def _resolve(self) -> tuple[bool, str | None, str | None]:
- """Returns (enabled, token, language). token/language are None when unset."""
+ """
+ Returns (has_token, token, language). token/language are None when
+ unset. Whether TMDB is used *at all* is a per-group decision made by
+ the caller (roster.tmdb_enabled(group_id), checked in
+ webrtc_server.py before any of this client's methods are called) —
+ this client only knows the node-wide credential/language, and has no
+ group to check against.
+ """
if self._roster is not None:
- enabled, custom_token, language = await self._roster.tmdb_config()
+ custom_token, language = await self._roster.tmdb_config()
else:
- enabled, custom_token, language = True, None, None
+ custom_token, language = None, None
token = custom_token or os.environ.get(_DEFAULT_TOKEN_ENV) or None
- return enabled and bool(token), token, language
+ return bool(token), token, language
async def _get(self, path: str, params: dict) -> dict | None:
- enabled, token, language = await self._resolve()
- if not enabled:
+ has_token, token, language = await self._resolve()
+ if not has_token:
return None
if language and "language" not in params:
params = {**params, "language": language}
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