package handlers import ( "context" "database/sql" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/go-chi/chi/v5" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/catalog" "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" "github.com/Silo-Server/silo-server/internal/userdb" "github.com/Silo-Server/silo-server/internal/userstore" ) type testUserStoreProvider struct { store userstore.UserStore } func (p testUserStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) { return p.store, nil } func (p testUserStoreProvider) Close() error { return nil } type testPlaybackFileResolver struct { file *models.MediaFile } func (r testPlaybackFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) { return r.file, nil } type mapPlaybackFileResolver struct { files map[int]*models.MediaFile } func (r mapPlaybackFileResolver) GetByID(_ context.Context, id int) (*models.MediaFile, error) { return r.files[id], nil } type testPlaybackFileVersionFetcher struct { byContent map[string][]*models.MediaFile byEpisode map[string][]*models.MediaFile } func (f testPlaybackFileVersionFetcher) GetByContentID(_ context.Context, id string) ([]*models.MediaFile, error) { return f.byContent[id], nil } func (f testPlaybackFileVersionFetcher) GetByEpisodeID(_ context.Context, id string) ([]*models.MediaFile, error) { return f.byEpisode[id], nil } type testPlaybackSettingsRepo struct { values map[string]string } func (r testPlaybackSettingsRepo) Get(_ context.Context, key string) (string, error) { return r.values[key], nil } type allowAllPlaybackItemAccess struct{} func (allowAllPlaybackItemAccess) EnsureAccessible( context.Context, string, catalog.AccessFilter, ) error { return nil } type noopPlaybackAdminStore struct{} func (noopPlaybackAdminStore) RecordHistory(context.Context, AdminPlaybackHistoryEntry) error { return nil } func (noopPlaybackAdminStore) DeleteSession(context.Context, string) error { return nil } type testEpisodeLookup struct { episode *models.Episode } func (l testEpisodeLookup) GetByID(context.Context, string) (*models.Episode, error) { return l.episode, nil } type failingSessionManager struct{} func (failingSessionManager) StartSession(int, string, int, playback.PlayMethod, bool) (*playback.Session, error) { return nil, errors.New("boom") } func (failingSessionManager) StartSessionWithFiles(int, string, int, int, playback.PlayMethod, bool) (*playback.Session, error) { return nil, errors.New("boom") } func (failingSessionManager) UpdateProgress(string, float64, bool) error { return nil } func (failingSessionManager) UpdateAudioTrack(string, int, playback.PlayMethod) error { return nil } func (failingSessionManager) UpdateStreamState(string, playback.SessionStreamState) error { return nil } func (failingSessionManager) TouchActivity(string) error { return nil } func (failingSessionManager) BeginTransport(string) error { return nil } func (failingSessionManager) EndTransport(string) error { return nil } func (failingSessionManager) SetEffectiveMediaFileID(string, int) error { return nil } func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return nil } func (failingSessionManager) SetWebSocket(string, bool) error { return nil } func (failingSessionManager) StopSession(string) error { return nil } func (failingSessionManager) GetSession(string) (*playback.Session, error) { return nil, nil } func newPlaybackTestStore(t *testing.T) userstore.UserStore { t.Helper() db, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := userdb.InitSchema(db); err != nil { t.Fatalf("init schema: %v", err) } store := userdb.NewSQLiteUserStore(db) if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-1", Name: "Main"}); err != nil { t.Fatalf("create profile: %v", err) } return store } func newAuthorizedPlaybackContext() context.Context { ctx := context.Background() ctx = apimw.SetClaims(ctx, &auth.Claims{UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess}) return apimw.SetProfileID(ctx, "profile-1") } func withPlaybackRouteParam(req *http.Request, key, value string) *http.Request { routeCtx := chi.NewRouteContext() routeCtx.URLParams.Add(key, value) return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) } func writePlaybackTestFFmpeg(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "fake-ffmpeg.sh") if err := os.WriteFile(path, []byte("#!/bin/sh\nsleep 30\n"), 0o755); err != nil { t.Fatalf("write fake ffmpeg: %v", err) } return path } func writePlaybackTestMediaFile(t *testing.T, name string) string { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir media path: %v", err) } if err := os.WriteFile(path, []byte("video"), 0o644); err != nil { t.Fatalf("write media file: %v", err) } return path } type recordingMissingMarker struct { ids []int } func (m *recordingMissingMarker) MarkMissing(_ context.Context, id int, _ time.Time) error { m.ids = append(m.ids, id) return nil } type recordingSessionSyncer struct { calls int } func (s *recordingSessionSyncer) SyncNow(context.Context) error { s.calls++ return nil } type recordingPlaybackAdminStore struct { history []AdminPlaybackHistoryEntry deleted []string } func (s *recordingPlaybackAdminStore) RecordHistory(_ context.Context, entry AdminPlaybackHistoryEntry) error { s.history = append(s.history, entry) return nil } func (s *recordingPlaybackAdminStore) DeleteSession(_ context.Context, sessionID string) error { s.deleted = append(s.deleted, sessionID) return nil } func TestHandleStartPlayback_UsesExplicitZeroStartPositionInsteadOfStoredResume(t *testing.T) { store := newPlaybackTestStore(t) if err := store.SetProgress(context.Background(), "profile-1", "movie-1", 900, 3600, userstore.ProgressThresholds{}); err != nil { t.Fatalf("seed progress: %v", err) } file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie.mkv"), Resolution: "1080p", Duration: 3600, } sessionMgr := playback.NewSessionManager(0, 0) handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.StoreProvider = testUserStoreProvider{store: store} handler.ItemAccess = allowAllPlaybackItemAccess{} tests := []struct { name string body string wantPosition float64 }{ { name: "restore saved resume when start_position is omitted", body: `{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`, wantPosition: 900, }, { name: "respect explicit zero start position", body: `{"file_id":42,"profile_id":"profile-1","start_position":0,"codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`, wantPosition: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(tt.body)) req = req.WithContext(newAuthorizedPlaybackContext()) rr := httptest.NewRecorder() handler.HandleStartPlayback(rr, req) if rr.Code != 201 { t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) } var resp playbackSessionResponse if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.Position != tt.wantPosition { t.Fatalf("position = %v, want %v", resp.Position, tt.wantPosition) } }) } } func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) { file := &models.MediaFile{Duration: 3600} copyResp := buildTranscodeStartResponse( transcodeStartRequest{ SessionID: "session-copy", SeekSeconds: 18.261, TargetCodecVideo: "copy", }, file, nil, "/playback/transcode/session-copy/master.m3u8", ) if copyResp.CanSeekAnywhere { t.Fatal("copy-mode response should require explicit restart seeks") } if copyResp.PlayerStartSeconds != 0 { t.Fatalf("copy-mode PlayerStartSeconds = %v, want 0", copyResp.PlayerStartSeconds) } if copyResp.StreamOriginSeconds != 18.261 { t.Fatalf("copy-mode StreamOriginSeconds = %v, want 18.261", copyResp.StreamOriginSeconds) } if copyResp.TimelineOffsetSeconds != 18.261 { t.Fatalf("copy-mode TimelineOffsetSeconds = %v, want 18.261", copyResp.TimelineOffsetSeconds) } encodedResp := buildTranscodeStartResponse( transcodeStartRequest{ SessionID: "session-encoded", SeekSeconds: 18.261, TargetCodecVideo: "h264", }, file, nil, "/playback/transcode/session-encoded/master.m3u8", ) if !encodedResp.CanSeekAnywhere { t.Fatal("encoded response should advertise seek-anywhere") } if encodedResp.PlayerStartSeconds != 18.261 { t.Fatalf("encoded PlayerStartSeconds = %v, want 18.261", encodedResp.PlayerStartSeconds) } if encodedResp.StreamOriginSeconds != 0 { t.Fatalf("encoded StreamOriginSeconds = %v, want 0", encodedResp.StreamOriginSeconds) } if encodedResp.TimelineOffsetSeconds != 0 { t.Fatalf("encoded TimelineOffsetSeconds = %v, want 0", encodedResp.TimelineOffsetSeconds) } } func TestHandleStartPlayback_PersistsSeriesPlaybackPreferenceForEpisodes(t *testing.T) { store := newPlaybackTestStore(t) file := &models.MediaFile{ ID: 42, ContentID: "movie-1", EpisodeID: "episode-1", FilePath: writePlaybackTestMediaFile(t, "episode.mkv"), Resolution: "1080p", CodecVideo: "h264", HDR: false, Duration: 3600, } handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file}) handler.StoreProvider = testUserStoreProvider{store: store} handler.ItemAccess = allowAllPlaybackItemAccess{} handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: "episode-1", SeriesID: "series-1"}} req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) req = req.WithContext(newAuthorizedPlaybackContext()) rr := httptest.NewRecorder() handler.HandleStartPlayback(rr, req) if rr.Code != 201 { t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) } pref, err := store.GetSeriesPlaybackPreference(context.Background(), "profile-1", "series-1") if err != nil { t.Fatalf("GetSeriesPlaybackPreference: %v", err) } if pref == nil { t.Fatal("expected series playback preference to be stored") } if pref.Resolution != "1080p" || pref.CodecVideo != "h264" || pref.HDR { t.Fatalf("stored pref = %+v, want 1080p/h264/false", pref) } } func TestHandleStartPlayback_DoesNotPersistSeriesPlaybackPreferenceOnFailure(t *testing.T) { store := newPlaybackTestStore(t) file := &models.MediaFile{ ID: 42, ContentID: "movie-1", EpisodeID: "episode-1", FilePath: writePlaybackTestMediaFile(t, "episode.mkv"), Resolution: "1080p", CodecVideo: "h264", HDR: false, Duration: 3600, } handler := NewPlaybackHandler(failingSessionManager{}, testPlaybackFileResolver{file: file}) handler.StoreProvider = testUserStoreProvider{store: store} handler.ItemAccess = allowAllPlaybackItemAccess{} handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: "episode-1", SeriesID: "series-1"}} req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) req = req.WithContext(newAuthorizedPlaybackContext()) rr := httptest.NewRecorder() handler.HandleStartPlayback(rr, req) if rr.Code != 500 { t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) } pref, err := store.GetSeriesPlaybackPreference(context.Background(), "profile-1", "series-1") if err != nil { t.Fatalf("GetSeriesPlaybackPreference: %v", err) } if pref != nil { t.Fatalf("expected no persisted preference on failure, got %+v", pref) } } func TestHandleChangeAudioTrack_PersistsSeriesAudioPreferenceSignature(t *testing.T) { store := newPlaybackTestStore(t) file := &models.MediaFile{ ID: 42, ContentID: "movie-1", EpisodeID: "episode-1", FilePath: writePlaybackTestMediaFile(t, "episode.mkv"), Resolution: "1080p", CodecVideo: "h264", CodecAudio: "aac", Container: "mp4", Duration: 3600, AudioTracks: []models.AudioTrack{ {Language: "eng", Codec: "aac", Layout: "stereo", Channels: 2, Title: "English Stereo", Default: true}, {Language: "eng", Codec: "aac", Layout: "5.1", Channels: 6, Title: "English 5.1", EmbeddedTitle: "Surround"}, }, } sessionMgr := playback.NewSessionManager(0, 0) handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.StoreProvider = testUserStoreProvider{store: store} handler.ItemAccess = allowAllPlaybackItemAccess{} handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: "episode-1", SeriesID: "series-1"}} startReq := httptest.NewRequest( "POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`), ) startReq = startReq.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 startResp playbackSessionResponse if err := json.NewDecoder(startRR.Body).Decode(&startResp); err != nil { t.Fatalf("decode start response: %v", err) } changeReq := httptest.NewRequest( http.MethodPatch, "/api/v1/playback/"+startResp.SessionID+"/audio", strings.NewReader(`{"audio_track_index":1,"position":120}`), ) changeReq = changeReq.WithContext(newAuthorizedPlaybackContext()) changeReq = withPlaybackRouteParam(changeReq, "session_id", startResp.SessionID) changeRR := httptest.NewRecorder() handler.HandleChangeAudioTrack(changeRR, changeReq) if changeRR.Code != http.StatusOK { t.Fatalf("change status = %d, body = %s", changeRR.Code, changeRR.Body.String()) } pref, err := store.GetAudioPreference(context.Background(), "profile-1", "series-1") if err != nil { t.Fatalf("GetAudioPreference: %v", err) } if pref == nil { t.Fatal("expected series audio preference to be stored") } if pref.AudioTrackIndex != 1 { t.Fatalf("AudioTrackIndex = %d, want 1", pref.AudioTrackIndex) } if pref.AudioLanguage != "eng" { t.Fatalf("AudioLanguage = %q, want eng", pref.AudioLanguage) } if pref.TrackSignature == nil { t.Fatal("expected audio track signature to be stored") } if pref.TrackSignature.Title != "English 5.1" { t.Fatalf("TrackSignature.Title = %q, want %q", pref.TrackSignature.Title, "English 5.1") } if pref.TrackSignature.EmbeddedTitle != "Surround" { t.Fatalf("TrackSignature.EmbeddedTitle = %q, want %q", pref.TrackSignature.EmbeddedTitle, "Surround") } if pref.TrackSignature.Codec != "aac" { t.Fatalf("TrackSignature.Codec = %q, want aac", pref.TrackSignature.Codec) } if pref.TrackSignature.Layout != "5.1" { t.Fatalf("TrackSignature.Layout = %q, want 5.1", pref.TrackSignature.Layout) } if pref.TrackSignature.Channels != 6 { t.Fatalf("TrackSignature.Channels = %d, want 6", pref.TrackSignature.Channels) } if pref.TrackSignature.Default { t.Fatal("TrackSignature.Default = true, want false") } } func TestBuildAdminHistoryEntry_UsesRequestedMediaFileID(t *testing.T) { handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), mapPlaybackFileResolver{ files: map[int]*models.MediaFile{ 42: { ID: 42, ContentID: "movie-1", FilePath: "/media/movie-4k.mkv", Resolution: "2160p", CodecVideo: "hevc", Duration: 3600, }, 99: { ID: 99, ContentID: "movie-1", FilePath: "/media/movie-1080p.mkv", Resolution: "1080p", CodecVideo: "h264", Duration: 3600, }, }, }) handler.AdminStore = noopPlaybackAdminStore{} entry, err := handler.buildAdminHistoryEntry(context.Background(), &playback.Session{ ID: "session-1", UserID: 1, ProfileID: "profile-1", MediaFileID: 99, RequestedMediaFileID: 42, PlayMethod: playback.PlayTranscode, Position: 120, StartedAt: time.Now().Add(-2 * time.Minute), }) if err != nil { t.Fatalf("buildAdminHistoryEntry: %v", err) } if entry == nil { t.Fatal("expected admin history entry") } if entry.MediaFileID != 42 { t.Fatalf("MediaFileID = %d, want 42", entry.MediaFileID) } if entry.MediaItemID != "movie-1" { t.Fatalf("MediaItemID = %q, want movie-1", entry.MediaItemID) } } func TestPersistStopAndHistory_SkipsZeroProgressStops(t *testing.T) { store := newPlaybackTestStore(t) if err := store.SetProgress(context.Background(), "profile-1", "movie-1", 900, 3600, userstore.ProgressThresholds{}); err != nil { t.Fatalf("seed progress: %v", err) } file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: "/media/movie.mkv", Resolution: "1080p", Duration: 3600, } handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file}) handler.StoreProvider = testUserStoreProvider{store: store} handler.persistStopAndHistory(context.Background(), &playback.Session{ ID: "session-1", UserID: 1, ProfileID: "profile-1", MediaFileID: 42, Position: 0, }) progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1") if err != nil { t.Fatalf("get progress: %v", err) } if progress == nil || progress.PositionSeconds != 900 { t.Fatalf("position after zero stop = %v, want 900", progress) } history, err := store.ListHistory(context.Background(), "profile-1", 10, 0) if err != nil { t.Fatalf("list history: %v", err) } if len(history) != 0 { t.Fatalf("history len = %d, want 0", len(history)) } } func TestPersistStopAndHistory_PersistsPositiveProgressStops(t *testing.T) { store := newPlaybackTestStore(t) file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: "/media/movie.mkv", Resolution: "1080p", Duration: 3600, } handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file}) handler.StoreProvider = testUserStoreProvider{store: store} handler.persistStopAndHistory(context.Background(), &playback.Session{ ID: "session-2", UserID: 1, ProfileID: "profile-1", MediaFileID: 42, Position: 240, }) progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1") if err != nil { t.Fatalf("get progress: %v", err) } if progress == nil || progress.PositionSeconds != 240 { t.Fatalf("position after positive stop = %v, want 240", progress) } history, err := store.ListHistory(context.Background(), "profile-1", 10, 0) if err != nil { t.Fatalf("list history: %v", err) } if len(history) != 1 { t.Fatalf("history len = %d, want 1", len(history)) } if history[0].Source != userstore.WatchHistorySourcePlayback { t.Fatalf("history source = %q, want %q", history[0].Source, userstore.WatchHistorySourcePlayback) } } func TestHandleStartPlayback_Recomputes4KFallbackAsRemux(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) requested := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie-4k.mkv"), Resolution: "2160p", CodecVideo: "hevc", CodecAudio: "eac3", Container: "mkv", HDR: true, Bitrate: 15000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "eac3", Default: true}, }, } effective := &models.MediaFile{ ID: 99, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie-1080p.mkv"), Resolution: "1080p", CodecVideo: "h264", CodecAudio: "eac3", Container: "mkv", Bitrate: 8000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "eac3", Default: true}, }, } handler := NewPlaybackHandler(sessionMgr, mapPlaybackFileResolver{ files: map[int]*models.MediaFile{ 42: requested, 99: effective, }, }) handler.ItemAccess = allowAllPlaybackItemAccess{} handler.SettingsRepo = testPlaybackSettingsRepo{ values: map[string]string{"allow_4k_transcode": "false"}, } handler.FileVersionFetcher = testPlaybackFileVersionFetcher{ byContent: map[string][]*models.MediaFile{ "movie-1": {requested, effective}, }, } req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) req = req.WithContext(newAuthorizedPlaybackContext()) rr := httptest.NewRecorder() handler.HandleStartPlayback(rr, req) if rr.Code != 201 { t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) } var resp playbackSessionResponse if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.MediaFileID != 99 { t.Fatalf("MediaFileID = %d, want 99", resp.MediaFileID) } if resp.PlayMethod != string(playback.PlayRemux) { t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayRemux) } session, err := sessionMgr.GetSession(resp.SessionID) if err != nil { t.Fatalf("GetSession: %v", err) } if session.MediaFileID != 99 { t.Fatalf("session.MediaFileID = %d, want 99", session.MediaFileID) } if session.RequestedMediaFileID != 42 { t.Fatalf("session.RequestedMediaFileID = %d, want 42", session.RequestedMediaFileID) } if session.BasePlayMethod != playback.PlayRemux { t.Fatalf("session.BasePlayMethod = %q, want %q", session.BasePlayMethod, playback.PlayRemux) } } func TestHandleStartPlayback_Recomputes4KFallbackAsDirect(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) requested := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie-4k.mkv"), Resolution: "2160p", CodecVideo: "hevc", CodecAudio: "eac3", Container: "mkv", HDR: true, Bitrate: 15000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "eac3", Default: true}, }, } effective := &models.MediaFile{ ID: 99, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie-1080p.mp4"), Resolution: "1080p", CodecVideo: "h264", CodecAudio: "aac", Container: "mp4", Bitrate: 8000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "aac", Default: true}, }, } handler := NewPlaybackHandler(sessionMgr, mapPlaybackFileResolver{ files: map[int]*models.MediaFile{ 42: requested, 99: effective, }, }) handler.ItemAccess = allowAllPlaybackItemAccess{} handler.SettingsRepo = testPlaybackSettingsRepo{ values: map[string]string{"allow_4k_transcode": "false"}, } handler.FileVersionFetcher = testPlaybackFileVersionFetcher{ byContent: map[string][]*models.MediaFile{ "movie-1": {requested, effective}, }, } req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) req = req.WithContext(newAuthorizedPlaybackContext()) rr := httptest.NewRecorder() handler.HandleStartPlayback(rr, req) if rr.Code != 201 { t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) } var resp playbackSessionResponse if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.MediaFileID != 99 { t.Fatalf("MediaFileID = %d, want 99", resp.MediaFileID) } if resp.PlayMethod != string(playback.PlayDirect) { t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayDirect) } } func TestHandleStartPlayback_AppleExplicitDirectPreservesSelectedAudio(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie-h264.mkv"), Resolution: "1080p", CodecVideo: "h264", CodecAudio: "aac", Container: "mkv", Bitrate: 12000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "aac", Default: true}, {Codec: "dts"}, }, } handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.ItemAccess = allowAllPlaybackItemAccess{} req := httptest.NewRequest( "POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","audio_track_index":1,"preserve_direct_audio_selection":true,"codecs_video":["h264","hevc"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`), ) 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 resp playbackSessionResponse if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.PlayMethod != string(playback.PlayDirect) { t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayDirect) } if resp.AudioTrackIndex != 1 { t.Fatalf("AudioTrackIndex = %d, want 1", resp.AudioTrackIndex) } session, err := sessionMgr.GetSession(resp.SessionID) if err != nil { t.Fatalf("GetSession: %v", err) } if session.TranscodeAudio { t.Fatal("TranscodeAudio = true, want false") } if session.AudioTrackIndex != 1 { t.Fatalf("session.AudioTrackIndex = %d, want 1", session.AudioTrackIndex) } } func TestHandleStartPlayback_ExplicitDirectPromotesSelectedAudioWithoutApplePreserveFlag(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie-h264.mkv"), Resolution: "1080p", CodecVideo: "h264", CodecAudio: "aac", Container: "mkv", Bitrate: 12000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "aac", Default: true}, {Codec: "dts"}, }, } handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.ItemAccess = allowAllPlaybackItemAccess{} req := httptest.NewRequest( "POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","audio_track_index":1,"codecs_video":["h264","hevc"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`), ) 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 resp playbackSessionResponse if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.PlayMethod != string(playback.PlayRemux) { t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayRemux) } if resp.AudioTrackIndex != 1 { t.Fatalf("AudioTrackIndex = %d, want 1", resp.AudioTrackIndex) } session, err := sessionMgr.GetSession(resp.SessionID) if err != nil { t.Fatalf("GetSession: %v", err) } if !session.TranscodeAudio { t.Fatal("TranscodeAudio = false, want true for unsupported selected audio") } if session.AudioTrackIndex != 1 { t.Fatalf("session.AudioTrackIndex = %d, want 1", session.AudioTrackIndex) } } func TestHandleStartPlayback_AutoDirectIgnoresApplePreserveFlagForSelectedAudio(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie-h264.mp4"), Resolution: "1080p", CodecVideo: "h264", CodecAudio: "aac", Container: "mp4", Bitrate: 12000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "aac", Default: true}, {Codec: "dts"}, }, } handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.ItemAccess = allowAllPlaybackItemAccess{} req := httptest.NewRequest( "POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","audio_track_index":1,"preserve_direct_audio_selection":true,"codecs_video":["h264","hevc"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`), ) 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 resp playbackSessionResponse if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.PlayMethod != string(playback.PlayRemux) { t.Fatalf("PlayMethod = %q, want %q", resp.PlayMethod, playback.PlayRemux) } if resp.AudioTrackIndex != 1 { t.Fatalf("AudioTrackIndex = %d, want 1", resp.AudioTrackIndex) } session, err := sessionMgr.GetSession(resp.SessionID) if err != nil { t.Fatalf("GetSession: %v", err) } if !session.TranscodeAudio { t.Fatal("TranscodeAudio = false, want true for unsupported selected audio") } if session.AudioTrackIndex != 1 { t.Fatalf("session.AudioTrackIndex = %d, want 1", session.AudioTrackIndex) } } func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) requested := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie-4k.mkv"), Resolution: "2160p", CodecVideo: "hevc", CodecAudio: "eac3", Container: "mkv", HDR: true, Bitrate: 15000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "eac3", Default: true}, }, } effective := &models.MediaFile{ ID: 99, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie-1080p.mkv"), Resolution: "1080p", CodecVideo: "h264", CodecAudio: "eac3", Container: "mkv", Bitrate: 8000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "eac3", Default: true}, }, } handler := NewPlaybackHandler(sessionMgr, mapPlaybackFileResolver{ files: map[int]*models.MediaFile{ 42: requested, 99: effective, }, }) handler.ItemAccess = allowAllPlaybackItemAccess{} handler.SettingsRepo = testPlaybackSettingsRepo{ values: map[string]string{"allow_4k_transcode": "false"}, } handler.FileVersionFetcher = testPlaybackFileVersionFetcher{ byContent: map[string][]*models.MediaFile{ "movie-1": {requested, effective}, }, } req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) req = req.WithContext(newAuthorizedPlaybackContext()) rr := httptest.NewRecorder() handler.HandleStartPlayback(rr, req) if rr.Code != 201 { t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String()) } var startResp playbackSessionResponse if err := json.NewDecoder(rr.Body).Decode(&startResp); err != nil { t.Fatalf("decode start response: %v", err) } if startResp.PlayMethod != string(playback.PlayRemux) { t.Fatalf("start PlayMethod = %q, want %q", startResp.PlayMethod, playback.PlayRemux) } if err := sessionMgr.UpdateAudioTrack(startResp.SessionID, 1, playback.PlayRemux); err != nil { t.Fatalf("UpdateAudioTrack: %v", err) } var remoteStartRequests int var remoteStartReq transcodenode.TranscodeStartRequest remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/transcode/start" { t.Fatalf("unexpected remote request %s %s", r.Method, r.URL.Path) } remoteStartRequests++ if err := json.NewDecoder(r.Body).Decode(&remoteStartReq); err != nil { t.Fatalf("decode remote start request: %v", err) } w.WriteHeader(http.StatusAccepted) })) defer remote.Close() pool := nodepool.NewTranscodePool() pool.SetNodes([]*nodepool.Node{{ Name: "transcode-1", Type: nodepool.NodeTypeTranscode, URL: remote.URL, Enabled: true, Healthy: true, ActiveJobs: 0, }}) handler.TranscodePool = pool transcodeReq := httptest.NewRequest( "POST", "/api/v1/playback/transcode/start", strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), ) transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) transcodeRR := httptest.NewRecorder() handler.HandleStartTranscode(transcodeRR, transcodeReq) if transcodeRR.Code != 202 { t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) } if remoteStartRequests != 1 { t.Fatalf("remote start requests = %d, want 1", remoteStartRequests) } if remoteStartReq.AudioTrackIndex != 1 { t.Fatalf("remote audio_track_index = %d, want 1", remoteStartReq.AudioTrackIndex) } session, err := sessionMgr.GetSession(startResp.SessionID) if err != nil { t.Fatalf("GetSession: %v", err) } if session.PlayMethod != playback.PlayTranscode { t.Fatalf("session.PlayMethod = %q, want %q", session.PlayMethod, playback.PlayTranscode) } if session.BasePlayMethod != playback.PlayRemux { t.Fatalf("session.BasePlayMethod = %q, want %q", session.BasePlayMethod, playback.PlayRemux) } } func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) filePath := writePlaybackTestMediaFile(t, "movie.mkv") file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: filePath, Resolution: "1080p", CodecVideo: "h264", CodecAudio: "aac", Container: "mkv", Bitrate: 8000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "ac3", Default: true}, {Codec: "aac"}, }, } handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.ItemAccess = allowAllPlaybackItemAccess{} handler.FFmpegPath = writePlaybackTestFFmpeg(t) handler.TranscodeDir = t.TempDir() req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","audio_track_index":1,"codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) req = req.WithContext(newAuthorizedPlaybackContext()) rr := httptest.NewRecorder() handler.HandleStartPlayback(rr, req) if rr.Code != 201 { t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String()) } var startResp playbackSessionResponse if err := json.NewDecoder(rr.Body).Decode(&startResp); err != nil { t.Fatalf("decode start response: %v", err) } transcodeReq := httptest.NewRequest( "POST", "/api/v1/playback/transcode/start", strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), ) transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) transcodeRR := httptest.NewRecorder() handler.HandleStartTranscode(transcodeRR, transcodeReq) if transcodeRR.Code != 202 { t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) } transcodeSession := handler.getTranscodeSession(startResp.SessionID) if transcodeSession == nil { t.Fatal("expected local transcode session") } t.Cleanup(func() { _ = transcodeSession.Close() }) if got := transcodeSession.Opts().AudioTrackIndex; got != 1 { t.Fatalf("local transcode audio track index = %d, want 1", got) } } func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) filePath := writePlaybackTestMediaFile(t, "movie-mpeg2.mkv") file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: filePath, Resolution: "1080p", CodecVideo: "mpeg2video", CodecAudio: "dts", Container: "mkv", Bitrate: 25000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "dts", Default: true}, }, } session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) if err != nil { t.Fatalf("StartSession: %v", err) } handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.ItemAccess = allowAllPlaybackItemAccess{} handler.FFmpegPath = writePlaybackTestFFmpeg(t) handler.TranscodeDir = t.TempDir() transcodeReq := httptest.NewRequest( "POST", "/api/v1/playback/transcode/start", strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"1080p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), ) transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) transcodeRR := httptest.NewRecorder() handler.HandleStartTranscode(transcodeRR, transcodeReq) if transcodeRR.Code != 202 { t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) } transcodeSession := handler.getTranscodeSession(session.ID) if transcodeSession == nil { t.Fatal("expected local transcode session") } t.Cleanup(func() { _ = transcodeSession.Close() }) opts := transcodeSession.Opts() if got := opts.TargetCodecVideo; got != "copy" { t.Fatalf("mpeg2 seeked copy target video codec = %q, want copy", got) } if got := opts.SourceVideoCodec; got != "mpeg2video" { t.Fatalf("mpeg2 seeked copy source video codec = %q, want mpeg2video", got) } if got := opts.TargetCodecAudio; got != "aac" { t.Fatalf("mpeg2 seeked copy target audio codec = %q, want aac", got) } } func TestHandleStartPlayback_MarksMissingFileAndSkipsSessionCreation(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) marker := &recordingMissingMarker{} file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: filepath.Join(t.TempDir(), "missing.mkv"), Duration: 3600, } handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.ItemAccess = allowAllPlaybackItemAccess{} handler.MissingMarker = marker req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) req = req.WithContext(newAuthorizedPlaybackContext()) rr := httptest.NewRecorder() handler.HandleStartPlayback(rr, req) if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) } if got := len(sessionMgr.AllSessions()); got != 0 { t.Fatalf("session count = %d, want 0", got) } if len(marker.ids) != 1 || marker.ids[0] != 42 { t.Fatalf("marked ids = %v, want [42]", marker.ids) } } func TestHandleStartTranscode_AbortsSessionWhenBackingFileIsMissing(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) filePath := writePlaybackTestMediaFile(t, "movie.mkv") file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: filePath, Resolution: "1080p", CodecVideo: "h264", CodecAudio: "aac", Container: "mkv", Bitrate: 8000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "aac", Default: true}, }, } marker := &recordingMissingMarker{} syncer := &recordingSessionSyncer{} adminStore := &recordingPlaybackAdminStore{} handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.ItemAccess = allowAllPlaybackItemAccess{} handler.MissingMarker = marker handler.SessionSyncer = syncer handler.AdminStore = adminStore startReq := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) startReq = startReq.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 startResp playbackSessionResponse if err := json.NewDecoder(startRR.Body).Decode(&startResp); err != nil { t.Fatalf("decode start response: %v", err) } if err := os.Remove(filePath); err != nil { t.Fatalf("remove file: %v", err) } transcodeReq := httptest.NewRequest( "POST", "/api/v1/playback/transcode/start", strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), ) transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) transcodeRR := httptest.NewRecorder() handler.HandleStartTranscode(transcodeRR, transcodeReq) if transcodeRR.Code != http.StatusNotFound { t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) } if _, err := sessionMgr.GetSession(startResp.SessionID); !errors.Is(err, playback.ErrSessionNotFound) { t.Fatalf("GetSession error = %v, want %v", err, playback.ErrSessionNotFound) } if len(marker.ids) != 1 || marker.ids[0] != 42 { t.Fatalf("marked ids = %v, want [42]", marker.ids) } if len(adminStore.deleted) != 1 || adminStore.deleted[0] != startResp.SessionID { t.Fatalf("deleted sessions = %v, want [%s]", adminStore.deleted, startResp.SessionID) } if len(adminStore.history) != 0 { t.Fatalf("history entries = %d, want 0", len(adminStore.history)) } if syncer.calls == 0 { t.Fatal("expected session sync after abort") } } func TestHandleStartTranscode_KeepsSessionWhenStartupFailsForNonMissingReason(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) filePath := writePlaybackTestMediaFile(t, "movie.mkv") file := &models.MediaFile{ ID: 42, ContentID: "movie-1", FilePath: filePath, Resolution: "1080p", CodecVideo: "h264", CodecAudio: "aac", Container: "mkv", Bitrate: 8000, Duration: 3600, AudioTracks: []models.AudioTrack{ {Codec: "aac", Default: true}, }, } syncer := &recordingSessionSyncer{} adminStore := &recordingPlaybackAdminStore{} handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.ItemAccess = allowAllPlaybackItemAccess{} handler.SessionSyncer = syncer handler.AdminStore = adminStore handler.TranscodeDir = writePlaybackTestMediaFile(t, "occupied-transcode-dir") startReq := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`)) startReq = startReq.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()) } initialSyncCalls := syncer.calls var startResp playbackSessionResponse if err := json.NewDecoder(startRR.Body).Decode(&startResp); err != nil { t.Fatalf("decode start response: %v", err) } transcodeReq := httptest.NewRequest( "POST", "/api/v1/playback/transcode/start", strings.NewReader(`{"session_id":"`+startResp.SessionID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), ) transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) transcodeRR := httptest.NewRecorder() handler.HandleStartTranscode(transcodeRR, transcodeReq) if transcodeRR.Code != http.StatusInternalServerError { t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) } if _, err := sessionMgr.GetSession(startResp.SessionID); err != nil { t.Fatalf("GetSession error = %v, want live session", err) } if len(adminStore.deleted) != 0 { t.Fatalf("deleted sessions = %v, want none", adminStore.deleted) } if len(adminStore.history) != 0 { t.Fatalf("history entries = %d, want 0", len(adminStore.history)) } if syncer.calls != initialSyncCalls { t.Fatalf("sync calls = %d, want unchanged value %d", syncer.calls, initialSyncCalls) } } func TestFindAlternateFile_DoesNotCrossEdition(t *testing.T) { source := &models.MediaFile{ ID: 1, ContentID: "movie-1", Resolution: "2160p", HDR: true, Bitrate: 30_000_000, EditionKey: "final_cut", } handler := &PlaybackHandler{ FileVersionFetcher: testPlaybackFileVersionFetcher{ byContent: map[string][]*models.MediaFile{ "movie-1": { source, { ID: 2, ContentID: "movie-1", Resolution: "1080p", HDR: false, Bitrate: 12_000_000, EditionKey: "theatrical", }, { ID: 3, ContentID: "movie-1", Resolution: "1080p", HDR: false, Bitrate: 10_000_000, EditionKey: "final_cut", }, }, }, }, } alternate, err := handler.findAlternateFile(context.Background(), source) if err != nil { t.Fatalf("findAlternateFile: %v", err) } if alternate == nil { t.Fatal("expected alternate file") } if alternate.ID != 3 { t.Fatalf("alternate.ID = %d, want 3", alternate.ID) } }