fix(jellycompat): honor client subtitle selection in PlaybackInfo (#222)
* fix(jellycompat): honor client subtitle selection in PlaybackInfo Jellyfin clients send the chosen subtitle as SubtitleStreamIndex on PlaybackInfo, but the compat layer only ever advertised the media's embedded default subtitle (or none). Picking a subtitle before playback had no effect and the video started without it — and an explicit "subtitles off" was ignored when the media had a default subtitle. Mirror the existing audio-selection plumbing for subtitles: - parse SubtitleStreamIndex from the PlaybackInfo body and query - persist it as SelectedSubtitleStreamIndex on the play-session source - advertise it via DefaultSubtitleStreamIndex and flip IsDefault onto the chosen stream (embedded, external, or Silo-downloaded) - honor a negative index as an explicit "subtitles off" The selection is validated against streamable tracks (bitmap subs that require burn-in are excluded, matching delivery) and the downloaded subtitle range, falling back to the media default when invalid. Out of scope (follow-ups): bitmap-subtitle burn-in transcode wiring and mid-playback subtitle changes on progress reports. Part of #217 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(jellycompat): handle downloaded-subtitle lookup failure in selection Don't let a transient ListDownloadedSubtitles error masquerade as "no downloaded subtitles", which silently downgraded a valid subtitle selection to the media default. The lookup error is now logged, and resolution honors a requested index it cannot validate (it may be a downloaded subtitle) instead of discarding the user's choice, while embedded/external selections continue to resolve normally. Addresses CodeRabbit review feedback on #222. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 4.8
parent
78d4f9acbf
commit
185a90b048
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "<nil>"
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user