diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 2d9038dde..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 @@ -533,10 +533,20 @@ 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") + 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(durationSeconds, req.SegmentDuration) } func buildTranscodeStartResponse( @@ -2369,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 { @@ -2779,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") @@ -3231,6 +3249,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 48aee648d..6b820f071 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", + 18, + ) + if longEncodedResp.CanSeekAnywhere { + t.Fatal("long encoded response should require explicit restart seeks") + } + if math.Abs(longEncodedResp.PlayerStartSeconds-0.261) > 0.0001 { + t.Fatalf("long encoded PlayerStartSeconds = %v, want 0.261", longEncodedResp.PlayerStartSeconds) + } + if longEncodedResp.StreamOriginSeconds != 18 { + t.Fatalf("long encoded StreamOriginSeconds = %v, want 18", longEncodedResp.StreamOriginSeconds) + } + if longEncodedResp.TimelineOffsetSeconds != 18 { + t.Fatalf("long encoded TimelineOffsetSeconds = %v, want 18", longEncodedResp.TimelineOffsetSeconds) + } } func TestHandleStartPlayback_PersistsSeriesPlaybackPreferenceForEpisodes(t *testing.T) { @@ -1614,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"}, @@ -1703,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) } @@ -1739,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) } @@ -2142,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{ @@ -2395,6 +2524,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/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index 1c8ca19cd..2e2e7d622 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -2021,13 +2021,18 @@ 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") { - plan.Timeline.PlayerStartSeconds = 0 + usesGrowingManifest := strings.EqualFold(videoCodec, "copy") || + !playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration) + if usesGrowingManifest { + // 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 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..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" @@ -1232,10 +1233,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 || 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" { + 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 || 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" { + t.Fatalf("unknown-duration timeline=%#v seek=%v segment=%d", unknownDurationPlan.Timeline, unknownDurationSeek, unknownDurationSegment) } } 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 e7955b4d0..cc240a6e6 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") @@ -279,7 +280,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 +300,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 +332,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 +352,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 +1569,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 } @@ -1579,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 @@ -1628,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{ @@ -1646,6 +1652,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 +1696,26 @@ 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) +} + +// 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 { + 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 9eafc44f2..76edb10e9 100644 --- a/internal/jellycompat/streams_test.go +++ b/internal/jellycompat/streams_test.go @@ -10,7 +10,9 @@ 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/nodepool" "github.com/Silo-Server/silo-server/internal/playback" ) @@ -81,6 +83,52 @@ 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 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 { + 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 != 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) + } +} + 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..c2bee7997 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,154 @@ 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. +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..9290f27cb 100644 --- a/internal/playback/transcode_manifest_test.go +++ b/internal/playback/transcode_manifest_test.go @@ -160,6 +160,116 @@ 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 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") + } + 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 5a3db0a8c..b4234be38 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 @@ -346,9 +354,90 @@ 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 } +// 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 { + return 0, false + } + + for _, stream := range raw.Streams { + if !isMainVideoStream(stream) { + 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. 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, + ) + normalizedStreamDuration := durationAfterStartWithinValidatedLimit( + streamDuration, + streamStart, + ) + rawDurationsAgree := longVideoDurationsAgree(formatDuration, streamDuration) + normalizedDurationsAgree := longVideoDurationsAgree(normalizedFormatDuration, normalizedStreamDuration) + matchingAbsoluteEnds := rawDurationsAgree && + durationHasDominantStartOffset(formatDuration, formatStart) && + durationHasDominantStartOffset(streamDuration, streamStart) + if normalizedDurationsAgree && (!rawDurationsAgree || matchingAbsoluteEnds) && + !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) { + return formatDuration, true + } + return 0, false + } + + 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 +} + +// 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 @@ -368,10 +457,25 @@ 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 } +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) } @@ -458,18 +562,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 durationIsReasonable(span) { - best = span + if durationIsWithinValidatedLimit(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 && + durationIsReasonable(frameDuration) && + !longVideoDurationsAgree(packetSpan, frameDuration) { + // 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 { + best = frameDuration } if best <= 0 { return 0 diff --git a/internal/scanner/probe_duration_test.go b/internal/scanner/probe_duration_test.go index 92706e3a0..121b4206c 100644 --- a/internal/scanner/probe_duration_test.go +++ b/internal/scanner/probe_duration_test.go @@ -96,6 +96,241 @@ 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 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 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() + + 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 TestDurationFromProbeMetadataNormalizesCorroboratedLongOffsetSpan(t *testing.T) { + t.Parallel() + + raw := &ffprobeOutput{ + Format: ffprobeFormat{ + StartTime: "180000.000000", + Duration: "350000.275000", + Size: "77507139196", + }, + Streams: []ffprobeStream{{ + CodecType: "video", + StartTime: "200000.000000", + Duration: "370000.196000", + }}, + } + + got, ok := durationFromProbeMetadata(raw) + if !ok || got != 170000 { + t.Fatalf("durationFromProbeMetadata() = %d, %v; want 170000, true", got, ok) + } +} + +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 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() + + 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 +341,72 @@ 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 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 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() + + 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() 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. 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)