* docs(playback): plan protocol v3 server implementation * docs(playback): incorporate protocol v3 review * feat(playback): implement protocol v3 server * fix(playback): persist empty route diagnostics * feat(playback): harden protocol v3 HDR routing * feat(playback): complete protocol v3 client contract * fix(playback): harden protocol v3 recovery * fix(playback): restore dovi_rpu strip filter for DV remuxes The v3 work renamed the Dolby Vision strip recipe to a dovi_split=mode=bl bitstream filter that does not exist in stock FFmpeg or jellyfin-ffmpeg; the probe failed closed on every deployment, disabling the new validated DV7-to-HDR10 route and regressing the previously working dovi_rpu=strip=1 remux path from main. Restore dovi_rpu across the probe, remux and HLS copy arguments, and the recipe-card constant. Also from review: validate the remux DV mode for every profile (garbage modes on non-P7 sources silently no-opped), reject preserve mode for P7 outright (a base-layer-only remux cannot preserve dual-layer DV), tag dvhe sample entries only for the explicit v3 preserve recipe so legacy web/jellycompat remuxes keep their pre-v3 hev1 labeling, and honor the token-frozen DV mode in the proxy remux path instead of legacy-auto. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): correct v3 planner policy and contract validation Review fixes to the v3 planner and wire contracts: - Bar Profile 7 sources from the non-strip progressive remux route: a base-layer-only remux can never deliver native dual-layer DV, so the planner no longer emits plans claiming validated Dolby Vision while the executed remux drops the enhancement layer. - Accept the device-quirks feature flag from either capability location, matching every other dual-location feature check. - Treat legacy hdr_unknown rows as HDR10 for HDR10-capable clients with a degradation warning instead of leaving them unplayable under v3. - Honor bandwidth_cap_kbps as a hard ceiling in every quality mode and wire the previously dead Metered signal into conservative auto rungs. - Degrade to the validated source-quality route instead of a terminal when only an implicit quality reduction demanded an unsupported transcode; explicit user-selected rungs keep terminal behavior. - Bound inner capability lists and strings; compare attempt keys exactly instead of case-folded; make ParseTrackIDV3 strict about canonical numerics; accept dvdsub/pgssub/dvbsub aliases and stop promising burn-in for unknown subtitle codecs; probe every h264 encoder rather than requiring libx264; normalize the file-level bitrate fallback. - Evaluate subtitle renderability against the engine each candidate route executes on, not always media3_direct. - Pin the with-quirks attempt-key preimage arity in the cross-language fixture so the Kotlin client stays in lockstep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): harden v3 control-plane reliability Review fixes to the v3 session, store, and handler layer: - Bound concurrent replans with a slot semaphore: each replan pins a pooled connection for its advisory lock while issuing further store queries from the same pool, so an unbounded recovery storm could turn every connection into a lock holder and deadlock the server. - Make CompleteReplan a real compare-and-swap (base-revision predicate, ErrReplanSupersededV3) and map BeginReplan insert races to a replay instead of a raw unique violation. - Fingerprint start requests (request_digest column): an attempt ID reused with different input is now a 409-style conflict rather than a silent replay, and both replay paths check session liveness so dead sessions surface as retryable terminals. - Pre-delete expired attempt rows on SaveAttempt so a retry during the cleanup window cannot wedge on an unreachable conflict. - Align the in-memory store's semantics with Postgres and add DB-backed planstore tests (SILO_TEST_DATABASE_URL), including a regression test inserting every route-event name against the real CHECK constraint. - Session manager: v3 route-set updates own RemuxDVMode outright so a replan onto an SDR source clears a stale strip mode; replacement reservations survive unrelated legacy stream updates; replacement admission excludes the replaced session explicitly instead of decrementing totals it may no longer be part of; the admission CAS loop is bounded and decider errors are logged. - Map transient store failures to 500s instead of terminal 404/403s; authorize route events via identity-only projections after the rate limiter; keep sanitized diagnostics deterministic. - Merge the server-computed durable plan key into replan exclusions so unreproducible client history cannot re-select the failed route. - Remap tracks only when the effective edition changes (a same-file replan no longer switches audio to a lookalike track) and remap ID-only subtitle selections on edition fallback. - Cache the v3/shadow feature flags for five seconds instead of one settings SELECT per playback request; stop remote transports best-effort when the start call times out; carry dvm/tid claims and the transport-scoped job identity through the legacy audio-change re-mint; index playback_route_events(received_at) for the retention delete; run store maintenance for DB-less deployments too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transcode): reap idle node jobs and gate WebVTT conversion - Add an idle reaper to the transcode node: a job untouched by manifest or segment requests for ten minutes is closed and unregistered. After a v3 replan retires a transport ID, a stale in-flight stream token could resurrect the old job via reconstruct and encode to end-of-file for nobody; jobs waiting on readiness count registration as access and are never reaped mid-wait, and reaping keeps the recipe so a still-valid token reconstructs on the next hit. - Reject bitmap subtitle tracks (PGS) on the .vtt conversion path with 415 before headers are written instead of spawning an ffmpeg command that always fails mid-response, and make the extract-format override fall back to source-driven mapping for bitmap codecs. - Drain error bodies on non-202 node responses so the HTTP transport can reuse connections. - Pin the transcode-dir cleanup separator-boundary semantics with a regression test (a session ID sharing another's prefix must not retain foreign directories). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): close v3 planner policy gaps from review - Clamp the final transcode bitrate to bandwidth_cap_kbps: the ladder has no rung below 480p/1500kbps, so lower caps were silently exceeded even though the cap is documented as a hard delivery ceiling. - Treat video-only media as audio-compatible instead of forcing an AAC conversion (or an audio_conversion_unsupported terminal) onto a file with no audio stream. Tracks whose codec failed to probe keep the gate. - Only promise a bitmap subtitle sidecar for embedded PGS with an engine that renders embedded bitmap: external/downloaded bitmap and embedded DVD/DVB published artifact URLs that always failed at fetch. They now fall through to burn-in or its terminal. - Accept client_video_transformations_v1 from either client_features or the nested context when validating client-executor transformations, matching the planner's dual-source reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): probe and execute DV remuxes with one ffmpeg binary The v3 transformation registry probed the configured playback.ffmpeg_path while progressive remux execution resolved the process-global discovery path, so a deployment where only one binary carries dovi_rpu could plan a server_dv7_to_hdr10 route and then fail it at stream time. Resolution now goes through a shared ResolveFFmpegPath (configured path first, discovery fallback — the same rule the transcode pipeline already used), the dovi_rpu probe is cached per binary path, and the stream handler and proxy worker pass their configured path into ServeRemuxWithDVMode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): harden v3 replan identity and control-plane limits - Seed failure-replan track selections from the durable current plan before overlaying the request: after an alternate-version fallback the normalized request still carries requested-edition track IDs, so a replan omitting unchanged tracks was rejected as a track/file mismatch. - Remap ID-only audio selections across edition changes (parse the ID to an index like the subtitle remap already does) instead of leaving a stale file-bound ID to fail validation. - Release the node planner reservation when a prepared remote transport rolls back after the node accepted the job; repeated failed starts could otherwise pin max-job/bandwidth budgets for the full reservation age. - Size the replan semaphore below the PostgreSQL pool via a store capacity advisor: with max_connections at or below the fixed bound, advisory-lock holders could starve the inner store queries they need to finish. - Contain shadow-planner panics with a recover boundary; it runs on a bare goroutine where an escaped panic kills the process for what is telemetry-only work. Document why the memory store's session lock is deliberately a no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transcode): serialize node job teardown against reconstructs - Look up and touch manifest/segment sessions in one critical section so the idle reaper cannot unregister a job between the lookup and its liveness refresh. - Re-validate each reap candidate under the per-session lifecycle lock before closing it: Close removes the output directory, and without the lock it could race a token reconstruct and wipe the segments the fresh ffmpeg is writing. - Take the lifecycle lock in handleStop so a stop racing a RequireReady start's readiness wait blocks until registration and tears the job down, instead of 404ing and orphaning the ffmpeg until the reaper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1339 lines
65 KiB
Go
1339 lines
65 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) {
|
|
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) {
|
|
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 = ©
|
|
}},
|
|
{name: "subtitle artifact format", mutate: func(value *playback.PlanV3) {
|
|
copy := *value.Subtitle.Artifact
|
|
copy.Format = "vtt"
|
|
value.Subtitle.Artifact = ©
|
|
}},
|
|
{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) {
|
|
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.SeekWindowEndSeconds != 600 ||
|
|
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)
|
|
}
|