diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index 792942a2..91bb704a 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -36,6 +36,7 @@ type playbackInfoRequest struct { UserID string `json:"UserId"` MediaSourceID string `json:"MediaSourceId"` AudioStreamIndex *compatIntValue `json:"AudioStreamIndex,omitempty"` + SubtitleStreamIndex *compatIntValue `json:"SubtitleStreamIndex,omitempty"` StartTimeTicks int64 `json:"StartTimeTicks"` EnableDirectPlay *bool `json:"EnableDirectPlay"` EnableDirectStream *bool `json:"EnableDirectStream"` @@ -423,11 +424,40 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) { continue } + + // Resolve the client's subtitle selection against both the + // embedded/external tracks and any downloaded subtitles before + // advertising the streams, so the chosen subtitle is marked default and + // starts with playback (mirrors the audio-selection plumbing). + var downloaded []subtitles.DownloadedSubtitle + downloadedKnown := true + if h.SubtitleRepo != nil { + var listErr error + downloaded, listErr = h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), source.Version.FileID) + if listErr != nil { + // Don't treat a lookup failure as "no downloaded subtitles": that + // would silently downgrade a valid downloaded selection to the + // media default. Resolution falls back to honoring the request. + downloaded = nil + downloadedKnown = false + slog.Warn("jellycompat downloaded subtitle lookup failed", + "file_id", source.Version.FileID, + "error", listErr, + ) + } + } + var requestedSubtitleIndex *int + if req.SubtitleStreamIndex != nil { + requestedSubtitleIndex = intPtr(int(*req.SubtitleStreamIndex)) + } + source.SelectedSubtitleStreamIndex = resolveSelectedSubtitleStreamIndex(source.Version, len(downloaded), downloadedKnown, requestedSubtitleIndex, source.DefaultSubtitleStreamIndex) + sources = append(sources, source) dto := h.mediaSourceDTO(routeItemID, playSessionID, session.Token, source) - // Append downloaded subtitles to the media streams. - if h.SubtitleRepo != nil { - downloaded, _ := h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), source.Version.FileID) + + // Append downloaded subtitles to the media streams, honoring the selection. + if len(downloaded) > 0 { + selectedSubtitleStreamIndex := effectiveCompatSubtitleStreamIndex(source) baseIndex := nextDownloadedSubtitleIndex(source.Version) for i, dl := range downloaded { streamIndex := baseIndex + i @@ -440,7 +470,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ Language: dl.Language, DisplayTitle: displayTitle, Title: displayTitle, - IsDefault: false, + IsDefault: selectedSubtitleStreamIndex != nil && streamIndex == *selectedSubtitleStreamIndex, IsExternal: true, IsForced: false, IsHearingImpaired: dl.HearingImpaired, @@ -596,8 +626,8 @@ func (h *PlaybackHandler) mediaSourceDTO(routeItemID, playSessionID, compatToken MediaAttachments: []map[string]any{}, Bitrate: source.Version.Bitrate * 1000, DefaultAudioStreamIndex: selectedAudioStreamIndex, - DefaultSubtitleStreamIndex: source.DefaultSubtitleStreamIndex, - MediaStreams: buildMediaStreamsWithSelection(routeItemID, source.ID, source.Version, selectedAudioStreamIndex, compatToken, playSessionID), + DefaultSubtitleStreamIndex: effectiveCompatSubtitleStreamIndex(source), + MediaStreams: buildMediaStreamsWithSelection(routeItemID, source.ID, source.Version, selectedAudioStreamIndex, source.SelectedSubtitleStreamIndex, compatToken, playSessionID), } if source.SupportsDirectPlay || source.SupportsDirectStream { dto.DirectStreamURL = fmt.Sprintf( @@ -621,10 +651,10 @@ func (h *PlaybackHandler) mediaSourceDTO(routeItemID, playSessionID, compatToken } func buildMediaStreams(routeItemID, mediaSourceID string, version catalog.FileVersion) []mediaStreamDTO { - return buildMediaStreamsWithSelection(routeItemID, mediaSourceID, version, nil, "", "") + return buildMediaStreamsWithSelection(routeItemID, mediaSourceID, version, nil, nil, "", "") } -func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version catalog.FileVersion, selectedAudioStreamIndex *int, compatToken, playSessionID string) []mediaStreamDTO { +func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version catalog.FileVersion, selectedAudioStreamIndex, selectedSubtitleStreamIndex *int, compatToken, playSessionID string) []mediaStreamDTO { streams := make([]mediaStreamDTO, 0, len(version.VideoTracks)+len(version.AudioTracks)+len(version.SubtitleTracks)) effectiveAudioStreamIndex := selectedAudioStreamIndex if effectiveAudioStreamIndex != nil && !isValidCompatAudioStreamIndex(version, *effectiveAudioStreamIndex) { @@ -707,6 +737,13 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c streamIndex := subtitleTrackIndex(version, track, index) format := subtitleRouteFormat(track.Codec) displayTitle := compatSubtitleDisplayTitle(track) + // When the client has made an explicit subtitle selection, only that + // stream is the default. A negative selection ("subtitles off") matches + // no stream, which correctly clears every embedded default. + isDefault := track.Default + if selectedSubtitleStreamIndex != nil { + isDefault = streamIndex == *selectedSubtitleStreamIndex + } streams = append(streams, mediaStreamDTO{ Index: streamIndex, Type: "Subtitle", @@ -715,7 +752,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c TimeBase: "1/1000", DisplayTitle: displayTitle, Title: displayTitle, - IsDefault: track.Default, + IsDefault: isDefault, IsExternal: track.External, IsForced: track.Forced, IsHearingImpaired: track.HearingImpaired, @@ -824,6 +861,69 @@ func subtitleTrackStreamable(codec string, external bool) bool { return external || !playback.NeedsBurnIn(codec) } +// isValidCompatSubtitleStreamIndex reports whether streamIndex addresses a +// deliverable subtitle: either a streamable embedded/external track (bitmap +// subs that require burn-in are excluded, matching buildMediaStreams) or one of +// the downloaded subtitles appended after the embedded streams. +func isValidCompatSubtitleStreamIndex(version catalog.FileVersion, downloadedCount, streamIndex int) bool { + for index, track := range version.SubtitleTracks { + if !subtitleTrackStreamable(track.Codec, track.External) { + continue + } + if subtitleTrackIndex(version, track, index) == streamIndex { + return true + } + } + if downloadedCount > 0 { + base := nextDownloadedSubtitleIndex(version) + if streamIndex >= base && streamIndex < base+downloadedCount { + return true + } + } + return false +} + +// resolveSelectedSubtitleStreamIndex maps a client-requested subtitle stream +// index onto the selection stored for the session. A nil request keeps the +// media default; a negative request is preserved as an explicit "subtitles off" +// (-1); a valid request is honored; an invalid request falls back to the media +// default. +// +// downloadedKnown reports whether the downloaded-subtitle list was loaded +// successfully. When it is false, an index that does not match an +// embedded/external track is honored rather than downgraded, because it may be a +// downloaded subtitle we could not enumerate — losing the user's choice on a +// transient lookup failure is worse than echoing an index whose stream is +// temporarily absent. +func resolveSelectedSubtitleStreamIndex(version catalog.FileVersion, downloadedCount int, downloadedKnown bool, requested, mediaDefault *int) *int { + if requested == nil { + return mediaDefault + } + if *requested < 0 { + return intPtr(-1) + } + if isValidCompatSubtitleStreamIndex(version, downloadedCount, *requested) { + return intPtr(*requested) + } + if !downloadedKnown { + return intPtr(*requested) + } + return mediaDefault +} + +// effectiveCompatSubtitleStreamIndex returns the subtitle stream index to +// advertise as the default for a source: the explicit selection when present +// (collapsing "subtitles off" to none), otherwise the media default. +func effectiveCompatSubtitleStreamIndex(source PlaybackMediaSource) *int { + if source.SelectedSubtitleStreamIndex != nil { + if *source.SelectedSubtitleStreamIndex < 0 { + return nil + } + return intPtr(*source.SelectedSubtitleStreamIndex) + } + return source.DefaultSubtitleStreamIndex +} + func anyDefaultAudioTrack(tracks []models.AudioTrack) bool { for _, track := range tracks { if track.Default { @@ -1137,6 +1237,9 @@ func applyPlaybackQueryOverrides(req *playbackInfoRequest, query url.Values) { if value, ok := parseOptionalInt(firstQueryValue(query, "AudioStreamIndex")); ok { req.AudioStreamIndex = compatIntValuePtr(value) } + if value, ok := parseOptionalInt(firstQueryValue(query, "SubtitleStreamIndex")); ok { + req.SubtitleStreamIndex = compatIntValuePtr(value) + } if value, ok := parseOptionalBool(firstQueryValue(query, "EnableDirectPlay")); ok { req.EnableDirectPlay = &value } diff --git a/internal/jellycompat/playback_sessions.go b/internal/jellycompat/playback_sessions.go index 2c8c156e..45ce4e91 100644 --- a/internal/jellycompat/playback_sessions.go +++ b/internal/jellycompat/playback_sessions.go @@ -27,17 +27,18 @@ type PlaybackSession struct { // PlaybackMediaSource stores one negotiated stream source within a compat play session. type PlaybackMediaSource struct { - ID string - FileID int - Version catalog.FileVersion - SupportsDirectPlay bool - SupportsDirectStream bool - SupportsTranscoding bool - TranscodeAudio bool - DefaultAudioStreamIndex *int - SelectedAudioStreamIndex *int - DefaultSubtitleStreamIndex *int - ETag string + ID string + FileID int + Version catalog.FileVersion + SupportsDirectPlay bool + SupportsDirectStream bool + SupportsTranscoding bool + TranscodeAudio bool + DefaultAudioStreamIndex *int + SelectedAudioStreamIndex *int + DefaultSubtitleStreamIndex *int + SelectedSubtitleStreamIndex *int + ETag string } // PlaybackSessionStore keeps compat playback sessions in memory. diff --git a/internal/jellycompat/subtitle_selection_test.go b/internal/jellycompat/subtitle_selection_test.go new file mode 100644 index 00000000..dafee607 --- /dev/null +++ b/internal/jellycompat/subtitle_selection_test.go @@ -0,0 +1,355 @@ +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 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) + } +}