fix(playback): freeze v3 seek reanchor recipes (#548)

* fix(playback): freeze v3 seek reanchor recipes

* fix(playback): address v3 recipe review feedback

* test(playback): isolate v3 fallback route fixture

* fix(database): split v3 recipe constraint validation

* fix(playback): preserve frozen seek recipe identity

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
Suspense
2026-08-06 10:43:21 -04:00
committed by GitHub
co-authored by Quick104
parent 40a9de7f26
commit fa1d7ba2b4
17 changed files with 1412 additions and 122 deletions
+10 -1
View File
@@ -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 ""
@@ -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,
+303 -58
View File
@@ -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
}
+616
View File
@@ -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
}
+60 -26
View File
@@ -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
+49
View File
@@ -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)
+16 -2
View File
@@ -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 &copy, 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) {
+110
View File
@@ -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,
}
}
@@ -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)
}
}
+21 -6
View File
@@ -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 {
+21 -9
View File
@@ -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
}
@@ -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.
+1
View File
@@ -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
+51 -3
View File
@@ -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,
+25 -17
View File
@@ -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 {
@@ -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;
@@ -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;