From 11a83159be4c52e23099753a8aedd32e9599e10c Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:41:49 -0400 Subject: [PATCH 01/11] fix(scanner): accept corroborated long video durations --- internal/scanner/probe.go | 55 ++++++++- internal/scanner/probe_duration_test.go | 123 ++++++++++++++++++++ internal/scanner/probe_repair_audio_test.go | 38 ++++++ 3 files changed, 213 insertions(+), 3 deletions(-) diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index 5a3db0a8c..93635379f 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -266,10 +266,18 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData { const ( maxReasonableMediaDurationSeconds = 100_000 + // Corroborated metadata and packet-derived durations have stronger evidence + // than a lone container timestamp, so they may use the same bounded ceiling + // as long-form audio. This supports multi-day video without accepting the + // multi-year timelines seen in malformed containers. + maxValidatedMediaDurationSeconds = 1_000_000 // Audio-only files (audiobooks, podcasts) legitimately exceed the video // ceiling, but still need a cap so malformed containers cannot persist // multi-year durations. - maxReasonableAudioDurationSeconds = 1_000_000 + maxReasonableAudioDurationSeconds = maxValidatedMediaDurationSeconds + + longVideoDurationAbsoluteToleranceSeconds = 1 + longVideoDurationRelativeTolerance = 0.001 ) // A video duration is implausible when it is either far too short in absolute @@ -327,6 +335,9 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) { if durationIsReasonable(formatDuration) && !durationLooksImplausible(raw, formatDuration) { return truncatedDuration(formatDuration), true } + if duration, ok := corroboratedLongVideoDuration(raw, formatDuration); ok { + return truncatedDuration(duration), true + } for _, stream := range raw.Streams { if !isMainVideoStream(stream) { @@ -349,6 +360,40 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) { return 0, false } +// corroboratedLongVideoDuration accepts an extended-range video duration only +// when the container and the primary video stream independently report nearly the +// same value. A small absolute/relative tolerance covers container rounding +// and stream-boundary differences without trusting a lone malformed timeline. +func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) (float64, bool) { + if raw == nil || + formatDuration <= maxReasonableMediaDurationSeconds || + !durationIsWithinValidatedLimit(formatDuration) || + durationLooksImplausible(raw, formatDuration) { + return 0, false + } + + for _, stream := range raw.Streams { + if !isMainVideoStream(stream) { + continue + } + streamDuration := parseFloat(stream.Duration) + if !durationIsWithinValidatedLimit(streamDuration) { + return 0, false + } + + tolerance := max( + longVideoDurationAbsoluteToleranceSeconds, + max(formatDuration, streamDuration)*longVideoDurationRelativeTolerance, + ) + if math.Abs(formatDuration-streamDuration) <= tolerance { + return formatDuration, true + } + return 0, false + } + + return 0, false +} + func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool { if raw == nil { return false @@ -372,6 +417,10 @@ func durationIsReasonable(duration float64) bool { return durationIsPositiveFinite(duration) && duration <= maxReasonableMediaDurationSeconds } +func durationIsWithinValidatedLimit(duration float64) bool { + return durationIsPositiveFinite(duration) && duration <= maxValidatedMediaDurationSeconds +} + func durationIsPositiveFinite(duration float64) bool { return duration > 0 && !math.IsNaN(duration) && !math.IsInf(duration, 0) } @@ -461,13 +510,13 @@ func estimateVideoPacketDuration(reader io.Reader, frameRate string) int { best := 0.0 if !math.IsInf(minTimestamp, 1) && !math.IsInf(maxTimestamp, -1) { span := maxTimestamp - minTimestamp - if durationIsReasonable(span) { + if durationIsWithinValidatedLimit(span) { best = span } } if fps := parseFrameRate(frameRate); fps > 0 && packetCount > 0 { frameDuration := float64(packetCount) / fps - if durationIsReasonable(frameDuration) && frameDuration > best { + if durationIsWithinValidatedLimit(frameDuration) && frameDuration > best { best = frameDuration } } diff --git a/internal/scanner/probe_duration_test.go b/internal/scanner/probe_duration_test.go index 92706e3a0..4d5665f83 100644 --- a/internal/scanner/probe_duration_test.go +++ b/internal/scanner/probe_duration_test.go @@ -96,6 +96,109 @@ func TestDurationFromProbeMetadataKeepsLongAudioDurationInSeconds(t *testing.T) } } +func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoDuration(t *testing.T) { + t.Parallel() + + raw := &ffprobeOutput{ + Format: ffprobeFormat{ + Duration: "182930.275000", + Size: "77507139196", + }, + Streams: []ffprobeStream{{ + CodecType: "video", + Duration: "182930.196000", + AvgFrameRate: "24/1", + }}, + } + + got, ok := durationFromProbeMetadata(raw) + if !ok || got != 182930 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok) + } +} + +func TestDurationFromProbeMetadataRejectsUncorroboratedLongVideoDuration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + formatDuration string + streamDuration string + }{ + {name: "missing stream duration", formatDuration: "182930.275000"}, + {name: "disagreeing durations", formatDuration: "182930.275000", streamDuration: "150000.000000"}, + {name: "beyond validated limit", formatDuration: "1000001.000000", streamDuration: "1000001.000000"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + raw := &ffprobeOutput{ + Format: ffprobeFormat{Duration: tc.formatDuration, Size: "77507139196"}, + Streams: []ffprobeStream{{ + CodecType: "video", + Duration: tc.streamDuration, + }}, + } + + got, ok := durationFromProbeMetadata(raw) + if ok || got != 0 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok) + } + }) + } +} + +func TestDurationFromProbeMetadataDoesNotUseSecondaryVideoForCorroboration(t *testing.T) { + t.Parallel() + + raw := &ffprobeOutput{ + Format: ffprobeFormat{Duration: "182930.275000", Size: "77507139196"}, + Streams: []ffprobeStream{ + {CodecType: "video", Duration: "150000.000000"}, + {CodecType: "video", Duration: "182930.196000"}, + }, + } + + got, ok := durationFromProbeMetadata(raw) + if ok || got != 0 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok) + } +} + +func TestProbeFileSkipsPacketScanForCorroboratedLongVideo(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + ffprobePath := filepath.Join(tempDir, "ffprobe") + packetScanMarker := filepath.Join(tempDir, "packet-scan-called") + script := `#!/bin/sh +case " $* " in + *" -show_format "*) + printf '%s\n' '{"format":{"duration":"182930.275000","size":"77507139196"},"streams":[{"codec_type":"video","duration":"182930.196000","avg_frame_rate":"24/1"}]}' + ;; + *) + : > "` + packetScanMarker + `" + exit 1 + ;; +esac +` + if err := os.WriteFile(ffprobePath, []byte(script), 0o755); err != nil { + t.Fatalf("writing fake ffprobe: %v", err) + } + + probe, err := ProbeFile(context.Background(), ffprobePath, "long.mp4") + if err != nil { + t.Fatalf("ProbeFile() returned error: %v", err) + } + if probe.Duration != 182930 { + t.Fatalf("ProbeFile() duration = %d, want 182930", probe.Duration) + } + if _, err := os.Stat(packetScanMarker); !os.IsNotExist(err) { + t.Fatalf("packet fallback ran for corroborated metadata; stat error = %v", err) + } +} + func TestEstimateVideoPacketDurationUsesPacketSpan(t *testing.T) { t.Parallel() @@ -106,6 +209,26 @@ func TestEstimateVideoPacketDurationUsesPacketSpan(t *testing.T) { } } +func TestEstimateVideoPacketDurationKeepsValidatedLongDuration(t *testing.T) { + t.Parallel() + + packets := strings.NewReader("0.000000\n182930.196000\n") + got := estimateVideoPacketDuration(packets, "") + if got != 182930 { + t.Fatalf("estimateVideoPacketDuration() = %d, want 182930", got) + } +} + +func TestEstimateVideoPacketDurationRejectsDurationBeyondValidatedLimit(t *testing.T) { + t.Parallel() + + packets := strings.NewReader("0.000000\n1000001.000000\n") + got := estimateVideoPacketDuration(packets, "") + if got != 0 { + t.Fatalf("estimateVideoPacketDuration() = %d, want 0", got) + } +} + func TestEstimateVideoPacketDurationUsesFrameCountForCollapsedTimestamps(t *testing.T) { t.Parallel() diff --git a/internal/scanner/probe_repair_audio_test.go b/internal/scanner/probe_repair_audio_test.go index 030075310..62142f831 100644 --- a/internal/scanner/probe_repair_audio_test.go +++ b/internal/scanner/probe_repair_audio_test.go @@ -102,6 +102,44 @@ func TestNeedsCriticalProbeRepair_ImplausiblyShortLargeVideoRepairs(t *testing.T } } +func TestNeedsCriticalProbeRepair_LongVideoConverges(t *testing.T) { + now := time.Now().UTC() + f := &models.MediaFile{ + ProbeSource: "local", + ProbeUpdatedAt: &now, + FileSize: 77_507_139_196, + Duration: 182930, + Container: "mp4", + CodecAudio: "aac", + AudioTracks: []models.AudioTrack{{Language: "eng"}}, + CodecVideo: "h264", + Resolution: "1080p", + VideoTracks: []models.VideoTrack{{Codec: "h264", ColorRange: "unknown"}}, + Chapters: []models.MediaChapter{}, + } + + if NeedsCriticalProbeRepair(f) { + t.Fatal("an accepted long video must not need request-time probe repair") + } + + scanFile := &scanStateFile{ + ProbeSource: f.ProbeSource, + ProbeUpdatedAt: f.ProbeUpdatedAt, + FileSize: f.FileSize, + Duration: f.Duration, + Container: f.Container, + CodecVideo: f.CodecVideo, + CodecAudio: f.CodecAudio, + Resolution: f.Resolution, + HasVideoTracks: true, + HasAudioTracks: true, + HasChapters: true, + } + if needsCriticalProbeRepairScanState(scanFile) { + t.Fatal("an accepted long video must not be reprobed on subsequent library scans") + } +} + // A short duration re-derived by the fixed parser (packet scan) is // authoritative: re-flagging it would reprobe genuinely short clips on every // playback decision forever. From 2b4ef4747f7f16f9f58ca292a377192edca55225 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:52:23 -0400 Subject: [PATCH 02/11] fix(scanner): harden long duration fallbacks --- internal/scanner/probe.go | 8 +++--- internal/scanner/probe_duration_test.go | 36 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index 93635379f..28789d795 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -335,9 +335,6 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) { if durationIsReasonable(formatDuration) && !durationLooksImplausible(raw, formatDuration) { return truncatedDuration(formatDuration), true } - if duration, ok := corroboratedLongVideoDuration(raw, formatDuration); ok { - return truncatedDuration(duration), true - } for _, stream := range raw.Streams { if !isMainVideoStream(stream) { @@ -357,6 +354,9 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) { if duration > 0 && !durationLooksImplausible(raw, duration) { return truncatedDuration(duration), true } + if duration, ok := corroboratedLongVideoDuration(raw, formatDuration); ok { + return truncatedDuration(duration), true + } return 0, false } @@ -516,7 +516,7 @@ func estimateVideoPacketDuration(reader io.Reader, frameRate string) int { } if fps := parseFrameRate(frameRate); fps > 0 && packetCount > 0 { frameDuration := float64(packetCount) / fps - if durationIsWithinValidatedLimit(frameDuration) && frameDuration > best { + if durationIsReasonable(frameDuration) && frameDuration > best { best = frameDuration } } diff --git a/internal/scanner/probe_duration_test.go b/internal/scanner/probe_duration_test.go index 4d5665f83..660ee69af 100644 --- a/internal/scanner/probe_duration_test.go +++ b/internal/scanner/probe_duration_test.go @@ -117,6 +117,28 @@ func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoDuration(t *testing. } } +func TestDurationFromProbeMetadataNormalizesOffsetBeforeCorroboratingLongVideo(t *testing.T) { + t.Parallel() + + raw := &ffprobeOutput{ + Format: ffprobeFormat{ + StartTime: "180000.000000", + Duration: "182930.275000", + Size: "77507139196", + }, + Streams: []ffprobeStream{{ + CodecType: "video", + StartTime: "180000.000000", + Duration: "182930.196000", + }}, + } + + got, ok := durationFromProbeMetadata(raw) + if !ok || got != 2930 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 2930, true", got, ok) + } +} + func TestDurationFromProbeMetadataRejectsUncorroboratedLongVideoDuration(t *testing.T) { t.Parallel() @@ -229,6 +251,20 @@ func TestEstimateVideoPacketDurationRejectsDurationBeyondValidatedLimit(t *testi } } +func TestEstimateVideoPacketDurationKeepsOrdinaryCapForFrameRateEstimate(t *testing.T) { + t.Parallel() + + var packets strings.Builder + for range 900 { + packets.WriteString("3.022000\n") + } + + got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "1/1000") + if got != 0 { + t.Fatalf("estimateVideoPacketDuration() = %d, want 0", got) + } +} + func TestEstimateVideoPacketDurationUsesFrameCountForCollapsedTimestamps(t *testing.T) { t.Parallel() From 50ceeb2dd65debdd9ed06dac66484de6cb2c7db0 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:23:19 -0400 Subject: [PATCH 03/11] fix(scanner): address long duration review feedback --- internal/jellycompat/streams.go | 26 +++++-- internal/jellycompat/streams_test.go | 13 ++++ internal/playback/transcode.go | 33 +++++++-- internal/playback/transcode_args_test.go | 18 +++++ internal/playback/transcode_manifest_test.go | 57 +++++++++++++++ internal/scanner/probe.go | 77 +++++++++++++++----- internal/scanner/probe_duration_test.go | 38 ++++++++++ 7 files changed, 232 insertions(+), 30 deletions(-) diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index e7955b4d0..35f3c8283 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -279,7 +279,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re } // Ensure the transcode process is running. - _, err = h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source) + manifest, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source) if err != nil { if errors.Is(err, errTranscode4KDisallowed) { writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server") @@ -299,7 +299,9 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re segDuration := h.compatSegmentDuration() - manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds) + if manifest == nil { + manifest = generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds) + } w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") w.WriteHeader(http.StatusOK) @@ -329,7 +331,7 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque } // Ensure the transcode process is running. - _, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source) + manifest, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source) if err != nil { if errors.Is(err, errTranscode4KDisallowed) { writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server") @@ -349,7 +351,9 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque segDuration := h.compatSegmentDuration() - manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds) + if manifest == nil { + manifest = generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds) + } w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") w.WriteHeader(http.StatusOK) _, _ = w.Write(rewriteManifest(manifest, playSession.RouteItemID, playSession.ID, source.ID)) @@ -1564,10 +1568,11 @@ func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSes return nil, err } - // When duration is known, Jellycompat serves its own synthetic VOD manifest. - // We only need ffmpeg running; waiting for startup segments here adds - // unnecessary latency before the player can request the actual target segment. - if source.Version.Duration > 0 { + // When the duration fits the shared segment-count bound, Jellycompat serves + // its own synthetic VOD manifest. Longer media waits for FFmpeg's bounded + // real playlist so one request cannot allocate hundreds of thousands of + // segment entries. + if shouldGenerateCompatFullManifest(source, h.compatSegmentDuration()) { return nil, nil } @@ -1646,6 +1651,7 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio FFmpegPath: h.FFmpegPath, HWAccel: h.HWAccel, AudioTrackIndex: compatAudioTrackIndexOrDefault(source), + TotalDuration: float64(source.Version.Duration), FastStart: true, } if source.TranscodeAudio { @@ -1689,6 +1695,10 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio return transcodeSession, nil } +func shouldGenerateCompatFullManifest(source PlaybackMediaSource, segmentDuration int) bool { + return playback.CanGenerateSyntheticManifest(float64(source.Version.Duration), segmentDuration) +} + // audioSelectionChanged reports whether an incoming AudioStreamIndex differs // from what the play session already records for the target media source. // Used to short-circuit progress reports that merely echo the current diff --git a/internal/jellycompat/streams_test.go b/internal/jellycompat/streams_test.go index 9eafc44f2..c9bca6403 100644 --- a/internal/jellycompat/streams_test.go +++ b/internal/jellycompat/streams_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/playback" ) @@ -81,6 +82,18 @@ func TestGenerateFullManifest_HLSVersionForResumeStartTag(t *testing.T) { } } +func TestShouldGenerateCompatFullManifestBoundsSegmentCount(t *testing.T) { + short := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 100_000}} + if !shouldGenerateCompatFullManifest(short, 2) { + t.Fatal("historical 50,000-segment compatibility manifest should remain supported") + } + + long := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 1_000_000}} + if shouldGenerateCompatFullManifest(long, 2) { + t.Fatal("long compatibility playback should use FFmpeg's bounded real manifest") + } +} + func TestRewriteManifest_PreservesPlaybackAndMediaSourceIDs(t *testing.T) { manifest := strings.Join([]string{ "#EXTM3U", diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index bcc550557..086b3e3b5 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -152,6 +152,11 @@ const defaultSegmentDuration = 2 // embedded length matches what the node actually produces. const DefaultSegmentDuration = defaultSegmentDuration +// maxSyntheticManifestSegments preserves the historical worst-case playlist +// size (100,000 seconds at two-second segments). Longer media uses FFmpeg's +// real sliding playlist instead of allocating a complete synthetic manifest. +const maxSyntheticManifestSegments = 50_000 + const maxPersistedFFmpegLines = 2000 const maxPersistedFFmpegBytes = 256 * 1024 const maxPersistedFFmpegChars = 2000 @@ -383,7 +388,10 @@ func buildFFmpegArgs(opts TranscodeOpts) []string { "-max_delay", "5000000", "-f", "hls", "-hls_time", fmt.Sprintf("%d", opts.SegmentDuration), - "-hls_list_size", "0", + // Bound real playlists as well as synthetic ones. Segment files remain on + // disk because delete_segments is not enabled, while the manifest itself + // cannot grow without limit during multi-day sessions. + "-hls_list_size", strconv.Itoa(maxSyntheticManifestSegments), "-hls_segment_type", segmentType, // Write segments to temp files first so the player never fetches a // partially-written segment during a quality switch. @@ -1023,12 +1031,14 @@ func (s *TranscodeSession) WaitForManifest(timeout time.Duration) ([]byte, error // Copy-video sessions always expose FFmpeg's real manifest so the playlist // timing matches the variable-length fragments FFmpeg actually writes and the // seekable window reflects what FFmpeg has produced so far. Encoded transcodes -// still use the synthetic full VOD manifest when duration is known because -// forced keyframes make that timeline stable and seek-anywhere friendly. +// use the synthetic full VOD manifest only while its segment count is bounded; +// longer media uses FFmpeg's real sliding playlist. func (s *TranscodeSession) BuildPlaybackManifest(segPrefix, rawQuery string) ([]byte, error) { opts := s.Opts() - if strings.EqualFold(opts.TargetCodecVideo, "copy") || opts.TotalDuration <= 0 { - // Copy-video or unknown-duration sessions must use FFmpeg's real manifest. + if strings.EqualFold(opts.TargetCodecVideo, "copy") || + !CanGenerateSyntheticManifest(opts.TotalDuration, opts.SegmentDuration) { + // Copy-video, unknown-duration, or oversized sessions must use FFmpeg's + // real manifest. manifest, err := s.WaitForManifest(30 * time.Second) if err != nil { return nil, err @@ -1039,6 +1049,19 @@ func (s *TranscodeSession) BuildPlaybackManifest(segPrefix, rawQuery string) ([] return s.GenerateFullManifest(segPrefix, rawQuery), nil } +// CanGenerateSyntheticManifest reports whether a complete VOD playlist fits +// within the shared segment-count bound. Callers outside playback use the same +// decision so native and compatibility manifests cannot drift. +func CanGenerateSyntheticManifest(totalDuration float64, segmentDuration int) bool { + if totalDuration <= 0 || math.IsNaN(totalDuration) || math.IsInf(totalDuration, 0) { + return false + } + if segmentDuration <= 0 { + segmentDuration = defaultSegmentDuration + } + return totalDuration <= float64(segmentDuration)*maxSyntheticManifestSegments +} + func firstNonEmptyManifestLine(manifest []byte) []byte { for line := range bytes.SplitSeq(manifest, []byte("\n")) { trimmed := bytes.TrimSpace(line) diff --git a/internal/playback/transcode_args_test.go b/internal/playback/transcode_args_test.go index 765eab0aa..c9cbe9591 100644 --- a/internal/playback/transcode_args_test.go +++ b/internal/playback/transcode_args_test.go @@ -65,6 +65,24 @@ func TestBuildFFmpegArgs_CPUPreservesSuperfastFastStart(t *testing.T) { } } +func TestBuildFFmpegArgsBoundsHLSManifestSize(t *testing.T) { + args := buildFFmpegArgs(TranscodeOpts{ + InputPath: "/media/long.mkv", + OutputDir: "/tmp/out", + SessionID: "session-long", + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + TotalDuration: 1_000_000, + }) + + joined := strings.Join(args, " ") + want := "-hls_list_size 50000" + if !strings.Contains(joined, want) { + t.Fatalf("FFmpeg args missing %q: %s", want, joined) + } +} + func TestBuildFFmpegArgs_CopyVideoFromStartUsesZeroBasedTimestamps(t *testing.T) { args := buildFFmpegArgs(TranscodeOpts{ InputPath: "/media/movie.mkv", diff --git a/internal/playback/transcode_manifest_test.go b/internal/playback/transcode_manifest_test.go index 21509bd70..6037dff06 100644 --- a/internal/playback/transcode_manifest_test.go +++ b/internal/playback/transcode_manifest_test.go @@ -160,6 +160,63 @@ func TestBuildPlaybackManifest_EncodedTranscodeUsesSyntheticVODManifest(t *testi } } +func TestBuildPlaybackManifest_LongEncodedTranscodeUsesRealManifest(t *testing.T) { + tempDir := t.TempDir() + manifest := strings.Join([]string{ + "#EXTM3U", + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:2", + "#EXT-X-MEDIA-SEQUENCE:0", + "#EXTINF:2.000000,", + "seg_00000.ts", + "#EXTINF:2.000000,", + "seg_00001.ts", + "", + }, "\n") + if err := os.WriteFile(filepath.Join(tempDir, "stream.m3u8"), []byte(manifest), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + session := &TranscodeSession{ + outputDir: tempDir, + opts: TranscodeOpts{ + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + TotalDuration: 1_000_000, + }, + } + + got, err := session.BuildPlaybackManifest("segment/", "token=test") + if err != nil { + t.Fatalf("BuildPlaybackManifest: %v", err) + } + + text := string(got) + if strings.Contains(text, "#EXT-X-PLAYLIST-TYPE:VOD") || + strings.Contains(text, "seg_499999.ts") { + t.Fatalf("long encoded manifest should not synthesize every segment:\n%s", text) + } + for _, want := range []string{ + "#EXT-X-MEDIA-SEQUENCE:0", + "segment/seg_00000.ts?token=test", + "segment/seg_00001.ts?token=test", + } { + if !strings.Contains(text, want) { + t.Fatalf("manifest missing %q:\n%s", want, text) + } + } +} + +func TestCanGenerateSyntheticManifestBoundsSegmentCount(t *testing.T) { + if !CanGenerateSyntheticManifest(100_000, 2) { + t.Fatal("historical 50,000-segment manifest should remain supported") + } + if CanGenerateSyntheticManifest(100_001, 2) { + t.Fatal("manifest above 50,000 segments should use the real playlist") + } +} + func TestBuildPlaybackManifest_UnknownDurationRejectsBrokenManifest(t *testing.T) { tempDir := t.TempDir() manifest := strings.Join([]string{ diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index 28789d795..9e06429a5 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -365,10 +365,7 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) { // same value. A small absolute/relative tolerance covers container rounding // and stream-boundary differences without trusting a lone malformed timeline. func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) (float64, bool) { - if raw == nil || - formatDuration <= maxReasonableMediaDurationSeconds || - !durationIsWithinValidatedLimit(formatDuration) || - durationLooksImplausible(raw, formatDuration) { + if raw == nil { return 0, false } @@ -377,15 +374,25 @@ func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) ( continue } streamDuration := parseFloat(stream.Duration) - if !durationIsWithinValidatedLimit(streamDuration) { - return 0, false - } - tolerance := max( - longVideoDurationAbsoluteToleranceSeconds, - max(formatDuration, streamDuration)*longVideoDurationRelativeTolerance, + // Some MPEG-TS/HLS timelines report duration as an absolute end + // timestamp. Prefer corroborated spans after subtracting each timeline's + // start so a large offset is not persisted as part of the runtime. + normalizedFormatDuration := durationAfterStartWithinValidatedLimit( + formatDuration, + parseFloat(raw.Format.StartTime), + ) + normalizedStreamDuration := durationAfterStartWithinValidatedLimit( + streamDuration, + parseFloat(stream.StartTime), ) - if math.Abs(formatDuration-streamDuration) <= tolerance { + if longVideoDurationsAgree(normalizedFormatDuration, normalizedStreamDuration) && + !durationLooksImplausible(raw, normalizedFormatDuration) { + return normalizedFormatDuration, true + } + + if longVideoDurationsAgree(formatDuration, streamDuration) && + !durationLooksImplausible(raw, formatDuration) { return formatDuration, true } return 0, false @@ -394,6 +401,19 @@ func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) ( return 0, false } +func longVideoDurationsAgree(first, second float64) bool { + if first <= maxReasonableMediaDurationSeconds || + !durationIsWithinValidatedLimit(first) || + !durationIsWithinValidatedLimit(second) { + return false + } + tolerance := max( + longVideoDurationAbsoluteToleranceSeconds, + max(first, second)*longVideoDurationRelativeTolerance, + ) + return math.Abs(first-second) <= tolerance +} + func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool { if raw == nil { return false @@ -413,6 +433,17 @@ func durationAfterStart(end, start float64) float64 { return duration } +func durationAfterStartWithinValidatedLimit(end, start float64) float64 { + if start <= 0 || end <= start { + return 0 + } + duration := end - start + if !durationIsWithinValidatedLimit(duration) { + return 0 + } + return duration +} + func durationIsReasonable(duration float64) bool { return durationIsPositiveFinite(duration) && duration <= maxReasonableMediaDurationSeconds } @@ -507,18 +538,30 @@ func estimateVideoPacketDuration(reader io.Reader, frameRate string) int { maxTimestamp = max(maxTimestamp, timestamp) } - best := 0.0 + packetSpan := 0.0 if !math.IsInf(minTimestamp, 1) && !math.IsInf(maxTimestamp, -1) { span := maxTimestamp - minTimestamp if durationIsWithinValidatedLimit(span) { - best = span + packetSpan = span } } + + frameDuration := 0.0 if fps := parseFrameRate(frameRate); fps > 0 && packetCount > 0 { - frameDuration := float64(packetCount) / fps - if durationIsReasonable(frameDuration) && frameDuration > best { - best = frameDuration - } + frameDuration = float64(packetCount) / fps + } + + best := packetSpan + if packetSpan > maxReasonableMediaDurationSeconds && + durationIsPositiveFinite(frameDuration) && + !longVideoDurationsAgree(packetSpan, frameDuration) { + // A long PTS span is strong evidence only when a usable frame-count + // estimate does not contradict it. This rejects isolated timestamp + // discontinuities while retaining the ordinary frame estimate below. + best = 0 + } + if durationIsReasonable(frameDuration) && frameDuration > best { + best = frameDuration } if best <= 0 { return 0 diff --git a/internal/scanner/probe_duration_test.go b/internal/scanner/probe_duration_test.go index 660ee69af..2bc276241 100644 --- a/internal/scanner/probe_duration_test.go +++ b/internal/scanner/probe_duration_test.go @@ -139,6 +139,28 @@ func TestDurationFromProbeMetadataNormalizesOffsetBeforeCorroboratingLongVideo(t } } +func TestDurationFromProbeMetadataNormalizesCorroboratedLongOffsetSpan(t *testing.T) { + t.Parallel() + + raw := &ffprobeOutput{ + Format: ffprobeFormat{ + StartTime: "180000.000000", + Duration: "350000.275000", + Size: "77507139196", + }, + Streams: []ffprobeStream{{ + CodecType: "video", + StartTime: "180000.000000", + Duration: "350000.196000", + }}, + } + + got, ok := durationFromProbeMetadata(raw) + if !ok || got != 170000 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 170000, true", got, ok) + } +} + func TestDurationFromProbeMetadataRejectsUncorroboratedLongVideoDuration(t *testing.T) { t.Parallel() @@ -265,6 +287,22 @@ func TestEstimateVideoPacketDurationKeepsOrdinaryCapForFrameRateEstimate(t *test } } +func TestEstimateVideoPacketDurationRejectsOutlierSpanWhenFrameCountDisagrees(t *testing.T) { + t.Parallel() + + var packets strings.Builder + packets.WriteString("0.000000\n") + for range 298 { + packets.WriteString("5.000000\n") + } + packets.WriteString("500000.000000\n") + + got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "30/1") + if got != 10 { + t.Fatalf("estimateVideoPacketDuration() = %d, want 10", got) + } +} + func TestEstimateVideoPacketDurationUsesFrameCountForCollapsedTimestamps(t *testing.T) { t.Parallel() From e2aede2d8c9e056352216027bd623f270424db48 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:55:14 -0400 Subject: [PATCH 04/11] fix(scanner): align long duration seek semantics --- internal/api/handlers/playback_v3.go | 6 +++-- internal/api/handlers/playback_v3_test.go | 19 +++++++++++--- internal/scanner/probe.go | 30 ++++++++++++++++++----- internal/scanner/probe_duration_test.go | 22 +++++++++++++++++ 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index 1c8ca19cd..7ae7faef3 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -2021,13 +2021,15 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur seek := alignedSeekSeconds(requested, segmentDuration, videoCodec) startSegment := computeStartSegment(seek, segmentDuration) plan.Timeline.SourceStartSeconds = requested - if strings.EqualFold(videoCodec, "copy") { + usesGrowingManifest := strings.EqualFold(videoCodec, "copy") || + !playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration) + if usesGrowingManifest { plan.Timeline.PlayerStartSeconds = 0 plan.Timeline.StreamOriginSeconds = seek plan.Timeline.TimelineOffsetSeconds = seek windowStart := seek plan.Timeline.SeekWindowStartSeconds = &windowStart - // A copy remux is served from FFmpeg's live, still-growing playlist + // This transport is served from FFmpeg's live, still-growing playlist // (see BuildPlaybackManifest), so the seekable extent is whatever has // been produced so far — a value this plan cannot know and could not // keep current if it did. Publishing the media runtime here instead diff --git a/internal/api/handlers/playback_v3_test.go b/internal/api/handlers/playback_v3_test.go index c81c57619..6f8f81221 100644 --- a/internal/api/handlers/playback_v3_test.go +++ b/internal/api/handlers/playback_v3_test.go @@ -1232,10 +1232,23 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) { encodePlan.Timeline.SeekRestoration != "player_position" { t.Fatalf("encode timeline=%#v seek=%v segment=%d", encodePlan.Timeline, encodeSeek, encodeSegment) } + + longEncodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}} + longEncodeSeek, longEncodeSegment := configureHLSTimelineV3(longEncodePlan, "h264", 2, 1_000_000) + if longEncodeSeek != 16 || longEncodeSegment != 8 || longEncodePlan.Timeline.StreamOriginSeconds != 16 || longEncodePlan.Timeline.TimelineOffsetSeconds != 16 || longEncodePlan.Timeline.PlayerStartSeconds != 0 || longEncodePlan.Timeline.CanSeekAnywhere || + longEncodePlan.Timeline.SeekWindowStartSeconds == nil || *longEncodePlan.Timeline.SeekWindowStartSeconds != 16 || + longEncodePlan.Timeline.SeekWindowEndSeconds != nil || + longEncodePlan.Timeline.SeekRestoration != "source_position" { + t.Fatalf("long encode timeline=%#v seek=%v segment=%d", longEncodePlan.Timeline, longEncodeSeek, longEncodeSegment) + } + unknownDurationPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}} - configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0) - if unknownDurationPlan.Timeline.CanSeekAnywhere { - t.Fatalf("unknown-duration timeline = %#v", unknownDurationPlan.Timeline) + unknownDurationSeek, unknownDurationSegment := configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0) + if unknownDurationSeek != 16 || unknownDurationSegment != 8 || unknownDurationPlan.Timeline.StreamOriginSeconds != 16 || unknownDurationPlan.Timeline.TimelineOffsetSeconds != 16 || unknownDurationPlan.Timeline.PlayerStartSeconds != 0 || unknownDurationPlan.Timeline.CanSeekAnywhere || + unknownDurationPlan.Timeline.SeekWindowStartSeconds == nil || *unknownDurationPlan.Timeline.SeekWindowStartSeconds != 16 || + unknownDurationPlan.Timeline.SeekWindowEndSeconds != nil || + unknownDurationPlan.Timeline.SeekRestoration != "source_position" { + t.Fatalf("unknown-duration timeline=%#v seek=%v segment=%d", unknownDurationPlan.Timeline, unknownDurationSeek, unknownDurationSegment) } } diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index 9e06429a5..bfe11f60d 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -374,24 +374,31 @@ func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) ( continue } streamDuration := parseFloat(stream.Duration) + formatStart := parseFloat(raw.Format.StartTime) + streamStart := parseFloat(stream.StartTime) // Some MPEG-TS/HLS timelines report duration as an absolute end - // timestamp. Prefer corroborated spans after subtracting each timeline's - // start so a large offset is not persisted as part of the runtime. + // timestamp. Normalize only when the starts are material relative to the + // reported ends; ordinary non-zero media starts must not shorten a valid + // corroborated duration. normalizedFormatDuration := durationAfterStartWithinValidatedLimit( formatDuration, - parseFloat(raw.Format.StartTime), + formatStart, ) normalizedStreamDuration := durationAfterStartWithinValidatedLimit( streamDuration, - parseFloat(stream.StartTime), + streamStart, ) - if longVideoDurationsAgree(normalizedFormatDuration, normalizedStreamDuration) && + rawDurationsAgree := longVideoDurationsAgree(formatDuration, streamDuration) + normalizedDurationsAgree := longVideoDurationsAgree(normalizedFormatDuration, normalizedStreamDuration) + startsLookLikeAbsoluteOffsets := durationStartOffsetIsMaterial(formatDuration, formatStart) && + durationStartOffsetIsMaterial(streamDuration, streamStart) + if normalizedDurationsAgree && (!rawDurationsAgree || startsLookLikeAbsoluteOffsets) && !durationLooksImplausible(raw, normalizedFormatDuration) { return normalizedFormatDuration, true } - if longVideoDurationsAgree(formatDuration, streamDuration) && + if rawDurationsAgree && !durationLooksImplausible(raw, formatDuration) { return formatDuration, true } @@ -414,6 +421,17 @@ func longVideoDurationsAgree(first, second float64) bool { return math.Abs(first-second) <= tolerance } +func durationStartOffsetIsMaterial(duration, start float64) bool { + if start <= 0 || duration <= start { + return false + } + tolerance := max( + longVideoDurationAbsoluteToleranceSeconds, + duration*longVideoDurationRelativeTolerance, + ) + return start > tolerance +} + func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool { if raw == nil { return false diff --git a/internal/scanner/probe_duration_test.go b/internal/scanner/probe_duration_test.go index 2bc276241..be7b6f3e4 100644 --- a/internal/scanner/probe_duration_test.go +++ b/internal/scanner/probe_duration_test.go @@ -117,6 +117,28 @@ func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoDuration(t *testing. } } +func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoWithOrdinaryStartTime(t *testing.T) { + t.Parallel() + + raw := &ffprobeOutput{ + Format: ffprobeFormat{ + StartTime: "30.000000", + Duration: "182930.275000", + Size: "77507139196", + }, + Streams: []ffprobeStream{{ + CodecType: "video", + StartTime: "30.000000", + Duration: "182930.196000", + }}, + } + + got, ok := durationFromProbeMetadata(raw) + if !ok || got != 182930 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok) + } +} + func TestDurationFromProbeMetadataNormalizesOffsetBeforeCorroboratingLongVideo(t *testing.T) { t.Parallel() From e47fbcf67dfdc53975fd333d6ebc106c9cfd84c4 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:21:41 -0400 Subject: [PATCH 05/11] fix(scanner): align legacy long media seeking --- internal/api/handlers/playback.go | 10 ++++++---- internal/api/handlers/playback_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 2d9038dde..427cb91d9 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -533,10 +533,12 @@ func canSeekAnywhere(req transcodeStartRequest, file *models.MediaFile) bool { if file == nil || file.Duration <= 0 { return false } - // Copy-video HLS sessions use FFmpeg's real manifest so the player only - // seeks within the currently exposed window. Out-of-window seeks should - // restart explicitly instead of relying on segment 404s to move FFmpeg. - return !strings.EqualFold(req.TargetCodecVideo, "copy") + // Copy-video, unknown-duration, and oversized HLS sessions use FFmpeg's + // real manifest so the player only seeks within the currently exposed + // window. Out-of-window seeks should restart explicitly instead of relying + // on segment 404s to move FFmpeg. + return !strings.EqualFold(req.TargetCodecVideo, "copy") && + playback.CanGenerateSyntheticManifest(float64(file.Duration), req.SegmentDuration) } func buildTranscodeStartResponse( diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 48aee648d..0f3592e2f 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -616,6 +616,31 @@ func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) { if encodedResp.TimelineOffsetSeconds != 0 { t.Fatalf("encoded TimelineOffsetSeconds = %v, want 0", encodedResp.TimelineOffsetSeconds) } + + longEncodedResp := buildTranscodeStartResponse( + transcodeStartRequest{ + SessionID: "session-long-encoded", + SeekSeconds: 18.261, + TargetCodecVideo: "h264", + SegmentDuration: 2, + }, + &models.MediaFile{Duration: 1_000_000}, + nil, + "/playback/transcode/session-long-encoded/master.m3u8", + 16, + ) + if longEncodedResp.CanSeekAnywhere { + t.Fatal("long encoded response should require explicit restart seeks") + } + if math.Abs(longEncodedResp.PlayerStartSeconds-2.261) > 0.0001 { + t.Fatalf("long encoded PlayerStartSeconds = %v, want 2.261", longEncodedResp.PlayerStartSeconds) + } + if longEncodedResp.StreamOriginSeconds != 16 { + t.Fatalf("long encoded StreamOriginSeconds = %v, want 16", longEncodedResp.StreamOriginSeconds) + } + if longEncodedResp.TimelineOffsetSeconds != 16 { + t.Fatalf("long encoded TimelineOffsetSeconds = %v, want 16", longEncodedResp.TimelineOffsetSeconds) + } } func TestHandleStartPlayback_PersistsSeriesPlaybackPreferenceForEpisodes(t *testing.T) { From 489fc18514b21abd12f34aed3d695733ff1a0b9f Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:54:40 -0400 Subject: [PATCH 06/11] fix(scanner): preserve long timeline origins --- internal/api/handlers/playback.go | 15 +++++- internal/api/handlers/playback_test.go | 69 ++++++++++++++++++++++--- internal/scanner/probe.go | 21 ++------ internal/scanner/probe_duration_test.go | 26 +++++++++- 4 files changed, 103 insertions(+), 28 deletions(-) diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 427cb91d9..26137401f 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -533,12 +533,20 @@ func canSeekAnywhere(req transcodeStartRequest, file *models.MediaFile) bool { if file == nil || file.Duration <= 0 { return false } + return !usesRealTranscodeManifest(req, file) +} + +func usesRealTranscodeManifest(req transcodeStartRequest, file *models.MediaFile) bool { + durationSeconds := 0.0 + if file != nil { + durationSeconds = float64(file.Duration) + } // Copy-video, unknown-duration, and oversized HLS sessions use FFmpeg's // real manifest so the player only seeks within the currently exposed // window. Out-of-window seeks should restart explicitly instead of relying // on segment 404s to move FFmpeg. - return !strings.EqualFold(req.TargetCodecVideo, "copy") && - playback.CanGenerateSyntheticManifest(float64(file.Duration), req.SegmentDuration) + return strings.EqualFold(req.TargetCodecVideo, "copy") || + !playback.CanGenerateSyntheticManifest(durationSeconds, req.SegmentDuration) } func buildTranscodeStartResponse( @@ -3233,6 +3241,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re transportSeekSeconds := alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo) startSegmentNumber := computeStartSegment(transportSeekSeconds, req.SegmentDuration) streamOriginSeconds := 0.0 + if usesRealTranscodeManifest(req, file) { + streamOriginSeconds = transportSeekSeconds + } if videoCopy { streamOriginSeconds = req.SeekSeconds if req.SeekSeconds > 0 { diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 0f3592e2f..875a60886 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -627,19 +627,19 @@ func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) { &models.MediaFile{Duration: 1_000_000}, nil, "/playback/transcode/session-long-encoded/master.m3u8", - 16, + 18, ) if longEncodedResp.CanSeekAnywhere { t.Fatal("long encoded response should require explicit restart seeks") } - if math.Abs(longEncodedResp.PlayerStartSeconds-2.261) > 0.0001 { - t.Fatalf("long encoded PlayerStartSeconds = %v, want 2.261", longEncodedResp.PlayerStartSeconds) + if math.Abs(longEncodedResp.PlayerStartSeconds-0.261) > 0.0001 { + t.Fatalf("long encoded PlayerStartSeconds = %v, want 0.261", longEncodedResp.PlayerStartSeconds) } - if longEncodedResp.StreamOriginSeconds != 16 { - t.Fatalf("long encoded StreamOriginSeconds = %v, want 16", longEncodedResp.StreamOriginSeconds) + if longEncodedResp.StreamOriginSeconds != 18 { + t.Fatalf("long encoded StreamOriginSeconds = %v, want 18", longEncodedResp.StreamOriginSeconds) } - if longEncodedResp.TimelineOffsetSeconds != 16 { - t.Fatalf("long encoded TimelineOffsetSeconds = %v, want 16", longEncodedResp.TimelineOffsetSeconds) + if longEncodedResp.TimelineOffsetSeconds != 18 { + t.Fatalf("long encoded TimelineOffsetSeconds = %v, want 18", longEncodedResp.TimelineOffsetSeconds) } } @@ -2420,6 +2420,61 @@ func TestHandleStartTranscode_SeekedCopyRemainsCopyVideo(t *testing.T) { } } +func TestHandleStartTranscode_LongEncodedUsesAlignedRealManifestOrigin(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-long-encoded.mkv"), + Resolution: "1080p", + CodecVideo: "hevc", + CodecAudio: "dts", + Container: "mkv", + Bitrate: 25000, + Duration: 1_000_000, + AudioTracks: []models.AudioTrack{{Codec: "dts", Default: true}}, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + + transcodeReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), + ).WithContext(newAuthorizedPlaybackContext()) + + transcodeRR := httptest.NewRecorder() + handler.HandleStartTranscode(transcodeRR, transcodeReq) + if transcodeRR.Code != http.StatusAccepted { + t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) + } + + var response transcodeStartResponse + if err := json.NewDecoder(transcodeRR.Body).Decode(&response); err != nil { + t.Fatalf("decode transcode response: %v", err) + } + if math.Abs(response.PlayerStartSeconds-0.261) > 0.0001 || response.StreamOriginSeconds != 18 || + response.TimelineOffsetSeconds != 18 || response.CanSeekAnywhere { + t.Fatalf("long encoded response timeline = %+v", response) + } + + transcodeSession := handler.tm.GetTranscodeSession(session.ID) + if transcodeSession == nil { + t.Fatal("expected local transcode session") + } + t.Cleanup(func() { _ = transcodeSession.Close() }) + opts := transcodeSession.Opts() + if opts.SeekSeconds != 18 || opts.StreamOriginSeconds != 18 || opts.CopySeekAnchorResolved || opts.StartSegmentNumber != 9 { + t.Fatalf("long encoded seek recipe = seek %v origin %v copy anchor %v segment %d", opts.SeekSeconds, opts.StreamOriginSeconds, opts.CopySeekAnchorResolved, opts.StartSegmentNumber) + } +} + func TestHandleStartTranscode_CopyAnchorFailureKeepsActiveTransport(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) file := &models.MediaFile{ diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index bfe11f60d..f722b8232 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -378,9 +378,9 @@ func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) ( streamStart := parseFloat(stream.StartTime) // Some MPEG-TS/HLS timelines report duration as an absolute end - // timestamp. Normalize only when the starts are material relative to the - // reported ends; ordinary non-zero media starts must not shorten a valid - // corroborated duration. + // timestamp. Use normalized spans only to reconcile raw end timestamps + // that disagree; matching raw duration fields remain authoritative even + // when the timelines have non-zero starts. normalizedFormatDuration := durationAfterStartWithinValidatedLimit( formatDuration, formatStart, @@ -391,9 +391,7 @@ func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) ( ) rawDurationsAgree := longVideoDurationsAgree(formatDuration, streamDuration) normalizedDurationsAgree := longVideoDurationsAgree(normalizedFormatDuration, normalizedStreamDuration) - startsLookLikeAbsoluteOffsets := durationStartOffsetIsMaterial(formatDuration, formatStart) && - durationStartOffsetIsMaterial(streamDuration, streamStart) - if normalizedDurationsAgree && (!rawDurationsAgree || startsLookLikeAbsoluteOffsets) && + if normalizedDurationsAgree && !rawDurationsAgree && !durationLooksImplausible(raw, normalizedFormatDuration) { return normalizedFormatDuration, true } @@ -421,17 +419,6 @@ func longVideoDurationsAgree(first, second float64) bool { return math.Abs(first-second) <= tolerance } -func durationStartOffsetIsMaterial(duration, start float64) bool { - if start <= 0 || duration <= start { - return false - } - tolerance := max( - longVideoDurationAbsoluteToleranceSeconds, - duration*longVideoDurationRelativeTolerance, - ) - return start > tolerance -} - func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool { if raw == nil { return false diff --git a/internal/scanner/probe_duration_test.go b/internal/scanner/probe_duration_test.go index be7b6f3e4..74901702e 100644 --- a/internal/scanner/probe_duration_test.go +++ b/internal/scanner/probe_duration_test.go @@ -139,6 +139,28 @@ func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoWithOrdinaryStartTim } } +func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoWithMaterialStartTime(t *testing.T) { + t.Parallel() + + raw := &ffprobeOutput{ + Format: ffprobeFormat{ + StartTime: "300.000000", + Duration: "182930.275000", + Size: "77507139196", + }, + Streams: []ffprobeStream{{ + CodecType: "video", + StartTime: "300.000000", + Duration: "182930.196000", + }}, + } + + got, ok := durationFromProbeMetadata(raw) + if !ok || got != 182930 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok) + } +} + func TestDurationFromProbeMetadataNormalizesOffsetBeforeCorroboratingLongVideo(t *testing.T) { t.Parallel() @@ -172,8 +194,8 @@ func TestDurationFromProbeMetadataNormalizesCorroboratedLongOffsetSpan(t *testin }, Streams: []ffprobeStream{{ CodecType: "video", - StartTime: "180000.000000", - Duration: "350000.196000", + StartTime: "200000.000000", + Duration: "370000.196000", }}, } From 3d32c03bcb3875cde54c6bf79c27b9e2cca61471 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:18:45 -0400 Subject: [PATCH 07/11] fix(scanner): preserve long audio-switch timelines --- internal/api/handlers/playback.go | 18 +++-- internal/api/handlers/playback_test.go | 106 ++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 26137401f..a68b895fd 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -462,7 +462,7 @@ type changeAudioResponse struct { PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"` } -func (resp *changeAudioResponse) setCopyTimeline(position, origin float64) { +func (resp *changeAudioResponse) setWindowedTimeline(position, origin float64) { playerStart := max(0, position-origin) canSeekAnywhere := false resp.PlayerStartSeconds = &playerStart @@ -2379,8 +2379,16 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. restartStartSegment := computeStartSegment(restartSeekSeconds, restartSegmentDuration) restartStreamOriginSeconds := 0.0 restartCopyAnchorResolved := false - legacyCopyRestart := session.PlayMethod == playback.PlayTranscode && - strings.EqualFold(targetVideoCodec, "copy") && isLegacyTransportSession(session) + restartManifestRequest := transcodeStartRequest{ + TargetCodecVideo: targetVideoCodec, + SegmentDuration: restartSegmentDuration, + } + legacyWindowedRestart := session.PlayMethod == playback.PlayTranscode && + isLegacyTransportSession(session) && usesRealTranscodeManifest(restartManifestRequest, file) + if legacyWindowedRestart { + restartStreamOriginSeconds = restartSeekSeconds + } + legacyCopyRestart := legacyWindowedRestart && strings.EqualFold(targetVideoCodec, "copy") if legacyCopyRestart { restartCopyAnchorResolved = true if req.Position > 0 { @@ -2789,8 +2797,8 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. } h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex) } - if legacyCopyRestart { - resp.setCopyTimeline(req.Position, restartStreamOriginSeconds) + if legacyWindowedRestart { + resp.setWindowedTimeline(req.Position, restartStreamOriginSeconds) } h.syncSessionsNow(r.Context(), "audio_change") diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 875a60886..6b820f071 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -1639,7 +1639,7 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t CodecAudio: "aac", Container: "mkv", Bitrate: 8000, - Duration: 3600, + Duration: 1_000_000, AudioTracks: []models.AudioTrack{ {Codec: "aac", Default: true}, {Codec: "ac3"}, @@ -1728,6 +1728,9 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t if remoteStartReq.SeekSeconds != 120 { t.Fatalf("remote SeekSeconds = %v, want aligned 120", remoteStartReq.SeekSeconds) } + if remoteStartReq.StreamOriginSeconds != 120 { + t.Fatalf("remote StreamOriginSeconds = %v, want aligned 120", remoteStartReq.StreamOriginSeconds) + } if remoteStartReq.TargetResolution != "720p" || remoteStartReq.TargetCodecVideo != "h264" { t.Fatalf("remote target recipe = %q/%q, want 720p/h264", remoteStartReq.TargetResolution, remoteStartReq.TargetCodecVideo) } @@ -1764,6 +1767,15 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t if claims.TargetBitrateKbps != 2000 { t.Fatalf("token TargetBitrateKbps = %d, want 2000 (recipe-complete)", claims.TargetBitrateKbps) } + if claims.StreamOriginSeconds != 120 { + t.Fatalf("token StreamOriginSeconds = %v, want aligned 120", claims.StreamOriginSeconds) + } + if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 || + resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 || + resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 || + resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere { + t.Fatalf("remote long encoded response timeline = %+v", resp) + } if claims.SeekSeconds != 120 { t.Fatalf("token SeekSeconds = %v, want aligned 120", claims.SeekSeconds) } @@ -2167,6 +2179,98 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T } } +func TestHandleChangeAudioTrack_LocalLongEncodedPreservesWindowedTimeline(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-local-long-encoded.mkv"), + Resolution: "1080p", + CodecVideo: "hevc", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 1_000_000, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}, {Codec: "dts"}}, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.JWTSecret = "test-secret" + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + + startReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1}`), + ).WithContext(newAuthorizedPlaybackContext()) + startRR := httptest.NewRecorder() + handler.HandleStartTranscode(startRR, startReq) + if startRR.Code != http.StatusAccepted { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + + predecessor := handler.tm.GetTranscodeSession(session.ID) + if predecessor == nil { + t.Fatal("expected local long encoded session") + } + t.Cleanup(func() { handler.tm.CloseTranscodeSession(session.ID, "") }) + + changeReq := httptest.NewRequest( + http.MethodPatch, + "/api/v1/playback/"+session.ID+"/audio", + strings.NewReader(`{"audio_track_index":1,"position":121.5}`), + ).WithContext(newAuthorizedPlaybackContext()) + changeReq = withPlaybackRouteParam(changeReq, "session_id", session.ID) + changeRR := httptest.NewRecorder() + handler.HandleChangeAudioTrack(changeRR, changeReq) + if changeRR.Code != http.StatusOK { + t.Fatalf("change status = %d, body = %s", changeRR.Code, changeRR.Body.String()) + } + + successor := handler.tm.GetTranscodeSession(session.ID) + if successor == nil || successor == predecessor { + t.Fatal("audio switch did not publish a prepared successor") + } + if predecessor.IsRunning() { + t.Fatal("audio switch predecessor is still running after commit") + } + opts := successor.Opts() + if opts.TargetCodecVideo != "h264" || opts.SeekSeconds != 120 || + opts.StreamOriginSeconds != 120 || opts.CopySeekAnchorResolved || + opts.StartSegmentNumber != 60 || opts.AudioTrackIndex != 1 { + t.Fatalf("local long encoded restart opts = %+v", opts) + } + + var resp changeAudioResponse + if err := json.NewDecoder(changeRR.Body).Decode(&resp); err != nil { + t.Fatalf("decode change response: %v", err) + } + manifestURL, err := url.Parse(resp.StreamURL) + if err != nil { + t.Fatalf("parse stream URL: %v", err) + } + claims, err := streamtoken.Verify(manifestURL.Query().Get(streamTokenParam), handler.JWTSecret) + if err != nil { + t.Fatalf("verify stream token: %v", err) + } + if claims.TargetCodec != "h264" || claims.SeekSeconds != 120 || + claims.StreamOriginSeconds != 120 || claims.CopySeekAnchorResolved || + claims.StartSegmentNumber != 60 || claims.AudioTrackIndex != 1 { + t.Fatalf("local long encoded reconstruction claims = %+v", claims) + } + if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 || + resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 || + resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 || + resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere { + t.Fatalf("local long encoded response timeline = %+v", resp) + } +} + func TestHandleChangeAudioTrack_LocalCopyRestartUsesFreshSeekAnchor(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) file := &models.MediaFile{ From 058f9d814d1644a3ac1130be658b6941da7679a8 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:45:47 -0400 Subject: [PATCH 08/11] fix(scanner): distinguish absolute-end timelines --- internal/scanner/probe.go | 20 ++++++++++++++++---- internal/scanner/probe_duration_test.go | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index f722b8232..f9000b7bc 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -378,9 +378,10 @@ func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) ( streamStart := parseFloat(stream.StartTime) // Some MPEG-TS/HLS timelines report duration as an absolute end - // timestamp. Use normalized spans only to reconcile raw end timestamps - // that disagree; matching raw duration fields remain authoritative even - // when the timelines have non-zero starts. + // timestamp. Use normalized spans to reconcile raw end timestamps that + // disagree, or when matching timestamps have a dominant start offset that + // strongly indicates the absolute-end shape. Ordinary non-zero starts remain + // part of an already corroborated raw duration. normalizedFormatDuration := durationAfterStartWithinValidatedLimit( formatDuration, formatStart, @@ -391,7 +392,10 @@ func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) ( ) rawDurationsAgree := longVideoDurationsAgree(formatDuration, streamDuration) normalizedDurationsAgree := longVideoDurationsAgree(normalizedFormatDuration, normalizedStreamDuration) - if normalizedDurationsAgree && !rawDurationsAgree && + matchingAbsoluteEnds := rawDurationsAgree && + durationHasDominantStartOffset(formatDuration, formatStart) && + durationHasDominantStartOffset(streamDuration, streamStart) + if normalizedDurationsAgree && (!rawDurationsAgree || matchingAbsoluteEnds) && !durationLooksImplausible(raw, normalizedFormatDuration) { return normalizedFormatDuration, true } @@ -419,6 +423,14 @@ func longVideoDurationsAgree(first, second float64) bool { return math.Abs(first-second) <= tolerance } +// durationHasDominantStartOffset identifies the conservative absolute-end +// shape where the start timestamp occupies at least half of the reported end. +// Smaller starts are common media offsets and cannot disambiguate a duration +// field from an absolute end timestamp. +func durationHasDominantStartOffset(end, start float64) bool { + return start > 0 && end > start && start >= end-start +} + func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool { if raw == nil { return false diff --git a/internal/scanner/probe_duration_test.go b/internal/scanner/probe_duration_test.go index 74901702e..0dd52dc2c 100644 --- a/internal/scanner/probe_duration_test.go +++ b/internal/scanner/probe_duration_test.go @@ -205,6 +205,28 @@ func TestDurationFromProbeMetadataNormalizesCorroboratedLongOffsetSpan(t *testin } } +func TestDurationFromProbeMetadataNormalizesMatchingLongAbsoluteEndTimestamps(t *testing.T) { + t.Parallel() + + raw := &ffprobeOutput{ + Format: ffprobeFormat{ + StartTime: "180000.000000", + Duration: "350000.275000", + Size: "77507139196", + }, + Streams: []ffprobeStream{{ + CodecType: "video", + StartTime: "180000.000000", + Duration: "350000.196000", + }}, + } + + got, ok := durationFromProbeMetadata(raw) + if !ok || got != 170000 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 170000, true", got, ok) + } +} + func TestDurationFromProbeMetadataRejectsUncorroboratedLongVideoDuration(t *testing.T) { t.Parallel() From 6e7bafec8873e88ac56c040c334d8f271dc92319 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:08:20 -0400 Subject: [PATCH 09/11] fix(scanner): preserve long resume semantics --- internal/api/handlers/playback_v3.go | 5 +++- internal/api/handlers/playback_v3_test.go | 5 ++-- internal/jellycompat/streams.go | 32 ++++++++++++++++++----- internal/jellycompat/streams_test.go | 14 ++++++++++ internal/scanner/probe.go | 8 +++--- internal/scanner/probe_duration_test.go | 16 ++++++++++++ 6 files changed, 67 insertions(+), 13 deletions(-) diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index 7ae7faef3..2e2e7d622 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -2024,7 +2024,10 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur usesGrowingManifest := strings.EqualFold(videoCodec, "copy") || !playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration) if usesGrowingManifest { - plan.Timeline.PlayerStartSeconds = 0 + // Encoded streams seek to the preceding segment boundary. Preserve the + // requested sub-segment offset so playback still begins at the exact + // requested source position. Copy seeks are already exact, making this 0. + plan.Timeline.PlayerStartSeconds = max(0, requested-seek) plan.Timeline.StreamOriginSeconds = seek plan.Timeline.TimelineOffsetSeconds = seek windowStart := seek diff --git a/internal/api/handlers/playback_v3_test.go b/internal/api/handlers/playback_v3_test.go index 6f8f81221..f215ea83b 100644 --- a/internal/api/handlers/playback_v3_test.go +++ b/internal/api/handlers/playback_v3_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "net/http/httptest" "net/url" @@ -1235,7 +1236,7 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) { longEncodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}} longEncodeSeek, longEncodeSegment := configureHLSTimelineV3(longEncodePlan, "h264", 2, 1_000_000) - if longEncodeSeek != 16 || longEncodeSegment != 8 || longEncodePlan.Timeline.StreamOriginSeconds != 16 || longEncodePlan.Timeline.TimelineOffsetSeconds != 16 || longEncodePlan.Timeline.PlayerStartSeconds != 0 || longEncodePlan.Timeline.CanSeekAnywhere || + if longEncodeSeek != 16 || longEncodeSegment != 8 || longEncodePlan.Timeline.StreamOriginSeconds != 16 || longEncodePlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(longEncodePlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || longEncodePlan.Timeline.CanSeekAnywhere || longEncodePlan.Timeline.SeekWindowStartSeconds == nil || *longEncodePlan.Timeline.SeekWindowStartSeconds != 16 || longEncodePlan.Timeline.SeekWindowEndSeconds != nil || longEncodePlan.Timeline.SeekRestoration != "source_position" { @@ -1244,7 +1245,7 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) { unknownDurationPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}} unknownDurationSeek, unknownDurationSegment := configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0) - if unknownDurationSeek != 16 || unknownDurationSegment != 8 || unknownDurationPlan.Timeline.StreamOriginSeconds != 16 || unknownDurationPlan.Timeline.TimelineOffsetSeconds != 16 || unknownDurationPlan.Timeline.PlayerStartSeconds != 0 || unknownDurationPlan.Timeline.CanSeekAnywhere || + if unknownDurationSeek != 16 || unknownDurationSegment != 8 || unknownDurationPlan.Timeline.StreamOriginSeconds != 16 || unknownDurationPlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(unknownDurationPlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || unknownDurationPlan.Timeline.CanSeekAnywhere || unknownDurationPlan.Timeline.SeekWindowStartSeconds == nil || *unknownDurationPlan.Timeline.SeekWindowStartSeconds != 16 || unknownDurationPlan.Timeline.SeekWindowEndSeconds != nil || unknownDurationPlan.Timeline.SeekRestoration != "source_position" { diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index 35f3c8283..f85cf0574 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -246,7 +246,8 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re writeError(w, http.StatusInternalServerError, "ServerError", "Failed to bind transcode node") return } - if err := h.startRemoteTranscode(r.Context(), playSession.ID, playSession.UpstreamSessionID, *source, file, playSession.InitialSeekSeconds, tcNode.URL); err != nil { + initialSeekSeconds, _ := compatInitialTranscodePosition(*source, h.compatSegmentDuration(), playSession.InitialSeekSeconds) + if err := h.startRemoteTranscode(r.Context(), playSession.ID, playSession.UpstreamSessionID, *source, file, initialSeekSeconds, tcNode.URL); err != nil { failRemoteStart() if errors.Is(err, errTranscode4KDisallowed) { writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server") @@ -1633,11 +1634,11 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio initialSeekSeconds := 0.0 startSegmentNumber := 0 if playSession, ok := h.playbackStore.Get(playSessionID); ok { - initialSeekSeconds = playSession.InitialSeekSeconds - segDuration := h.compatSegmentDuration() - if initialSeekSeconds > 0 && segDuration > 0 { - startSegmentNumber = int(initialSeekSeconds / float64(segDuration)) - } + initialSeekSeconds, startSegmentNumber = compatInitialTranscodePosition( + source, + h.compatSegmentDuration(), + playSession.InitialSeekSeconds, + ) } opts := playback.TranscodeOpts{ @@ -1699,6 +1700,25 @@ func shouldGenerateCompatFullManifest(source PlaybackMediaSource, segmentDuratio return playback.CanGenerateSyntheticManifest(float64(source.Version.Duration), segmentDuration) } +// compatInitialTranscodePosition keeps the FFmpeg timeline consistent with the +// manifest exposed to Jellyfin clients. Bounded synthetic manifests retain the +// full source timeline and can start FFmpeg at the requested segment. Real +// growing manifests must start at source time zero because clients also apply +// their negotiated resume position; starting the playlist at that offset would +// make them seek twice. +func compatInitialTranscodePosition(source PlaybackMediaSource, segmentDuration int, requested float64) (float64, int) { + if requested <= 0 || !shouldGenerateCompatFullManifest(source, segmentDuration) { + return 0, 0 + } + if duration := float64(source.Version.Duration); duration > 0 && requested > duration { + requested = duration + } + if segmentDuration <= 0 { + segmentDuration = compatSegmentDuration + } + return requested, int(requested / float64(segmentDuration)) +} + // audioSelectionChanged reports whether an incoming AudioStreamIndex differs // from what the play session already records for the target media source. // Used to short-circuit progress reports that merely echo the current diff --git a/internal/jellycompat/streams_test.go b/internal/jellycompat/streams_test.go index c9bca6403..bda88fb23 100644 --- a/internal/jellycompat/streams_test.go +++ b/internal/jellycompat/streams_test.go @@ -94,6 +94,20 @@ func TestShouldGenerateCompatFullManifestBoundsSegmentCount(t *testing.T) { } } +func TestCompatInitialTranscodePositionKeepsRealManifestSourceAligned(t *testing.T) { + short := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 100_000}} + seek, segment := compatInitialTranscodePosition(short, 2, 17.3) + if seek != 17.3 || segment != 8 { + t.Fatalf("bounded manifest position = (%v, %d), want (17.3, 8)", seek, segment) + } + + long := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 1_000_000}} + seek, segment = compatInitialTranscodePosition(long, 2, 17.3) + if seek != 0 || segment != 0 { + t.Fatalf("real manifest position = (%v, %d), want source-aligned (0, 0)", seek, segment) + } +} + func TestRewriteManifest_PreservesPlaybackAndMediaSourceIDs(t *testing.T) { manifest := strings.Join([]string{ "#EXTM3U", diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index f9000b7bc..7046922e6 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -570,11 +570,11 @@ func estimateVideoPacketDuration(reader io.Reader, frameRate string) int { best := packetSpan if packetSpan > maxReasonableMediaDurationSeconds && - durationIsPositiveFinite(frameDuration) && + durationIsReasonable(frameDuration) && !longVideoDurationsAgree(packetSpan, frameDuration) { - // A long PTS span is strong evidence only when a usable frame-count - // estimate does not contradict it. This rejects isolated timestamp - // discontinuities while retaining the ordinary frame estimate below. + // A long PTS span is strong evidence only when a sane frame-count + // estimate contradicts it. Malformed frame rates can produce finite but + // unusable estimates and must not veto an otherwise valid packet span. best = 0 } if durationIsReasonable(frameDuration) && frameDuration > best { diff --git a/internal/scanner/probe_duration_test.go b/internal/scanner/probe_duration_test.go index 0dd52dc2c..074ac166b 100644 --- a/internal/scanner/probe_duration_test.go +++ b/internal/scanner/probe_duration_test.go @@ -353,6 +353,22 @@ func TestEstimateVideoPacketDurationKeepsOrdinaryCapForFrameRateEstimate(t *test } } +func TestEstimateVideoPacketDurationIgnoresUnusableFrameEstimateForLongSpan(t *testing.T) { + t.Parallel() + + var packets strings.Builder + packets.WriteString("0.000000\n") + for range 898 { + packets.WriteString("5.000000\n") + } + packets.WriteString("182930.196000\n") + + got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "1/1000") + if got != 182930 { + t.Fatalf("estimateVideoPacketDuration() = %d, want 182930", got) + } +} + func TestEstimateVideoPacketDurationRejectsOutlierSpanWhenFrameCountDisagrees(t *testing.T) { t.Parallel() From bb03344408ae89ed9a800eef7a7a467ac1a8b0d9 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:30:25 -0400 Subject: [PATCH 10/11] fix(scanner): reject ambiguous absolute ends --- internal/scanner/probe.go | 7 +++++++ internal/scanner/probe_duration_test.go | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/internal/scanner/probe.go b/internal/scanner/probe.go index 7046922e6..b4234be38 100644 --- a/internal/scanner/probe.go +++ b/internal/scanner/probe.go @@ -399,6 +399,13 @@ func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) ( !durationLooksImplausible(raw, normalizedFormatDuration) { return normalizedFormatDuration, true } + if matchingAbsoluteEnds { + // Dominant starts identify the raw values as absolute end + // timestamps. If their normalized spans do not corroborate, reject + // the metadata for packet repair instead of persisting an inflated + // raw end timestamp. + return 0, false + } if rawDurationsAgree && !durationLooksImplausible(raw, formatDuration) { diff --git a/internal/scanner/probe_duration_test.go b/internal/scanner/probe_duration_test.go index 074ac166b..121b4206c 100644 --- a/internal/scanner/probe_duration_test.go +++ b/internal/scanner/probe_duration_test.go @@ -227,6 +227,28 @@ func TestDurationFromProbeMetadataNormalizesMatchingLongAbsoluteEndTimestamps(t } } +func TestDurationFromProbeMetadataRejectsDisagreeingAbsoluteEndSpans(t *testing.T) { + t.Parallel() + + raw := &ffprobeOutput{ + Format: ffprobeFormat{ + StartTime: "180000.000000", + Duration: "350000.275000", + Size: "77507139196", + }, + Streams: []ffprobeStream{{ + CodecType: "video", + StartTime: "180000.000000", + Duration: "350300.196000", + }}, + } + + got, ok := durationFromProbeMetadata(raw) + if ok || got != 0 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok) + } +} + func TestDurationFromProbeMetadataRejectsUncorroboratedLongVideoDuration(t *testing.T) { t.Parallel() From aa35684c21a9f2d2317d166cf0ad5e8649c59ed2 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:02:12 -0400 Subject: [PATCH 11/11] fix(scanner): preserve compat real-manifest resumes --- internal/jellycompat/handlers_playback.go | 2 +- internal/jellycompat/streams.go | 13 +- internal/jellycompat/streams_test.go | 27 +++- internal/playback/transcode.go | 135 +++++++++++++++++++ internal/playback/transcode_manifest_test.go | 53 ++++++++ internal/transcodenode/server.go | 8 +- 6 files changed, 225 insertions(+), 13 deletions(-) diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index fa5f9dfaf..dd7c2bdec 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -388,7 +388,7 @@ func (h *PlaybackHandler) buildProxyRedirectURL( } return redirectURL, nil case string(playback.PlayTranscode): - return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8", nil + return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8?" + playback.SourceTimelineQueryParam + "=1", nil default: return "", fmt.Errorf("unsupported proxy method %q", method) } diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index f85cf0574..cc240a6e6 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -1585,7 +1585,7 @@ func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSes for { manifest, err := transcodeSession.GetManifest() if err == nil { - return manifest, nil + return playback.AlignRealManifestToSourceTimeline(manifest, transcodeSession.Opts(), "") } if !errors.Is(err, playback.ErrManifestNotReady) { return nil, err @@ -1700,14 +1700,11 @@ func shouldGenerateCompatFullManifest(source PlaybackMediaSource, segmentDuratio return playback.CanGenerateSyntheticManifest(float64(source.Version.Duration), segmentDuration) } -// compatInitialTranscodePosition keeps the FFmpeg timeline consistent with the -// manifest exposed to Jellyfin clients. Bounded synthetic manifests retain the -// full source timeline and can start FFmpeg at the requested segment. Real -// growing manifests must start at source time zero because clients also apply -// their negotiated resume position; starting the playlist at that offset would -// make them seek twice. +// compatInitialTranscodePosition keeps FFmpeg close to the requested resume +// position. Bounded synthetic manifests list the omitted source segments; +// seeked real manifests receive an EXT-X-GAP timeline anchor before serving. func compatInitialTranscodePosition(source PlaybackMediaSource, segmentDuration int, requested float64) (float64, int) { - if requested <= 0 || !shouldGenerateCompatFullManifest(source, segmentDuration) { + if requested <= 0 { return 0, 0 } if duration := float64(source.Version.Duration); duration > 0 && requested > duration { diff --git a/internal/jellycompat/streams_test.go b/internal/jellycompat/streams_test.go index bda88fb23..76edb10e9 100644 --- a/internal/jellycompat/streams_test.go +++ b/internal/jellycompat/streams_test.go @@ -12,6 +12,7 @@ import ( "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/playback" ) @@ -94,7 +95,7 @@ func TestShouldGenerateCompatFullManifestBoundsSegmentCount(t *testing.T) { } } -func TestCompatInitialTranscodePositionKeepsRealManifestSourceAligned(t *testing.T) { +func TestCompatInitialTranscodePositionKeepsResumeNearRequestedSegment(t *testing.T) { short := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 100_000}} seek, segment := compatInitialTranscodePosition(short, 2, 17.3) if seek != 17.3 || segment != 8 { @@ -103,8 +104,28 @@ func TestCompatInitialTranscodePositionKeepsRealManifestSourceAligned(t *testing long := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 1_000_000}} seek, segment = compatInitialTranscodePosition(long, 2, 17.3) - if seek != 0 || segment != 0 { - t.Fatalf("real manifest position = (%v, %d), want source-aligned (0, 0)", seek, segment) + if seek != 17.3 || segment != 8 { + t.Fatalf("real manifest position = (%v, %d), want (17.3, 8)", seek, segment) + } +} + +func TestBuildProxyRedirectURLRequestsSourceAlignedCompatManifest(t *testing.T) { + h := &PlaybackHandler{JWTSecret: "test-secret"} + redirectURL, err := h.buildProxyRedirectURL( + "play-1", + "upstream-1", + string(playback.PlayTranscode), + &models.MediaFile{FilePath: "/media/movie.mkv"}, + PlaybackMediaSource{}, + "http://transcode-1", + 0, + &nodepool.Node{URL: "http://proxy-1"}, + ) + if err != nil { + t.Fatalf("buildProxyRedirectURL: %v", err) + } + if !strings.HasSuffix(redirectURL, "/master.m3u8?"+playback.SourceTimelineQueryParam+"=1") { + t.Fatalf("redirect URL = %q, want source-timeline opt-in", redirectURL) } } diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index 086b3e3b5..c2bee7997 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -1049,6 +1049,141 @@ func (s *TranscodeSession) BuildPlaybackManifest(segPrefix, rawQuery string) ([] return s.GenerateFullManifest(segPrefix, rawQuery), nil } +// SourceTimelineQueryParam opts a real transcode manifest into source-time +// alignment for compatibility clients that apply their resume position to the +// HLS timeline themselves. +const SourceTimelineQueryParam = "source_timeline" + +// BuildSourceAlignedPlaybackManifest builds the normal playback manifest and, +// when it is a seeked real playlist, prepends a virtual unavailable span so the +// first produced segment retains its source-time position. Synthetic manifests +// already cover the full source timeline and need no adjustment. +func (s *TranscodeSession) BuildSourceAlignedPlaybackManifest(segPrefix, rawQuery string) ([]byte, error) { + manifest, err := s.BuildPlaybackManifest(segPrefix, rawQuery) + if err != nil { + return nil, err + } + opts := s.Opts() + usesRealManifest := strings.EqualFold(opts.TargetCodecVideo, "copy") || + !CanGenerateSyntheticManifest(opts.TotalDuration, opts.SegmentDuration) + if opts.SeekSeconds <= 0 || !usesRealManifest { + return manifest, nil + } + + gapURI := segPrefix + "source_timeline_gap" + hlsSegmentExtension(opts) + if rawQuery != "" { + gapURI += "?" + rawQuery + } + return AlignRealManifestToSourceTimeline(manifest, opts, gapURI) +} + +// AlignRealManifestToSourceTimeline prepends bounded EXT-X-GAP segments to a +// seeked FFmpeg playlist. The gaps contribute the omitted source time while +// allowing clients to seek to the original source position. +func AlignRealManifestToSourceTimeline(manifest []byte, opts TranscodeOpts, gapURI string) ([]byte, error) { + if opts.SeekSeconds <= 0 { + return manifest, nil + } + timeline, err := parseManifestTimeline(manifest) + if err != nil { + return nil, err + } + if len(timeline.entries) == 0 { + return nil, fmt.Errorf("manifest contains no media segments") + } + + segmentDuration := opts.SegmentDuration + if segmentDuration <= 0 { + segmentDuration = defaultSegmentDuration + } + firstSegment := timeline.entries[0].number + advancedSegments := max(0, firstSegment-opts.StartSegmentNumber) + gapDuration := opts.SeekSeconds + float64(advancedSegments*segmentDuration) + if gapDuration <= 0 { + return manifest, nil + } + if gapURI == "" { + gapURI = "source_timeline_gap" + hlsSegmentExtension(opts) + } + + lines := bytes.Split(manifest, []byte("\n")) + targetDuration := segmentDuration + for _, line := range lines { + trimmed := bytes.TrimSpace(line) + if !bytes.HasPrefix(trimmed, []byte("#EXT-X-TARGETDURATION:")) { + continue + } + value := strings.TrimSpace(strings.TrimPrefix(string(trimmed), "#EXT-X-TARGETDURATION:")) + parsed, parseErr := strconv.Atoi(value) + if parseErr != nil || parsed <= 0 { + return nil, fmt.Errorf("parse manifest target duration %q", value) + } + targetDuration = parsed + break + } + + // Keep the real segment's media sequence aligned with its FFmpeg segment + // number when possible. Very large seeks remain bounded by the same limit as + // generated VOD manifests; their gap durations grow instead of their count. + gapCount := max(1, firstSegment) + if gapCount > maxSyntheticManifestSegments { + gapCount = maxSyntheticManifestSegments + } + gapSegmentDuration := gapDuration / float64(gapCount) + requiredTargetDuration := int(math.Ceil(gapSegmentDuration)) + if requiredTargetDuration > targetDuration { + targetDuration = requiredTargetDuration + } + mediaSequence := max(0, firstSegment-gapCount) + + result := make([][]byte, 0, len(lines)+gapCount*3) + insertedGap := false + foundSequence := false + foundVersion := false + for _, line := range lines { + trimmed := bytes.TrimSpace(line) + switch { + case bytes.HasPrefix(trimmed, []byte("#EXT-X-VERSION:")): + foundVersion = true + value := strings.TrimSpace(strings.TrimPrefix(string(trimmed), "#EXT-X-VERSION:")) + version, parseErr := strconv.Atoi(value) + if parseErr != nil { + return nil, fmt.Errorf("parse manifest version %q: %w", value, parseErr) + } + if version < 8 { + line = []byte("#EXT-X-VERSION:8") + } + case bytes.HasPrefix(trimmed, []byte("#EXT-X-TARGETDURATION:")): + line = []byte(fmt.Sprintf("#EXT-X-TARGETDURATION:%d", targetDuration)) + case bytes.HasPrefix(trimmed, []byte("#EXT-X-MEDIA-SEQUENCE:")): + foundSequence = true + line = []byte(fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d", mediaSequence)) + case bytes.HasPrefix(trimmed, []byte("#EXTINF:")) && !insertedGap: + if !foundVersion { + result = append(result, []byte("#EXT-X-VERSION:8")) + foundVersion = true + } + if !foundSequence { + result = append(result, []byte(fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d", mediaSequence))) + foundSequence = true + } + for range gapCount { + result = append(result, + []byte("#EXT-X-GAP"), + []byte(fmt.Sprintf("#EXTINF:%.6f,", gapSegmentDuration)), + []byte(gapURI), + ) + } + insertedGap = true + } + result = append(result, line) + } + if !insertedGap { + return nil, fmt.Errorf("manifest contains no segment duration") + } + return bytes.Join(result, []byte("\n")), nil +} + // CanGenerateSyntheticManifest reports whether a complete VOD playlist fits // within the shared segment-count bound. Callers outside playback use the same // decision so native and compatibility manifests cannot drift. diff --git a/internal/playback/transcode_manifest_test.go b/internal/playback/transcode_manifest_test.go index 6037dff06..9290f27cb 100644 --- a/internal/playback/transcode_manifest_test.go +++ b/internal/playback/transcode_manifest_test.go @@ -208,6 +208,59 @@ func TestBuildPlaybackManifest_LongEncodedTranscodeUsesRealManifest(t *testing.T } } +func TestBuildSourceAlignedPlaybackManifestAnchorsSeekedRealPlaylist(t *testing.T) { + tempDir := t.TempDir() + manifest := strings.Join([]string{ + "#EXTM3U", + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:2", + "#EXT-X-MEDIA-SEQUENCE:8", + "#EXTINF:2.000000,", + "seg_00008.ts", + "#EXTINF:2.000000,", + "seg_00009.ts", + "", + }, "\n") + if err := os.WriteFile(filepath.Join(tempDir, "stream.m3u8"), []byte(manifest), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + session := &TranscodeSession{ + outputDir: tempDir, + opts: TranscodeOpts{ + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + TotalDuration: 1_000_000, + SeekSeconds: 17.3, + StartSegmentNumber: 8, + }, + } + + got, err := session.BuildSourceAlignedPlaybackManifest("segment/", "source_timeline=1") + if err != nil { + t.Fatalf("BuildSourceAlignedPlaybackManifest: %v", err) + } + text := string(got) + for _, want := range []string{ + "#EXT-X-VERSION:8", + "#EXT-X-TARGETDURATION:3", + "#EXT-X-MEDIA-SEQUENCE:0", + "#EXT-X-GAP\n#EXTINF:2.162500,\nsegment/source_timeline_gap.ts?source_timeline=1", + "segment/seg_00008.ts?source_timeline=1", + } { + if !strings.Contains(text, want) { + t.Fatalf("source-aligned manifest missing %q:\n%s", want, text) + } + } + if gap := strings.Index(text, "source_timeline_gap.ts"); gap < 0 || gap > strings.Index(text, "seg_00008.ts") { + t.Fatalf("timeline gap must precede the first real segment:\n%s", text) + } + if count := strings.Count(text, "#EXT-X-GAP"); count != 8 { + t.Fatalf("timeline gap count = %d, want 8:\n%s", count, text) + } +} + func TestCanGenerateSyntheticManifestBoundsSegmentCount(t *testing.T) { if !CanGenerateSyntheticManifest(100_000, 2) { t.Fatal("historical 50,000-segment manifest should remain supported") diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index 03e8934c6..57c397ef7 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -846,7 +846,13 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { s.touchSession(sessionID) } - manifest, err := session.BuildPlaybackManifest("segment/", r.URL.RawQuery) + var manifest []byte + var err error + if r.URL.Query().Get(playback.SourceTimelineQueryParam) == "1" { + manifest, err = session.BuildSourceAlignedPlaybackManifest("segment/", r.URL.RawQuery) + } else { + manifest, err = session.BuildPlaybackManifest("segment/", r.URL.RawQuery) + } if err != nil { slog.ErrorContext(r.Context(), "get manifest", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID) http.Error(w, "manifest not ready", http.StatusServiceUnavailable)