From a98445ce246509b0d2600df14907f72b448a6d10 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 17 Sep 2026 10:12:31 +0200 Subject: feat: let the viewer pick the audio track The streaming path mapped 0:a:0 unconditionally, so a dubbed film played in whichever language was muxed first and the others were unreachable. The node now enumerates the tracks in stream_init and honours audio_track in stream_req; switching is the seek path, since one ffmpeg carries one track. MNP 3.2, additive: the player draws its selector from the node's own list and never from a version number, so an older node is never asked for a track it would ignore. MNP_MIN_SUPPORTED does not move. Co-Authored-By: Claude Opus 5 --- .../meshbay-node/src/meshbay_node/media_probe.py | 85 +++++++++++++++++++--- 1 file changed, 73 insertions(+), 12 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/media_probe.py') diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py index ad668fa..7858ebe 100644 --- a/packages/meshbay-node/src/meshbay_node/media_probe.py +++ b/packages/meshbay-node/src/meshbay_node/media_probe.py @@ -8,6 +8,7 @@ already imports from) can call it too without a circular import. import asyncio import json +from dataclasses import dataclass, field _H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"} @@ -21,12 +22,46 @@ _H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"} BROWSER_INCOMPATIBLE_VIDEO_CODECS = frozenset({"hevc"}) -async def probe_video( - path: str, -) -> tuple[str | None, float, bool, int | None, int | None, str | None]: +@dataclass(frozen=True) +class AudioTrack: """ - Probe video file with ffprobe, return (MSE codec string, duration, - has_audio, width, height, raw video codec name). + One selectable audio track. + + **`ordinal` is the position among the audio streams, not the container + stream index**, because that is what `-map 0:a:` takes. A file whose + audio sits at container indices 1, 2 and 3 has ordinals 0, 1 and 2, and + mapping `0:a:1` on the container index would silently serve the third + track — the failure this field's name exists to prevent. + """ + ordinal: int + language: str | None + title: str | None + codec_name: str | None + channels: int | None + + +@dataclass +class VideoProbe: + """ + What one ffprobe call says about a video file. + + A dataclass rather than the tuple this used to return: the tuple had six + positional fields, `has_audio` sat third and `raw_codec_name` sixth, and + adding a seventh for the track list would have made every call site a + counting exercise. + """ + codec: str | None + duration: float + has_audio: bool + width: int | None + height: int | None + raw_codec_name: str | None + audio_tracks: list[AudioTrack] = field(default_factory=list) + + +async def probe_video(path: str) -> VideoProbe: + """ + Probe a video file with ffprobe. 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 @@ -51,6 +86,13 @@ async def probe_video( H264. It answered it by refusing until 2026-09-09, which read to the operator as a broken file rather than as an unwired code path. + **Every audio track is reported, not just the first.** The streaming path + transcodes audio unconditionally, so serving the second track costs exactly + what serving the first costs and the choice is the viewer's to make; a + library of dubbed films is one where the first track is a language half the + group does not want. `has_audio` stays as the single question the muxing + decisions ask, and is now `bool(audio_tracks)`. + 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). @@ -58,7 +100,9 @@ async def probe_video( from meshbay_node.platform import ffprobe_cmd proc = await asyncio.create_subprocess_exec( ffprobe_cmd(), "-v", "error", - "-show_entries", "stream=codec_name,profile,level,codec_type,width,height", + "-show_entries", + "stream=codec_name,profile,level,codec_type,width,height,channels", + "-show_entries", "stream_tags=language,title", "-show_entries", "format=duration", "-of", "json", path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -69,9 +113,9 @@ async def probe_video( v_codec = "" raw_codec_name: str | None = None - has_audio = False width: int | None = None height: int | None = None + audio_tracks: list[AudioTrack] = [] for s in info.get("streams", []): if s.get("codec_type") == "video" and not v_codec: cn = s.get("codec_name", "") @@ -89,9 +133,26 @@ async def probe_video( width = s.get("width") height = s.get("height") elif s.get("codec_type") == "audio": - has_audio = True + tags = s.get("tags") or {} + audio_tracks.append(AudioTrack( + # Counted here, never read from `s["index"]` — see AudioTrack. + ordinal=len(audio_tracks), + language=(tags.get("language") or "").strip() or None, + title=(tags.get("title") or "").strip() or None, + codec_name=s.get("codec_name") or None, + channels=s.get("channels"), + )) - if not v_codec: - 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, raw_codec_name + has_audio = bool(audio_tracks) + codec = None + if v_codec: + codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec + return VideoProbe( + codec=codec, + duration=duration, + has_audio=has_audio, + width=width, + height=height, + raw_codec_name=raw_codec_name, + audio_tracks=audio_tracks, + ) -- cgit v1.2.3