summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_probe.py85
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py46
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():