diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index a68b895f..c22a4103 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -2132,17 +2132,26 @@ func buildSubtitleURLs( Label: dl.ReleaseName + " (" + dl.Provider + ")", Source: "downloaded", HearingImpaired: dl.HearingImpaired, - URL: subtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID), + URL: downloadedSubtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID, dl.ID), }) } return urls } +const downloadedSubtitleIDParam = "downloaded_subtitle_id" + func subtitleStreamURL(sessionID string, trackIndex int, codec string, fileID int) string { return fmt.Sprintf("/stream/%s/subtitles/%d%s?file_id=%d", sessionID, trackIndex, subtitleURLExt(codec), fileID) } +// downloadedSubtitleStreamURL binds a downloaded subtitle URL to its stable +// database identity while preserving the combined track index for clients. +func downloadedSubtitleStreamURL(sessionID string, trackIndex int, codec string, fileID, downloadedID int) string { + return subtitleStreamURL(sessionID, trackIndex, codec, fileID) + + "&" + downloadedSubtitleIDParam + "=" + strconv.Itoa(downloadedID) +} + func subtitleFontBundleURL(sessionID string, trackIndex int, codec string, fileID int) string { if !playback.IsASS(codec) { return "" diff --git a/internal/api/handlers/playback_subtitle_urls_test.go b/internal/api/handlers/playback_subtitle_urls_test.go index a338abe3..e87a9916 100644 --- a/internal/api/handlers/playback_subtitle_urls_test.go +++ b/internal/api/handlers/playback_subtitle_urls_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/subtitles" ) func TestSubtitleURLExt(t *testing.T) { @@ -26,6 +27,17 @@ func TestSubtitleURLExt(t *testing.T) { } } +func TestBuildSubtitleURLsBindsDownloadedSubtitleIdentity(t *testing.T) { + file := &models.MediaFile{ID: 42} + urls := buildSubtitleURLs("sess-downloaded", file, []subtitles.DownloadedSubtitle{{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT}}, true) + if len(urls) != 1 { + t.Fatalf("downloaded subtitle URLs = %#v", urls) + } + if want := "/stream/sess-downloaded/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71"; urls[0].URL != want { + t.Fatalf("downloaded subtitle URL = %q, want %q", urls[0].URL, want) + } +} + func TestBuildSubtitleURLs_IncludesAllBitmapTracksForBurnInClients(t *testing.T) { file := &models.MediaFile{ ID: 42, diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index 2e2e7d62..627a07b8 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -35,6 +35,11 @@ const ( maxPlaybackV3EventBodyBytes = 32 << 10 replanLeaseDurationV3 = 15 * time.Second v3NodeCapabilityTTL = time.Minute + playbackNodeIntegratedV3 = "integrated" + subtitleFormatVTTV3 = "vtt" + subtitleMIMEVTTV3 = "text/vtt" + subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable" + seekRestorationPlayerV3 = "player_position" // Failed capability fetches are memoized briefly so an unreachable node // costs one timeout per window instead of one per planning request. v3NodeCapabilityErrorTTL = 15 * time.Second @@ -44,6 +49,8 @@ const ( v3NodeCapabilityPlanTimeout = 3 * time.Second ) +var errSubtitleStoreUnavailableV3 = errors.New("subtitle store unavailable") + type v3NodeCapabilityCache struct { transformations []playback.TransformationV3 err error @@ -67,6 +74,19 @@ type transportErrorV3 struct { cause error } +func subtitleArtifactErrorV3(message string, cause error) *transportErrorV3 { + return &transportErrorV3{ + reason: subtitleUnavailableReasonV3, + message: message, + retryable: errors.Is(cause, errSubtitleStoreUnavailableV3), + cause: cause, + } +} + +func wrapSubtitleStoreErrorV3(err error) error { + return fmt.Errorf("%w: %w", errSubtitleStoreUnavailableV3, err) +} + type v3ReplanLock struct { mu sync.Mutex refs int @@ -525,19 +545,24 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to load the initialized playback session.", cause: err} } result.Plan.SessionID = session.ID + frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result) + if frozenErr != nil { + abort() + return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr) + } transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result) if transportErr != nil { abort() return playback.DecisionResponseV3{}, transportErr } result.Plan.Stream.URL = transport.url - if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil { + if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &frozenRecipe); err != nil { transport.rollback() abort() - return playback.DecisionResponseV3{}, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err} + return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err) } response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: playback.ServerFeaturesV3(), Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan} - record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)} + record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)} if err := h.updateV3SessionState(r.Context(), session, effectiveFile, result, transport); err != nil { transport.rollback() abort() @@ -708,9 +733,10 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 { videoCodec = "copy" } - seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration)) + sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result) + seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration) unlock := h.tm.LockSessionLifecycle(session.ID) - ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), FastStart: true, NodeType: "integrated", ExecutionMode: "integrated", FFmpegLogSink: h.FFmpegLogSink}) + ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink}) if err != nil { unlock() return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err} @@ -765,8 +791,9 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 { videoCodec = "copy" } - seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration)) - req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), RequireReady: true} + sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result) + seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration) + req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, RequireReady: true} nodeResp, status, err := h.startRemotePlaybackTransport(r.Context(), node.URL, req) if err != nil { // A timeout can fire after the node actually started the job; the @@ -812,6 +839,16 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla }}, nil } +func sourceExecutionMetadataV3(file *models.MediaFile, result playback.PlannerResultV3) (string, float64) { + if result.FrozenSourceMetadata != nil { + return result.FrozenSourceMetadata.VideoCodec, result.FrozenSourceMetadata.DurationSeconds + } + if file == nil { + return "", 0 + } + return file.CodecVideo, float64(file.Duration) +} + func (h *PlaybackHandler) v3SessionStreamState(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, transport preparedTransportV3) playback.SessionStreamState { state := playback.SessionStreamState{PlayMethod: result.PlayMethod, BasePlayMethod: result.PlayMethod, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), TranscodeAudio: result.TranscodeAudio, RemuxDVMode: remuxDVModeForPlanV3(result.Plan), TranscodeNodeURL: transport.nodeURL, TranscodeTransportID: transport.transportID, TranscodeRouteSet: true, ClientIP: clientip.FromContext(ctx), ClientName: session.ClientName, ClientVersion: session.ClientVersion, ClientUserAgent: session.ClientUserAgent, StreamBitrateKbps: result.TargetBitrateKbps, TargetVideoCodec: result.TargetVideoCodec, TargetAudioCodec: result.TargetAudioCodec, TargetResolution: result.TargetResolution, SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn} if result.Plan != nil && (result.Plan.Delivery == playback.DeliveryTranscodeHLSV3 || result.Plan.Delivery == playback.DeliveryRemuxHLSV3) { @@ -842,16 +879,38 @@ func transportGenerationV3(sessionID, planID string) string { return sessionID + "-" + planSuffix + "-" + uuid.NewString()[:8] } -func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int) error { +func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int, recipe *playback.ExecutableRecipeV3) error { if plan == nil || file == nil || selectedIndex < 0 || (plan.Subtitle.Mode != playback.SubtitleRenderV3 && plan.Subtitle.Mode != playback.SubtitleConvertV3) { return nil } + if recipe != nil && recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 { + if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 { + return errors.New("the frozen downloaded subtitle is unavailable") + } + downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID) + if err != nil { + return wrapSubtitleStoreErrorV3(err) + } + if downloaded == nil || downloaded.MediaFileID != file.ID { + return errors.New("the frozen downloaded subtitle is unavailable for the selected media file") + } + url := downloadedSubtitleStreamURL(sessionID, selectedIndex, string(downloaded.Format), file.ID, downloaded.ID) + format := strings.ToLower(string(downloaded.Format)) + mime := subtitleMIMEV3(format) + if plan.Subtitle.Mode == playback.SubtitleConvertV3 { + format = subtitleFormatVTTV3 + mime = subtitleMIMEVTTV3 + url = forceSubtitleExtensionV3(url, ".vtt") + } + plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds} + return nil + } var downloaded []subtitles.DownloadedSubtitle if h.SubtitleRepo != nil { var err error downloaded, err = h.SubtitleRepo.ListDownloadedSubtitles(ctx, file.ID) if err != nil { - return err + return wrapSubtitleStoreErrorV3(err) } } for _, value := range buildSubtitleURLs(sessionID, file, downloaded, true) { @@ -862,8 +921,8 @@ func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionI mime := subtitleMIMEV3(format) url := value.URL if plan.Subtitle.Mode == playback.SubtitleConvertV3 { - format = "vtt" - mime = "text/vtt" + format = subtitleFormatVTTV3 + mime = subtitleMIMEVTTV3 url = forceSubtitleExtensionV3(value.URL, ".vtt") } plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds} @@ -883,7 +942,7 @@ func (h *PlaybackHandler) downloadedSubtitleInventoryV3(ctx context.Context, fil base := len(file.ExternalSubtitles) + len(file.SubtitleTracks) result := make([]playback.SubtitleInventoryEntryV3, 0, len(downloaded)) for index, value := range downloaded { - result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded"}) + result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded", DownloadedSubtitleID: value.ID}) } return result } @@ -1182,7 +1241,11 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte cause: err, } } - if seekScopedRecovery && effectiveFile.Duration > 0 && req.PositionSeconds > float64(effectiveFile.Duration) { + seekDuration := float64(effectiveFile.Duration) + if seekReanchor && record.FrozenRecipe.ValidFor(record.CurrentPlan) { + seekDuration = record.FrozenRecipe.SourceDurationSeconds + } + if seekScopedRecovery && seekDuration > 0 && req.PositionSeconds > seekDuration { return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{ reason: "invalid_seek_position", message: "The requested seek position is beyond the end of the selected media source.", @@ -1191,9 +1254,12 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte if _, err := start.NormalizeAndValidate(); err != nil { return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "invalid_replan", message: err.Error()} } - audioIndex, err := resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex) - if err != nil { - return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()} + audioIndex := 0 + if !seekReanchor { + audioIndex, err = resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex) + if err != nil { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()} + } } attemptedKeys := []string(nil) if !intentChange && !seekReanchor { @@ -1212,7 +1278,23 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte attemptedKeys = append(attemptedKeys, currentKey) } } - result := playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}) + var result playback.PlannerResultV3 + if seekReanchor { + if err := h.validateFrozenSubtitleIdentityV3(r.Context(), effectiveFile, record.FrozenRecipe); err != nil { + return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("The selected subtitle is no longer available at its frozen route.", err) + } + var frozenErr error + result, frozenErr = frozenSeekReanchorResultV3(record, req.PositionSeconds, time.Now()) + if frozenErr != nil { + return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{ + reason: "seek_reanchor_recipe_unavailable", + message: "The active playback recipe cannot be reopened; start a new playback attempt.", + retryable: true, + } + } + } else { + result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}) + } if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && replanAllowsAlternateFileV3(operation, start.QualityPreference) { if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil { alternate = h.ensurePlaybackProbe(r.Context(), alternate) @@ -1252,17 +1334,31 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte } } result.Plan.SessionID = session.ID + artifactRecipe := record.FrozenRecipe + if !seekReanchor { + frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result) + if frozenErr != nil { + return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr) + } + artifactRecipe = frozenRecipe + } transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result) if transportErr != nil { return playback.DecisionResponseV3{}, *record, nil, transportErr } result.Plan.Stream.URL = transport.url - if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil { + if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &artifactRecipe); err != nil { transport.rollback() - return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err} + return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err) } if seekReanchor { if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil { + changedFields := seekReanchorIdentityChangesV3(record, result.Plan) + slog.ErrorContext(r.Context(), "protocol v3 seek reanchor changed route identity", + "session", record.SessionID, + "playback_attempt_id", record.PlaybackAttemptID, + "changed_fields", changedFields, + ) transport.rollback() return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{ reason: "seek_reanchor_route_changed", @@ -1274,6 +1370,15 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte updated := *record updated.CurrentPlanID = result.Plan.PlanID updated.CurrentPlan = *result.Plan + // A seek reanchor replays the durable recipe verbatim (updated already + // carries it); re-freezing from live inventory could only re-introduce + // the drift this path exists to exclude. Every other replan just accepted + // a freshly planned route and must freeze its recipe — loudly, because a + // recipe with a silently missing subtitle identity would disable drift + // detection for every later seek on this attempt. + if !seekReanchor { + updated.FrozenRecipe = artifactRecipe + } updated.NormalizedRequest = start updated.EffectiveMediaFileID = effectiveFile.ID updated.ExpiresAt = time.Now().Add(playback.MaxTokenTTL) @@ -1314,6 +1419,133 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte return response, updated, &transport, nil } +func frozenSeekReanchorResultV3(record *playback.AttemptRecordV3, position float64, now time.Time) (playback.PlannerResultV3, error) { + if record == nil || !record.FrozenRecipe.ValidFor(record.CurrentPlan) { + return playback.PlannerResultV3{}, errors.New("the active playback recipe is unavailable") + } + plan := record.CurrentPlan + plan.ExpiresAt = playback.NewPlanExpiryV3(now) + plan.Timeline = playback.TimelineV3{ + SourceStartSeconds: position, + PlayerStartSeconds: position, + CanSeekAnywhere: true, + SeekRestoration: seekRestorationPlayerV3, + } + return record.FrozenRecipe.PlannerResult(&plan), nil +} + +type subtitleIndexLocationV3 struct { + source string + offset int +} + +// classifySubtitleIndexV3 maps the combined subtitle index used by +// buildSubtitleURLs to its inventory segment and segment-local offset. +func classifySubtitleIndexV3(file *models.MediaFile, index int) (subtitleIndexLocationV3, bool) { + if file == nil || index < 0 { + return subtitleIndexLocationV3{}, false + } + externalCount := len(file.ExternalSubtitles) + if index < externalCount { + return subtitleIndexLocationV3{source: playback.SubtitleSourceExternalV3, offset: index}, true + } + embeddedOffset := index - externalCount + if embeddedOffset < len(file.SubtitleTracks) { + return subtitleIndexLocationV3{source: playback.SubtitleSourceEmbeddedV3, offset: embeddedOffset}, true + } + return subtitleIndexLocationV3{ + source: playback.SubtitleSourceDownloadedV3, + offset: embeddedOffset - len(file.SubtitleTracks), + }, true +} + +// freezeExecutableRecipeV3 extends the pure planner freeze with the identity +// of the selected sidecar subtitle. The combined subtitle index space +// (externals, then embedded, then downloaded — see buildSubtitleURLs) is not +// stable across inventory changes, so the index alone cannot anchor a durable +// selection. A downloaded selection whose identity cannot be established is +// an error: silently omitting it would disable drift detection for exactly +// the seeks this recipe exists to protect. +func (h *PlaybackHandler) freezeExecutableRecipeV3(_ context.Context, file *models.MediaFile, result playback.PlannerResultV3) (playback.ExecutableRecipeV3, error) { + recipe := playback.FreezeExecutableRecipeV3(result) + if file != nil { + recipe.SourceVideoCodec = file.CodecVideo + recipe.SourceDurationSeconds = float64(file.Duration) + } + if file == nil || result.SubtitleTrackIndex < 0 { + return recipe, nil + } + // A downloaded row ID was selected from the planner's inventory snapshot. + // Treat it as authoritative before consulting the mutable combined-index + // segments: an external or embedded subtitle added after planning must not + // make this downloaded selection look like a different source. + if recipe.DownloadedSubtitleID > 0 { + recipe.SubtitleSource = playback.SubtitleSourceDownloadedV3 + return recipe, nil + } + location, ok := classifySubtitleIndexV3(file, result.SubtitleTrackIndex) + if !ok { + return recipe, nil + } + switch location.source { + case playback.SubtitleSourceExternalV3: + recipe.SubtitleSource = playback.SubtitleSourceExternalV3 + recipe.ExternalSubtitlePath = file.ExternalSubtitles[location.offset].Path + case playback.SubtitleSourceEmbeddedV3: + recipe.SubtitleSource = playback.SubtitleSourceEmbeddedV3 + recipe.EmbeddedStreamIndex = file.SubtitleTracks[location.offset].Index + case playback.SubtitleSourceDownloadedV3: + if recipe.DownloadedSubtitleID <= 0 { + return playback.ExecutableRecipeV3{}, errors.New("the selected downloaded subtitle has no stable identity") + } + } + return recipe, nil +} + +// validateFrozenSubtitleIdentityV3 confirms the frozen combined subtitle +// index still resolves to the identical inventory entry it was frozen +// against. It mirrors the segment layout of buildSubtitleURLs so a change in +// any earlier segment's size — which shifts every later index — is detected +// as an identity mismatch rather than silently re-resolved. +func (h *PlaybackHandler) validateFrozenSubtitleIdentityV3(ctx context.Context, file *models.MediaFile, recipe playback.ExecutableRecipeV3) error { + if recipe.SubtitleSource == "" { + return nil + } + if file == nil || recipe.SubtitleTrackIndex < 0 { + return errors.New("the frozen subtitle selection is unavailable") + } + if recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 { + if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 { + return errors.New("the downloaded subtitle inventory is unavailable") + } + downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID) + if err != nil { + return wrapSubtitleStoreErrorV3(err) + } + if downloaded == nil || downloaded.MediaFileID != file.ID { + return errors.New("the frozen downloaded subtitle identity changed") + } + return nil + } + location, ok := classifySubtitleIndexV3(file, recipe.SubtitleTrackIndex) + if !ok || location.source != recipe.SubtitleSource { + return errors.New("the frozen subtitle inventory segment changed") + } + switch recipe.SubtitleSource { + case playback.SubtitleSourceExternalV3: + if file.ExternalSubtitles[location.offset].Path != recipe.ExternalSubtitlePath { + return errors.New("the frozen external subtitle identity changed") + } + case playback.SubtitleSourceEmbeddedV3: + if file.SubtitleTracks[location.offset].Index != recipe.EmbeddedStreamIndex { + return errors.New("the frozen embedded subtitle identity changed") + } + default: + return errors.New("the frozen subtitle identity is unrecognized") + } + return nil +} + func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playback.ReplanRequestV3) error { if record == nil { return errors.New("the current playback attempt is unavailable") @@ -1332,41 +1564,66 @@ func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playbac return nil } +// seekReanchorIdentityChangesV3 returns only bounded, non-secret field names. +// It is safe for structured logs: values, URLs, headers, tokens, and subtitle +// artifact locations are deliberately excluded. +func seekReanchorIdentityChangesV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) []string { + if record == nil || candidate == nil { + return []string{"route"} + } + current := record.CurrentPlan + changed := make([]string, 0, 16) + add := func(name string, differs bool) { + if differs { + changed = append(changed, name) + } + } + add("plan_id", candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID) + add("requested_file_id", candidate.RequestedMediaFileID != record.RequestedMediaFileID) + add("effective_file_id", candidate.EffectiveMediaFileID != record.EffectiveMediaFileID) + add("delivery", candidate.Delivery != current.Delivery) + add("engine", candidate.Engine != current.Engine) + add("protocol", candidate.Stream.Protocol != current.Stream.Protocol) + add("container", candidate.Stream.Container != current.Stream.Container) + add("mime_type", candidate.Stream.MIMEType != current.Stream.MIMEType) + add("header_refresh", candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh) + add("video_codec", candidate.EffectiveRecipe.VideoCodec != current.EffectiveRecipe.VideoCodec) + add("audio_codec", candidate.EffectiveRecipe.AudioCodec != current.EffectiveRecipe.AudioCodec) + add("resolution", !optionalIntEqualV3(candidate.EffectiveRecipe.Width, current.EffectiveRecipe.Width) || !optionalIntEqualV3(candidate.EffectiveRecipe.Height, current.EffectiveRecipe.Height)) + add("frame_rate", !optionalFloatEqualV3(candidate.EffectiveRecipe.FrameRate, current.EffectiveRecipe.FrameRate)) + add("bitrate", !optionalIntEqualV3(candidate.EffectiveRecipe.BitrateKbps, current.EffectiveRecipe.BitrateKbps)) + add("dynamic_range", candidate.EffectiveRecipe.DynamicRange != current.EffectiveRecipe.DynamicRange) + add("audio_channels", !optionalIntEqualV3(candidate.EffectiveRecipe.AudioChannels, current.EffectiveRecipe.AudioChannels) || candidate.EffectiveRecipe.AudioLayout != current.EffectiveRecipe.AudioLayout) + add("selected_audio", !sameTrackIdentityV3(candidate.SelectedTracks.Audio, current.SelectedTracks.Audio)) + add("selected_subtitle", !sameTrackIdentityV3(candidate.SelectedTracks.Subtitle, current.SelectedTracks.Subtitle)) + add("subtitle_mode", candidate.Subtitle.Mode != current.Subtitle.Mode || candidate.Subtitle.TrackID != current.Subtitle.TrackID) + add("subtitle_artifact_route", !sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact)) + add("subtitle_fidelity", candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy) + add("transformations", !sameTransformationsV3(candidate.Transformations, current.Transformations)) + add("quirks", !sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks)) + add("runtime_corrections", !sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections)) + add("claims", candidate.Claims != current.Claims) + return changed +} + func validateSeekReanchorPlanV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) error { if record == nil || candidate == nil { return errors.New("seek reanchor produced no playback route") } - current := record.CurrentPlan - if candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID { + changedFields := seekReanchorIdentityChangesV3(record, candidate) + if len(changedFields) == 0 { + return nil + } + if containsStringExactV3(changedFields, "plan_id") { return errors.New("seek reanchor changed the playback plan identity") } - if candidate.RequestedMediaFileID != record.RequestedMediaFileID || candidate.EffectiveMediaFileID != record.EffectiveMediaFileID { + if containsStringExactV3(changedFields, "requested_file_id") || containsStringExactV3(changedFields, "effective_file_id") { return errors.New("seek reanchor changed the selected media version") } - if !sameSelectedTracksV3(candidate.SelectedTracks, current.SelectedTracks) { + if containsStringExactV3(changedFields, "selected_audio") || containsStringExactV3(changedFields, "selected_subtitle") { return errors.New("seek reanchor changed selected tracks") } - if candidate.Engine != current.Engine || - candidate.Stream.MIMEType != current.Stream.MIMEType || - candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh || - !sameEffectiveRecipeV3(candidate.EffectiveRecipe, current.EffectiveRecipe) || - candidate.Claims != current.Claims || - candidate.Subtitle.Mode != current.Subtitle.Mode || - candidate.Subtitle.TrackID != current.Subtitle.TrackID || - !sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact) || - candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy || - !sameTransformationsV3(candidate.Transformations, current.Transformations) || - !sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks) || - !sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections) { - return errors.New("seek reanchor changed the playback route semantics") - } - generation := record.NormalizedRequest.OutputRouteGeneration - currentKey := playback.PlanAttemptKeyV3(current, generation, nil) - candidateKey := playback.PlanAttemptKeyV3(*candidate, generation, nil) - if candidateKey != currentKey { - return errors.New("seek reanchor changed the playback route recipe") - } - return nil + return errors.New("seek reanchor changed the playback route semantics") } func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool { @@ -1378,18 +1635,6 @@ func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool return left.MIMEType == right.MIMEType && left.Format == right.Format } -func sameEffectiveRecipeV3(left, right playback.EffectiveRecipeV3) bool { - return left.VideoCodec == right.VideoCodec && - left.AudioCodec == right.AudioCodec && - optionalIntEqualV3(left.Width, right.Width) && - optionalIntEqualV3(left.Height, right.Height) && - optionalFloatEqualV3(left.FrameRate, right.FrameRate) && - optionalIntEqualV3(left.BitrateKbps, right.BitrateKbps) && - left.DynamicRange == right.DynamicRange && - optionalIntEqualV3(left.AudioChannels, right.AudioChannels) && - left.AudioLayout == right.AudioLayout -} - func sameTransformationsV3(left, right []playback.TransformationV3) bool { if len(left) != len(right) { return false @@ -1934,7 +2179,7 @@ func subtitleMIMEV3(format string) string { case "pgs", "hdmv_pgs_subtitle": return "application/octet-stream" default: - return "text/vtt" + return subtitleMIMEVTTV3 } } @@ -2054,7 +2299,7 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur plan.Timeline.SeekWindowStartSeconds = nil plan.Timeline.SeekWindowEndSeconds = nil plan.Timeline.CanSeekAnywhere = durationSeconds > 0 - plan.Timeline.SeekRestoration = "player_position" + plan.Timeline.SeekRestoration = seekRestorationPlayerV3 } return seek, startSegment } diff --git a/internal/api/handlers/playback_v3_test.go b/internal/api/handlers/playback_v3_test.go index f215ea83..08d28793 100644 --- a/internal/api/handlers/playback_v3_test.go +++ b/internal/api/handlers/playback_v3_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "errors" "fmt" "math" "net/http" @@ -17,6 +18,7 @@ import ( "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/subtitles" "github.com/Silo-Server/silo-server/internal/transcodenode" ) @@ -606,6 +608,274 @@ func TestHandleReplanPlaybackV3SeekReanchorKeepsCurrentRecipeEligible(t *testing } } +func TestHandleReplanPlaybackV3SeekReanchorIgnoresRefreshedProbeMetadata(t *testing.T) { + file := v3HandlerFixtureFile(t) + file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo"}) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + startRequest := v3HandlerStartRequest() + selectedAudio := 1 + startRequest.AudioTrackIndex = &selectedAudio + startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}} + + startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()) + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + var started playback.DecisionResponseV3 + if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil { + t.Fatal(err) + } + if started.PlaybackPlan == nil || started.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 { + t.Fatalf("start plan = %#v", started.PlaybackPlan) + } + + // Simulate a refreshed probe changing a live planner input after the route + // was durably accepted. A seek is not authority to consume that drift. + file.Container = "mkv" + file.AudioTracks = file.AudioTracks[:1] + currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil) + reanchor := playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, + ReplanRequestID: "seek-reanchor-probe-drift-0001", FailedPlanID: started.PlaybackPlan.PlanID, + PlanAttemptID: "plan-attempt-seek-probe-0001", PlanAttemptKey: currentKey, + AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 321, + OutputRouteGeneration: startRequest.OutputRouteGeneration, + SelectedTracks: started.PlaybackPlan.SelectedTracks, + Capabilities: startRequest.Capabilities, + ClientPlaybackContext: startRequest.ClientPlaybackContext, + } + body, err := json.Marshal(reanchor) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext()) + req = withPlaybackRouteParam(req, "session_id", started.SessionID) + rr := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("reanchor status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.PlaybackPlan == nil || response.PlaybackPlan.PlanID != started.PlaybackPlan.PlanID || + response.PlaybackPlan.Delivery != started.PlaybackPlan.Delivery || + response.PlaybackPlan.Timeline.SourceStartSeconds != 321 { + t.Fatalf("reanchored plan = %#v, terminal = %#v", response.PlaybackPlan, response.Terminal) + } +} + +func TestStartPlaybackV3FreezesDownloadedSubtitleFromPlanningSnapshot(t *testing.T) { + file := v3HandlerFixtureFile(t) + repo := newMockSubtitleRepoForHandler() + first := subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT} + reordered := subtitles.DownloadedSubtitle{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT} + repo.listResults = [][]subtitles.DownloadedSubtitle{{first}, {reordered, first}} + repo.subtitles[first.ID] = &first + repo.subtitles[reordered.ID] = &reordered + + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.SubtitleRepo = repo + request := v3HandlerStartRequest() + downloadedIndex := 0 + request.SubtitleTrackIndex = &downloadedIndex + request.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", downloadedIndex) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, request))).WithContext(newAuthorizedPlaybackContext()) + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.PlaybackPlan == nil || response.PlaybackPlan.Subtitle.Artifact == nil || + !strings.Contains(response.PlaybackPlan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") { + t.Fatalf("playback plan = %#v, want downloaded subtitle 71", response.PlaybackPlan) + } + if repo.listCalls != 1 { + t.Fatalf("downloaded inventory listed %d times, want one planning snapshot", repo.listCalls) + } + record, err := handler.PlanStoreV3.GetAttempt(context.Background(), response.SessionID) + if err != nil { + t.Fatal(err) + } + if record.FrozenRecipe.DownloadedSubtitleID != 71 { + t.Fatalf("frozen downloaded subtitle = %d, want 71", record.FrozenRecipe.DownloadedSubtitleID) + } +} + +func TestHandleReplanPlaybackV3SeekReanchorPreservesFallbackRecipe(t *testing.T) { + file := v3HandlerFixtureFile(t) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{ + {Name: "audio_to_aac", RecipeVersion: "1", Available: true}, + {Name: "video_to_h264", RecipeVersion: "1", Available: true}, + {Name: "server_dv7_to_hdr10", RecipeVersion: "1", Available: true}, + })) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + startRequest := v3HandlerStartRequest() + startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true} + startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3HLSV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true} + + startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()) + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + var active playback.DecisionResponseV3 + if err := json.Unmarshal(startRR.Body.Bytes(), &active); err != nil { + t.Fatal(err) + } + if active.PlaybackPlan == nil || active.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 { + t.Fatalf("initial plan = %#v", active.PlaybackPlan) + } + + attempted := []string{} + wantFallbacks := []playback.DeliveryV3{playback.DeliveryRemuxProgressiveV3, playback.DeliveryRemuxHLSV3} + for index, classification := range []string{"playback_error", "decoder_error"} { + currentKey := playback.PlanAttemptKeyV3(*active.PlaybackPlan, startRequest.OutputRouteGeneration, nil) + attempted = append(attempted, currentKey) + response := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, + ReplanRequestID: fmt.Sprintf("fallback-replan-%04d", index+1), + FailedPlanID: active.PlaybackPlan.PlanID, + PlanAttemptID: fmt.Sprintf("fallback-plan-attempt-%04d", index+1), + PlanAttemptKey: currentKey, AttemptedPlanKeys: append([]string(nil), attempted...), + AttemptCount: index + 1, QualityPreference: startRequest.QualityPreference, + OutputRouteGeneration: startRequest.OutputRouteGeneration, + SelectedTracks: active.PlaybackPlan.SelectedTracks, + Failure: playback.FailureV3{Classification: classification}, + Capabilities: startRequest.Capabilities, + ClientPlaybackContext: startRequest.ClientPlaybackContext, + }) + if response.PlaybackPlan == nil { + t.Fatalf("fallback %d = %#v", index, response) + } + if response.PlaybackPlan.Delivery != wantFallbacks[index] { + t.Fatalf("fallback %d delivery = %q, want %q; response=%#v", index, response.PlaybackPlan.Delivery, wantFallbacks[index], response) + } + active = response + } + if active.PlaybackPlan.Delivery != playback.DeliveryRemuxHLSV3 { + t.Fatalf("fallback route = %#v", active.PlaybackPlan) + } + frozen := *active.PlaybackPlan + currentKey := playback.PlanAttemptKeyV3(frozen, startRequest.OutputRouteGeneration, nil) + reanchored := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, + ReplanRequestID: "fallback-seek-reanchor-0001", FailedPlanID: frozen.PlanID, + PlanAttemptID: "fallback-seek-plan-0001", PlanAttemptKey: currentKey, + AttemptCount: 3, QualityPreference: startRequest.QualityPreference, PositionSeconds: 819.185, + OutputRouteGeneration: startRequest.OutputRouteGeneration, + SelectedTracks: frozen.SelectedTracks, + Capabilities: startRequest.Capabilities, + ClientPlaybackContext: startRequest.ClientPlaybackContext, + }) + if reanchored.PlaybackPlan == nil || reanchored.PlaybackPlan.PlanID != frozen.PlanID || + reanchored.PlaybackPlan.Delivery != frozen.Delivery || reanchored.PlaybackPlan.Engine != frozen.Engine || + reanchored.PlaybackPlan.Timeline.SourceStartSeconds != 819.185 { + t.Fatalf("reanchored fallback = %#v, terminal = %#v", reanchored.PlaybackPlan, reanchored.Terminal) + } +} + +// A pre-migration attempt row carries frozen_recipe = '{}', which decodes to +// an invalid zero recipe. A seek reanchor against it must fail with the +// dedicated retryable reason — telling the client to mint a fresh playback +// attempt — while leaving the durable attempt fully usable for subsequent +// failure replans. +func TestHandleReplanPlaybackV3SeekReanchorWithoutFrozenRecipeFailsRetryably(t *testing.T) { + file := v3HandlerFixtureFile(t) + manager := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file}) + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}} + handler.ItemAccess = allowAllPlaybackItemAccess{} + startRequest := v3HandlerStartRequest() + startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}} + + startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()) + startRR := httptest.NewRecorder() + handler.HandleStartPlayback(startRR, startReq) + if startRR.Code != http.StatusCreated { + t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String()) + } + var started playback.DecisionResponseV3 + if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil { + t.Fatal(err) + } + if started.PlaybackPlan == nil { + t.Fatalf("start response = %s", startRR.Body.String()) + } + + // Simulate the row predating the frozen_recipe migration: the JSONB + // default '{}' unmarshals to the zero recipe. + record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + record.FrozenRecipe = playback.ExecutableRecipeV3{} + handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record) + + currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil) + seekResponse := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, + ReplanRequestID: "legacy-seek-reanchor-0001", FailedPlanID: started.PlaybackPlan.PlanID, + PlanAttemptID: "legacy-seek-plan-0001", PlanAttemptKey: currentKey, + AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456, + OutputRouteGeneration: startRequest.OutputRouteGeneration, + SelectedTracks: started.PlaybackPlan.SelectedTracks, + Capabilities: startRequest.Capabilities, + ClientPlaybackContext: startRequest.ClientPlaybackContext, + }) + if seekResponse.Terminal == nil || seekResponse.Terminal.Reason != "seek_reanchor_recipe_unavailable" || !seekResponse.Terminal.Retryable { + t.Fatalf("legacy seek reanchor = %#v, terminal = %#v", seekResponse.PlaybackPlan, seekResponse.Terminal) + } + + // The failed seek must not have consumed the durable attempt: the current + // plan is unchanged and an ordinary failure replan still succeeds. + after, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID) + if err != nil { + t.Fatal(err) + } + if after.CurrentPlanID != started.PlaybackPlan.PlanID { + t.Fatalf("failed legacy seek moved the current plan: %q -> %q", started.PlaybackPlan.PlanID, after.CurrentPlanID) + } + recovery := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, + ReplanRequestID: "legacy-recovery-0001", FailedPlanID: started.PlaybackPlan.PlanID, + PlanAttemptID: "legacy-recovery-plan-0001", PlanAttemptKey: currentKey, + AttemptedPlanKeys: []string{currentKey}, + AttemptCount: 2, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456, + OutputRouteGeneration: startRequest.OutputRouteGeneration, + SelectedTracks: started.PlaybackPlan.SelectedTracks, + Failure: playback.FailureV3{Classification: "playback_error"}, + Capabilities: startRequest.Capabilities, + ClientPlaybackContext: startRequest.ClientPlaybackContext, + }) + if recovery.PlaybackPlan == nil { + t.Fatalf("failure replan after legacy seek = %#v, terminal = %#v", recovery.PlaybackPlan, recovery.Terminal) + } +} + func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *testing.T) { // This test has never passed. It fails at 854d07cf, the commit that // introduced it, so it describes behavior that was specified and not @@ -829,6 +1099,7 @@ func TestHandleReplanPlaybackV3SeekUsesEffectiveEditionWhenRequestedEditionIsGon record.CurrentPlan, ) record.CurrentPlanID = record.CurrentPlan.PlanID + record.FrozenRecipe.PlanID = record.CurrentPlan.PlanID handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record) currentKey := playback.PlanAttemptKeyV3(record.CurrentPlan, record.NormalizedRequest.OutputRouteGeneration, nil) @@ -1062,6 +1333,283 @@ func TestValidateSeekReanchorPlanV3RejectsRouteDrift(t *testing.T) { } } +func TestSeekReanchorIdentityChangesV3ReportsOnlyChangedFieldNames(t *testing.T) { + current := playback.PlanV3{ + PlanID: "plan:current", Delivery: playback.DeliveryOriginalHTTPV3, + Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4"}, + EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac"}, + RequestedMediaFileID: 42, EffectiveMediaFileID: 42, + } + record := &playback.AttemptRecordV3{ + RequestedMediaFileID: 42, EffectiveMediaFileID: 42, + CurrentPlanID: current.PlanID, CurrentPlan: current, + } + candidate := current + candidate.PlanID = "plan:candidate" + candidate.Delivery = playback.DeliveryRemuxHLSV3 + candidate.Stream.Container = "hls" + candidate.EffectiveRecipe.AudioCodec = "ac3" + got := strings.Join(seekReanchorIdentityChangesV3(record, &candidate), ",") + if want := "plan_id,delivery,container,audio_codec"; got != want { + t.Fatalf("changed fields = %q, want %q", got, want) + } +} + +func TestFrozenSeekReanchorResultV3PreservesRouteMatrix(t *testing.T) { + audioIndex := 0 + basePlan := func(name string) playback.PlanV3 { + return playback.PlanV3{ + ProtocolVersion: playback.ProtocolV3, PlanID: "plan:" + name, + Delivery: playback.DeliveryOriginalHTTPV3, Engine: playback.EngineMedia3DirectV3, + Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4", MIMEType: "video/mp4", HeaderRefresh: playback.HeaderRefreshSessionV3}, + SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "audio", audioIndex), Index: &audioIndex}}, + EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac", DynamicRange: "sdr"}, + Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleOffV3}, + Transformations: []playback.TransformationV3{}, AppliedQuirks: []playback.AppliedQuirkV3{}, RuntimeCorrections: []string{}, + RequestedMediaFileID: 42, EffectiveMediaFileID: 42, + } + } + tests := []struct { + name string + mutate func(*playback.PlanV3, *playback.PlannerResultV3) + }{ + {name: "direct"}, + {name: "progressive remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) { + plan.Delivery = playback.DeliveryRemuxProgressiveV3 + plan.Engine = playback.EngineMedia3ProgressiveRemuxV3 + result.PlayMethod = playback.PlayRemux + }}, + {name: "HLS remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) { + plan.Delivery = playback.DeliveryRemuxHLSV3 + plan.Engine = playback.EngineMedia3HLSV3 + plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3} + result.PlayMethod = playback.PlayRemux + result.TargetVideoCodec = "copy" + result.TargetAudioCodec = "copy" + }}, + {name: "audio converting remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) { + plan.Delivery = playback.DeliveryRemuxProgressiveV3 + plan.Engine = playback.EngineMedia3ProgressiveRemuxV3 + plan.Transformations = []playback.TransformationV3{{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}} + result.PlayMethod = playback.PlayRemux + result.TranscodeAudio = true + result.TargetAudioCodec = "aac" + result.TargetAudioChannels = 2 + }}, + {name: "downloaded subtitle", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) { + subtitleIndex := 7 + plan.SelectedTracks.Subtitle = &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "subtitle", subtitleIndex), Index: &subtitleIndex} + plan.Subtitle = playback.SubtitleDecisionV3{Mode: playback.SubtitleConvertV3, TrackID: plan.SelectedTracks.Subtitle.ID, Artifact: &playback.SubtitleArtifactV3{MIMEType: "text/vtt", Format: "vtt"}} + result.SubtitleTrackIndex = subtitleIndex + result.SubtitleTransportTrackIndex = subtitleIndex + result.SubtitleCodec = "srt" + result.DownloadedSubtitleID = 71 + }}, + {name: "Dolby Vision transformation", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) { + plan.EffectiveRecipe.DynamicRange = "hdr10" + plan.Transformations = []playback.TransformationV3{{Name: "server_dv7_to_hdr10", Executor: "server", RecipeVersion: "1"}} + }}, + {name: "pooled node only transformation", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) { + plan.Delivery = playback.DeliveryTranscodeHLSV3 + plan.Engine = playback.EngineMedia3HLSV3 + plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3} + plan.Transformations = []playback.TransformationV3{{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"}} + result.PlayMethod = playback.PlayTranscode + result.TargetVideoCodec = "h264" + result.TargetAudioCodec = "aac" + result.TargetResolution = "1080p" + }}, + {name: "device quirks and runtime corrections", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) { + plan.AppliedQuirks = []playback.AppliedQuirkV3{{ID: "apple-hls-audio", RegistryRevision: "3", Action: "force-aac"}} + plan.RuntimeCorrections = []string{"pcm_fallback"} + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan := basePlan(strings.ReplaceAll(test.name, " ", "-")) + operational := playback.PlannerResultV3{Plan: &plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1} + if test.mutate != nil { + test.mutate(&plan, &operational) + } + operational.Plan = &plan + record := &playback.AttemptRecordV3{ + PlaybackAttemptID: "attempt-matrix-0001", RequestedMediaFileID: 42, EffectiveMediaFileID: 42, + CurrentPlanID: plan.PlanID, CurrentPlan: plan, FrozenRecipe: playback.FreezeExecutableRecipeV3(operational), + } + result, err := frozenSeekReanchorResultV3(record, 321.25, time.Unix(1_786_000_000, 0)) + if err != nil || result.Plan == nil { + t.Fatalf("frozen reanchor: result=%#v err=%v", result, err) + } + if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil { + t.Fatalf("route semantics changed: %v, plan=%#v", err, result.Plan) + } + if result.Plan.Timeline.SourceStartSeconds != 321.25 || result.PlayMethod != operational.PlayMethod || + result.TranscodeAudio != operational.TranscodeAudio || result.TargetVideoCodec != operational.TargetVideoCodec || + result.TargetAudioCodec != operational.TargetAudioCodec || result.TargetAudioChannels != operational.TargetAudioChannels || + result.TargetResolution != operational.TargetResolution || result.SubtitleTrackIndex != operational.SubtitleTrackIndex || + result.SubtitleTransportTrackIndex != operational.SubtitleTransportTrackIndex || result.SubtitleBurnIn != operational.SubtitleBurnIn || + result.SubtitleCodec != operational.SubtitleCodec || result.DownloadedSubtitleID != operational.DownloadedSubtitleID { + t.Fatalf("operational recipe changed: got=%#v want=%#v", result, operational) + } + }) + } +} + +func TestFrozenDownloadedSubtitleV3AcceptsInventoryReordering(t *testing.T) { + file := v3HandlerFixtureFile(t) + file.ExternalSubtitles = []models.ExternalSubtitle{{Format: "srt"}} + file.SubtitleTracks = []models.SubtitleTrack{{Index: 0, Codec: "ass"}} + repo := newMockSubtitleRepoForHandler() + repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT} + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + handler.SubtitleRepo = repo + downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks) + plan := &playback.PlanV3{PlanID: "plan:downloaded", SelectedTracks: playback.SelectedTracksV3{Subtitle: &playback.TrackIdentityV3{ID: playback.TrackIDV3(file.ID, "subtitle", downloadedIndex), Index: &downloadedIndex}}} + result := playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex, DownloadedSubtitleID: 71} + recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result) + if err != nil { + t.Fatalf("freeze: %v", err) + } + if recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 { + t.Fatalf("frozen subtitle identity = %q/%d, want downloaded/71", recipe.SubtitleSource, recipe.DownloadedSubtitleID) + } + if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil { + t.Fatalf("stable inventory rejected: %v", err) + } + repo.list = []subtitles.DownloadedSubtitle{{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT}, {ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}} + repo.listErr = errors.New("mutable inventory must not be consulted") + if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil { + t.Fatalf("reordered downloaded subtitle inventory rejected stable identity: %v", err) + } + repo.getErr = errors.New("database unavailable") + if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); !errors.Is(err, errSubtitleStoreUnavailableV3) { + t.Fatalf("validation error = %v, want wrapped subtitle-store failure", err) + } +} + +func TestAttachSubtitleArtifactV3UsesFrozenDownloadedIdentityWithoutOrdinalLookup(t *testing.T) { + file := v3HandlerFixtureFile(t) + file.ExternalSubtitles = nil + file.SubtitleTracks = nil + repo := newMockSubtitleRepoForHandler() + repo.subtitles[71] = &subtitles.DownloadedSubtitle{ + ID: 71, MediaFileID: file.ID, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt", + } + // A second ordinal lookup would either fail or select a different row. + // Artifact attachment must use GetDownloadedSubtitle(71) exclusively. + repo.listErr = errors.New("mutable inventory must not be consulted") + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + handler.SubtitleRepo = repo + selectedIndex := 0 + plan := &playback.PlanV3{ + Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3}, + Timeline: playback.TimelineV3{StreamOriginSeconds: 321}, + } + recipe := playback.ExecutableRecipeV3{ + SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71, + SubtitleTrackIndex: selectedIndex, SubtitleCodec: "vtt", + } + if err := handler.attachSubtitleArtifactV3(context.Background(), "session-frozen-subtitle", file, plan, selectedIndex, &recipe); err != nil { + t.Fatalf("attach frozen downloaded subtitle: %v", err) + } + if plan.Subtitle.Artifact == nil || !strings.Contains(plan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") { + t.Fatalf("artifact = %#v, want frozen downloaded subtitle 71", plan.Subtitle.Artifact) + } +} + +func TestSubtitleArtifactStoreFailuresAreRetryable(t *testing.T) { + storeErr := errors.New("database unavailable") + wantRetryable := subtitleArtifactErrorV3("subtitle lookup failed", wrapSubtitleStoreErrorV3(storeErr)) + if !wantRetryable.retryable || !errors.Is(wantRetryable.cause, errSubtitleStoreUnavailableV3) { + t.Fatalf("store failure = %#v, want retryable subtitle error", wantRetryable) + } + wantPermanent := subtitleArtifactErrorV3("subtitle identity changed", errors.New("identity changed")) + if wantPermanent.retryable { + t.Fatalf("identity failure = %#v, want non-retryable subtitle error", wantPermanent) + } + + file := v3HandlerFixtureFile(t) + file.ExternalSubtitles = nil + file.SubtitleTracks = nil + repo := newMockSubtitleRepoForHandler() + repo.getErr = storeErr + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + handler.SubtitleRepo = repo + plan := &playback.PlanV3{ + Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3}, + } + recipe := playback.ExecutableRecipeV3{ + SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71, + SubtitleTrackIndex: 0, SubtitleCodec: "vtt", + } + err := handler.attachSubtitleArtifactV3(context.Background(), "session-store-error", file, plan, 0, &recipe) + if !errors.Is(err, errSubtitleStoreUnavailableV3) { + t.Fatalf("attach error = %v, want wrapped subtitle-store failure", err) + } +} + +func TestFreezeExecutableRecipeV3FailsLoudlyWhenDownloadedIdentityUnavailable(t *testing.T) { + file := v3HandlerFixtureFile(t) + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks) + result := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:downloaded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex} + if _, err := handler.freezeExecutableRecipeV3(context.Background(), file, result); err == nil { + t.Fatal("downloaded subtitle without a planning-snapshot identity was frozen") + } + result.DownloadedSubtitleID = 71 + file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/added-after-planning.srt", Format: "srt"}} + recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result) + if err != nil || recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 { + t.Fatalf("freeze planning snapshot identity: recipe=%#v err=%v", recipe, err) + } +} + +func TestFrozenSubtitleIdentityV3RejectsExternalAndEmbeddedInventoryDrift(t *testing.T) { + file := v3HandlerFixtureFile(t) + file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/en.srt", Format: "srt"}, {Path: "/subs/de.srt", Format: "srt"}} + file.SubtitleTracks = []models.SubtitleTrack{{Index: 3, Codec: "ass"}} + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + + externalIndex := 1 // /subs/de.srt + externalResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:external"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: externalIndex, SubtitleTransportTrackIndex: externalIndex} + externalRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, externalResult) + if err != nil { + t.Fatalf("freeze external: %v", err) + } + if externalRecipe.SubtitleSource != playback.SubtitleSourceExternalV3 || externalRecipe.ExternalSubtitlePath != "/subs/de.srt" { + t.Fatalf("frozen external identity = %q/%q", externalRecipe.SubtitleSource, externalRecipe.ExternalSubtitlePath) + } + + embeddedIndex := 2 // combined index of SubtitleTracks[0] + embeddedResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:embedded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: embeddedIndex, SubtitleTransportTrackIndex: 3} + embeddedRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, embeddedResult) + if err != nil { + t.Fatalf("freeze embedded: %v", err) + } + if embeddedRecipe.SubtitleSource != playback.SubtitleSourceEmbeddedV3 || embeddedRecipe.EmbeddedStreamIndex != 3 { + t.Fatalf("frozen embedded identity = %q/%d", embeddedRecipe.SubtitleSource, embeddedRecipe.EmbeddedStreamIndex) + } + + if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err != nil { + t.Fatalf("stable external inventory rejected: %v", err) + } + if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err != nil { + t.Fatalf("stable embedded inventory rejected: %v", err) + } + + // Deleting the first external subtitle shifts every later combined index. + // The frozen external selection now points past the external segment and + // the frozen embedded selection resolves one entry early; both must be + // rejected rather than silently re-resolved to a different artifact. + file.ExternalSubtitles = file.ExternalSubtitles[1:] + if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err == nil { + t.Fatal("external subtitle deletion was accepted for the frozen external selection") + } + if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err == nil { + t.Fatal("external subtitle deletion was accepted for the frozen embedded selection") + } +} + func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) { handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) handler.JWTSecret = "test-secret" @@ -1175,6 +1723,54 @@ func TestPrepareTransportV3RequiresRemoteManifestReadiness(t *testing.T) { } } +func TestPrepareTransportV3UsesFrozenSourceMetadataAfterProbeDrift(t *testing.T) { + var startRequest transcodenode.TranscodeStartRequest + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/hw-capabilities": + writeJSON(w, http.StatusOK, playback.HWAccelInfo{}) + case r.Method == http.MethodPost && r.URL.Path == "/transcode/start": + if err := json.NewDecoder(r.Body).Decode(&startRequest); err != nil { + t.Errorf("decode remote start: %v", err) + } + writeJSON(w, http.StatusAccepted, transcodenode.TranscodeStartResponse{SessionID: startRequest.SessionID, Status: "started"}) + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer remote.Close() + + file := v3HandlerFixtureFile(t) + file.CodecVideo = "hevc" + file.Duration = 7_201 + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0)) + handler.JWTSecret = "test-secret" + handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}} + plan := &playback.PlanV3{PlanID: "plan:frozen-source", Delivery: playback.DeliveryRemuxHLSV3} + initial := playback.PlannerResultV3{ + Plan: plan, PlayMethod: playback.PlayRemux, TargetVideoCodec: "copy", TargetAudioCodec: "copy", + SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1, + } + recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, initial) + if err != nil { + t.Fatalf("freeze executable recipe: %v", err) + } + file.CodecVideo = "mpeg2video" + file.Duration = 99 + result := recipe.PlannerResult(plan) + request := httptest.NewRequest(http.MethodPost, "/", nil) + transport, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-frozen-source", UserID: 7, ProfileID: "profile-1"}, file, result) + if transportErr != nil { + t.Fatalf("prepare remote transport: %v", transportErr) + } + defer transport.rollback() + if startRequest.SourceVideoCodec != "hevc" || startRequest.TotalDuration != 7_201 { + t.Fatalf("remote start consumed refreshed probe metadata: %#v", startRequest) + } +} + func TestHandleStartPlaybackUnknownProtocolUsesLegacyBranch(t *testing.T) { file := v3HandlerFixtureFile(t) manager := playback.NewSessionManager(0, 0) @@ -1374,3 +1970,23 @@ func marshalV3StartRequest(t *testing.T, request playback.StartRequestV3) string } return string(body) } + +func postPlaybackReplanV3(t *testing.T, handler *PlaybackHandler, sessionID string, request playback.ReplanRequestV3) playback.DecisionResponseV3 { + t.Helper() + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+sessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext()) + req = withPlaybackRouteParam(req, "session_id", sessionID) + rr := httptest.NewRecorder() + handler.HandleReplanPlaybackV3(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("replan status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response playback.DecisionResponseV3 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response +} diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index ef198fc3..dbb64510 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -219,6 +219,37 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) { return } + // New subtitle artifact URLs bind downloaded subtitles by their stable row + // identity. The path ordinal remains for compatibility and display, but it + // must not be re-resolved against a mutable inventory after a seek reanchor. + if rawID := strings.TrimSpace(r.URL.Query().Get(downloadedSubtitleIDParam)); rawID != "" { + downloadedID, parseErr := strconv.Atoi(rawID) + if parseErr != nil || downloadedID <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid downloaded subtitle identity") + return + } + if h.SubtitleRepo == nil || h.S3Client == nil { + writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found") + return + } + downloaded, lookupErr := h.SubtitleRepo.GetDownloadedSubtitle(r.Context(), downloadedID) + if lookupErr != nil { + slog.ErrorContext(r.Context(), "get downloaded subtitle failed", "component", "api", + "file_id", file.ID, + "downloaded_subtitle_id", downloadedID, + "error", lookupErr, + ) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load downloaded subtitle") + return + } + if downloaded == nil || downloaded.MediaFileID != file.ID { + writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found") + return + } + h.serveDownloadedSubtitle(w, r, *downloaded, requestedFormat) + return + } + externalCount := len(file.ExternalSubtitles) if trackIndex < externalCount { sub := file.ExternalSubtitles[trackIndex] @@ -287,32 +318,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) { downloadedIndex := embeddedIndex - len(file.SubtitleTracks) if downloadedIndex >= 0 && downloadedIndex < len(downloaded) { - dl := downloaded[downloadedIndex] - data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, dl.S3Key) - if err != nil { - writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage") - return - } - - // Serve ASS/SSA downloaded subtitles as raw data. - if playback.IsASS(string(dl.Format)) && requestedFormat != "vtt" { - playback.ServeSubtitle(w, data, "ass") - return - } - - // If the subtitle is already VTT, serve directly. - if dl.Format == subtitles.FormatVTT { - playback.ServeSubtitle(w, data, "vtt") - return - } - - // Convert to VTT using the playback conversion pipeline. - vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(dl.Format), h.ffmpegPath()) - if err != nil { - writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle") - return - } - playback.ServeSubtitle(w, vttData, "vtt") + h.serveDownloadedSubtitle(w, r, downloaded[downloadedIndex], requestedFormat) return } } @@ -320,6 +326,34 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found") } +func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.Request, subtitle subtitles.DownloadedSubtitle, requestedFormat string) { + data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, subtitle.S3Key) + if err != nil { + writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage") + return + } + + // Serve ASS/SSA downloaded subtitles as raw data. + if playback.IsASS(string(subtitle.Format)) && requestedFormat != "vtt" { + playback.ServeSubtitle(w, data, "ass") + return + } + + // If the subtitle is already VTT, serve directly. + if subtitle.Format == subtitles.FormatVTT { + playback.ServeSubtitle(w, data, "vtt") + return + } + + // Convert other text formats to VTT using the playback conversion pipeline. + vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(subtitle.Format), h.ffmpegPath()) + if err != nil { + writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle") + return + } + playback.ServeSubtitle(w, vttData, "vtt") +} + // subtitleSourceFileID pins a subtitle URL to the file whose track list was // used to create it. A quality/seek restart may change session.MediaFileID to // an alternate version; interpreting the old combined track index against the diff --git a/internal/api/handlers/stream_test.go b/internal/api/handlers/stream_test.go index 348d38fc..b401c171 100644 --- a/internal/api/handlers/stream_test.go +++ b/internal/api/handlers/stream_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/playback" + "github.com/Silo-Server/silo-server/internal/subtitles" ) type hookedSessionManager struct { @@ -246,6 +248,53 @@ func TestHandleSubtitle_ListDownloadedSubtitlesErrorReturns500(t *testing.T) { } } +func TestHandleSubtitleUsesBoundDownloadedIdentityAfterInventoryReorder(t *testing.T) { + file := &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: "/tmp/movie.mkv", Duration: 3600} + baseMgr := playback.NewSessionManager(0, 0) + session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + repo := newMockSubtitleRepoForHandler() + repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"} + // The mutable ordinal now points at a different subtitle. An ID-bound URL + // must still fetch 71 without consulting this reordered list. + repo.list = []subtitles.DownloadedSubtitle{ + {ID: 72, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "other-72.vtt"}, + {ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"}, + } + handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file}) + handler.SubtitleRepo = repo + handler.S3Client = subtitleContentS3Client{objects: map[string][]byte{ + "selected-71.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nselected-71\n"), + "other-72.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nother-72\n"), + }} + handler.S3Bucket = "test-bucket" + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71", nil) + req = req.WithContext(newAuthorizedPlaybackContext()) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("session_id", session.ID) + routeCtx.URLParams.Add("track", "0.vtt") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) + + rr := httptest.NewRecorder() + handler.HandleSubtitle(rr, req) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "selected-71") || strings.Contains(rr.Body.String(), "other-72") { + t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String()) + } +} + +type subtitleContentS3Client struct { + objects map[string][]byte +} + +func (subtitleContentS3Client) PutObject(context.Context, string, string, []byte) error { return nil } +func (c subtitleContentS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) { + return append([]byte(nil), c.objects[key]...), nil +} +func (subtitleContentS3Client) DeleteObject(context.Context, string, string) error { return nil } + func TestHandleSubtitle_NilMediaFileReturns404(t *testing.T) { baseMgr := playback.NewSessionManager(0, 0) session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false) diff --git a/internal/api/handlers/subtitle_search_test.go b/internal/api/handlers/subtitle_search_test.go index 7e8f4580..984c8fc6 100644 --- a/internal/api/handlers/subtitle_search_test.go +++ b/internal/api/handlers/subtitle_search_test.go @@ -239,9 +239,15 @@ type handlerMockSubtitleRepo struct { subtitles map[int]*subtitles.DownloadedSubtitle nextID int byKey map[string]*subtitles.DownloadedSubtitle + // getErr, when set, is returned by GetDownloadedSubtitle to simulate a + // backing-store failure. + getErr error // listErr, when set, is returned by ListDownloadedSubtitles to simulate a // backing-store failure. - listErr error + listErr error + list []subtitles.DownloadedSubtitle + listResults [][]subtitles.DownloadedSubtitle + listCalls int } func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo { @@ -260,6 +266,9 @@ func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, su } func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) { + if m.getErr != nil { + return nil, m.getErr + } if sub, ok := m.subtitles[id]; ok { copy := *sub return ©, nil @@ -268,10 +277,15 @@ func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id in } func (m *handlerMockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) { + m.listCalls++ if m.listErr != nil { return nil, m.listErr } - return nil, nil + if len(m.listResults) > 0 { + index := min(m.listCalls-1, len(m.listResults)-1) + return append([]subtitles.DownloadedSubtitle(nil), m.listResults[index]...), nil + } + return append([]subtitles.DownloadedSubtitle(nil), m.list...), nil } func (m *handlerMockSubtitleRepo) DeleteDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) { diff --git a/internal/playback/executable_recipe_v3.go b/internal/playback/executable_recipe_v3.go new file mode 100644 index 00000000..e7e1a159 --- /dev/null +++ b/internal/playback/executable_recipe_v3.go @@ -0,0 +1,110 @@ +package playback + +// ExecutableRecipeV3 is the frozen operational half of a protocol-v3 plan. +// PlanV3 describes the client-visible route identity; these fields are the +// additional inputs needed to open another transport for that same route. +// Keeping them with the durable attempt prevents seek reanchoring from +// reverse-engineering execution details from presentation fields or mutable +// planner inputs. +type ExecutableRecipeV3 struct { + Version int `json:"version"` + PlanID string `json:"plan_id"` + PlayMethod PlayMethod `json:"play_method"` + TranscodeAudio bool `json:"transcode_audio"` + TargetVideoCodec string `json:"target_video_codec,omitempty"` + TargetAudioCodec string `json:"target_audio_codec,omitempty"` + TargetAudioChannels int `json:"target_audio_channels,omitempty"` + TargetResolution string `json:"target_resolution,omitempty"` + TargetBitrateKbps int `json:"target_bitrate_kbps,omitempty"` + SourceVideoCodec string `json:"source_video_codec,omitempty"` + SourceDurationSeconds float64 `json:"source_duration_seconds,omitempty"` + SubtitleTrackIndex int `json:"subtitle_track_index"` + SubtitleTransportTrackIndex int `json:"subtitle_transport_track_index"` + SubtitleBurnIn bool `json:"subtitle_burn_in"` + SubtitleCodec string `json:"subtitle_codec,omitempty"` + // SubtitleSource pins which sidecar inventory segment SubtitleTrackIndex + // pointed into when the plan was accepted, and the identity fields below + // pin the exact entry. The combined index space (externals, then embedded, + // then downloaded) shifts when any segment grows or shrinks; a seek + // reanchor must detect that drift and fail rather than silently resolve + // the frozen index to a different artifact. + SubtitleSource string `json:"subtitle_source,omitempty"` + ExternalSubtitlePath string `json:"external_subtitle_path,omitempty"` + EmbeddedStreamIndex int `json:"embedded_stream_index,omitempty"` + DownloadedSubtitleID int `json:"downloaded_subtitle_id,omitempty"` +} + +const executableRecipeVersionV3 = 1 + +const ( + SubtitleSourceExternalV3 = "external" + SubtitleSourceEmbeddedV3 = "embedded" + SubtitleSourceDownloadedV3 = "downloaded" +) + +func FreezeExecutableRecipeV3(result PlannerResultV3) ExecutableRecipeV3 { + planID := "" + if result.Plan != nil { + planID = result.Plan.PlanID + } + sourceMetadata := SourceExecutionMetadataV3{} + if result.FrozenSourceMetadata != nil { + sourceMetadata = *result.FrozenSourceMetadata + } + return ExecutableRecipeV3{ + Version: executableRecipeVersionV3, + PlanID: planID, + PlayMethod: result.PlayMethod, + TranscodeAudio: result.TranscodeAudio, + TargetVideoCodec: result.TargetVideoCodec, + TargetAudioCodec: result.TargetAudioCodec, + TargetAudioChannels: result.TargetAudioChannels, + TargetResolution: result.TargetResolution, + TargetBitrateKbps: result.TargetBitrateKbps, + SourceVideoCodec: sourceMetadata.VideoCodec, + SourceDurationSeconds: sourceMetadata.DurationSeconds, + SubtitleTrackIndex: result.SubtitleTrackIndex, + SubtitleTransportTrackIndex: result.SubtitleTransportTrackIndex, + SubtitleBurnIn: result.SubtitleBurnIn, + SubtitleCodec: result.SubtitleCodec, + DownloadedSubtitleID: result.DownloadedSubtitleID, + } +} + +func (r ExecutableRecipeV3) Valid() bool { + if r.Version != executableRecipeVersionV3 || r.PlanID == "" { + return false + } + switch r.PlayMethod { + case PlayDirect, PlayRemux, PlayTranscode: + return true + default: + return false + } +} + +func (r ExecutableRecipeV3) ValidFor(plan PlanV3) bool { + return r.Valid() && r.PlanID == plan.PlanID +} + +func (r ExecutableRecipeV3) PlannerResult(plan *PlanV3) PlannerResultV3 { + return PlannerResultV3{ + Plan: plan, + PlayMethod: r.PlayMethod, + TranscodeAudio: r.TranscodeAudio, + TargetVideoCodec: r.TargetVideoCodec, + TargetAudioCodec: r.TargetAudioCodec, + TargetAudioChannels: r.TargetAudioChannels, + TargetResolution: r.TargetResolution, + TargetBitrateKbps: r.TargetBitrateKbps, + FrozenSourceMetadata: &SourceExecutionMetadataV3{ + VideoCodec: r.SourceVideoCodec, + DurationSeconds: r.SourceDurationSeconds, + }, + SubtitleTrackIndex: r.SubtitleTrackIndex, + SubtitleTransportTrackIndex: r.SubtitleTransportTrackIndex, + SubtitleBurnIn: r.SubtitleBurnIn, + SubtitleCodec: r.SubtitleCodec, + DownloadedSubtitleID: r.DownloadedSubtitleID, + } +} diff --git a/internal/playback/executable_recipe_v3_test.go b/internal/playback/executable_recipe_v3_test.go new file mode 100644 index 00000000..5fb2922e --- /dev/null +++ b/internal/playback/executable_recipe_v3_test.go @@ -0,0 +1,75 @@ +package playback + +import ( + "encoding/json" + "testing" +) + +func TestExecutableRecipeV3RoundTripPreservesOperationalFields(t *testing.T) { + plan := &PlanV3{PlanID: "plan:frozen", Delivery: DeliveryRemuxHLSV3} + want := PlannerResultV3{ + Plan: plan, PlayMethod: PlayRemux, TranscodeAudio: true, + TargetVideoCodec: "copy", TargetAudioCodec: "aac", TargetAudioChannels: 6, + TargetResolution: "1080p", TargetBitrateKbps: 18_000, + FrozenSourceMetadata: &SourceExecutionMetadataV3{VideoCodec: "hevc", DurationSeconds: 7_201}, + SubtitleTrackIndex: 4, SubtitleTransportTrackIndex: 2, + SubtitleBurnIn: true, SubtitleCodec: "hdmv_pgs_subtitle", DownloadedSubtitleID: 71, + } + recipe := FreezeExecutableRecipeV3(want) + if !recipe.Valid() { + t.Fatalf("frozen recipe is invalid: %#v", recipe) + } + if !recipe.ValidFor(*plan) { + t.Fatalf("frozen recipe does not match its plan: %#v", recipe) + } + changedPlan := *plan + changedPlan.PlanID = "plan:newer" + if recipe.ValidFor(changedPlan) { + t.Fatal("stale frozen recipe matched a newer plan") + } + got := recipe.PlannerResult(plan) + if got.Plan != plan || got.PlayMethod != want.PlayMethod || got.TranscodeAudio != want.TranscodeAudio || + got.TargetVideoCodec != want.TargetVideoCodec || got.TargetAudioCodec != want.TargetAudioCodec || + got.TargetAudioChannels != want.TargetAudioChannels || got.TargetResolution != want.TargetResolution || + got.TargetBitrateKbps != want.TargetBitrateKbps || got.SubtitleTrackIndex != want.SubtitleTrackIndex || + got.SubtitleTransportTrackIndex != want.SubtitleTransportTrackIndex || got.SubtitleBurnIn != want.SubtitleBurnIn || + got.SubtitleCodec != want.SubtitleCodec || got.DownloadedSubtitleID != want.DownloadedSubtitleID || got.FrozenSourceMetadata == nil || + got.FrozenSourceMetadata.VideoCodec != want.FrozenSourceMetadata.VideoCodec || got.FrozenSourceMetadata.DurationSeconds != want.FrozenSourceMetadata.DurationSeconds { + t.Fatalf("thawed result = %#v, want %#v", got, want) + } +} + +func TestExecutableRecipeV3SurvivesJSONRoundTrip(t *testing.T) { + plan := &PlanV3{PlanID: "plan:frozen"} + recipe := FreezeExecutableRecipeV3(PlannerResultV3{ + Plan: plan, PlayMethod: PlayRemux, + FrozenSourceMetadata: &SourceExecutionMetadataV3{VideoCodec: "hevc", DurationSeconds: 7_201}, + SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: 0, + }) + recipe.SubtitleSource = SubtitleSourceDownloadedV3 + recipe.DownloadedSubtitleID = 71 + + encoded, err := json.Marshal(recipe) + if err != nil { + t.Fatalf("marshal recipe: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatalf("unmarshal recipe fields: %v", err) + } + for _, field := range []string{"subtitle_track_index", "subtitle_transport_track_index"} { + if _, ok := fields[field]; !ok { + t.Fatalf("encoded recipe omitted meaningful zero-value field %q: %s", field, encoded) + } + } + var decoded ExecutableRecipeV3 + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal recipe: %v", err) + } + if decoded != recipe { + t.Fatalf("decoded recipe = %#v, want %#v", decoded, recipe) + } + if !decoded.ValidFor(*plan) { + t.Fatalf("decoded recipe no longer matches its plan: %#v", decoded) + } +} diff --git a/internal/playback/plan_v3.go b/internal/playback/plan_v3.go index af9282ff..6e4df927 100644 --- a/internal/playback/plan_v3.go +++ b/internal/playback/plan_v3.go @@ -49,6 +49,13 @@ type PlannerInputV3 struct { AdditionalSubtitles []SubtitleInventoryEntryV3 } +// SourceExecutionMetadataV3 is the immutable source probe snapshot used to +// reopen a frozen playback recipe without consuming later catalog drift. +type SourceExecutionMetadataV3 struct { + VideoCodec string + DurationSeconds float64 +} + // dvRPUStrippable resolves the per-source strip verdict, defaulting to true // when no probe is wired in. func (input PlannerInputV3) dvRPUStrippable() bool { @@ -84,6 +91,14 @@ type PlannerResultV3 struct { SubtitleTransportTrackIndex int SubtitleBurnIn bool SubtitleCodec string + // DownloadedSubtitleID comes from the same inventory snapshot used for + // planning. Freezing must not re-list a mutable ordinal inventory after the + // route has already been accepted. + DownloadedSubtitleID int + // FrozenSourceMetadata is set only when a durable executable recipe is + // thawed for a seek reanchor. Transport construction must then use this + // captured source snapshot instead of a freshly probed media row. + FrozenSourceMetadata *SourceExecutionMetadataV3 } func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 { @@ -254,7 +269,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 { }) finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID) if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) { - return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec} + return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID} } } if clientHDR10Eligible { @@ -275,7 +290,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 { }) finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID) if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) { - return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec} + return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID} } } } @@ -289,7 +304,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 { applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk) finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID) if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) { - return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec} + return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID} } } @@ -350,7 +365,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 { plan.Claims.Subtitles = remuxSubtitle.Claims finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID) if engineAvailableV3(input.Request, EngineMedia3ProgressiveRemuxV3) && !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) { - return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec} + return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec, DownloadedSubtitleID: remuxSubtitle.DownloadedSubtitleID} } } if engineAvailableV3(input.Request, EngineMedia3HLSV3) && hlsRemuxSubtitleOK { @@ -414,7 +429,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 { if hlsTranscodeAudio { targetAudio = "aac" } - return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetAudioChannels: hlsAudioChannels, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec} + return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetAudioChannels: hlsAudioChannels, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec, DownloadedSubtitleID: hlsSubtitle.DownloadedSubtitleID} } } } @@ -490,7 +505,7 @@ func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescri if planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) { return terminalPlannerResultV3("adaptation_exhausted", "All compatible playback recipes have already failed for this output route.", false) } - return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetAudioChannels: targetAudioChannels, TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec} + return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetAudioChannels: targetAudioChannels, TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID} } func canStripDolbyVisionToHDR10V3(source SourceDescriptorV3, request StartRequestV3, registry *TransformationRegistryV3) bool { diff --git a/internal/playback/planstore/postgres.go b/internal/playback/planstore/postgres.go index 921ca3b0..8072bcf7 100644 --- a/internal/playback/planstore/postgres.go +++ b/internal/playback/planstore/postgres.go @@ -79,6 +79,10 @@ func (s *Postgres) SaveAttempt(ctx context.Context, record playback.AttemptRecor if err != nil { return err } + recipeJSON, err := json.Marshal(record.FrozenRecipe) + if err != nil { + return err + } tx, err := s.db.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return err @@ -98,12 +102,12 @@ func (s *Postgres) SaveAttempt(ctx context.Context, record playback.AttemptRecor INSERT INTO playback_v3_attempts ( playback_attempt_id, session_id, user_id, profile_id, requested_media_file_id, effective_media_file_id, - current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at - ) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + current_plan_id, current_replan_request_id, current_plan, frozen_recipe, normalized_request, request_digest, expires_at + ) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT DO NOTHING`, record.PlaybackAttemptID, record.SessionID, record.UserID, record.ProfileID, record.RequestedMediaFileID, record.EffectiveMediaFileID, - record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.RequestDigest, record.ExpiresAt) + record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, recipeJSON, requestJSON, record.RequestDigest, record.ExpiresAt) if err != nil { return err } @@ -159,16 +163,16 @@ func (s *Postgres) getAttemptIdentity(ctx context.Context, predicate string, val func (s *Postgres) getAttempt(ctx context.Context, predicate string, value any) (*playback.AttemptRecordV3, error) { var record playback.AttemptRecordV3 - var planJSON, requestJSON []byte + var planJSON, recipeJSON, requestJSON []byte err := s.db.QueryRow(ctx, ` SELECT playback_attempt_id, session_id::text, user_id, profile_id, requested_media_file_id, effective_media_file_id, - current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at + current_plan_id, current_replan_request_id, current_plan, frozen_recipe, normalized_request, request_digest, expires_at FROM playback_v3_attempts WHERE `+predicate+` AND expires_at > NOW()`, value).Scan( &record.PlaybackAttemptID, &record.SessionID, &record.UserID, &record.ProfileID, &record.RequestedMediaFileID, &record.EffectiveMediaFileID, - &record.CurrentPlanID, &record.CurrentReplanRequestID, &planJSON, &requestJSON, &record.RequestDigest, &record.ExpiresAt, + &record.CurrentPlanID, &record.CurrentReplanRequestID, &planJSON, &recipeJSON, &requestJSON, &record.RequestDigest, &record.ExpiresAt, ) if errors.Is(err, pgx.ErrNoRows) { return nil, playback.ErrSessionNotFound @@ -179,6 +183,9 @@ func (s *Postgres) getAttempt(ctx context.Context, predicate string, value any) if err := json.Unmarshal(planJSON, &record.CurrentPlan); err != nil { return nil, err } + if err := json.Unmarshal(recipeJSON, &record.FrozenRecipe); err != nil { + return nil, err + } if err := json.Unmarshal(requestJSON, &record.NormalizedRequest); err != nil { return nil, err } @@ -273,6 +280,10 @@ func (s *Postgres) CompleteReplan(ctx context.Context, sessionID, requestID, bas if err != nil { return err } + recipeJSON, err := json.Marshal(record.FrozenRecipe) + if err != nil { + return err + } // The base-revision predicate makes the commit a true compare-and-swap: // under the advisory session lock it never fails, but a skipped or broken // lock must surface as a conflict rather than silently last-writer-win @@ -280,9 +291,10 @@ func (s *Postgres) CompleteReplan(ctx context.Context, sessionID, requestID, bas attemptResult, err := tx.Exec(ctx, ` UPDATE playback_v3_attempts SET effective_media_file_id = $2, current_plan_id = $3, - current_replan_request_id = $4, current_plan = $5, normalized_request = $6, expires_at = $7, updated_at = NOW() - WHERE session_id = $1::uuid AND current_replan_request_id = $8`, - sessionID, record.EffectiveMediaFileID, record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.ExpiresAt, baseReplanRequestID) + current_replan_request_id = $4, current_plan = $5, frozen_recipe = $6, + normalized_request = $7, expires_at = $8, updated_at = NOW() + WHERE session_id = $1::uuid AND current_replan_request_id = $9`, + sessionID, record.EffectiveMediaFileID, record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, recipeJSON, requestJSON, record.ExpiresAt, baseReplanRequestID) if err != nil { return err } diff --git a/internal/playback/planstore/postgres_test.go b/internal/playback/planstore/postgres_test.go index a99d6aad..f3ab2a67 100644 --- a/internal/playback/planstore/postgres_test.go +++ b/internal/playback/planstore/postgres_test.go @@ -60,6 +60,17 @@ func newPlanstoreFixture(t *testing.T) *planstoreFixture { if !hasRevision { t.Skip("test database has not applied the playback v3 attempt revision migration") } + var hasFrozenRecipe bool + if err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'playback_v3_attempts' AND column_name = 'frozen_recipe' + )`).Scan(&hasFrozenRecipe); err != nil { + t.Fatalf("check frozen_recipe column: %v", err) + } + if !hasFrozenRecipe { + t.Skip("test database has not applied the playback v3 frozen recipe migration") + } f := &planstoreFixture{pool: pool} unique := fmt.Sprintf("planstore-test-%d", time.Now().UnixNano()) @@ -116,6 +127,10 @@ func (f *planstoreFixture) attemptRecord(sessionID, attemptID, digest string) pl RequestedMediaFileID: f.mediaFileID, EffectiveMediaFileID: f.mediaFileID, }, + FrozenRecipe: playback.ExecutableRecipeV3{ + Version: 1, PlanID: "plan-1", PlayMethod: playback.PlayDirect, + SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1, + }, NormalizedRequest: playback.StartRequestV3{ ProtocolVersion: 3, FileID: f.mediaFileID, @@ -265,6 +280,9 @@ func TestPostgresPlanStore(t *testing.T) { if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan)) { t.Fatalf("%s plan JSON did not round-trip:\n got %s\nwant %s", name, mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan)) } + if !bytes.Equal(mustJSON(t, got.FrozenRecipe), mustJSON(t, record.FrozenRecipe)) { + t.Fatalf("%s frozen recipe did not round-trip:\n got %s\nwant %s", name, mustJSON(t, got.FrozenRecipe), mustJSON(t, record.FrozenRecipe)) + } if !bytes.Equal(mustJSON(t, got.NormalizedRequest), mustJSON(t, record.NormalizedRequest)) { t.Fatalf("%s normalized request JSON did not round-trip", name) } @@ -400,6 +418,7 @@ func TestPostgresPlanStore(t *testing.T) { updated.CurrentPlanID = "plan-2" updated.CurrentReplanRequestID = "rq-1" updated.CurrentPlan.PlanID = "plan-2" + updated.FrozenRecipe.PlanID = "plan-2" updated.CurrentPlan.EffectiveMediaFileID = f.altFileID updated.CurrentPlan.DecisionReason = "transcode_fallback" updated.ExpiresAt = time.Now().Add(2 * time.Hour).UTC().Truncate(time.Microsecond) @@ -425,6 +444,9 @@ func TestPostgresPlanStore(t *testing.T) { if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan)) { t.Fatalf("plan JSON mismatch after replan:\n got %s\nwant %s", mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan)) } + if !bytes.Equal(mustJSON(t, got.FrozenRecipe), mustJSON(t, updated.FrozenRecipe)) { + t.Fatalf("frozen recipe mismatch after replan:\n got %s\nwant %s", mustJSON(t, got.FrozenRecipe), mustJSON(t, updated.FrozenRecipe)) + } // The migration's sync trigger must not fight the in-transaction CAS: // the raw column must equal the new request ID, with no extra rewrite. diff --git a/internal/playback/protocol_store_v3.go b/internal/playback/protocol_store_v3.go index ed567ea5..af499e05 100644 --- a/internal/playback/protocol_store_v3.go +++ b/internal/playback/protocol_store_v3.go @@ -27,6 +27,7 @@ type AttemptRecordV3 struct { CurrentPlanID string CurrentReplanRequestID string CurrentPlan PlanV3 + FrozenRecipe ExecutableRecipeV3 NormalizedRequest StartRequestV3 // RequestDigest fingerprints the normalized start request so an attempt-ID // reused with different input is a detectable idempotency violation rather diff --git a/internal/playback/protocol_v3_test.go b/internal/playback/protocol_v3_test.go index bf25ed91..7471d989 100644 --- a/internal/playback/protocol_v3_test.go +++ b/internal/playback/protocol_v3_test.go @@ -645,7 +645,7 @@ func TestPlanPlaybackV3PassthroughRequiresExactLayoutEntry(t *testing.T) { } } -func TestPlanPlaybackV3DownloadedSubtitleUsesFrozenCombinedOrdinal(t *testing.T) { +func TestPlanPlaybackV3DownloadedSubtitleCarriesStableIdentity(t *testing.T) { file := detailedFixtureFileV3() req := validStartRequestV3() req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3) @@ -655,8 +655,8 @@ func TestPlanPlaybackV3DownloadedSubtitleUsesFrozenCombinedOrdinal(t *testing.T) index := 0 req.SubtitleTrackIndex = &index req.SubtitleTrackID = TrackIDV3(file.ID, "subtitle", index) - result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AdditionalSubtitles: []SubtitleInventoryEntryV3{{CombinedIndex: 0, Codec: "srt", Source: "downloaded"}}}) - if result.Plan == nil || result.Plan.Subtitle.Mode != SubtitleRenderV3 || result.Plan.SelectedTracks.Subtitle == nil || result.Plan.SelectedTracks.Subtitle.ID != req.SubtitleTrackID { + result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AdditionalSubtitles: []SubtitleInventoryEntryV3{{CombinedIndex: 0, Codec: "srt", Source: "downloaded", DownloadedSubtitleID: 71}}}) + if result.Plan == nil || result.Plan.Subtitle.Mode != SubtitleRenderV3 || result.Plan.SelectedTracks.Subtitle == nil || result.Plan.SelectedTracks.Subtitle.ID != req.SubtitleTrackID || result.DownloadedSubtitleID != 71 { t.Fatalf("result = %#v", result) } } @@ -1016,6 +1016,54 @@ func TestPlanPlaybackV3TimelineChangePreservesRouteIdentity(t *testing.T) { } } +func TestPlanPlaybackV3DroppingFallbackHistoryReintroducesRejectedRoute(t *testing.T) { + file := detailedFixtureFileV3() + file.FilePath = "/media/movie.mp4" + file.Container = "mp4" + file.CodecVideo = "h264" + file.Resolution = "1080p" + file.Bitrate = 8_000 + file.VideoTracks[0] = models.VideoTrack{Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"} + request := validStartRequestV3() + request.ClientFeatures = append(request.ClientFeatures, FeatureDetailedDecodeV3) + request.ClientPlaybackContext.Features = append(request.ClientPlaybackContext.Features, FeatureDetailedDecodeV3) + request.Capabilities.CodecsVideo = []string{"h264"} + request.Capabilities.CodecsVideoHardware = []string{"h264"} + request.Capabilities.Containers = []string{"mp4"} + request.Capabilities.MaxResolution = "1080p" + request.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}} + input := PlannerInputV3{ + Request: request, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, + Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3(), + } + direct := PlanPlaybackV3(input) + if direct.Plan == nil || direct.Plan.Delivery != DeliveryOriginalHTTPV3 { + t.Fatalf("direct plan = %#v", direct) + } + input.AttemptedKeys = []string{PlanAttemptKeyV3(*direct.Plan, request.OutputRouteGeneration, nil)} + progressive := PlanPlaybackV3(input) + if progressive.Plan == nil || progressive.Plan.Delivery != DeliveryRemuxProgressiveV3 { + t.Fatalf("progressive fallback = %#v", progressive) + } + input.AttemptedKeys = append(input.AttemptedKeys, PlanAttemptKeyV3(*progressive.Plan, request.OutputRouteGeneration, nil)) + hls := PlanPlaybackV3(input) + if hls.Plan == nil || hls.Plan.Delivery != DeliveryRemuxHLSV3 { + t.Fatalf("HLS fallback = %#v", hls) + } + + seek := 321.25 + input.Request.StartPosition = &seek + input.AttemptedKeys = nil // This is what the old seek-reanchor path did. + replanned := PlanPlaybackV3(input) + if replanned.Plan == nil || replanned.Plan.PlanID != direct.Plan.PlanID || replanned.Plan.PlanID == hls.Plan.PlanID { + t.Fatalf("dropped fallback history did not reproduce identity drift: direct=%#v hls=%#v replanned=%#v", direct.Plan, hls.Plan, replanned.Plan) + } + if replanned.Plan.Delivery != DeliveryOriginalHTTPV3 || replanned.Plan.Engine != EngineMedia3DirectV3 || + replanned.Plan.Stream.Protocol != StreamHTTPProgressiveV3 || replanned.Plan.Stream.Container != "mp4" { + t.Fatalf("reintroduced route = %#v", replanned.Plan) + } +} + func validStartRequestV3() StartRequestV3 { return StartRequestV3{ ProtocolVersion: ProtocolV3, diff --git a/internal/playback/subtitle_policy_v3.go b/internal/playback/subtitle_policy_v3.go index 00a586c6..1b636916 100644 --- a/internal/playback/subtitle_policy_v3.go +++ b/internal/playback/subtitle_policy_v3.go @@ -7,20 +7,22 @@ import ( ) type SubtitlePolicyResultV3 struct { - Decision SubtitleDecisionV3 - Claims SubtitleClaimsV3 - RequiresBurn bool - SelectedIndex int - TransportIndex int - Codec string - Source string - Terminal *TerminalV3 + Decision SubtitleDecisionV3 + Claims SubtitleClaimsV3 + RequiresBurn bool + SelectedIndex int + TransportIndex int + Codec string + Source string + DownloadedSubtitleID int + Terminal *TerminalV3 } type SubtitleInventoryEntryV3 struct { - CombinedIndex int - Codec string - Source string + CombinedIndex int + Codec string + Source string + DownloadedSubtitleID int } // ResolveSubtitlePolicyV3 decides how the selected subtitle is delivered when @@ -44,10 +46,11 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra if file == nil { return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle inventory is unavailable.") } - codec, source, ok := subtitleCodecAtCombinedIndexV3(file, index, additional) + entry, ok := subtitleEntryAtCombinedIndexV3(file, index, additional) if !ok { return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle track is unavailable.") } + codec, source := entry.Codec, entry.Source trackID := TrackIDV3(file.ID, "subtitle", index) transportIndex := -1 if source == "embedded" { @@ -74,6 +77,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID}, Claims: SubtitleClaimsV3{ASSStylingPreserved: !ass || engineCaps.Subtitles.ASSStyling, Reason: "client_render_supported"}, SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source, + DownloadedSubtitleID: entry.DownloadedSubtitleID, } } if request.SubtitleFidelityPreference == SubtitleFidelityCompatibleV3 { @@ -81,6 +85,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra Decision: SubtitleDecisionV3{Mode: SubtitleConvertV3, TrackID: trackID}, Claims: SubtitleClaimsV3{Reason: "server_text_conversion"}, SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source, + DownloadedSubtitleID: entry.DownloadedSubtitleID, } } } @@ -95,6 +100,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID}, Claims: SubtitleClaimsV3{BitmapSidecar: true, Reason: "client_bitmap_render_supported"}, SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source, + DownloadedSubtitleID: entry.DownloadedSubtitleID, } } if transcodeAllowed { @@ -105,25 +111,27 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra Decision: SubtitleDecisionV3{Mode: SubtitleBurnInV3, TrackID: trackID}, Claims: SubtitleClaimsV3{BitmapOverlay: burnInBitmap, Reason: "server_burn_in_required"}, RequiresBurn: true, SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source, + DownloadedSubtitleID: entry.DownloadedSubtitleID, } } return subtitleTerminalV3("subtitle_conversion_unsupported", fmt.Sprintf("Subtitle format %s cannot meet the selected fidelity policy.", codec)) } -func subtitleCodecAtCombinedIndexV3(file *models.MediaFile, index int, additional []SubtitleInventoryEntryV3) (codec, source string, ok bool) { +func subtitleEntryAtCombinedIndexV3(file *models.MediaFile, index int, additional []SubtitleInventoryEntryV3) (SubtitleInventoryEntryV3, bool) { if index < len(file.ExternalSubtitles) { - return normalizeCodecV3(file.ExternalSubtitles[index].Format), "external", true + return SubtitleInventoryEntryV3{CombinedIndex: index, Codec: normalizeCodecV3(file.ExternalSubtitles[index].Format), Source: "external"}, true } embedded := index - len(file.ExternalSubtitles) if embedded >= 0 && embedded < len(file.SubtitleTracks) { - return normalizeCodecV3(file.SubtitleTracks[embedded].Codec), "embedded", true + return SubtitleInventoryEntryV3{CombinedIndex: index, Codec: normalizeCodecV3(file.SubtitleTracks[embedded].Codec), Source: "embedded"}, true } for _, entry := range additional { if entry.CombinedIndex == index { - return normalizeCodecV3(entry.Codec), entry.Source, true + entry.Codec = normalizeCodecV3(entry.Codec) + return entry, true } } - return "", "", false + return SubtitleInventoryEntryV3{}, false } func isTextSubtitleV3(codec string) bool { diff --git a/migrations/sql/20260806083316_add_playback_v3_frozen_recipe.sql b/migrations/sql/20260806083316_add_playback_v3_frozen_recipe.sql new file mode 100644 index 00000000..9493bc18 --- /dev/null +++ b/migrations/sql/20260806083316_add_playback_v3_frozen_recipe.sql @@ -0,0 +1,12 @@ +-- +goose Up +ALTER TABLE playback_v3_attempts + ADD COLUMN frozen_recipe JSONB NOT NULL DEFAULT '{}'::jsonb; + +ALTER TABLE playback_v3_attempts + ADD CONSTRAINT playback_v3_attempts_frozen_recipe_object + CHECK (jsonb_typeof(frozen_recipe) = 'object') NOT VALID; + +-- +goose Down +ALTER TABLE playback_v3_attempts + DROP CONSTRAINT IF EXISTS playback_v3_attempts_frozen_recipe_object, + DROP COLUMN IF EXISTS frozen_recipe; diff --git a/migrations/sql/20260806120421_validate_playback_v3_frozen_recipe.sql b/migrations/sql/20260806120421_validate_playback_v3_frozen_recipe.sql new file mode 100644 index 00000000..1dadd039 --- /dev/null +++ b/migrations/sql/20260806120421_validate_playback_v3_frozen_recipe.sql @@ -0,0 +1,8 @@ +-- +goose Up +ALTER TABLE playback_v3_attempts + VALIDATE CONSTRAINT playback_v3_attempts_frozen_recipe_object; + +-- +goose Down +-- PostgreSQL cannot mark a validated CHECK constraint NOT VALID again. The +-- preceding migration's Down step drops the constraint with its column. +SELECT 1;