From 1814d59e094e0d981decc43cd6cec106e58e2d63 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 3 Jun 2026 18:55:42 +0200 Subject: [PATCH] fix(stream): clamp out-of-range audio-track index to 0:a:0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web persists the chosen audioIndex globally, so a value from a multi-track file can arrive for a file with fewer tracks. buildHLSFFmpegArgsAt mapped `-map 0:a:N?` verbatim; the optional `?` then matched nothing and the HLS output had NO audio stream (video-only — 2026-06-03, Wistoria S02E08 had one audio track but the session carried audioIndex=2). Clamp an out-of-range index to the first track so audio is never silently dropped. Regression test: TestBuildHLSFFmpegArgsAudioClamp. --- internal/engine/hls.go | 11 ++++++++ internal/engine/hls_url_args_test.go | 40 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/internal/engine/hls.go b/internal/engine/hls.go index bb5009b..181973b 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1217,6 +1217,17 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin } } } + // Clamp to an audio track that actually exists. The web persists the chosen + // audioIndex globally, so a value from a multi-track file can arrive for a + // file with fewer tracks; `-map 0:a:N?` would then match nothing and the + // optional `?` silently yields a VIDEO-ONLY stream (no sound — 2026-06-03, + // Wistoria S02E08 had one audio track but the session carried audioIndex=2). + // Fall back to the first track so audio is never silently dropped. + if n := len(probe.AudioTracks); n > 0 && audioIdx >= n { + log.Printf("[hls %s] audioIndex %d out of range (%d audio track(s)) — using 0:a:0", + shortHLSID(cfg.SessionID), audioIdx, n) + audioIdx = 0 + } args = append(args, "-map", fmt.Sprintf("0:a:%d?", audioIdx)) // Video encode. Codec + preset come from the EncoderProfile resolved at diff --git a/internal/engine/hls_url_args_test.go b/internal/engine/hls_url_args_test.go index a36f2d4..b20c18a 100644 --- a/internal/engine/hls_url_args_test.go +++ b/internal/engine/hls_url_args_test.go @@ -188,3 +188,43 @@ func TestBuildHLSFFmpegArgsBurnSubtitle(t *testing.T) { } }) } + +// Audio clamp (2026-06-03 no-sound regression): the web persists audioIndex +// globally, so a stale value from a multi-track file can arrive for a file with +// fewer tracks. buildHLSFFmpegArgsAt must clamp an out-of-range index to 0:a:0 +// rather than emit `-map 0:a:N?` for a track that doesn't exist — the optional +// `?` would otherwise silently drop audio and yield a video-only stream. +func TestBuildHLSFFmpegArgsAudioClamp(t *testing.T) { + cfg := func(audioIdx int) HLSSessionConfig { + return HLSSessionConfig{ + SessionID: "audio", + SourcePath: "/tmp/movie.mkv", + Quality: "1080p", + AudioIndex: audioIdx, + Transcode: TranscodeRuntime{ + FFmpegPath: "/usr/bin/ffmpeg", + FFprobePath: "/usr/bin/ffprobe", + HWAccel: HWAccelNone, + }, + } + } + oneTrack := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100, AudioTracks: []ProbeAudioTrack{{}}} + + t.Run("out-of-range index clamps to 0:a:0", func(t *testing.T) { + got := strings.Join(buildHLSFFmpegArgsAt(cfg(2), oneTrack, "/tmp/d", 0, 0), " ") + if !strings.Contains(got, "-map 0:a:0?") { + t.Errorf("out-of-range audioIndex must clamp to 0:a:0?: %s", got) + } + if strings.Contains(got, "0:a:2?") { + t.Errorf("must not map the non-existent 0:a:2: %s", got) + } + }) + + t.Run("in-range index is preserved", func(t *testing.T) { + twoTracks := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100, AudioTracks: []ProbeAudioTrack{{}, {}}} + got := strings.Join(buildHLSFFmpegArgsAt(cfg(1), twoTracks, "/tmp/d", 0, 0), " ") + if !strings.Contains(got, "-map 0:a:1?") { + t.Errorf("in-range audioIndex 1 must be preserved: %s", got) + } + }) +}