378 lines
13 KiB
Go
378 lines
13 KiB
Go
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 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)
|
|
}
|
|
}
|