Skip to content
Merged
39 changes: 30 additions & 9 deletions internal/api/handlers/playback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve long-manifest timeline on audio switches

When this predicate makes a long encoded session use FFmpeg's real sliding playlist (for example a 1,000,000s file with 2s segments), the audio-switch restart path still only applies windowed timeline handling for copy-video sessions: it leaves restartStreamOriginSeconds at 0, passes that into the restarted opts/node request, and omits can_seek_anywhere/timeline fields unless legacyCopyRestart is true. The web clients default omitted can_seek_anywhere to true, so changing audio on these long encoded sessions can tell the player to seek locally inside a bounded/growing playlist instead of reanchoring, causing stalls or a reload at the wrong origin; the same real-manifest origin/response logic needs to cover audio-switch restarts too.

AGENTS.md reference: AGENTS.md:L12-L14

Useful? React with 👍 / 👎.

}

func buildTranscodeStartResponse(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
186 changes: 185 additions & 1 deletion internal/api/handlers/playback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down
11 changes: 8 additions & 3 deletions internal/api/handlers/playback_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions internal/api/handlers/playback_v3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/jellycompat/handlers_playback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading