diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
3 files changed, 118 insertions, 20 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py index 19ab9ce..6eeb1ef 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py @@ -238,11 +238,12 @@ class Enricher: duration = cached_meta["duration"] else: try: - _codec, duration, _has_audio, width, height, _raw = await asyncio.wait_for( + probe = await asyncio.wait_for( probe_video(str(file_path)), timeout=PROBE_TIMEOUT_SECS) + duration = probe.duration fields["duration"] = int(duration) if duration else None - fields["width"] = width - fields["height"] = height + fields["width"] = probe.width + fields["height"] = probe.height except Exception as e: log.warning("Probe failed for %s: %s", file_path, e) await self._media_cache.put_video_meta( 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:<n>` 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, + ) 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 99ba3c9..b39941d 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -5948,11 +5948,14 @@ class WebRTCPeerSession: file_hash = bytes.fromhex(entry.id) try: - codec_str, duration, has_audio, _width, _height, raw_video_codec = \ - await _probe_video(str(file_path)) + probe = await _probe_video(str(file_path)) except Exception as e: self._send({"type": "error", "detail": f"Probe failed: {e}"}) return + codec_str = probe.codec + duration = probe.duration + has_audio = probe.has_audio + raw_video_codec = probe.raw_codec_name # No video stream at all is the only thing this path cannot serve, and # it is the only thing refused here. A source with no MSE codec string @@ -6051,8 +6054,23 @@ class WebRTCPeerSession: codec_str = "avc1.640029,mp4a.40.2" if has_audio else "avc1.640029" else: codec_args = ["-c:v", "copy"] + # Which audio track. A dubbed film carries several and the first one is + # not a neutral default — it is whatever the person who muxed the file + # happened to put first, which across a real library is overwhelmingly + # one language. Out of range falls back to the first rather than + # refusing: the client's list comes from a `stream_init` that may + # predate the file being replaced on disk, and a viewer who asked for + # the second track of a file that now has one wants the film, not an + # error. `stream_init` says which track was actually used, the same way + # it says which `start` was actually used and for the same reason. + try: + audio_track = int(msg.get("audio_track", 0) or 0) + except (TypeError, ValueError): + audio_track = 0 + if not 0 <= audio_track < len(probe.audio_tracks): + audio_track = 0 if has_audio: - map_args += ["-map", "0:a:0"] + map_args += ["-map", f"0:a:{audio_track}"] # Downmixed to stereo: a WEB-DL's 5.1 track becomes 6-channel AAC # with no "-ac", which ffprobe and VLC accept fine but which some # browsers' MSE decoder rejects outright once real fragments are @@ -6081,6 +6099,22 @@ class WebRTCPeerSession: # this is what the client adds back (`SourceBuffer.timestampOffset`) # to put the fragments where they belong on the timeline. "start": start, + # The track list is how a client discovers that this node can + # switch language at all — there is no version check anywhere in + # the player. A node that does not send it gets no selector, and + # the client then never sends `audio_track` to a peer that would + # ignore it and serve the wrong language without saying so. + "audio_tracks": [ + { + "i": tr.ordinal, + "lang": tr.language, + "title": tr.title, + "codec": tr.codec_name, + "ch": tr.channels, + } + for tr in probe.audio_tracks + ], + "audio_track": audio_track if has_audio else None, }) # A client that says nothing gets the old behaviour, which is why this @@ -6097,8 +6131,10 @@ class WebRTCPeerSession: self._stream_started_at = time.monotonic() self._stream_segments = 0 reason = "eof" - log.info("stream: stream_init sent file=%s paced=%s credits=%d start=%.1fs", - file_id[:12], paced, self._stream_credit, start) + log.info("stream: stream_init sent file=%s paced=%s credits=%d start=%.1fs " + "audio=%s/%d", + file_id[:12], paced, self._stream_credit, start, + audio_track if has_audio else "-", len(probe.audio_tracks)) try: while True: if paced and not await self._await_stream_credit(): |