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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -239,9 +239,15 @@ type handlerMockSubtitleRepo struct {
|
||||
subtitles map[int]*subtitles.DownloadedSubtitle
|
||||
nextID int
|
||||
byKey map[string]*subtitles.DownloadedSubtitle
|
||||
// getErr, when set, is returned by GetDownloadedSubtitle to simulate a
|
||||
// backing-store failure.
|
||||
getErr error
|
||||
// listErr, when set, is returned by ListDownloadedSubtitles to simulate a
|
||||
// backing-store failure.
|
||||
listErr error
|
||||
listErr error
|
||||
list []subtitles.DownloadedSubtitle
|
||||
listResults [][]subtitles.DownloadedSubtitle
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo {
|
||||
@@ -260,6 +266,9 @@ func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, su
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
if sub, ok := m.subtitles[id]; ok {
|
||||
copy := *sub
|
||||
return ©, nil
|
||||
@@ -268,10 +277,15 @@ func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id in
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) {
|
||||
m.listCalls++
|
||||
if m.listErr != nil {
|
||||
return nil, m.listErr
|
||||
}
|
||||
return nil, nil
|
||||
if len(m.listResults) > 0 {
|
||||
index := min(m.listCalls-1, len(m.listResults)-1)
|
||||
return append([]subtitles.DownloadedSubtitle(nil), m.listResults[index]...), nil
|
||||
}
|
||||
return append([]subtitles.DownloadedSubtitle(nil), m.list...), nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) DeleteDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user