From 23162cb6fcf9b753d2794b7a5554c10a08f93b00 Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:25:09 +1100 Subject: [PATCH 1/3] fix(playback): keep the requested version when only its subtitle is blocked --- internal/api/handlers/playback_v3.go | 30 ++++ .../playback_v3_subtitle_version_test.go | 164 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 internal/api/handlers/playback_v3_subtitle_version_test.go diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index c80465625c..84c9b176d6 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -1780,6 +1780,17 @@ func (h *PlaybackHandler) startPlaybackApplicationV3(r *http.Request, body []byt firstFailureAudioIndex = candidateAudioIndex } } + if result.Terminal != nil && droppedSubtitle != nil && subtitleOnlyRefusalV3(result.Terminal) { + // Only the subtitle kept the requested file from playing, so + // it plays without one. Every sibling that plays drops the + // subtitle too, and would lose the requested picture as well. + withoutSubtitle := baseReq + withoutSubtitle.SubtitleTrackID, withoutSubtitle.SubtitleTrackIndex = "", nil + candidateResult, candidateToneMapErr := h.planPlaybackWithCapabilitiesV3(r.Context(), playback.PlannerInputV3{Request: withoutSubtitle, RequestedFile: requestedFile, EffectiveFile: effectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, effectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}) + if candidateResult.Terminal == nil { + droppedSubtitle = &alternateCandidateV3{file: effectiveFile, request: withoutSubtitle, audioIndex: baseAudioIndex, result: candidateResult, toneMapErr: candidateToneMapErr} + } + } if result.Terminal != nil && droppedSubtitle != nil { req = droppedSubtitle.request effectiveFile = droppedSubtitle.file @@ -5147,6 +5158,17 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte firstFailureAudioIndex = candidateAudioIndex } } + if result.Terminal != nil && droppedSubtitle != nil && subtitleOnlyRefusalV3(result.Terminal) { + // Only the subtitle kept the effective file from playing, so + // it plays without one rather than on a sibling that drops + // the subtitle too. + withoutSubtitle := baseStart + withoutSubtitle.SubtitleTrackID, withoutSubtitle.SubtitleTrackIndex = "", nil + candidateResult, candidateToneMapErr := h.planPlaybackWithCapabilitiesV3(r.Context(), playback.PlannerInputV3{Request: withoutSubtitle, RequestedFile: plannerRequestedFile, EffectiveFile: baseEffectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, baseEffectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: plannerSettings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), baseEffectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), baseEffectiveFile)}) + if candidateResult.Terminal == nil { + droppedSubtitle = &alternateCandidateV3{file: baseEffectiveFile, request: withoutSubtitle, audioIndex: baseAudioIndex, result: candidateResult, toneMapErr: candidateToneMapErr} + } + } if result.Terminal != nil && droppedSubtitle != nil { start = droppedSubtitle.request effectiveFile = droppedSubtitle.file @@ -5969,6 +5991,14 @@ func terminalAllowsAlternateFileV3(terminal *playback.TerminalV3) bool { } } +// subtitleOnlyRefusalV3 reports a refusal caused by the selected subtitle +// alone. The planner names the subtitle only when its burn-in requirement was +// the sole reason for an adaptation the file cannot take, so the same file +// plays once the subtitle is cleared. +func subtitleOnlyRefusalV3(terminal *playback.TerminalV3) bool { + return terminal != nil && terminal.Reason == terminalSubtitleConversionUnsupportedV3 +} + // alternateCandidateV3 is a planned alternate version held back while the // fallback loop looks for a better one. type alternateCandidateV3 struct { diff --git a/internal/api/handlers/playback_v3_subtitle_version_test.go b/internal/api/handlers/playback_v3_subtitle_version_test.go new file mode 100644 index 0000000000..99b91a4093 --- /dev/null +++ b/internal/api/handlers/playback_v3_subtitle_version_test.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/playback" +) + +// subtitleBlockedHDRFixtureV3 is a 4K HDR10 HEVC file with an English PGS +// subtitle and a 1080p SDR H.264 sibling without one. The client direct +// plays the 4K file but cannot render PGS, and the server cannot re-encode +// HDR, so burning the subtitle in is the only thing the 4K file cannot do. +func subtitleBlockedHDRFixtureV3(t *testing.T) (*PlaybackHandler, *models.MediaFile, *models.MediaFile, playback.StartRequestV3) { + t.Helper() + source := v3HandlerFixtureFile(t) + source.Container = "mkv" + source.FilePath = writePlaybackTestMediaFile(t, "movie-4k.mkv") + source.CodecVideo = "hevc" + source.Resolution = "2160p" + source.Bitrate = 64_000 + source.VideoTracks = []models.VideoTrack{{ + Codec: "hevc", Profile: "main 10", Level: 153, Width: 3840, Height: 2160, + FrameRate: "24000/1001", Bitrate: 64_000, BitDepth: 10, + VideoRange: "HDR10", VideoRangeType: "HDR10", + }} + source.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Codec: "hdmv_pgs_subtitle", Language: "eng", Title: "English"}} + + alternateValue := *source + alternate := &alternateValue + alternate.ID = 84 + alternate.FilePath = writePlaybackTestMediaFile(t, "movie-1080p.mkv") + alternate.CodecVideo = "h264" + alternate.Resolution = "1080p" + alternate.Bitrate = 8_000 + alternate.VideoTracks = []models.VideoTrack{{ + Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, + FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, + VideoRange: "SDR", VideoRangeType: "SDR", + }} + alternate.SubtitleTracks = nil + + files := map[int]*models.MediaFile{source.ID: source, alternate.ID: alternate} + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), mapPlaybackFileResolver{files: files}) + handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{ + source.ContentID: {source, alternate}, + }} + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"transcode_enabled": "true", "allow_4k_transcode": "true"}} + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{ + {Name: playback.TransformationAudioToAACV3, RecipeVersion: playback.TransformationAudioToAACRecipeVersionV3, Available: true}, + {Name: playback.TransformationVideoToH264V3, RecipeVersion: "2", Available: true}, + })) + handler.ItemAccess = allowAllPlaybackItemAccess{} + + startRequest := v3HandlerStartRequest() + startRequest.QualityPreference = "auto" + startRequest.Capabilities.CodecsVideo = []string{"hevc", "h264"} + startRequest.Capabilities.CodecsVideoHardware = []string{"hevc", "h264"} + startRequest.Capabilities.Containers = []string{"mkv", "hls"} + startRequest.Capabilities.MaxResolution = "2160p" + startRequest.Capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{ + Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, + MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true, + }} + hdr := &playback.HDRCapabilitiesV3{HDR10: true} + startRequest.Capabilities.HDRDetails = hdr + startRequest.ClientPlaybackContext.Output.HDRDetails = hdr + startRequest.ClientPlaybackContext.Deliveries = map[string]playback.DeliveryCapabilityV3{ + playback.DeliveryClassOriginalHTTPV3: { + Enabled: true, SupportedOnDevice: true, Containers: []string{"mkv"}, + VideoCodecs: []string{"hevc", "h264"}, AudioDecodeCodecs: []string{"aac"}, + }, + playback.DeliveryClassHLSV3: { + Enabled: true, SupportedOnDevice: true, Containers: []string{"hls"}, + VideoCodecs: []string{"h264"}, AudioDecodeCodecs: []string{"aac"}, + }, + } + return handler, source, alternate, startRequest +} + +// requireRequestedVersionWithoutSubtitleV3 fails unless plan stays on the +// requested 4K file, plays it without converting the video, and reports the +// subtitle it could not show exactly once. +func requireRequestedVersionWithoutSubtitleV3(t *testing.T, plan *playback.PlanV3, source *models.MediaFile) { + t.Helper() + if plan.EffectiveMediaFileID != source.ID { + t.Fatalf("effective file = %d, want the requested 4K file %d: only its subtitle was blocked", plan.EffectiveMediaFileID, source.ID) + } + if plan.Subtitle.Mode != playback.SubtitleOffV3 || plan.SelectedTracks.Subtitle != nil { + t.Fatalf("subtitle = %#v selected = %#v, want subtitles off", plan.Subtitle, plan.SelectedTracks.Subtitle) + } + if plan.EffectiveRecipe.VideoCodec != "hevc" || !plan.Claims.Video.HDR10 { + t.Fatalf("recipe = %#v claims = %#v, want the 4K HDR10 HEVC picture", plan.EffectiveRecipe, plan.Claims.Video) + } + warned := 0 + for _, warning := range plan.DegradationWarnings { + if warning.Code == playback.DegradationWarningSubtitleTrackUnavailableV3 { + warned++ + } + } + if warned != 1 { + t.Fatalf("subtitle_track_unavailable reported %d times: %#v", warned, plan.DegradationWarnings) + } +} + +// A subtitle that only the requested file cannot show is no reason to switch +// to a lower version that has no subtitle either: the viewer would lose the +// 4K picture and still get no subtitle. Playback starts on the requested +// file with the subtitle off and says so. +func TestHandleStartPlaybackV3SubtitleOnlyRefusalKeepsTheRequestedVersion(t *testing.T) { + handler, source, _, startRequest := subtitleBlockedHDRFixtureV3(t) + subtitleIndex := 0 + startRequest.SubtitleTrackID = playback.TrackIDV3(source.ID, "subtitle", subtitleIndex) + startRequest.SubtitleTrackIndex = &subtitleIndex + + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil { + t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String()) + } + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + requireRequestedVersionWithoutSubtitleV3(t, started.PlaybackPlan, source) +} + +// The same holds when the viewer turns the subtitle on during playback. +func TestHandleReplanPlaybackV3SubtitleOnlyRefusalKeepsTheRequestedVersion(t *testing.T) { + handler, source, _, startRequest := subtitleBlockedHDRFixtureV3(t) + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil { + t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String()) + } + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + if started.PlaybackPlan.EffectiveMediaFileID != source.ID { + t.Fatalf("start effective file = %d, want the 4K file %d", started.PlaybackPlan.EffectiveMediaFileID, source.ID) + } + + subtitleIndex := 0 + replanned := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationTrackChangeV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "subtitle-only-refusal-0001", + FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "subtitle-only-refusal-attempt-0001", + PlanAttemptKey: started.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 30, + QualityPreference: "auto", + SelectedTracks: playback.SelectedTracksV3{ + Audio: started.PlaybackPlan.SelectedTracks.Audio, + Subtitle: &playback.TrackIdentityV3{ + ID: playback.TrackIDV3(source.ID, "subtitle", subtitleIndex), Index: &subtitleIndex, + }, + }, + Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext, + }) + if replanned.PlaybackPlan == nil || replanned.Terminal != nil { + t.Fatalf("subtitle replan = %#v", replanned) + } + requireRequestedVersionWithoutSubtitleV3(t, replanned.PlaybackPlan, source) +} From ea8c9f4b444992f8f8694c12125695cf99546165 Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:01:43 +1100 Subject: [PATCH 2/3] fix(playback): return to the requested edition and say why the subtitle is off --- internal/api/handlers/playback_v3.go | 48 ++++++++---- .../playback_v3_subtitle_version_test.go | 76 +++++++++++++++++++ internal/playback/protocol_v3.go | 9 +++ 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index 84c9b176d6..d033d97f01 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -1780,15 +1780,18 @@ func (h *PlaybackHandler) startPlaybackApplicationV3(r *http.Request, body []byt firstFailureAudioIndex = candidateAudioIndex } } - if result.Terminal != nil && droppedSubtitle != nil && subtitleOnlyRefusalV3(result.Terminal) { - // Only the subtitle kept the requested file from playing, so - // it plays without one. Every sibling that plays drops the - // subtitle too, and would lose the requested picture as well. + subtitleNotShown := false + if result.Terminal != nil && droppedSubtitle != nil && subtitleRefusalV3(result.Terminal) { + // The planner pinned the requested file's refusal on the + // subtitle, and every sibling that plays drops the subtitle + // too. Playing the requested file without it keeps its + // picture, when that plan succeeds. withoutSubtitle := baseReq withoutSubtitle.SubtitleTrackID, withoutSubtitle.SubtitleTrackIndex = "", nil candidateResult, candidateToneMapErr := h.planPlaybackWithCapabilitiesV3(r.Context(), playback.PlannerInputV3{Request: withoutSubtitle, RequestedFile: requestedFile, EffectiveFile: effectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, effectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}) if candidateResult.Terminal == nil { droppedSubtitle = &alternateCandidateV3{file: effectiveFile, request: withoutSubtitle, audioIndex: baseAudioIndex, result: candidateResult, toneMapErr: candidateToneMapErr} + subtitleNotShown = true } } if result.Terminal != nil && droppedSubtitle != nil { @@ -1797,7 +1800,11 @@ func (h *PlaybackHandler) startPlaybackApplicationV3(r *http.Request, body []byt audioIndex = droppedSubtitle.audioIndex result = droppedSubtitle.result toneMapCapabilityErr = droppedSubtitle.toneMapErr - warnings = append(warnings, playback.SubtitleTrackUnavailableWarningV3()) + if subtitleNotShown { + warnings = append(warnings, playback.SubtitleNotShownWarningV3()) + } else { + warnings = append(warnings, playback.SubtitleTrackUnavailableWarningV3()) + } } else if result.Terminal != nil && firstFailureFile != nil { req = firstFailureReq effectiveFile = firstFailureFile @@ -4795,6 +4802,9 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte r = r.WithContext(withServerBitrateCapV3(r.Context(), record.ServerBitrateCapKbps)) audioDegraded := false subtitleDegraded := false + // subtitleNotShown marks a subtitle the effective file has but cannot + // show, as opposed to one the file lacks. + subtitleNotShown := false reservationHeld := false reservationHandedOff := false cancelReservation := func() { @@ -5158,15 +5168,19 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte firstFailureAudioIndex = candidateAudioIndex } } - if result.Terminal != nil && droppedSubtitle != nil && subtitleOnlyRefusalV3(result.Terminal) { - // Only the subtitle kept the effective file from playing, so - // it plays without one rather than on a sibling that drops - // the subtitle too. + // A held-back requested edition is already the picture the + // viewer asked for, so it keeps its place. + if result.Terminal != nil && droppedSubtitle != nil && droppedSubtitle.file.ID != requestedFile.ID && subtitleRefusalV3(result.Terminal) { + // The planner pinned the effective file's refusal on the + // subtitle, and every sibling that plays drops the subtitle + // too. Playing the effective file without it keeps its + // picture, when that plan succeeds. withoutSubtitle := baseStart withoutSubtitle.SubtitleTrackID, withoutSubtitle.SubtitleTrackIndex = "", nil candidateResult, candidateToneMapErr := h.planPlaybackWithCapabilitiesV3(r.Context(), playback.PlannerInputV3{Request: withoutSubtitle, RequestedFile: plannerRequestedFile, EffectiveFile: baseEffectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, baseEffectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: plannerSettings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), baseEffectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), baseEffectiveFile)}) if candidateResult.Terminal == nil { droppedSubtitle = &alternateCandidateV3{file: baseEffectiveFile, request: withoutSubtitle, audioIndex: baseAudioIndex, result: candidateResult, toneMapErr: candidateToneMapErr} + subtitleNotShown = true } } if result.Terminal != nil && droppedSubtitle != nil { @@ -5218,7 +5232,11 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte result.Plan.DegradationWarnings = append(result.Plan.DegradationWarnings, playback.AudioTrackUnavailableWarningV3()) } if subtitleDegraded { - result.Plan.DegradationWarnings = appendWarningOnceV3(result.Plan.DegradationWarnings, playback.SubtitleTrackUnavailableWarningV3()) + warning := playback.SubtitleTrackUnavailableWarningV3() + if subtitleNotShown { + warning = playback.SubtitleNotShownWarningV3() + } + result.Plan.DegradationWarnings = appendWarningOnceV3(result.Plan.DegradationWarnings, warning) } session, err := h.sessionMgr.GetSession(record.SessionID) if err != nil { @@ -5991,11 +6009,11 @@ func terminalAllowsAlternateFileV3(terminal *playback.TerminalV3) bool { } } -// subtitleOnlyRefusalV3 reports a refusal caused by the selected subtitle -// alone. The planner names the subtitle only when its burn-in requirement was -// the sole reason for an adaptation the file cannot take, so the same file -// plays once the subtitle is cleared. -func subtitleOnlyRefusalV3(terminal *playback.TerminalV3) bool { +// subtitleRefusalV3 reports a refusal the planner pinned on the selected +// subtitle: a burn-in that was the only reason for an adaptation the file +// cannot take, or a subtitle the fidelity policy cannot serve. The file may +// then play with the subtitle cleared; callers re-plan it to find out. +func subtitleRefusalV3(terminal *playback.TerminalV3) bool { return terminal != nil && terminal.Reason == terminalSubtitleConversionUnsupportedV3 } diff --git a/internal/api/handlers/playback_v3_subtitle_version_test.go b/internal/api/handlers/playback_v3_subtitle_version_test.go index 99b91a4093..0c16a7203e 100644 --- a/internal/api/handlers/playback_v3_subtitle_version_test.go +++ b/internal/api/handlers/playback_v3_subtitle_version_test.go @@ -2,10 +2,13 @@ package handlers import ( "encoding/json" + "maps" "net/http" "net/http/httptest" + "slices" "strings" "testing" + "time" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/playback" @@ -106,6 +109,9 @@ func requireRequestedVersionWithoutSubtitleV3(t *testing.T, plan *playback.PlanV if warned != 1 { t.Fatalf("subtitle_track_unavailable reported %d times: %#v", warned, plan.DegradationWarnings) } + if !slices.Contains(plan.DegradationWarnings, playback.SubtitleNotShownWarningV3()) { + t.Fatalf("warnings = %#v, want the subtitle reported as on the file but not shown", plan.DegradationWarnings) + } } // A subtitle that only the requested file cannot show is no reason to switch @@ -162,3 +168,73 @@ func TestHandleReplanPlaybackV3SubtitleOnlyRefusalKeepsTheRequestedVersion(t *te } requireRequestedVersionWithoutSubtitleV3(t, replanned.PlaybackPlan, source) } + +// The active alternate is kept during an output change only to keep the +// viewer's subtitle. When the new output cannot show that subtitle on the +// alternate either, playback still returns to the requested edition without +// it: the requested edition is the picture the viewer asked for. +func TestHandleReplanPlaybackV3OutputChangeReturnsToRequestedEditionWhenSubtitleCannotBeShown(t *testing.T) { + handler, active, requested, startRequest := subtitleBlockedHDRFixtureV3(t) + failedAt := time.Now().UTC() + repaired := *requested + *requested = models.MediaFile{ID: repaired.ID, ContentID: repaired.ContentID, FilePath: repaired.FilePath, ProbeFailedAt: &failedAt} + startRequest.FileID = requested.ID + original := startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] + original.Subtitles.EmbeddedBitmap = true + startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = original + + // The requested 1080p edition is unreadable at start, so playback lands + // on the 4K edition, the only one with the English PGS. + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil || started.PlaybackPlan.EffectiveMediaFileID != active.ID { + t.Fatalf("start did not land on the 4K edition: status=%d body=%s", rec.Code, rec.Body.String()) + } + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + *requested = repaired + + subtitleIndex := 0 + subtitled := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationTrackChangeV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "kept-edition-track-0001", + FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "kept-edition-track-attempt-0001", + PlanAttemptKey: started.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 30, + QualityPreference: "auto", + SelectedTracks: playback.SelectedTracksV3{ + Audio: started.PlaybackPlan.SelectedTracks.Audio, + Subtitle: &playback.TrackIdentityV3{ID: playback.TrackIDV3(active.ID, "subtitle", subtitleIndex), Index: &subtitleIndex}, + }, + Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext, + }) + if subtitled.PlaybackPlan == nil || subtitled.PlaybackPlan.EffectiveMediaFileID != active.ID || subtitled.PlaybackPlan.SelectedTracks.Subtitle == nil { + t.Fatalf("the PGS on the 4K edition was not selected: %#v", subtitled) + } + + // The new output cannot draw PGS, so the 4K edition would need a burn-in + // its HDR picture cannot take. + nextContext := startRequest.ClientPlaybackContext + nextContext.Output.OutputContextID = "route-2" + nextContext.Deliveries = maps.Clone(startRequest.ClientPlaybackContext.Deliveries) + original.Subtitles.EmbeddedBitmap = false + nextContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = original + replanned := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationOutputChangeV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "kept-edition-output-0001", + FailedPlanID: subtitled.PlaybackPlan.PlanID, PlanAttemptID: "kept-edition-output-attempt-0001", + PlanAttemptKey: subtitled.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 40, + QualityPreference: "auto", + SelectedTracks: subtitled.PlaybackPlan.SelectedTracks, + Capabilities: startRequest.Capabilities, ClientPlaybackContext: nextContext, + }) + if replanned.PlaybackPlan == nil || replanned.Terminal != nil { + t.Fatalf("output change = %#v", replanned) + } + plan := replanned.PlaybackPlan + if plan.EffectiveMediaFileID != requested.ID || plan.SelectedTracks.Subtitle != nil { + t.Fatalf("effective file = %d subtitle = %#v, want the requested edition %d without the subtitle", plan.EffectiveMediaFileID, plan.SelectedTracks.Subtitle, requested.ID) + } + if !slices.Contains(plan.DegradationWarnings, playback.SubtitleTrackUnavailableWarningV3()) { + t.Fatalf("warnings = %#v, want the subtitle reported as not on this file", plan.DegradationWarnings) + } +} diff --git a/internal/playback/protocol_v3.go b/internal/playback/protocol_v3.go index 724a6b8df5..9e7784ea13 100644 --- a/internal/playback/protocol_v3.go +++ b/internal/playback/protocol_v3.go @@ -143,6 +143,15 @@ func SubtitleTrackUnavailableWarningV3() DegradationWarningV3 { return DegradationWarningV3{Code: DegradationWarningSubtitleTrackUnavailableV3, Message: "The selected subtitle track is not on this file; starting without it."} } +// SubtitleNotShownWarningV3 reports a selected subtitle that is on the file +// but cannot be shown with any route the file can take (a bitmap burn-in an +// HDR source cannot be re-encoded for, say), so playback continues without +// it. The code is the one SubtitleTrackUnavailableWarningV3 uses: clients +// handle both the same way. +func SubtitleNotShownWarningV3() DegradationWarningV3 { + return DegradationWarningV3{Code: DegradationWarningSubtitleTrackUnavailableV3, Message: "The selected subtitle cannot be shown on this file; playing without it."} +} + // ServerFeaturesV3 returns the complete feature set advertised by protocol-v3 // capability and decision responses. A fresh slice prevents callers from // mutating the shared contract. From cddfe7183ab64b572700759e21913d23bd3df4f1 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:23:55 -0400 Subject: [PATCH 3/3] fix(playback): keep a playable sibling and the requested edition in the subtitle fallback Plan the requested file without its subtitle through one helper that escalates a refused progressive remux before the result replaces a sibling that already plays. Without it, a worker-only server with header-authenticated media could end the start or replan in local_transcode_disabled where main played the sibling. When the active alternate was kept only for its subtitle, hold back the requested edition ahead of other versions that drop the subtitle, so it still wins when the item has more than two versions. Describe the same-file fallback and the wider subtitle_track_unavailable meaning in the protocol doc. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/playback-protocol-v3.md | 12 +- internal/api/handlers/playback_v3.go | 41 +++-- .../playback_v3_subtitle_version_test.go | 164 ++++++++++++++++-- 3 files changed, 187 insertions(+), 30 deletions(-) diff --git a/docs/architecture/playback-protocol-v3.md b/docs/architecture/playback-protocol-v3.md index 85dd398a86..e940d5531e 100644 --- a/docs/architecture/playback-protocol-v3.md +++ b/docs/architecture/playback-protocol-v3.md @@ -974,11 +974,15 @@ fallback to another media version must remap the selected subtitle. It prefers a version with an equivalent track; when none has one, playback continues with subtitles off and the plan carries `subtitle_track_unavailable` rather than ending in a terminal. A selection the effective file cannot honor -on a direct start degrades the same way. A replan that would have to drop the +on a direct start degrades the same way. When the file being planned was +refused with `subtitle_conversion_unsupported` and the only other versions that +play drop the subtitle, the server plays that file with subtitles off instead +of moving to one of them, provided that plan can run; when no other version +plays, the terminal stands. A replan that would have to drop the subtitle to return to the requested version stays on the version already playing, unless quality `original` pins the requested version; the requested -version remains a fallback without the subtitle if no version that keeps it can -play. Malformed +version remains a fallback without the subtitle, ahead of any other version +without it, if no version that keeps it can play. Malformed selections (a track identity that does not parse or names another file, or a negative index) are still rejected. @@ -1220,7 +1224,7 @@ The plan will play, but something the user might notice was given up. | `bandwidth_cap_applied` | `bandwidth_cap_kbps` limited the selection | | `evidence_insufficient_for_direct` | Evidence tier blocked a direct route | | `audio_track_unavailable` | The selected audio track is not on the effective file; its default track plays | -| `subtitle_track_unavailable` | The selected subtitle has no equivalent on the effective file; playback starts with subtitles off | +| `subtitle_track_unavailable` | The selected subtitle has no equivalent on the effective file, or is on it but cannot be shown there; playback continues with subtitles off. `message` says which | ### 7.3 Terminal reasons diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index d033d97f01..68c7596553 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -1785,12 +1785,9 @@ func (h *PlaybackHandler) startPlaybackApplicationV3(r *http.Request, body []byt // The planner pinned the requested file's refusal on the // subtitle, and every sibling that plays drops the subtitle // too. Playing the requested file without it keeps its - // picture, when that plan succeeds. - withoutSubtitle := baseReq - withoutSubtitle.SubtitleTrackID, withoutSubtitle.SubtitleTrackIndex = "", nil - candidateResult, candidateToneMapErr := h.planPlaybackWithCapabilitiesV3(r.Context(), playback.PlannerInputV3{Request: withoutSubtitle, RequestedFile: requestedFile, EffectiveFile: effectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, effectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}) - if candidateResult.Terminal == nil { - droppedSubtitle = &alternateCandidateV3{file: effectiveFile, request: withoutSubtitle, audioIndex: baseAudioIndex, result: candidateResult, toneMapErr: candidateToneMapErr} + // picture, when that plan can run. + if candidate := h.planWithoutSubtitleV3(r.Context(), playback.PlannerInputV3{Request: baseReq, RequestedFile: requestedFile, EffectiveFile: effectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, effectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}); candidate != nil { + droppedSubtitle = candidate subtitleNotShown = true } } @@ -5146,8 +5143,10 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte } if candidateResult.Terminal == nil && subtitleDropped { // Prefer a version that keeps the viewer's subtitle; hold - // this one back in case none does. - if droppedSubtitle == nil { + // this one back in case none does. The requested edition, + // listed last when it was passed over only for the + // subtitle, outranks any other version held back. + if droppedSubtitle == nil || candidateFile.ID == requestedFile.ID { droppedSubtitle = &alternateCandidateV3{file: candidateFile, request: candidateStart, audioIndex: candidateAudioIndex, result: candidateResult, toneMapErr: candidateToneMapErr} } continue @@ -5174,12 +5173,9 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte // The planner pinned the effective file's refusal on the // subtitle, and every sibling that plays drops the subtitle // too. Playing the effective file without it keeps its - // picture, when that plan succeeds. - withoutSubtitle := baseStart - withoutSubtitle.SubtitleTrackID, withoutSubtitle.SubtitleTrackIndex = "", nil - candidateResult, candidateToneMapErr := h.planPlaybackWithCapabilitiesV3(r.Context(), playback.PlannerInputV3{Request: withoutSubtitle, RequestedFile: plannerRequestedFile, EffectiveFile: baseEffectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, baseEffectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: plannerSettings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), baseEffectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), baseEffectiveFile)}) - if candidateResult.Terminal == nil { - droppedSubtitle = &alternateCandidateV3{file: baseEffectiveFile, request: withoutSubtitle, audioIndex: baseAudioIndex, result: candidateResult, toneMapErr: candidateToneMapErr} + // picture, when that plan can run. + if candidate := h.planWithoutSubtitleV3(r.Context(), playback.PlannerInputV3{Request: baseStart, RequestedFile: plannerRequestedFile, EffectiveFile: baseEffectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, baseEffectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: plannerSettings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), baseEffectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), baseEffectiveFile)}); candidate != nil { + droppedSubtitle = candidate subtitleNotShown = true } } @@ -6017,6 +6013,23 @@ func subtitleRefusalV3(terminal *playback.TerminalV3) bool { return terminal != nil && terminal.Reason == terminalSubtitleConversionUnsupportedV3 } +// planWithoutSubtitleV3 plans input's file again with the selected subtitle +// cleared. It returns nil unless that plan can run: a progressive remux this +// attempt cannot reach must escalate here, because the caller drops a sibling +// that already plays in favor of the result. +func (h *PlaybackHandler) planWithoutSubtitleV3(ctx context.Context, input playback.PlannerInputV3) *alternateCandidateV3 { + input.Request.SubtitleTrackID, input.Request.SubtitleTrackIndex = "", nil + result, toneMapErr := h.planPlaybackWithCapabilitiesV3(ctx, input) + if result.Terminal != nil { + return nil + } + result, escalateErr := h.escalateRefusedProgressiveRemuxV3(ctx, headerAuthenticatedMediaV3(input.Request.ClientFeatures), func() playback.PlannerInputV3 { return input }, result) + if escalateErr != nil { + return nil + } + return &alternateCandidateV3{file: input.EffectiveFile, request: input.Request, audioIndex: input.AudioTrackIndex, result: result, toneMapErr: toneMapErr} +} + // alternateCandidateV3 is a planned alternate version held back while the // fallback loop looks for a better one. type alternateCandidateV3 struct { diff --git a/internal/api/handlers/playback_v3_subtitle_version_test.go b/internal/api/handlers/playback_v3_subtitle_version_test.go index 0c16a7203e..7a9793a067 100644 --- a/internal/api/handlers/playback_v3_subtitle_version_test.go +++ b/internal/api/handlers/playback_v3_subtitle_version_test.go @@ -169,22 +169,26 @@ func TestHandleReplanPlaybackV3SubtitleOnlyRefusalKeepsTheRequestedVersion(t *te requireRequestedVersionWithoutSubtitleV3(t, replanned.PlaybackPlan, source) } -// The active alternate is kept during an output change only to keep the -// viewer's subtitle. When the new output cannot show that subtitle on the -// alternate either, playback still returns to the requested edition without -// it: the requested edition is the picture the viewer asked for. -func TestHandleReplanPlaybackV3OutputChangeReturnsToRequestedEditionWhenSubtitleCannotBeShown(t *testing.T) { - handler, active, requested, startRequest := subtitleBlockedHDRFixtureV3(t) +// outputChangeAfterKeptAlternateV3 starts on the 4K edition (every other +// version is unreadable at start), turns its English PGS on, repairs the other +// versions, and then switches to an output that cannot draw PGS. The active +// 4K edition was kept only for that subtitle, so the returned plan shows +// where playback goes once the subtitle cannot be shown there either. +func outputChangeAfterKeptAlternateV3(t *testing.T, handler *PlaybackHandler, active, requested *models.MediaFile, startRequest playback.StartRequestV3, others ...*models.MediaFile) *playback.PlanV3 { + t.Helper() failedAt := time.Now().UTC() - repaired := *requested - *requested = models.MediaFile{ID: repaired.ID, ContentID: repaired.ContentID, FilePath: repaired.FilePath, ProbeFailedAt: &failedAt} + unreadable := append([]*models.MediaFile{requested}, others...) + repaired := make([]models.MediaFile, len(unreadable)) + for i, file := range unreadable { + repaired[i] = *file + *file = models.MediaFile{ID: file.ID, ContentID: file.ContentID, FilePath: file.FilePath, ProbeFailedAt: &failedAt} + } startRequest.FileID = requested.ID original := startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] original.Subtitles.EmbeddedBitmap = true + startRequest.ClientPlaybackContext.Deliveries = maps.Clone(startRequest.ClientPlaybackContext.Deliveries) startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = original - // The requested 1080p edition is unreadable at start, so playback lands - // on the 4K edition, the only one with the English PGS. rec := httptest.NewRecorder() handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) var started playback.DecisionResponseV3 @@ -192,7 +196,9 @@ func TestHandleReplanPlaybackV3OutputChangeReturnsToRequestedEditionWhenSubtitle t.Fatalf("start did not land on the 4K edition: status=%d body=%s", rec.Code, rec.Body.String()) } t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) - *requested = repaired + for i, file := range unreadable { + *file = repaired[i] + } subtitleIndex := 0 subtitled := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ @@ -230,7 +236,13 @@ func TestHandleReplanPlaybackV3OutputChangeReturnsToRequestedEditionWhenSubtitle if replanned.PlaybackPlan == nil || replanned.Terminal != nil { t.Fatalf("output change = %#v", replanned) } - plan := replanned.PlaybackPlan + return replanned.PlaybackPlan +} + +// requireRequestedEditionWithoutSubtitleV3 fails unless plan is back on the +// requested edition with the subtitle off and reported as not on that file. +func requireRequestedEditionWithoutSubtitleV3(t *testing.T, plan *playback.PlanV3, requested *models.MediaFile) { + t.Helper() if plan.EffectiveMediaFileID != requested.ID || plan.SelectedTracks.Subtitle != nil { t.Fatalf("effective file = %d subtitle = %#v, want the requested edition %d without the subtitle", plan.EffectiveMediaFileID, plan.SelectedTracks.Subtitle, requested.ID) } @@ -238,3 +250,131 @@ func TestHandleReplanPlaybackV3OutputChangeReturnsToRequestedEditionWhenSubtitle t.Fatalf("warnings = %#v, want the subtitle reported as not on this file", plan.DegradationWarnings) } } + +// The active alternate is kept during an output change only to keep the +// viewer's subtitle. When the new output cannot show that subtitle on the +// alternate either, playback still returns to the requested edition without +// it: the requested edition is the picture the viewer asked for. +func TestHandleReplanPlaybackV3OutputChangeReturnsToRequestedEditionWhenSubtitleCannotBeShown(t *testing.T) { + handler, active, requested, startRequest := subtitleBlockedHDRFixtureV3(t) + requireRequestedEditionWithoutSubtitleV3(t, outputChangeAfterKeptAlternateV3(t, handler, active, requested, startRequest), requested) +} + +// The requested edition is listed after every other version, which the +// fallback tries lower resolutions first. Another version that also drops the +// subtitle must not take its place, and neither may the 4K edition without it. +func TestHandleReplanPlaybackV3OutputChangeReturnsToRequestedEditionAheadOfOtherVersions(t *testing.T) { + handler, active, requested, startRequest := subtitleBlockedHDRFixtureV3(t) + lowerValue := *requested + lower := &lowerValue + lower.ID = 126 + lower.FilePath = writePlaybackTestMediaFile(t, "movie-720p.mkv") + lower.Resolution = "720p" + lower.Bitrate = 3_000 + lower.VideoTracks = []models.VideoTrack{{ + Codec: "h264", Profile: "high", Level: 31, Width: 1280, Height: 720, + FrameRate: "24000/1001", Bitrate: 3_000, BitDepth: 8, + VideoRange: "SDR", VideoRangeType: "SDR", + }} + handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{ + active.ContentID: {active, requested, lower}, + }} + requireRequestedEditionWithoutSubtitleV3(t, outputChangeAfterKeptAlternateV3(t, handler, active, requested, startRequest, lower), requested) +} + +// workerOnlyHeaderAuthenticatedV3 moves the fixture onto header-authenticated +// media with remux_execution=worker_only, where the API refuses progressive +// remuxes. The 1080p sibling becomes an MP4 the client direct plays, while +// the 4K MKV without its subtitle needs a progressive MP4 remux, and HLS +// carries only H.264, so that remux has nowhere to escalate. +func workerOnlyHeaderAuthenticatedV3(t *testing.T, handler *PlaybackHandler, alternate *models.MediaFile, startRequest *playback.StartRequestV3) { + t.Helper() + requireWorkerRoutingV3(handler) + alternate.Container = "mp4" + alternate.FilePath = writePlaybackTestMediaFile(t, "movie-1080p.mp4") + startRequest.ClientFeatures = append(startRequest.ClientFeatures, playback.FeatureHeaderAuthenticatedMediaV3) + startRequest.Capabilities.VideoDecode = append(startRequest.Capabilities.VideoDecode, v3HandlerStartRequest().Capabilities.VideoDecode[0]) + startRequest.Capabilities.Containers = []string{"mp4", "hls"} + deliveries := maps.Clone(startRequest.ClientPlaybackContext.Deliveries) + original := deliveries[playback.DeliveryClassOriginalHTTPV3] + original.Containers = []string{"mp4"} + deliveries[playback.DeliveryClassOriginalHTTPV3] = original + deliveries[playback.DeliveryClassProgressiveV3] = playback.DeliveryCapabilityV3{ + Enabled: true, SupportedOnDevice: true, Containers: []string{"mp4"}, + VideoCodecs: []string{"hevc", "h264"}, AudioDecodeCodecs: []string{"aac"}, + } + startRequest.ClientPlaybackContext.Deliveries = deliveries +} + +// requireExecutableSiblingV3 fails unless response plays the sibling that was +// already found playable, rather than a requested-file route nothing can run. +func requireExecutableSiblingV3(t *testing.T, response playback.DecisionResponseV3, alternate *models.MediaFile) { + t.Helper() + if response.PlaybackPlan == nil || response.Terminal != nil { + t.Fatalf("response = terminal %#v, want the playable sibling %d", response.Terminal, alternate.ID) + } + if response.PlaybackPlan.EffectiveMediaFileID != alternate.ID || response.PlaybackPlan.Delivery != playback.DeliveryClassOriginalHTTPV3 { + t.Fatalf("effective file = %d delivery = %s, want sibling %d direct", response.PlaybackPlan.EffectiveMediaFileID, response.PlaybackPlan.Delivery, alternate.ID) + } +} + +// The requested file without its subtitle only replaces a playable sibling +// when its plan can run. A progressive remux the API refuses and HLS cannot +// carry would otherwise end the start in a terminal. +func TestHandleStartPlaybackV3SubtitleOnlyRefusalKeepsAnExecutableSibling(t *testing.T) { + handler, source, alternate, startRequest := subtitleBlockedHDRFixtureV3(t) + workerOnlyHeaderAuthenticatedV3(t, handler, alternate, &startRequest) + startRequest.SubtitleTrackID = playback.TrackIDV3(source.ID, "subtitle", 0) + startRequest.SubtitleTrackIndex = new(0) + + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil { + t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String()) + } + if started.SessionID != "" { + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + } + requireExecutableSiblingV3(t, started, alternate) +} + +// The same holds for an output change that leaves the 4K file unable to show +// the PGS it was playing with. +func TestHandleReplanPlaybackV3SubtitleOnlyRefusalKeepsAnExecutableSibling(t *testing.T) { + handler, source, alternate, startRequest := subtitleBlockedHDRFixtureV3(t) + workerOnlyHeaderAuthenticatedV3(t, handler, alternate, &startRequest) + original := startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] + original.Containers = []string{"mkv", "mp4"} + original.Subtitles.EmbeddedBitmap = true + startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = original + startRequest.Capabilities.Containers = []string{"mkv", "mp4", "hls"} + startRequest.SubtitleTrackID = playback.TrackIDV3(source.ID, "subtitle", 0) + startRequest.SubtitleTrackIndex = new(0) + + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil || started.PlaybackPlan.EffectiveMediaFileID != source.ID || started.PlaybackPlan.SelectedTracks.Subtitle == nil { + t.Fatalf("start did not play the 4K file with its PGS: status=%d body=%s", rec.Code, rec.Body.String()) + } + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + + capabilities := startRequest.Capabilities + capabilities.Containers = []string{"mp4", "hls"} + nextContext := startRequest.ClientPlaybackContext + nextContext.Output.OutputContextID = "route-2" + nextContext.Deliveries = maps.Clone(startRequest.ClientPlaybackContext.Deliveries) + original.Containers = []string{"mp4"} + original.Subtitles.EmbeddedBitmap = false + nextContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = original + replanned := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationOutputChangeV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "executable-sibling-output-0001", + FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "executable-sibling-output-attempt-0001", + PlanAttemptKey: started.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 30, + QualityPreference: "auto", SelectedTracks: started.PlaybackPlan.SelectedTracks, + Capabilities: capabilities, ClientPlaybackContext: nextContext, + }) + requireExecutableSiblingV3(t, replanned, alternate) +}