* fix(scanner): reject durations that imply an impossible bitrate The duration-plausibility rule only rejected videos of 10 seconds or less, so a feature film that probed as 61 seconds passed untouched and persisted. Clients then had nothing trustworthy to anchor on: Android's grow-only duration ratchet has no floor to hold when the catalog value is wrong, so the playback engine's growing-HLS-window duration won and a 90-minute movie displayed as ~1 minute. Size and duration together pin an implied bitrate, which separates the two cases the absolute floor conflates. A genuine short clip has an ordinary bitrate; a 100 GB file claiming 61 seconds implies ~13 Gbps. The ceiling sits far above any real medium, so legitimate content cannot trip it — and unlike the absolute floor, it does not false-positive on a genuine high-bitrate short. Also bump the repair-rule revision marker so rows judged by the previous, weaker rule are re-checked once under this one. Without that bump an improved rule never reaches the rows it was written for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(playback): publish source runtime in v3 plans and stop faking the copy seek window Two defects with one root: a v3 plan described where playback sits without ever stating how long the media is. Add source.duration_seconds. It is the file's full runtime, never `total - source_start` and never adjusted by timeline_offset_seconds, and it is omitted rather than null when unknown — clients that coerce null to a numeric default would read it as zero, the exact value this field exists to stop them inventing. It is set in SourceDescriptorFromFileV3, the single place every delivery already flows through, so direct play, progressive remux, HLS remux and HLS transcode all carry it. Until now the v3 plan omitted duration entirely, so clients fell back to the playback engine. On an HLS copy remux the server intentionally serves FFmpeg's still-growing playlist, so the engine reports the length produced so far. With no server-supplied runtime to anchor on, a feature film played back as a couple of minutes. The legacy protocol already answered this correctly via fileDurationSeconds; this restores parity. Separately, the copy branch published seek_window_end_seconds as the media runtime. That made the window look *complete*, which clients read as proof that any target inside it is locally seekable, so they native-seek past the produced head of a growing playlist instead of asking for a reanchor. Leave the end open: an incomplete window plus can_seek_anywhere=false routes every seek through the server, which is what legacy did before v3 added the bound. Advertise plan_source_duration_v1 so a client can distinguish "this server does not populate the field" from "this server knows the runtime is genuinely unknown" — without it, both look like an absent field and a client cannot tell whether its own catalog fallback is still required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(web): pair the exit position with the media runtime, not the element duration The player's exit state converts its position to media time but took the duration from the video element, which is player-local. On a remux or transcode stream the element only covers the window produced so far, so the two values live in different coordinate systems. Resuming a movie 50 minutes in makes that concrete: the exit position is ~3060s of media time while the element reports ~120s. The progress cache then evaluates `position >= duration`, marks the item completed, latches the watched badge, and — because completion clears the resume point — resets position to 0. Exiting a resumed movie destroyed the resume point and claimed it had been watched. The server's runtime is authoritative and already expressed in media time, so prefer it and fall back to the element only when no server value exists. The rule moves into mediaTimeline.ts next to the coordinate conversions it depends on, which is also what makes it testable — VideoPlayer itself has no test harness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1347 lines
66 KiB
Go
1347 lines
66 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) {
|
|
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) {
|
|
// 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)
|
|
}
|