package jellycompat import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "github.com/go-chi/chi/v5" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/subtitles" ) func ptrString(p *int) string { if p == nil { return "" } return strconv.Itoa(*p) } // subtitleSelectionVersion is a fixture with one video, one audio, one embedded // default text subtitle (stream index 2), one external text subtitle (stream // index 3), and one bitmap subtitle that requires burn-in (not streamable). func subtitleSelectionVersion() catalog.FileVersion { return catalog.FileVersion{ FileID: 42, Duration: 3600, Container: "mkv", Bitrate: 8000, VideoTracks: []models.VideoTrack{ {Codec: "h264", Width: 1920, Height: 1080}, }, AudioTracks: []models.AudioTrack{ {Codec: "aac", Default: true, Title: "Main"}, }, SubtitleTracks: []catalog.VersionSubtitleTrack{ {Index: 2, Codec: "subrip", Language: "eng", Title: "English", Default: true}, {Codec: "srt", Language: "spa", Title: "Spanish", External: true}, {Codec: "dvd_subtitle", Language: "fre", Title: "French (bitmap)"}, }, } } func TestPlaybackInfoRequest_AcceptsStringSubtitleStreamIndex(t *testing.T) { var req playbackInfoRequest if err := json.Unmarshal([]byte(`{"SubtitleStreamIndex":"3"}`), &req); err != nil { t.Fatalf("unmarshal playback request: %v", err) } if req.SubtitleStreamIndex == nil { t.Fatal("expected subtitle stream index") } if got := int(*req.SubtitleStreamIndex); got != 3 { t.Fatalf("SubtitleStreamIndex = %d, want 3", got) } } func TestIsValidCompatSubtitleStreamIndex(t *testing.T) { version := subtitleSelectionVersion() const downloadedCount = 1 // downloaded subtitle occupies stream index 5 (after the bitmap track at 4) cases := []struct { name string streamIndex int want bool }{ {"video stream", 0, false}, {"audio stream", 1, false}, {"embedded text subtitle", 2, true}, {"external text subtitle", 3, true}, {"bitmap subtitle (needs burn-in)", 4, false}, {"downloaded subtitle", 5, true}, {"out of range", 6, false}, {"negative", -1, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := isValidCompatSubtitleStreamIndex(version, downloadedCount, tc.streamIndex); got != tc.want { t.Fatalf("isValidCompatSubtitleStreamIndex(%d) = %v, want %v", tc.streamIndex, got, tc.want) } }) } } func TestIsValidCompatSubtitleStreamIndex_ExcludesBitmapSubtitle(t *testing.T) { // A version whose only subtitle is bitmap (needs burn-in). Its computed // stream index must not be selectable, because it is filtered out of the // delivered streams. version := catalog.FileVersion{ FileID: 7, VideoTracks: []models.VideoTrack{ {Codec: "h264"}, }, AudioTracks: []models.AudioTrack{ {Codec: "aac"}, }, SubtitleTracks: []catalog.VersionSubtitleTrack{ {Codec: "dvd_subtitle", Language: "eng"}, }, } // Bitmap track would otherwise compute to stream index 2. if isValidCompatSubtitleStreamIndex(version, 0, 2) { t.Fatal("bitmap subtitle stream index should not be selectable") } } func TestExternalSubtitleRouteIndexSkipsEmbeddedStreamIndexGaps(t *testing.T) { file := &models.MediaFile{ VideoTracks: []models.VideoTrack{{Codec: "hevc"}}, AudioTracks: []models.AudioTrack{{Codec: "eac3"}}, SubtitleTracks: []models.SubtitleTrack{ {Index: 4, Codec: "subrip"}, {Index: 0, Codec: "hdmv_pgs_subtitle"}, }, ExternalSubtitles: []models.ExternalSubtitle{ {Format: "srt"}, {Format: "ass"}, }, } if got := externalSubtitleRouteIndex(file, 0); got != 5 { t.Fatalf("first external route index = %d, want 5", got) } if got := externalSubtitleRouteIndex(file, 1); got != 6 { t.Fatalf("second external route index = %d, want 6", got) } } func TestResolveSelectedSubtitleStreamIndex(t *testing.T) { version := subtitleSelectionVersion() const downloadedCount = 1 mediaDefault := intPtr(2) cases := []struct { name string downloadedKnown bool requested *int want *int }{ {"no request falls back to media default", true, nil, intPtr(2)}, {"explicit off", true, intPtr(-1), intPtr(-1)}, {"valid embedded selection", true, intPtr(2), intPtr(2)}, {"valid external selection", true, intPtr(3), intPtr(3)}, {"valid downloaded selection", true, intPtr(5), intPtr(5)}, {"invalid selection falls back to media default", true, intPtr(99), intPtr(2)}, // When the downloaded list could not be loaded, an embedded/external // selection still resolves, but an index we cannot validate is honored // rather than downgraded to the media default. {"lookup failure honors embedded selection", false, intPtr(3), intPtr(3)}, {"lookup failure honors unverifiable selection", false, intPtr(5), intPtr(5)}, {"lookup failure still respects off", false, intPtr(-1), intPtr(-1)}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { // A failed lookup yields no enumerable downloaded subtitles. count := downloadedCount if !tc.downloadedKnown { count = 0 } got := resolveSelectedSubtitleStreamIndex(version, count, tc.downloadedKnown, tc.requested, mediaDefault) if (got == nil) != (tc.want == nil) { t.Fatalf("resolveSelectedSubtitleStreamIndex = %v, want %v", ptrString(got), ptrString(tc.want)) } if got != nil && *got != *tc.want { t.Fatalf("resolveSelectedSubtitleStreamIndex = %d, want %d", *got, *tc.want) } }) } } func TestEffectiveCompatSubtitleStreamIndex(t *testing.T) { cases := []struct { name string source PlaybackMediaSource want *int }{ { name: "selected overrides default", source: PlaybackMediaSource{SelectedSubtitleStreamIndex: intPtr(3), DefaultSubtitleStreamIndex: intPtr(2)}, want: intPtr(3), }, { name: "off yields none", source: PlaybackMediaSource{SelectedSubtitleStreamIndex: intPtr(-1), DefaultSubtitleStreamIndex: intPtr(2)}, want: nil, }, { name: "no selection falls back to default", source: PlaybackMediaSource{DefaultSubtitleStreamIndex: intPtr(2)}, want: intPtr(2), }, { name: "no selection no default", source: PlaybackMediaSource{}, want: nil, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := effectiveCompatSubtitleStreamIndex(tc.source) if (got == nil) != (tc.want == nil) { t.Fatalf("effectiveCompatSubtitleStreamIndex = %v, want %v", ptrString(got), ptrString(tc.want)) } if got != nil && *got != *tc.want { t.Fatalf("effectiveCompatSubtitleStreamIndex = %d, want %d", *got, *tc.want) } }) } } func newSubtitleSelectionHandler(t *testing.T) (*PlaybackHandler, string) { t.Helper() codec := NewResourceIDCodec() contentID := "movie-1" routeID := codec.EncodeStringID(EncodedIDItem, contentID) version := subtitleSelectionVersion() handler := &PlaybackHandler{ content: &stubContentService{detail: &upstreamItemDetail{ ContentID: contentID, Versions: []catalog.FileVersion{version}, }}, codec: codec, deviceProfiles: NewDeviceProfileStore(time.Hour, nil), playbackStore: NewPlaybackSessionStore(time.Hour, nil), SubtitleRepo: fakeSubtitleRepository{downloaded: map[int][]subtitles.DownloadedSubtitle{ 42: { {MediaFileID: 42, Language: "deu", Format: subtitles.FormatSRT, Provider: "opensubtitles"}, }, }}, } return handler, routeID } func postPlaybackInfo(t *testing.T, handler *PlaybackHandler, routeID, body string) playbackInfoResponseDTO { t.Helper() req := httptest.NewRequest(http.MethodPost, "/Items/"+routeID+"/PlaybackInfo", strings.NewReader(body)) routeCtx := chi.NewRouteContext() routeCtx.URLParams.Add("id", routeID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: "token-1"})) rr := httptest.NewRecorder() handler.HandlePlaybackInfo(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) } var resp playbackInfoResponseDTO if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } if len(resp.MediaSources) != 1 { t.Fatalf("media sources = %d, want 1", len(resp.MediaSources)) } return resp } func defaultSubtitleStreamFromResponse(t *testing.T, resp playbackInfoResponseDTO) (index int, found bool) { t.Helper() for _, stream := range resp.MediaSources[0].MediaStreams { if stream.Type == "Subtitle" && stream.IsDefault { if found { t.Fatal("more than one subtitle stream marked default") } index = stream.Index found = true } } return index, found } func TestHandlePlaybackInfo_DefaultsToEmbeddedDefaultSubtitle(t *testing.T) { handler, routeID := newSubtitleSelectionHandler(t) resp := postPlaybackInfo(t, handler, routeID, `{}`) if resp.MediaSources[0].DefaultSubtitleStreamIndex == nil { t.Fatal("expected DefaultSubtitleStreamIndex to be set") } if got := *resp.MediaSources[0].DefaultSubtitleStreamIndex; got != 2 { t.Fatalf("DefaultSubtitleStreamIndex = %d, want 2", got) } index, found := defaultSubtitleStreamFromResponse(t, resp) if !found || index != 2 { t.Fatalf("default subtitle stream = (%d, %v), want (2, true)", index, found) } } func TestHandlePlaybackInfo_HonorsSelectedExternalSubtitle(t *testing.T) { handler, routeID := newSubtitleSelectionHandler(t) resp := postPlaybackInfo(t, handler, routeID, `{"SubtitleStreamIndex":3}`) if resp.MediaSources[0].DefaultSubtitleStreamIndex == nil { t.Fatal("expected DefaultSubtitleStreamIndex to be set") } if got := *resp.MediaSources[0].DefaultSubtitleStreamIndex; got != 3 { t.Fatalf("DefaultSubtitleStreamIndex = %d, want 3", got) } index, found := defaultSubtitleStreamFromResponse(t, resp) if !found || index != 3 { t.Fatalf("default subtitle stream = (%d, %v), want (3, true)", index, found) } } func TestHandlePlaybackInfo_HonorsSelectedDownloadedSubtitle(t *testing.T) { handler, routeID := newSubtitleSelectionHandler(t) // The downloaded subtitle lands at stream index 5 (after the bitmap track at 4). resp := postPlaybackInfo(t, handler, routeID, `{"SubtitleStreamIndex":5}`) if resp.MediaSources[0].DefaultSubtitleStreamIndex == nil { t.Fatal("expected DefaultSubtitleStreamIndex to be set") } if got := *resp.MediaSources[0].DefaultSubtitleStreamIndex; got != 5 { t.Fatalf("DefaultSubtitleStreamIndex = %d, want 5", got) } index, found := defaultSubtitleStreamFromResponse(t, resp) if !found || index != 5 { t.Fatalf("default subtitle stream = (%d, %v), want (5, true)", index, found) } } // erroringSubtitleRepository simulates a transient failure of the downloaded // subtitle lookup while satisfying the rest of the repository interface. type erroringSubtitleRepository struct { fakeSubtitleRepository } func (erroringSubtitleRepository) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) { return nil, errors.New("subtitle store unavailable") } func TestHandlePlaybackInfo_HonorsExternalSelectionWhenDownloadedLookupFails(t *testing.T) { codec := NewResourceIDCodec() contentID := "movie-1" routeID := codec.EncodeStringID(EncodedIDItem, contentID) handler := &PlaybackHandler{ content: &stubContentService{detail: &upstreamItemDetail{ ContentID: contentID, Versions: []catalog.FileVersion{subtitleSelectionVersion()}, }}, codec: codec, deviceProfiles: NewDeviceProfileStore(time.Hour, nil), playbackStore: NewPlaybackSessionStore(time.Hour, nil), SubtitleRepo: erroringSubtitleRepository{}, } // A failed downloaded-subtitle lookup must not discard a valid // embedded/external selection (the primary case for external SRT). resp := postPlaybackInfo(t, handler, routeID, `{"SubtitleStreamIndex":3}`) if resp.MediaSources[0].DefaultSubtitleStreamIndex == nil { t.Fatal("expected DefaultSubtitleStreamIndex to be set") } if got := *resp.MediaSources[0].DefaultSubtitleStreamIndex; got != 3 { t.Fatalf("DefaultSubtitleStreamIndex = %d, want 3", got) } index, found := defaultSubtitleStreamFromResponse(t, resp) if !found || index != 3 { t.Fatalf("default subtitle stream = (%d, %v), want (3, true)", index, found) } } func TestHandlePlaybackInfo_SubtitlesOff(t *testing.T) { handler, routeID := newSubtitleSelectionHandler(t) resp := postPlaybackInfo(t, handler, routeID, `{"SubtitleStreamIndex":-1}`) if resp.MediaSources[0].DefaultSubtitleStreamIndex != nil { t.Fatalf("expected DefaultSubtitleStreamIndex to be unset, got %d", *resp.MediaSources[0].DefaultSubtitleStreamIndex) } if index, found := defaultSubtitleStreamFromResponse(t, resp); found { t.Fatalf("expected no default subtitle stream, got index %d", index) } }