Files
silo-server/internal/api/handlers/playback_v3_test.go
T
Quick d7452a8207 fix(jellycompat): scan the parent directory when a sidecar changes
Autoscan matched scantrigger rejections by comparing RequestError.Message
against literal strings. One of those messages became "Unsupported media file
extension for library type" and the copy in handlers_autoscan.go did not, so
the comparison silently stopped matching.

The effect is user-visible: a Jellyfin client posting a change for Movie.nfo
or poster.jpg gets a 400 and the batch is abandoned, when the sidecar should
have resolved to a scan of the directory containing it. Three tests covered
exactly this and had been excluded rather than read.

RequestError now carries a Reason the caller can switch on. Message stays
prose for the client reading the response — it is meant to be reworded, and
nothing should break when it is.

Also makes two tests honest about asynchronous work. The Jellyfin Web
teardown deleted its install root while the operation goroutine was still
writing to it, where a late write recreates a path RemoveAll already walked
past; it now waits for the operation's terminal state, which required
exporting CurrentWebOperation. And the direct-play If-Range test pinned size
and mtime so ctime was the only remaining validator, then read it back inside
a single coarse-clock tick — it failed about 85% of the time on main for a
reason unrelated to what it tests, and now rewrites until the stamp moves.

With those fixed, GOTEST_KNOWN_FAILURES is empty and gone: make test-go runs
the whole Go suite. The one test that cannot pass yet —
TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion, which
has failed since the commit that introduced it and describes unimplemented v3
planner behavior — carries a t.Skip explaining that where the test is, rather
than a regex in the Makefile.

Reported by CodeRabbit review on #479.
2026-07-27 00:23:02 +00:00

