aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/media_probe.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 10:04:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 10:04:46 +0200
commit6af05abf410bbd038ce7fa6915a659defc509071 (patch)
tree09b1c941fa446b077ff51282fa18250998528263 /packages/meshbay-node/src/meshbay_node/media_probe.py
parentc4981454078a59f776d484f0f1828f2fc5eaad09 (diff)
downloadmeshbay-6af05abf410bbd038ce7fa6915a659defc509071.tar.gz
feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)
Implements docs/mediacenter.md: a "Videos" group application built on the existing files index rather than a separate catalogue. On the node side, new indexer enrichment (technical probe, filename/season parsing, thumbnail generation) runs per-file once an operator has chosen a video_root for the group, plus a TMDB client for on-demand poster/metadata lookups (never client-side, thumbnails delivered over the existing chunk path). On the hub side, a new video-app.js renders a lazily-mounted poster grid or a thumbnail-only flat list, with TMDB entirely optional per group. Along the way: the global apps registry now drives Settings' default-tab picker instead of a hardcoded list, and the video_root is configured from group Settings (like uploads) rather than from Files, with the node refusing to run any TMDB/thumbnail work until one is set. Fixes several bugs found via live testing against a real library, notably a race between two effects writing the same "image ready" state that could leave a poster grid spinning forever on a same-tab revisit — see mediacenter.md §5.4 for the full account of each one.
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/media_probe.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_probe.py70
1 files changed, 70 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py
new file mode 100644
index 0000000..8ad883d
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/media_probe.py
@@ -0,0 +1,70 @@
+"""
+ffprobe wrapper shared by stream-time codec detection (transport/webrtc_server.py)
+and index-time technical-field enrichment (indexer/enrich.py).
+
+Split out of webrtc_server.py so the indexer package (which webrtc_server.py
+already imports from) can call it too without a circular import.
+"""
+
+import asyncio
+import json
+
+_H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"}
+
+
+async def probe_video(path: str) -> tuple[str | None, float, bool, int | None, int | None]:
+ """
+ Probe video file with ffprobe, return (MSE codec string, duration,
+ has_audio, width, height).
+
+ 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
+ path always transcodes audio to AAC and never copies it: MSE in every
+ mainstream browser only decodes AAC/Opus, and a source codec outside
+ 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.
+
+ width/height come from the same ffprobe call (one extra `-show_entries`
+ field, no second process spawn) — resolution is deliberately never
+ guessed from the filename (docs/mediacenter.md §3.5).
+ """
+ proc = await asyncio.create_subprocess_exec(
+ "ffprobe", "-v", "error",
+ "-show_entries", "stream=codec_name,profile,level,codec_type,width,height",
+ "-show_entries", "format=duration",
+ "-of", "json", path,
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, _ = await proc.communicate()
+ info = json.loads(stdout)
+ duration = float(info.get("format", {}).get("duration", 0))
+
+ v_codec = ""
+ 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", "")
+ if cn == "h264":
+ p = _H264_PROFILES.get(s.get("profile", "High"), "64")
+ lvl = int(s.get("level", 40))
+ v_codec = f"avc1.{p}00{lvl:02x}"
+ elif cn == "hevc":
+ v_codec = "hev1.1.6.L93.B0"
+ elif cn == "vp9":
+ v_codec = "vp09.00.10.08"
+ elif cn == "av1":
+ v_codec = "av01.0.01M.08"
+ width = s.get("width")
+ height = s.get("height")
+ elif s.get("codec_type") == "audio":
+ has_audio = True
+
+ if not v_codec:
+ return None, duration, has_audio, width, height
+ codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec
+ return codec, duration, has_audio, width, height