1363 lines
67 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"time"
"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/transcodenode"
)
type mutablePlaybackSettingsV3 struct {
mu sync.Mutex
values map[string]string
}
type failingCompletePlanStoreV3 struct {
playback.PlanStoreV3
}
type staticNodePlannerV3 struct {
plan nodepool.Plan
}
func (p staticNodePlannerV3) PlanSession(string, string, bool, int) nodepool.Plan {
return p.plan
}
func (f *failingCompletePlanStoreV3) CompleteReplan(context.Context, string, string, string, json.RawMessage, playback.AttemptRecordV3) error {
return fmt.Errorf("injected complete replan failure")
}
func TestShouldTryAlternateFileV3PinsOriginalQuality(t *testing.T) {
if shouldTryAlternateFileV3("original") || shouldTryAlternateFileV3(" ORIGINAL ") {
t.Fatal("original quality must pin the requested media file")
}
for _, quality := range []string{"auto", "2160p", "1080p", "480p"} {
if !shouldTryAlternateFileV3(quality) {
t.Fatalf("quality %q should permit alternate selection", quality)
}
}
}
func TestReplanAllowsAlternateFileV3PinsSeekOperations(t *testing.T) {
tests := []struct {
name string
operation playback.ReplanOperationV3
quality string
want bool
}{
{name: "ordinary failure may use another version", operation: playback.ReplanOperationFailureRecoveryV3, quality: "auto", want: true},
{name: "original quality remains pinned", operation: playback.ReplanOperationFailureRecoveryV3, quality: "original", want: false},
{name: "exact seek reanchor pins current version", operation: playback.ReplanOperationSeekReanchorV3, quality: "auto", want: false},
{name: "failed seek recovery pins current version", operation: playback.ReplanOperationSeekFailureRecoveryV3, quality: "auto", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := replanAllowsAlternateFileV3(test.operation, test.quality); got != test.want {
t.Fatalf("replanAllowsAlternateFileV3(%q, %q) = %v, want %v", test.operation, test.quality, got, test.want)
}
})
}
}
func (s *mutablePlaybackSettingsV3) Get(_ context.Context, key string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.values[key], nil
}
func (s *mutablePlaybackSettingsV3) set(key, value string) {
s.mu.Lock()
defer s.mu.Unlock()
s.values[key] = value
}
func TestHandleStartPlaybackV3DisabledDoesNotAllocateLegacySession(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "false"}}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, v3HandlerStartRequest())))
req = req.WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("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.ProtocolVersion != playback.ProtocolV3 || len(response.ServerFeatures) != 0 || response.SessionID != "" {
t.Fatalf("response = %#v", response)
}
if got := len(manager.AllSessions()); got != 0 {
t.Fatalf("sessions = %d, want 0", got)
}
}
func TestHandlePlaybackCapabilityV3ReadsFlagPerRequest(t *testing.T) {
settings := &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "false"}}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.SettingsRepo = settings
request := func() playback.CapabilityResponseV3 {
req := httptest.NewRequest(http.MethodGet, "/api/v1/playback/capability", nil).WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandlePlaybackCapabilityV3(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var response playback.CapabilityResponseV3
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response
}
if response := request(); response.Enabled || response.Reason != "disabled" {
t.Fatalf("disabled response = %#v", response)
}
settings.set("playback.protocol_v3_enabled", "true")
// The flag stays DB-backed (no restart needed for rollback) behind a
// short TTL cache; expiring the cache stands in for the TTL elapsing.
handler.v3FlagMu.Lock()
handler.v3Flags = nil
handler.v3FlagMu.Unlock()
if response := request(); !response.Enabled ||
len(response.Deliveries) != 4 ||
!playback.HasFeatureV3(response.Features, playback.FeatureSeekReanchorV3) ||
!playback.HasFeatureV3(response.Features, playback.FeatureDirectStreamResumeV3) {
t.Fatalf("enabled response = %#v", response)
}
}
func TestHandleStartPlaybackV3ReturnsExecutableDirectPlan(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", "allow_4k_transcode": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, v3HandlerStartRequest())))
req = req.WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("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.Outcome != playback.OutcomePlayableV3 || response.PlaybackPlan == nil {
t.Fatalf("response = %#v", response)
}
if response.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 || response.PlaybackPlan.Engine != playback.EngineMedia3DirectV3 || response.PlaybackPlan.Stream.URL == "" {
t.Fatalf("plan = %#v", response.PlaybackPlan)
}
if response.PlaybackPlan.RequestedMediaFileID != file.ID || response.PlaybackPlan.EffectiveMediaFileID != file.ID || response.PlaybackPlan.Source.MediaFileID != file.ID {
t.Fatalf("source identity = %#v", response.PlaybackPlan)
}
if got := len(manager.AllSessions()); got != 1 {
t.Fatalf("sessions = %d, want 1", got)
}
}
func TestHandleStartPlaybackV3DuplicateAttemptReturnsOriginalSession(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", "allow_4k_transcode": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
body := marshalV3StartRequest(t, v3HandlerStartRequest())
start := func() playback.DecisionResponseV3 {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(body)).WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("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
}
first := start()
second := start()
if first.SessionID == "" || second.SessionID != first.SessionID {
t.Fatalf("first session %q, second %q", first.SessionID, second.SessionID)
}
if got := len(manager.AllSessions()); got != 1 {
t.Fatalf("sessions = %d, want 1", got)
}
}
func TestHandleStartPlaybackV3RejectsProfileMismatch(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
request := v3HandlerStartRequest()
request.ProfileID = "profile-other"
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.StatusBadRequest || len(manager.AllSessions()) != 0 {
t.Fatalf("status = %d, sessions = %d, body = %s", rr.Code, len(manager.AllSessions()), rr.Body.String())
}
}
func TestHandleReplanPlaybackV3UpdatesSelectedAudioAndReplaysIdempotently(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"})
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
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.Fatal("start returned no plan")
}
audioIndex := 1
bandwidthEstimate := 3_500
bandwidthCap := 4_000
failedKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
replan := playback.ReplanRequestV3{ProtocolVersion: playback.ProtocolV3, PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "replan-0001", FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "plan-attempt-0001", PlanAttemptKey: failedKey, AttemptedPlanKeys: []string{failedKey}, AttemptCount: 1, QualityPreference: "original", PositionSeconds: 12, OutputRouteGeneration: startRequest.OutputRouteGeneration, Metered: true, BandwidthEstimateKbps: &bandwidthEstimate, BandwidthCapKbps: &bandwidthCap, SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(file.ID, "audio", audioIndex), Index: &audioIndex}}, Failure: playback.FailureV3{Classification: "audio_renderer_error"}, Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext}
replanBody, err := json.Marshal(replan)
if err != nil {
t.Fatal(err)
}
call := func() playback.DecisionResponseV3 {
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(replanBody))).WithContext(newAuthorizedPlaybackContext())
req = withPlaybackRouteParam(req, "session_id", started.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
}
first := call()
second := call()
if first.PlaybackPlan == nil || second.PlaybackPlan == nil || first.PlaybackPlan.PlanID != second.PlaybackPlan.PlanID {
t.Fatalf("first=%#v second=%#v", first, second)
}
session, err := manager.GetSession(started.SessionID)
if err != nil {
t.Fatal(err)
}
if session.AudioTrackIndex != audioIndex {
t.Fatalf("audio index = %d, want %d", session.AudioTrackIndex, audioIndex)
}
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
if !record.NormalizedRequest.Metered || record.NormalizedRequest.BandwidthEstimateKbps == nil || *record.NormalizedRequest.BandwidthEstimateKbps != bandwidthEstimate ||
record.NormalizedRequest.BandwidthCapKbps == nil || *record.NormalizedRequest.BandwidthCapKbps != bandwidthCap {
t.Fatalf("stored replan network evidence = %#v", record.NormalizedRequest)
}
replan.PositionSeconds++
conflictBody, err := json.Marshal(replan)
if err != nil {
t.Fatal(err)
}
conflictReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(conflictBody))).WithContext(newAuthorizedPlaybackContext())
conflictReq = withPlaybackRouteParam(conflictReq, "session_id", started.SessionID)
conflictRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(conflictRR, conflictReq)
if conflictRR.Code != http.StatusConflict || !strings.Contains(conflictRR.Body.String(), "idempotency_key_reused") {
t.Fatalf("conflict status = %d, body = %s", conflictRR.Code, conflictRR.Body.String())
}
}
func TestHandleReplanPlaybackV3RollsBackLiveSessionWhenPersistenceFails(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"})
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
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 || started.PlaybackPlan == nil {
t.Fatalf("start response: err=%v response=%#v", err, started)
}
beforeSession, err := manager.GetSession(started.SessionID)
if err != nil {
t.Fatal(err)
}
beforeRecord, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
underlyingStore := handler.PlanStoreV3
handler.PlanStoreV3 = &failingCompletePlanStoreV3{PlanStoreV3: underlyingStore}
audioIndex := 1
failedKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
replan := playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "replan-rollback-0001",
FailedPlanID: started.PlaybackPlan.PlanID,
PlanAttemptID: "plan-attempt-rollback-0001",
PlanAttemptKey: failedKey,
AttemptedPlanKeys: []string{failedKey},
AttemptCount: 1,
QualityPreference: "original",
PositionSeconds: 12,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{
ID: playback.TrackIDV3(file.ID, "audio", audioIndex), Index: &audioIndex,
}},
Failure: playback.FailureV3{Classification: "audio_renderer_error"},
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
}
body, err := json.Marshal(replan)
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.StatusInternalServerError {
t.Fatalf("replan status = %d, body = %s", rr.Code, rr.Body.String())
}
afterSession, err := manager.GetSession(started.SessionID)
if err != nil {
t.Fatal(err)
}
if afterSession.MediaFileID != beforeSession.MediaFileID || afterSession.AudioTrackIndex != beforeSession.AudioTrackIndex ||
afterSession.PlayMethod != beforeSession.PlayMethod || afterSession.TranscodeNodeURL != beforeSession.TranscodeNodeURL {
t.Fatalf("live session was not rolled back: before=%#v after=%#v", beforeSession, afterSession)
}
afterRecord, err := underlyingStore.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
if afterRecord.CurrentPlanID != beforeRecord.CurrentPlanID || afterRecord.CurrentReplanRequestID != beforeRecord.CurrentReplanRequestID {
t.Fatalf("durable attempt changed after failed commit: before=%#v after=%#v", beforeRecord, afterRecord)
}
}
func TestHandleReplanPlaybackV3SeekReanchorKeepsCurrentRecipeEligible(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.SubtitleTracks = []models.SubtitleTrack{{Index: 0, Codec: "ass", Language: "eng"}}
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "false"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
subtitleIndex := 0
startRequest.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", subtitleIndex)
startRequest.SubtitleTrackIndex = &subtitleIndex
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.Fatal("start returned no plan")
}
if started.PlaybackPlan.Subtitle.Artifact == nil || started.PlaybackPlan.Subtitle.Artifact.Format != "ass" {
t.Fatalf("start returned no ASS artifact: %#v", started.PlaybackPlan.Subtitle)
}
if err := manager.UpdateProgress(started.SessionID, 12, true); err != nil {
t.Fatal(err)
}
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
reanchor := playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "seek-reanchor-0001", FailedPlanID: started.PlaybackPlan.PlanID,
PlanAttemptID: "plan-attempt-seek-0001", PlanAttemptKey: currentKey,
// Clients may include the current key defensively. A seek reanchor must
// ignore it because the recipe did not fail.
AttemptedPlanKeys: []string{currentKey}, AttemptCount: 1,
QualityPreference: "original", PositionSeconds: 321,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: started.PlaybackPlan.SelectedTracks,
Failure: playback.FailureV3{},
Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext,
}
body, err := json.Marshal(reanchor)
if err != nil {
t.Fatal(err)
}
call := func() playback.DecisionResponseV3 {
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)
}
return response
}
first := call()
second := call()
for index, response := range []playback.DecisionResponseV3{first, second} {
if response.PlaybackPlan == nil || response.SessionID != started.SessionID ||
response.PlaybackPlan.SessionID != started.SessionID ||
response.PlaybackPlan.PlanID != started.PlaybackPlan.PlanID ||
response.PlaybackPlan.RequestedMediaFileID != started.PlaybackPlan.RequestedMediaFileID ||
response.PlaybackPlan.EffectiveMediaFileID != started.PlaybackPlan.EffectiveMediaFileID ||
!sameSelectedTracksV3(response.PlaybackPlan.SelectedTracks, started.PlaybackPlan.SelectedTracks) ||
response.PlaybackPlan.Timeline.SourceStartSeconds != 321 ||
playback.PlanAttemptKeyV3(*response.PlaybackPlan, startRequest.OutputRouteGeneration, nil) != currentKey {
t.Fatalf("reanchored response %d = %#v", index, response.PlaybackPlan)
}
if response.PlaybackPlan.Subtitle.Artifact == nil ||
response.PlaybackPlan.Subtitle.Artifact.Format != started.PlaybackPlan.Subtitle.Artifact.Format ||
response.PlaybackPlan.Subtitle.Artifact.MIMEType != started.PlaybackPlan.Subtitle.Artifact.MIMEType {
t.Fatalf("reanchored response %d changed the ASS artifact: %#v", index, response.PlaybackPlan.Subtitle)
}
if !playback.HasFeatureV3(response.ServerFeatures, playback.FeatureSeekReanchorV3) {
t.Fatalf("reanchored response %d omitted %q: %#v", index, playback.FeatureSeekReanchorV3, response.ServerFeatures)
}
}
liveSessionManager := handler.sessionMgr
handler.sessionMgr = playback.NewSessionManager(0, 0)
restartReplayReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
restartReplayReq = withPlaybackRouteParam(restartReplayReq, "session_id", started.SessionID)
restartReplayRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(restartReplayRR, restartReplayReq)
handler.sessionMgr = liveSessionManager
if restartReplayRR.Code != http.StatusNotFound || !strings.Contains(restartReplayRR.Body.String(), playbackSessionNotFoundErrorCode) {
t.Fatalf("restart replay status = %d, body = %s", restartReplayRR.Code, restartReplayRR.Body.String())
}
session, err := manager.GetSession(started.SessionID)
if err != nil {
t.Fatal(err)
}
if session.Position != 321 || !session.IsPaused {
t.Fatalf("session progress = (%v, paused=%v), want (321, paused=true)", session.Position, session.IsPaused)
}
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
if record.CurrentPlanID != started.PlaybackPlan.PlanID || record.EffectiveMediaFileID != file.ID ||
record.NormalizedRequest.StartPosition == nil || *record.NormalizedRequest.StartPosition != 321 {
t.Fatalf("stored reanchor = %#v", record)
}
mismatch := reanchor
mismatch.ReplanRequestID = "seek-reanchor-mismatch-0001"
mismatch.QualityPreference = "480p"
mismatchBody, err := json.Marshal(mismatch)
if err != nil {
t.Fatal(err)
}
mismatchReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(mismatchBody))).WithContext(newAuthorizedPlaybackContext())
mismatchReq = withPlaybackRouteParam(mismatchReq, "session_id", started.SessionID)
mismatchRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(mismatchRR, mismatchReq)
if mismatchRR.Code != http.StatusOK {
t.Fatalf("mismatch status = %d, body = %s", mismatchRR.Code, mismatchRR.Body.String())
}
var mismatchResponse playback.DecisionResponseV3
if err := json.Unmarshal(mismatchRR.Body.Bytes(), &mismatchResponse); err != nil {
t.Fatal(err)
}
if mismatchResponse.Terminal == nil || mismatchResponse.Terminal.Reason != "seek_reanchor_intent_mismatch" {
t.Fatalf("mismatch response = %#v", mismatchResponse)
}
recordAfterMismatch, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
if recordAfterMismatch.CurrentPlanID != record.CurrentPlanID || recordAfterMismatch.NormalizedRequest.StartPosition == nil || *recordAfterMismatch.NormalizedRequest.StartPosition != 321 {
t.Fatalf("intent mismatch changed stored route: before=%#v after=%#v", record, recordAfterMismatch)
}
newer := reanchor
newer.ReplanRequestID = "seek-reanchor-0002"
newer.PositionSeconds = 500
newerBody, err := json.Marshal(newer)
if err != nil {
t.Fatal(err)
}
newerReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(newerBody))).WithContext(newAuthorizedPlaybackContext())
newerReq = withPlaybackRouteParam(newerReq, "session_id", started.SessionID)
newerRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(newerRR, newerReq)
if newerRR.Code != http.StatusOK {
t.Fatalf("newer reanchor status = %d, body = %s", newerRR.Code, newerRR.Body.String())
}
staleRetryReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
staleRetryReq = withPlaybackRouteParam(staleRetryReq, "session_id", started.SessionID)
staleRetryRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(staleRetryRR, staleRetryReq)
if staleRetryRR.Code != http.StatusConflict || !strings.Contains(staleRetryRR.Body.String(), "stale_playback_plan") {
t.Fatalf("stale reanchor replay status = %d, body = %s", staleRetryRR.Code, staleRetryRR.Body.String())
}
latestRecord, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
if latestRecord.CurrentReplanRequestID != newer.ReplanRequestID || latestRecord.NormalizedRequest.StartPosition == nil || *latestRecord.NormalizedRequest.StartPosition != newer.PositionSeconds {
t.Fatalf("stale replay changed latest reanchor: %#v", latestRecord)
}
// Simulate a rolling-deploy writer which updated the current plan but did
// not know about CurrentReplanRequestID. The durable plan comparison must
// still reject A's cached response after B became active.
mixedWriterRecord := *latestRecord
mixedWriterRecord.CurrentReplanRequestID = reanchor.ReplanRequestID
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), mixedWriterRecord)
mixedWriterRetryReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
mixedWriterRetryReq = withPlaybackRouteParam(mixedWriterRetryReq, "session_id", started.SessionID)
mixedWriterRetryRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(mixedWriterRetryRR, mixedWriterRetryReq)
if mixedWriterRetryRR.Code != http.StatusConflict || !strings.Contains(mixedWriterRetryRR.Body.String(), "stale_playback_plan") {
t.Fatalf("mixed-writer stale replay status = %d, body = %s", mixedWriterRetryRR.Code, mixedWriterRetryRR.Body.String())
}
beyondEnd := newer
beyondEnd.ReplanRequestID = "seek-reanchor-beyond-end-0001"
beyondEnd.PositionSeconds = float64(file.Duration) + 1
beyondBody, err := json.Marshal(beyondEnd)
if err != nil {
t.Fatal(err)
}
beyondReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(beyondBody))).WithContext(newAuthorizedPlaybackContext())
beyondReq = withPlaybackRouteParam(beyondReq, "session_id", started.SessionID)
beyondRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(beyondRR, beyondReq)
if beyondRR.Code != http.StatusOK || !strings.Contains(beyondRR.Body.String(), "invalid_seek_position") {
t.Fatalf("beyond-end reanchor status = %d, body = %s", beyondRR.Code, beyondRR.Body.String())
}
missingSince := time.Now()
file.MissingSince = &missingSince
missing := newer
missing.ReplanRequestID = "seek-reanchor-missing-source-0001"
missing.PositionSeconds = 600
missingBody, err := json.Marshal(missing)
if err != nil {
t.Fatal(err)
}
missingReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(missingBody))).WithContext(newAuthorizedPlaybackContext())
missingReq = withPlaybackRouteParam(missingReq, "session_id", started.SessionID)
missingRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(missingRR, missingReq)
if missingRR.Code != http.StatusOK || !strings.Contains(missingRR.Body.String(), "source_unavailable") {
t.Fatalf("missing-source reanchor status = %d, body = %s", missingRR.Code, missingRR.Body.String())
}
unchanged, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
if unchanged.CurrentPlanID != latestRecord.CurrentPlanID || unchanged.EffectiveMediaFileID != latestRecord.EffectiveMediaFileID ||
unchanged.NormalizedRequest.StartPosition == nil || *unchanged.NormalizedRequest.StartPosition != newer.PositionSeconds {
t.Fatalf("rejected seeks changed the active route: before=%#v after=%#v", latestRecord, unchanged)
}
}
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
// implemented rather than behavior that regressed.
//
// What it asks for: when a seek fails and the client's replan capabilities
// have narrowed to 1080p, recovery must stay on the pinned 4K media version
// and must not video-transcode it. Today the planner takes the narrowed
// per-request capabilities at face value, finds the 4K source unplayable
// with allow_4k_transcode disabled, and answers adaptation_unavailable.
//
// Making it pass means deciding whether replan capabilities may narrow
// media-version selection at all, which is a protocol v3 planner change and
// does not belong to whichever change happens to notice the failure. Skipped
// rather than excluded in the Makefile so the reason travels with the test.
t.Skip("specifies unimplemented v3 planner behavior; see the comment above")
source := v3HandlerFixtureFile(t)
source.Resolution = "2160p"
source.Bitrate = 32_000
source.VideoTracks = append([]models.VideoTrack(nil), source.VideoTracks...)
source.VideoTracks[0].Level = 51
source.VideoTracks[0].Width = 3840
source.VideoTracks[0].Height = 2160
source.VideoTracks[0].Bitrate = 32_000
alternateValue := *source
alternate := &alternateValue
alternate.ID = 84
alternate.Resolution = "1080p"
alternate.Bitrate = 8_000
alternate.VideoTracks = append([]models.VideoTrack(nil), source.VideoTracks...)
alternate.VideoTracks[0].Level = 41
alternate.VideoTracks[0].Width = 1920
alternate.VideoTracks[0].Height = 1080
alternate.VideoTracks[0].Bitrate = 8_000
manager := playback.NewSessionManager(0, 0)
files := map[int]*models.MediaFile{
source.ID: source, alternate.ID: alternate,
}
handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: files})
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{
source.ContentID: {source, alternate},
}}
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true", "allow_4k_transcode": "false"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
startRequest.QualityPreference = "auto"
startRequest.Capabilities.MaxResolution = "2160p"
startRequest.Capabilities.VideoDecode[0].Levels = []int{51}
startRequest.Capabilities.VideoDecode[0].MaxWidth = 3840
startRequest.Capabilities.VideoDecode[0].MaxHeight = 2160
startRequest.Capabilities.VideoDecode[0].MaxBitrateKbps = 50_000
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 started playback.DecisionResponseV3
if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil {
t.Fatal(err)
}
if started.PlaybackPlan == nil || started.PlaybackPlan.EffectiveMediaFileID != source.ID {
t.Fatalf("initial 4K plan = %#v", started.PlaybackPlan)
}
if err := manager.UpdateProgress(started.SessionID, 15, true); err != nil {
t.Fatal(err)
}
seekCapabilities := startRequest.Capabilities
seekCapabilities.VideoDecode = append([]playback.VideoDecodeCapabilityV3(nil), startRequest.Capabilities.VideoDecode...)
seekCapabilities.MaxResolution = "1080p"
seekCapabilities.VideoDecode[0].MaxWidth = 1920
seekCapabilities.VideoDecode[0].MaxHeight = 1080
seekCapabilities.VideoDecode[0].MaxBitrateKbps = 20_000
seekContext := startRequest.ClientPlaybackContext
seekContext.Device.Model = "request-only-model"
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
staleClientKey := "v3:0000000000000000"
replan := playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3,
Operation: playback.ReplanOperationSeekFailureRecoveryV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "seek-failure-recovery-0001",
FailedPlanID: started.PlaybackPlan.PlanID,
PlanAttemptID: "plan-attempt-seek-failure-0001",
PlanAttemptKey: staleClientKey,
AttemptedPlanKeys: nil,
AttemptCount: 1,
QualityPreference: startRequest.QualityPreference,
PositionSeconds: 417,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: started.PlaybackPlan.SelectedTracks,
Failure: playback.FailureV3{Classification: "decoder_failure", Message: "reanchored route failed"},
Capabilities: seekCapabilities,
ClientPlaybackContext: seekContext,
}
body, err := json.Marshal(replan)
if err != nil {
t.Fatal(err)
}
replanReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
replanReq = withPlaybackRouteParam(replanReq, "session_id", started.SessionID)
replanRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(replanRR, replanReq)
if replanRR.Code != http.StatusOK {
t.Fatalf("replan status = %d, body = %s", replanRR.Code, replanRR.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(replanRR.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.PlaybackPlan == nil || response.Terminal != nil ||
response.PlaybackPlan.RequestedMediaFileID != source.ID || response.PlaybackPlan.EffectiveMediaFileID != source.ID {
t.Fatalf("failed seek did not recover on the pinned media version: %#v", response)
}
if response.PlaybackPlan.Delivery == playback.DeliveryTranscodeHLSV3 ||
response.PlaybackPlan.EffectiveRecipe.Width == nil || *response.PlaybackPlan.EffectiveRecipe.Width != 3840 ||
response.PlaybackPlan.EffectiveRecipe.Height == nil || *response.PlaybackPlan.EffectiveRecipe.Height != 2160 {
t.Fatalf("failed seek downgraded or video-transcoded the pinned 4K source: %#v", response.PlaybackPlan)
}
if playback.PlanAttemptKeyV3(*response.PlaybackPlan, startRequest.OutputRouteGeneration, nil) == currentKey {
t.Fatalf("failed seek retried the failed route: %#v", response.PlaybackPlan)
}
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
if record.EffectiveMediaFileID != source.ID || record.CurrentPlan.EffectiveMediaFileID != source.ID {
t.Fatalf("failed seek changed the durable media version: %#v", record)
}
if record.NormalizedRequest.Capabilities.MaxResolution != startRequest.Capabilities.MaxResolution ||
record.NormalizedRequest.ClientPlaybackContext.Device.Model != startRequest.ClientPlaybackContext.Device.Model {
t.Fatalf("failed seek accepted request-only capability evidence: %#v", record.NormalizedRequest)
}
session, err := manager.GetSession(started.SessionID)
if err != nil {
t.Fatal(err)
}
if session.Position != replan.PositionSeconds || !session.IsPaused {
t.Fatalf("session progress = (%v, paused=%v), want (%v, paused=true)", session.Position, session.IsPaused, replan.PositionSeconds)
}
delete(files, source.ID)
for index, operation := range []playback.ReplanOperationV3{
playback.ReplanOperationSeekReanchorV3,
playback.ReplanOperationSeekFailureRecoveryV3,
} {
missing := replan
missing.Operation = operation
missing.ReplanRequestID = fmt.Sprintf("seek-missing-current-%04d", index)
missing.FailedPlanID = response.PlaybackPlan.PlanID
missing.PlanAttemptKey = playback.PlanAttemptKeyV3(*response.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
missing.AttemptedPlanKeys = []string{missing.PlanAttemptKey}
missing.SelectedTracks = response.PlaybackPlan.SelectedTracks
missing.PositionSeconds++
if operation == playback.ReplanOperationSeekReanchorV3 {
missing.Failure = playback.FailureV3{}
}
missingBody, err := json.Marshal(missing)
if err != nil {
t.Fatal(err)
}
missingReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(missingBody))).WithContext(newAuthorizedPlaybackContext())
missingReq = withPlaybackRouteParam(missingReq, "session_id", started.SessionID)
missingRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(missingRR, missingReq)
if missingRR.Code != http.StatusOK || !strings.Contains(missingRR.Body.String(), "source_unavailable") {
t.Fatalf("missing current %s status = %d, body = %s", operation, missingRR.Code, missingRR.Body.String())
}
}
finalRecord, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
finalSession, err := manager.GetSession(started.SessionID)
if err != nil {
t.Fatal(err)
}
if finalRecord.EffectiveMediaFileID != source.ID || finalRecord.CurrentPlan.EffectiveMediaFileID != source.ID || finalSession.MediaFileID != source.ID {
t.Fatalf("missing 4K source fell through to alternate: record=%#v session=%#v", finalRecord, finalSession)
}
}
func TestHandleReplanPlaybackV3SeekUsesEffectiveEditionWhenRequestedEditionIsGone(t *testing.T) {
effective := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: map[int]*models.MediaFile{effective.ID: effective}})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
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 || started.PlaybackPlan == nil {
t.Fatalf("start response: err=%v response=%#v", err, started)
}
const missingRequestedID = 84
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
record.RequestedMediaFileID = missingRequestedID
record.NormalizedRequest.FileID = missingRequestedID
record.CurrentPlan.RequestedMediaFileID = missingRequestedID
record.CurrentPlan.PlanID = playback.DeterministicPlanIDV3(
record.PlaybackAttemptID,
missingRequestedID,
effective.ID,
record.CurrentPlan,
)
record.CurrentPlanID = record.CurrentPlan.PlanID
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
currentKey := playback.PlanAttemptKeyV3(record.CurrentPlan, record.NormalizedRequest.OutputRouteGeneration, nil)
reanchor := playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3,
Operation: playback.ReplanOperationSeekReanchorV3,
PlaybackAttemptID: record.PlaybackAttemptID,
ReplanRequestID: "seek-missing-requested-0001",
FailedPlanID: record.CurrentPlanID,
PlanAttemptID: "plan-attempt-missing-requested-0001",
PlanAttemptKey: currentKey,
AttemptedPlanKeys: []string{currentKey},
AttemptCount: 1,
QualityPreference: record.NormalizedRequest.QualityPreference,
PositionSeconds: 300,
OutputRouteGeneration: record.NormalizedRequest.OutputRouteGeneration,
SelectedTracks: record.CurrentPlan.SelectedTracks,
Capabilities: record.NormalizedRequest.Capabilities,
ClientPlaybackContext: record.NormalizedRequest.ClientPlaybackContext,
}
body, err := json.Marshal(reanchor)
if err != nil {
t.Fatal(err)
}
reanchorReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
reanchorReq = withPlaybackRouteParam(reanchorReq, "session_id", started.SessionID)
reanchorRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(reanchorRR, reanchorReq)
if reanchorRR.Code != http.StatusOK {
t.Fatalf("reanchor status = %d, body = %s", reanchorRR.Code, reanchorRR.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(reanchorRR.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.PlaybackPlan == nil || response.PlaybackPlan.RequestedMediaFileID != missingRequestedID ||
response.PlaybackPlan.EffectiveMediaFileID != effective.ID || response.PlaybackPlan.Timeline.SourceStartSeconds != reanchor.PositionSeconds {
t.Fatalf("reanchor did not retain the effective edition: %#v", response)
}
current, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
outputContext := current.NormalizedRequest.ClientPlaybackContext
outputContext.Output.OutputRouteGeneration++
currentKey = playback.PlanAttemptKeyV3(current.CurrentPlan, current.NormalizedRequest.OutputRouteGeneration, nil)
ordinary := playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3,
PlaybackAttemptID: current.PlaybackAttemptID,
ReplanRequestID: "ordinary-missing-requested-0001",
FailedPlanID: current.CurrentPlanID,
PlanAttemptID: "plan-attempt-ordinary-missing-0001",
PlanAttemptKey: currentKey,
AttemptedPlanKeys: []string{currentKey},
AttemptCount: 1,
QualityPreference: current.NormalizedRequest.QualityPreference,
PositionSeconds: 320,
OutputRouteGeneration: outputContext.Output.OutputRouteGeneration,
SelectedTracks: current.CurrentPlan.SelectedTracks,
Failure: playback.FailureV3{Classification: "output_route_changed"},
Capabilities: current.NormalizedRequest.Capabilities,
ClientPlaybackContext: outputContext,
}
ordinaryBody, err := json.Marshal(ordinary)
if err != nil {
t.Fatal(err)
}
ordinaryReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(ordinaryBody))).WithContext(newAuthorizedPlaybackContext())
ordinaryReq = withPlaybackRouteParam(ordinaryReq, "session_id", started.SessionID)
ordinaryRR := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(ordinaryRR, ordinaryReq)
if ordinaryRR.Code != http.StatusOK {
t.Fatalf("ordinary replan status = %d, body = %s", ordinaryRR.Code, ordinaryRR.Body.String())
}
var ordinaryResponse playback.DecisionResponseV3
if err := json.Unmarshal(ordinaryRR.Body.Bytes(), &ordinaryResponse); err != nil {
t.Fatal(err)
}
if ordinaryResponse.PlaybackPlan == nil || ordinaryResponse.PlaybackPlan.RequestedMediaFileID != missingRequestedID ||
ordinaryResponse.PlaybackPlan.EffectiveMediaFileID != effective.ID {
t.Fatalf("ordinary replan lost the requested/effective split: %#v", ordinaryResponse)
}
}
func TestValidateSeekRecoveryRequestV3PinsCurrentIntent(t *testing.T) {
audioIndex := 0
start := v3HandlerStartRequest()
start.AudioTrackID = playback.TrackIDV3(start.FileID, "audio", audioIndex)
start.AudioTrackIndex = &audioIndex
selected := playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: start.AudioTrackID, Index: &audioIndex}}
plan := playback.PlanV3{
PlanID: "plan:seek-current",
RequestedMediaFileID: start.FileID,
EffectiveMediaFileID: start.FileID,
SelectedTracks: selected,
}
record := &playback.AttemptRecordV3{
RequestedMediaFileID: start.FileID,
EffectiveMediaFileID: start.FileID,
CurrentPlanID: plan.PlanID,
CurrentPlan: plan,
NormalizedRequest: start,
}
request := playback.ReplanRequestV3{
Operation: playback.ReplanOperationSeekReanchorV3,
QualityPreference: start.QualityPreference,
OutputRouteGeneration: start.OutputRouteGeneration,
SelectedTracks: selected,
}
if err := validateSeekRecoveryRequestV3(record, request); err != nil {
t.Fatalf("valid seek reanchor guard: %v", err)
}
// Capabilities and client context are structurally validated by the request
// decoder but are not route inputs for a same-recipe reanchor.
request.Capabilities.CodecsVideo = []string{"av1"}
request.ClientPlaybackContext.Device.Model = "changed-client-claim"
if err := validateSeekRecoveryRequestV3(record, request); err != nil {
t.Fatalf("ignored client evidence changed stored intent: %v", err)
}
tests := []struct {
name string
mutate func(*playback.ReplanRequestV3)
}{
{name: "quality", mutate: func(value *playback.ReplanRequestV3) { value.QualityPreference = "480p" }},
{name: "output route", mutate: func(value *playback.ReplanRequestV3) { value.OutputRouteGeneration++ }},
{name: "audio track", mutate: func(value *playback.ReplanRequestV3) {
index := 1
value.SelectedTracks.Audio = &playback.TrackIdentityV3{ID: playback.TrackIDV3(start.FileID, "audio", index), Index: &index}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidate := request
test.mutate(&candidate)
if err := validateSeekRecoveryRequestV3(record, candidate); err == nil {
t.Fatalf("%s change was accepted", test.name)
}
})
}
}
func TestValidateSeekReanchorPlanV3RejectsRouteDrift(t *testing.T) {
audioIndex := 0
frameRate := 23.976
audioChannels := 2
current := playback.PlanV3{
PlanID: "plan:seek-current",
Delivery: playback.DeliveryOriginalHTTPV3,
Engine: playback.EngineMedia3DirectV3,
Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4", MIMEType: "video/mp4", HeaderRefresh: playback.HeaderRefreshNoneV3},
SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "audio", audioIndex), Index: &audioIndex}},
EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac", FrameRate: &frameRate, AudioChannels: &audioChannels, AudioLayout: "stereo"},
Claims: playback.ValidationClaimsV3{Video: playback.VideoClaimsV3{HDR10: true}},
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3, TrackID: playback.TrackIDV3(42, "subtitle", 0), Artifact: &playback.SubtitleArtifactV3{
MIMEType: "text/x-ssa", Format: "ass",
}},
Transformations: []playback.TransformationV3{{Name: "subtitle-convert", Executor: "ffmpeg", RecipeVersion: "1", ValidatedClaims: []string{"ass"}}},
AppliedQuirks: []playback.AppliedQuirkV3{{ID: "quirk-1", RegistryRevision: "1", Action: "force-remux"}},
RuntimeCorrections: []string{"pcm_fallback"},
RequestedMediaFileID: 42,
EffectiveMediaFileID: 42,
SubtitleFidelityPolicy: "preserve_styling",
}
record := &playback.AttemptRecordV3{
RequestedMediaFileID: 42,
EffectiveMediaFileID: 42,
CurrentPlanID: current.PlanID,
CurrentPlan: current,
NormalizedRequest: playback.StartRequestV3{OutputRouteGeneration: 9},
}
candidate := current
candidate.Timeline.SourceStartSeconds = 321
if err := validateSeekReanchorPlanV3(record, &candidate); err != nil {
t.Fatalf("timeline-only change rejected: %v", err)
}
tests := []struct {
name string
mutate func(*playback.PlanV3)
}{
{name: "delivery recipe", mutate: func(value *playback.PlanV3) { value.Delivery = playback.DeliveryRemuxProgressiveV3 }},
{name: "engine", mutate: func(value *playback.PlanV3) { value.Engine = playback.EngineMedia3HLSV3 }},
{name: "stream MIME", mutate: func(value *playback.PlanV3) { value.Stream.MIMEType = "application/x-mpegURL" }},
{name: "header refresh", mutate: func(value *playback.PlanV3) { value.Stream.HeaderRefresh = playback.HeaderRefreshSessionV3 }},
{name: "frame rate", mutate: func(value *playback.PlanV3) {
changed := 24.0
value.EffectiveRecipe.FrameRate = &changed
}},
{name: "audio channels", mutate: func(value *playback.PlanV3) {
changed := 6
value.EffectiveRecipe.AudioChannels = &changed
}},
{name: "audio layout", mutate: func(value *playback.PlanV3) { value.EffectiveRecipe.AudioLayout = "5.1" }},
{name: "claims", mutate: func(value *playback.PlanV3) { value.Claims.Video.HDR10 = false }},
{name: "subtitle artifact MIME", mutate: func(value *playback.PlanV3) {
copy := *value.Subtitle.Artifact
copy.MIMEType = "text/vtt"
value.Subtitle.Artifact = &copy
}},
{name: "subtitle artifact format", mutate: func(value *playback.PlanV3) {
copy := *value.Subtitle.Artifact
copy.Format = "vtt"
value.Subtitle.Artifact = &copy
}},
{name: "subtitle fidelity", mutate: func(value *playback.PlanV3) { value.SubtitleFidelityPolicy = "compatibility" }},
{name: "transformation claims", mutate: func(value *playback.PlanV3) {
value.Transformations = append([]playback.TransformationV3(nil), value.Transformations...)
value.Transformations[0].ValidatedClaims = []string{"vtt"}
}},
{name: "quirk action", mutate: func(value *playback.PlanV3) {
value.AppliedQuirks = append([]playback.AppliedQuirkV3(nil), value.AppliedQuirks...)
value.AppliedQuirks[0].Action = "disable-passthrough"
}},
{name: "effective version", mutate: func(value *playback.PlanV3) { value.EffectiveMediaFileID = 84 }},
{name: "track", mutate: func(value *playback.PlanV3) {
index := 1
value.SelectedTracks.Audio = &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "audio", index), Index: &index}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
drifted := candidate
test.mutate(&drifted)
if err := validateSeekReanchorPlanV3(record, &drifted); err == nil {
t.Fatalf("%s drift was accepted", test.name)
}
})
}
}
func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
session := &playback.Session{ID: "session-progressive", UserID: 7, ProfileID: "profile-1", MediaFileID: 42, PlayMethod: playback.PlayRemux, BasePlayMethod: playback.PlayRemux, AudioTrackIndex: 0}
for index, origin := range []float64{321.25, 654.5} {
plan := &playback.PlanV3{
PlanID: "plan:progressive",
Delivery: playback.DeliveryRemuxProgressiveV3,
EffectiveMediaFileID: 42,
Timeline: playback.TimelineV3{SourceStartSeconds: origin, PlayerStartSeconds: origin, CanSeekAnywhere: true, SeekRestoration: "player_position"},
}
transport := handler.prepareIdentityTransportV3(session, playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux})
parsed, err := url.Parse(transport.url)
if err != nil {
transport.rollback()
t.Fatal(err)
}
if parsed.Query().Get("st") == "" || parsed.Query().Get("seek") != strconv.FormatFloat(origin, 'f', -1, 64) {
transport.rollback()
t.Fatalf("progressive reanchor URL %d = %q", index, transport.url)
}
if plan.Timeline.PlayerStartSeconds != 0 || plan.Timeline.StreamOriginSeconds != origin ||
plan.Timeline.TimelineOffsetSeconds != origin || plan.Timeline.CanSeekAnywhere ||
plan.Timeline.SeekWindowStartSeconds == nil || *plan.Timeline.SeekWindowStartSeconds != origin ||
plan.Timeline.SeekWindowEndSeconds != nil || plan.Timeline.SeekRestoration != "source_position" {
transport.rollback()
t.Fatalf("progressive reanchor timeline %d = %#v", index, plan.Timeline)
}
transport.rollback()
}
}
func TestPrepareTransportV3RejectsNodeMissingRequiredTransformation(t *testing.T) {
startHits := 0
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/hw-capabilities":
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"}}})
case "/transcode/start":
startHits++
w.WriteHeader(http.StatusAccepted)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer remote.Close()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}}
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.local_transcode_fallback": "false"}}
plan := &playback.PlanV3{
PlanID: "plan:remote-capability",
Delivery: playback.DeliveryTranscodeHLSV3,
Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
},
}
request := httptest.NewRequest(http.MethodPost, "/", nil)
_, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-capability"}, v3HandlerFixtureFile(t), playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayTranscode, TargetVideoCodec: "h264", TargetAudioCodec: "aac"})
if transportErr == nil || transportErr.reason != "transcode_node_capability_unavailable" {
t.Fatalf("transport error = %#v", transportErr)
}
if startHits != 0 {
t.Fatalf("incompatible node received %d start requests", startHits)
}
}
func TestPrepareTransportV3RequiresRemoteManifestReadiness(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{Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
}})
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()
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:remote-ready",
Delivery: playback.DeliveryTranscodeHLSV3,
Transformations: []playback.TransformationV3{
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
},
}
request := httptest.NewRequest(http.MethodPost, "/", nil)
transport, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-ready", UserID: 7, ProfileID: "profile-1"}, v3HandlerFixtureFile(t), playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayTranscode, TargetVideoCodec: "h264", TargetAudioCodec: "aac"})
if transportErr != nil {
t.Fatalf("prepare remote transport: %v", transportErr)
}
defer transport.rollback()
if !startRequest.RequireReady {
t.Fatal("protocol-v3 remote start did not require manifest readiness")
}
}
func TestHandleStartPlaybackUnknownProtocolUsesLegacyBranch(t *testing.T) {
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(`{"protocol_version":99,"file_id":42,"profile_id":"profile-1","play_method":"direct"}`))
req = req.WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var response playbackSessionResponse
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.SessionID == "" || response.PlayMethod != string(playback.PlayDirect) {
t.Fatalf("legacy response = %#v", response)
}
}
func TestHandleStartPlaybackLegacyBranchPreservesTrailingBodyBehavior(t *testing.T) {
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct"} trailing`)).WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
// A copy remux streams FFmpeg's growing playlist, so its seek window must
// stay open-ended even though the runtime is known. A bounded window reads
// as "complete", which clients treat as proof that any target inside it is
// locally seekable — sending them past the produced head instead of back
// to the server. The runtime belongs on source.duration_seconds.
copyPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
copySeek, copySegment := configureHLSTimelineV3(copyPlan, "copy", 2, 600)
if copySeek != 17.3 || copySegment != 8 || copyPlan.Timeline.StreamOriginSeconds != 17.3 || copyPlan.Timeline.TimelineOffsetSeconds != 17.3 || copyPlan.Timeline.PlayerStartSeconds != 0 || copyPlan.Timeline.CanSeekAnywhere ||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 17.3 ||
copyPlan.Timeline.SeekWindowEndSeconds != nil ||
copyPlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("copy timeline=%#v seek=%v segment=%d", copyPlan.Timeline, copySeek, copySegment)
}
encodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
encodeSeek, encodeSegment := configureHLSTimelineV3(encodePlan, "h264", 2, 600)
if encodeSeek != 16 || encodeSegment != 8 || encodePlan.Timeline.StreamOriginSeconds != 0 || encodePlan.Timeline.TimelineOffsetSeconds != 0 || encodePlan.Timeline.PlayerStartSeconds != 17.3 || !encodePlan.Timeline.CanSeekAnywhere ||
encodePlan.Timeline.SeekWindowStartSeconds != nil || encodePlan.Timeline.SeekWindowEndSeconds != nil ||
encodePlan.Timeline.SeekRestoration != "player_position" {
t.Fatalf("encode timeline=%#v seek=%v segment=%d", encodePlan.Timeline, encodeSeek, encodeSegment)
}
unknownDurationPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0)
if unknownDurationPlan.Timeline.CanSeekAnywhere {
t.Fatalf("unknown-duration timeline = %#v", unknownDurationPlan.Timeline)
}
}
func TestTransportGenerationV3IsUniqueAndSessionScoped(t *testing.T) {
first := transportGenerationV3("session-1", "plan:abcdef")
second := transportGenerationV3("session-1", "plan:abcdef")
if first == second || !strings.HasPrefix(first, "session-1-abcdef-") || !strings.HasPrefix(second, "session-1-abcdef-") {
t.Fatalf("generations = %q, %q", first, second)
}
}
func TestRemuxDVModeForPlanV3ExecutesProfile8Strip(t *testing.T) {
plan := &playback.PlanV3{Source: playback.SourceDescriptorV3{DVProfile: 8}, Transformations: []playback.TransformationV3{{Name: "server_dv7_to_hdr10"}}}
if got := remuxDVModeForPlanV3(plan); got != playback.RemuxDVStripToHDR10V3 {
t.Fatalf("mode = %q", got)
}
}
func TestHandlePlaybackRouteEventV3RejectsWhileDisabled(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "false"}}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/route-events", strings.NewReader(`{}`)).WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandlePlaybackRouteEventV3(rr, req)
if rr.Code != http.StatusConflict {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestSanitizeDiagnosticsV3PreservesPlayerFailureEvidence(t *testing.T) {
got := sanitizeDiagnosticsV3(map[string]string{
"error_code": "2004",
"error_code_name": "ERROR_CODE_PARSING_CONTAINER_MALFORMED",
"error_cause": "ParserException",
"network_transport": "wifi",
"network_metered": "true",
"network_validated": "true",
"bandwidth_estimate_kbps": "3500",
"link_downstream_kbps": "5000",
"target_source_position_seconds": "321.5",
"reason": "seek_reanchor",
"message": "must not be persisted",
})
if got["error_code"] != "2004" || got["error_code_name"] == "" || got["error_cause"] != "ParserException" {
t.Fatalf("failure diagnostics = %#v", got)
}
if _, ok := got["message"]; ok {
t.Fatalf("unapproved message persisted: %#v", got)
}
for _, key := range []string{"network_transport", "network_metered", "network_validated", "bandwidth_estimate_kbps", "link_downstream_kbps", "target_source_position_seconds", "reason"} {
if got[key] == "" {
t.Errorf("client diagnostic %q was stripped: %#v", key, got)
}
}
}
func TestRouteEventV3AcceptsAndroidSeekEvents(t *testing.T) {
base := playback.RouteEventV3{
ProtocolVersion: playback.ProtocolV3,
PlaybackAttemptID: "attempt-route-0001",
OutputRouteGeneration: 1,
}
for _, event := range []string{playback.RouteEventSeekReanchorRequestedV3, playback.RouteEventSeekReanchoredV3} {
candidate := base
candidate.Event = event
if !validRouteEventV3(candidate) {
t.Errorf("Android route event %q was rejected", event)
}
}
}
func TestRemapSubtitleSelectionV3RejectsNegativeIndex(t *testing.T) {
index := -1
request := playback.StartRequestV3{SubtitleTrackIndex: &index}
source := &models.MediaFile{ID: 1, ExternalSubtitles: []models.ExternalSubtitle{{Language: "eng", Format: "srt"}}}
target := &models.MediaFile{ID: 2, ExternalSubtitles: []models.ExternalSubtitle{{Language: "eng", Format: "srt"}}}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
if err := handler.remapSubtitleSelectionV3(context.Background(), source, target, &request); err == nil {
t.Fatal("negative subtitle index was accepted")
}
}
func TestRouteEventV3HasPerUserLimitAcrossAttemptIDs(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
for i := 0; i < 600; i++ {
attemptID := "attempt-" + strconv.Itoa(i/100)
if !handler.allowRouteEventV3(7, attemptID) {
t.Fatalf("event %d was rejected before the user limit", i)
}
}
if handler.allowRouteEventV3(7, "attempt-rotated") {
t.Fatal("rotating attempt IDs bypassed the per-user limit")
}
}
func TestLegacyShadowRequestV3ProducesExplicitDetailedInference(t *testing.T) {
file := v3HandlerFixtureFile(t)
legacy := startPlaybackRequest{FileID: file.ID, ProfileID: "profile-1", CodecsVideo: []string{"h264"}, CodecsAudio: []string{"aac"}, Containers: []string{"mp4"}, MaxResolution: "1080p"}
request := legacyShadowRequestV3(legacy, file, 0, "session-1234")
if _, err := request.NormalizeAndValidate(); err != nil {
t.Fatalf("shadow request validation: %v", err)
}
if len(request.Capabilities.VideoDecode) != 1 || !request.Capabilities.VideoDecode[0].Hardware || !playback.HasFeatureV3(request.ClientFeatures, playback.FeatureDetailedDecodeV3) {
t.Fatalf("shadow request = %#v", request)
}
}
func v3HandlerFixtureFile(t *testing.T) *models.MediaFile {
t.Helper()
return &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie.mp4"), Container: "mp4", CodecVideo: "h264", CodecAudio: "aac", Resolution: "1080p", Bitrate: 8_000, AudioChannels: 2, Duration: 3600, VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo"}}}
}
func v3HandlerStartRequest() playback.StartRequestV3 {
return playback.StartRequestV3{ProtocolVersion: playback.ProtocolV3, ClientFeatures: []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3}, FileID: 42, ProfileID: "profile-1", PlaybackAttemptID: "attempt-handler-0001", QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3, OutputRouteGeneration: 1, Capabilities: playback.ClientCodecCapabilitiesV3{CodecsVideo: []string{"h264"}, CodecsVideoHardware: []string{"h264"}, CodecsAudio: []string{"aac"}, Containers: []string{"mp4"}, MaxResolution: "1080p", VideoDecode: []playback.VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}}, ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, Features: []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3}, Platform: "android", FormFactor: "tv", AppVersion: "test", Output: playback.OutputContextV3{OutputRouteGeneration: 1}, Engines: map[string]playback.EngineCapabilityV3{string(playback.EngineMedia3DirectV3): {Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}}}}
}
func marshalV3StartRequest(t *testing.T, request playback.StartRequestV3) string {
t.Helper()
body, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
return string(body)
}