fix(jellycompat): Wholphin (jellyfin-sdk-kotlin) playback & genre compatibility (#100)
* fix(jellycompat): match MediaSourceId across UUID formats (compact vs dashed)
* fix(jellycompat): honor ImageTypes=Backdrop as a filter on /Items
Wholphin genre cards request /Items?imageTypes=Backdrop&limit=1&sortBy=Random
and assume every returned item has a backdrop. Silo ignored ImageTypes, so a
random pick could lack a backdrop (BackdropImageTags: null), crashing Wholphin.
Push the filter down to the catalog browse SQL
(NULLIF(BTRIM(backdrop_path),'') IS NOT NULL) so random/limited selections only
ever consider backdrop-having items; empty genres correctly return [].
* fix(jellycompat): case-insensitive PlaySessionId + api_key in stream auth
Wholphin's jellyfin-sdk-kotlin builds its own direct-play URL
(/Videos/{id}/stream?static=true&playSessionId=...&mediaSourceId=...) with a
lowercase 'playSessionId', no api_key, and no auth header (ExoPlayer's data
source drops it). PlaybackSessionAuth read 'PlaySessionId'/'PlaySessionID'
case-sensitively, so the fallback never matched -> 401 on every direct-play
stream -> forced (often failing) transcode fallback. Resolve PlaySessionId via
newCaseInsensitiveQuery, and likewise accept case-variant api_key in
ExtractToken.
* fix(jellycompat): support Wholphin season item queries
---------
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Quick
parent
339ed20074
commit
cf4e080bf4
@@ -36,6 +36,7 @@ type BrowseFilters struct {
|
||||
MaxLimit int // optional caller-specific cap; zero keeps the default browse cap
|
||||
Offset int
|
||||
SnapshotAt *time.Time // pagination fence: exclude items created after this timestamp
|
||||
RequireBackdrop bool // only return items with a non-empty backdrop_path (Jellyfin ImageTypes=Backdrop filter)
|
||||
}
|
||||
|
||||
// BrowseResult contains the paginated result of a browse query.
|
||||
@@ -295,6 +296,13 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// Backdrop presence filter (Jellyfin ImageTypes=Backdrop). Pushed down so
|
||||
// random/limited selections only ever pick items that actually have a
|
||||
// backdrop — filtering after the LIMIT would wrongly return empty pages.
|
||||
if filters.RequireBackdrop {
|
||||
conditions = append(conditions, "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL")
|
||||
}
|
||||
|
||||
// Library access control: restrict to user's accessible libraries.
|
||||
needsLibJoin := filters.LibraryID > 0 || filters.LibraryIDs != nil || len(filters.DisabledLibraryIDs) > 0 || filters.Sort == "recently_added"
|
||||
needsPersonJoin := filters.PersonID > 0
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildBrowsePlan_RequireBackdrop asserts the ImageTypes=Backdrop filter
|
||||
// (BrowseFilters.RequireBackdrop) renders the backdrop-presence predicate into
|
||||
// the WHERE clause, and is absent otherwise. Guards against a future refactor
|
||||
// of buildBrowsePlan silently dropping the condition.
|
||||
func TestBuildBrowsePlan_RequireBackdrop(t *testing.T) {
|
||||
const predicate = "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL"
|
||||
repo := &BrowseRepository{}
|
||||
|
||||
plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Type: "movie", RequireBackdrop: true})
|
||||
if err != nil || earlyEmpty {
|
||||
t.Fatalf("buildBrowsePlan(RequireBackdrop) err=%v earlyEmpty=%v", err, earlyEmpty)
|
||||
}
|
||||
if !strings.Contains(plan.whereClause, predicate) {
|
||||
t.Fatalf("RequireBackdrop=true: whereClause missing predicate.\ngot: %s", plan.whereClause)
|
||||
}
|
||||
|
||||
plan, _, err = repo.buildBrowsePlan(BrowseFilters{Type: "movie"})
|
||||
if err != nil {
|
||||
t.Fatalf("buildBrowsePlan err=%v", err)
|
||||
}
|
||||
if strings.Contains(plan.whereClause, predicate) {
|
||||
t.Fatalf("RequireBackdrop unset: predicate should be absent.\ngot: %s", plan.whereClause)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,8 @@ func ExtractToken(r *http.Request) (string, bool) {
|
||||
if token := strings.TrimSpace(r.Header.Get("X-Mediabrowser-Token")); token != "" {
|
||||
return token, true
|
||||
}
|
||||
if token := strings.TrimSpace(r.URL.Query().Get("api_key")); token != "" {
|
||||
// Case-insensitive: Jellyfin clients vary the casing (api_key / Api_Key / API_KEY).
|
||||
if token := strings.TrimSpace(newCaseInsensitiveQuery(r.URL.Query()).Get("api_key")); token != "" {
|
||||
return token, true
|
||||
}
|
||||
|
||||
@@ -185,7 +186,12 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS
|
||||
// no auth header or api_key. Resolve the negotiated session's
|
||||
// CompatToken — which for an API-key stream is itself the sa_ key,
|
||||
// so it must go through the same session-or-API-key resolution.
|
||||
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
|
||||
//
|
||||
// The lookup must be case-insensitive: Wholphin's jellyfin-sdk-kotlin
|
||||
// builds its own direct-play URL with a lowercase "playSessionId"
|
||||
// (and no api_key / auth header), so a case-sensitive match would
|
||||
// miss it and 401 the stream — forcing a needless transcode fallback.
|
||||
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
|
||||
if playSession, found := playbackStore.Get(playSessionID); found {
|
||||
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
|
||||
serveWithSession(next, w, r, session)
|
||||
|
||||
@@ -119,6 +119,60 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
|
||||
now := fixedNow()
|
||||
clock := func() time.Time { return now }
|
||||
sessions := NewSessionStore(30*24*time.Hour, clock)
|
||||
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
|
||||
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
|
||||
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
|
||||
|
||||
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
wantCode int
|
||||
}{
|
||||
// Wholphin's jellyfin-sdk-kotlin direct-play URL: lowercase playSessionId,
|
||||
// no api_key and no auth header. Previously 401'd (case-sensitive lookup).
|
||||
{"lowercase playSessionId (Wholphin)", "static=true&mediaSourceId=x&playSessionId=ps-abc", http.StatusOK},
|
||||
{"canonical PlaySessionId", "PlaySessionId=ps-abc", http.StatusOK},
|
||||
{"legacy PlaySessionID", "PlaySessionID=ps-abc", http.StatusOK},
|
||||
{"unknown play session", "playSessionId=does-not-exist", http.StatusUnauthorized},
|
||||
{"no auth at all", "static=true", http.StatusUnauthorized},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+tc.rawQuery, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
gotSession := false
|
||||
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if SessionFromContext(r.Context()) != nil {
|
||||
gotSession = true
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantCode {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String())
|
||||
}
|
||||
if tc.wantCode == http.StatusOK && !gotSession {
|
||||
t.Fatal("expected authenticated session in context")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
|
||||
for _, key := range []string{"api_key", "Api_Key", "API_KEY"} {
|
||||
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+key+"=tok123", nil)
|
||||
if got, ok := ExtractToken(req); !ok || got != "tok123" {
|
||||
t.Fatalf("%s: ExtractToken = (%q, %v), want (tok123, true)", key, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
|
||||
authn := newAdminAPIKeyAuthForTest(
|
||||
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
|
||||
|
||||
@@ -226,6 +226,7 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
|
||||
Limit: fetchLimit,
|
||||
MaxLimit: compatBrowseMaxLimit,
|
||||
Offset: requestedOffset,
|
||||
RequireBackdrop: parseBool(params.Get("require_backdrop"), false),
|
||||
}
|
||||
|
||||
var collected []upstreamListItem
|
||||
|
||||
@@ -73,6 +73,7 @@ type baseItemDTO struct {
|
||||
SpecialFeatureCount int `json:"SpecialFeatureCount,omitempty"`
|
||||
MovieCount int `json:"MovieCount,omitempty"`
|
||||
SeriesCount int `json:"SeriesCount,omitempty"`
|
||||
SeasonCount int `json:"SeasonCount,omitempty"`
|
||||
EpisodeCount int `json:"EpisodeCount,omitempty"`
|
||||
LockedFields []string `json:"LockedFields,omitempty"`
|
||||
LockData bool `json:"LockData,omitempty"`
|
||||
|
||||
@@ -140,6 +140,8 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleSearchItems(w, r, session, query)
|
||||
case query.isFavorite:
|
||||
h.handleFavoriteItems(w, r, session, query)
|
||||
case isSeasonChildItemsQuery(query):
|
||||
h.handleSeasonChildItems(w, r, session, query)
|
||||
case query.parentLibraryID == 0 && len(query.itemTypes) == 0:
|
||||
// No ParentId and no type filter: return top-level library views.
|
||||
// Jellyfin clients (e.g. Findroid "My Media") call GET /Items?userId=...
|
||||
@@ -150,6 +152,18 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func isSeasonChildItemsQuery(query itemsQuery) bool {
|
||||
if query.parentItemID == "" {
|
||||
return false
|
||||
}
|
||||
for _, itemType := range query.itemTypes {
|
||||
if itemType == "season" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleItem serves GET /Items/{id}.
|
||||
func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
|
||||
session := SessionFromContext(r.Context())
|
||||
@@ -209,6 +223,7 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
|
||||
browsableSeasons := filterBrowsableSeasons(seasons)
|
||||
dto.ChildCount = len(browsableSeasons)
|
||||
dto.RecursiveItemCount = len(browsableSeasons)
|
||||
dto.SeasonCount = len(browsableSeasons)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" {
|
||||
@@ -919,9 +934,35 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
|
||||
writeCompatUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
h.writeSeasonItemsResponse(w, r, session, seriesID, seasons, query, false)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) handleSeasonChildItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
|
||||
seasons, err := h.content.ListSeasons(r.Context(), session, query.parentItemID, nil)
|
||||
if err != nil {
|
||||
writeCompatUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
h.writeSeasonItemsResponse(w, r, session, query.parentItemID, seasons, query, true)
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) writeSeasonItemsResponse(w http.ResponseWriter, r *http.Request, session *Session, seriesID string, seasons []upstreamSeason, query itemsQuery, page bool) {
|
||||
seasons = filterBrowsableSeasons(seasons)
|
||||
h.rememberSeasonImages(seasons, seriesID)
|
||||
|
||||
total := len(seasons)
|
||||
if page {
|
||||
start := query.startIndex
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := total
|
||||
if query.limit > 0 && start+query.limit < end {
|
||||
end = start + query.limit
|
||||
}
|
||||
seasons = seasons[start:end]
|
||||
}
|
||||
|
||||
favorites, err := resolveFavoritesForContentIDs(r.Context(), session, h.userData, seasonContentIDs(seasons))
|
||||
if err != nil {
|
||||
writeCompatUpstreamError(w, err)
|
||||
@@ -944,10 +985,16 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
items = append(items, h.mapper.seasonFromUpstream(season, seriesID, favorites[season.ContentID]))
|
||||
}
|
||||
applyImageTypeLimit(items, query.imageTypeLimit)
|
||||
|
||||
startIndex := 0
|
||||
if page {
|
||||
startIndex = query.startIndex
|
||||
}
|
||||
writeJSON(w, http.StatusOK, queryResultDTO{
|
||||
Items: items,
|
||||
TotalRecordCount: len(items),
|
||||
StartIndex: 0,
|
||||
TotalRecordCount: total,
|
||||
StartIndex: startIndex,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -19,7 +20,10 @@ import (
|
||||
type countingContentService struct {
|
||||
episodeDetail *upstreamItemDetail
|
||||
seriesDetail *upstreamItemDetail
|
||||
seasons []upstreamSeason
|
||||
getItemDetailCalls int
|
||||
listSeasonsCalls int
|
||||
listSeasonsSeries string
|
||||
}
|
||||
|
||||
func (s *countingContentService) GetItemDetail(_ context.Context, _ *Session, contentID string, _ *int) (*upstreamItemDetail, error) {
|
||||
@@ -50,8 +54,15 @@ func (s *countingContentService) SearchItems(context.Context, *Session, string,
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func (s *countingContentService) ListSeasons(context.Context, *Session, string, *int) ([]upstreamSeason, error) {
|
||||
panic("unused")
|
||||
func (s *countingContentService) ListSeasons(_ context.Context, _ *Session, seriesID string, _ *int) ([]upstreamSeason, error) {
|
||||
s.listSeasonsCalls++
|
||||
s.listSeasonsSeries = seriesID
|
||||
if s.seasons == nil {
|
||||
panic("unused")
|
||||
}
|
||||
out := make([]upstreamSeason, len(s.seasons))
|
||||
copy(out, s.seasons)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *countingContentService) GetSeason(context.Context, *Session, string, int, *int) (*upstreamSeason, error) {
|
||||
@@ -70,6 +81,65 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url.
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
seriesContentID := "series-1"
|
||||
encodedSeriesID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
|
||||
contentSvc := &countingContentService{
|
||||
seasons: []upstreamSeason{
|
||||
{ContentID: "season-1", SeasonNumber: 1, Title: "Season 1", EpisodeCount: 10},
|
||||
{ContentID: "season-2", SeasonNumber: 2, Title: "Season 2", EpisodeCount: 8},
|
||||
},
|
||||
}
|
||||
|
||||
h := &ItemsHandler{
|
||||
content: contentSvc,
|
||||
userData: &mockUserDataService{},
|
||||
codec: codec,
|
||||
mapper: newMapper(codec, &config.Config{}),
|
||||
images: NewImageCache(time.Hour, time.Now),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/Users/test/Items?ParentId="+encodedSeriesID+
|
||||
"&IncludeItemTypes=Season&Recursive=false&SortBy=IndexNumber&SortOrder=Ascending"+
|
||||
"&Fields=PrimaryImageAspectRatio,CanDelete&StartIndex=1&Limit=1", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
|
||||
StreamAppUserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleItems(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if contentSvc.listSeasonsCalls != 1 || contentSvc.listSeasonsSeries != seriesContentID {
|
||||
t.Fatalf("ListSeasons calls = %d for %q, want 1 for %q",
|
||||
contentSvc.listSeasonsCalls, contentSvc.listSeasonsSeries, seriesContentID)
|
||||
}
|
||||
|
||||
var result queryResultDTO
|
||||
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if result.TotalRecordCount != 2 || result.StartIndex != 1 {
|
||||
t.Fatalf("TotalRecordCount/StartIndex = %d/%d, want 2/1",
|
||||
result.TotalRecordCount, result.StartIndex)
|
||||
}
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("len(Items) = %d, want 1", len(result.Items))
|
||||
}
|
||||
item := result.Items[0]
|
||||
if item.Type != "Season" || item.Name != "Season 2" {
|
||||
t.Fatalf("item = {%q %q}, want Season/Season 2", item.Type, item.Name)
|
||||
}
|
||||
if item.ParentID != encodedSeriesID || item.SeriesID != encodedSeriesID {
|
||||
t.Fatalf("ParentID/SeriesID = %q/%q, want %q/%q",
|
||||
item.ParentID, item.SeriesID, encodedSeriesID, encodedSeriesID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies
|
||||
// that episode detail responses fetch parent series image metadata even when
|
||||
// image URLs are already cached. Cached URLs are not enough to build stable
|
||||
|
||||
@@ -439,7 +439,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
|
||||
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
|
||||
for _, version := range detail.Versions {
|
||||
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
||||
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
|
||||
if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, source)
|
||||
|
||||
@@ -141,6 +141,21 @@ func (c *ResourceIDCodec) LookupMediaSourceOwner(fileID int64) (string, bool) {
|
||||
return contentID, ok
|
||||
}
|
||||
|
||||
// mediaSourceIDsEqual reports whether two media-source IDs refer to the same
|
||||
// source, tolerating UUID format differences. Silo exposes the canonical
|
||||
// dashed compat UUID (e.g. "03000000-0000-0000-0000-00000019e8c2"), but some
|
||||
// Jellyfin clients (e.g. Wholphin) echo it back in the compact 32-char hex
|
||||
// form ("0300000000000000000000000019e8c2"). Both parse to the same UUID, so
|
||||
// matching must compare the parsed values rather than the raw strings.
|
||||
func mediaSourceIDsEqual(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
ua, errA := uuid.Parse(a)
|
||||
ub, errB := uuid.Parse(b)
|
||||
return errA == nil && errB == nil && ua == ub
|
||||
}
|
||||
|
||||
// DecodeID unpacks a compat UUID into its original numeric value.
|
||||
func DecodeID(raw string) (DecodedID, error) {
|
||||
parsed, err := uuid.Parse(raw)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package jellycompat
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMediaSourceIDsEqual(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a string
|
||||
b string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "identical dashed",
|
||||
a: "03000000-0000-0000-0000-00000019e8c2",
|
||||
b: "03000000-0000-0000-0000-00000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dashed vs compact (Wholphin)",
|
||||
a: "03000000-0000-0000-0000-00000019e8c2",
|
||||
b: "0300000000000000000000000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compact vs dashed",
|
||||
a: "0300000000000000000000000019e8c2",
|
||||
b: "03000000-0000-0000-0000-00000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive hex",
|
||||
a: "03000000-0000-0000-0000-00000019E8C2",
|
||||
b: "0300000000000000000000000019e8c2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different uuids",
|
||||
a: "03000000-0000-0000-0000-00000019e8c2",
|
||||
b: "0300000000000000000000000019e8c3",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non-uuid falls back to exact match",
|
||||
a: "abc",
|
||||
b: "abc",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non-uuid mismatch",
|
||||
a: "abc",
|
||||
b: "def",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := mediaSourceIDsEqual(tc.a, tc.b); got != tc.want {
|
||||
t.Fatalf("mediaSourceIDsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -106,8 +106,10 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
|
||||
dto.RecursiveItemCount = *item.EpisodeCount
|
||||
}
|
||||
if item.SeasonCount != nil {
|
||||
dto.ChildCount = *item.SeasonCount
|
||||
dto.RecursiveItemCount = *item.SeasonCount
|
||||
seasonCount := *item.SeasonCount
|
||||
dto.ChildCount = seasonCount
|
||||
dto.RecursiveItemCount = seasonCount
|
||||
dto.SeasonCount = seasonCount
|
||||
}
|
||||
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
|
||||
if tags := imageTagsWithSeed(m.imageTagSigner,
|
||||
|
||||
@@ -35,3 +35,39 @@ func TestItemListSortNamePrefersSortTitle(t *testing.T) {
|
||||
t.Fatalf("SortName = %q, want %q", dto.SortName, "Matrix, The")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesListIncludesSeasonCount(t *testing.T) {
|
||||
m := newMapper(NewResourceIDCodec(), &config.Config{})
|
||||
seasonCount := 4
|
||||
dto := m.itemFromList(upstreamListItem{
|
||||
ContentID: "series-1",
|
||||
Type: "series",
|
||||
Title: "Snowpiercer",
|
||||
SeasonCount: &seasonCount,
|
||||
}, false, nil, nil)
|
||||
|
||||
if dto.SeasonCount != 4 {
|
||||
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
|
||||
}
|
||||
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
|
||||
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesDetailIncludesSeasonCount(t *testing.T) {
|
||||
m := newMapper(NewResourceIDCodec(), &config.Config{})
|
||||
seasonCount := 4
|
||||
dto := m.itemFromDetail(upstreamItemDetail{
|
||||
ContentID: "series-1",
|
||||
Type: "series",
|
||||
Title: "Snowpiercer",
|
||||
SeasonCount: &seasonCount,
|
||||
}, false, nil)
|
||||
|
||||
if dto.SeasonCount != 4 {
|
||||
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
|
||||
}
|
||||
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
|
||||
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*Playba
|
||||
return &cp, nil, true
|
||||
}
|
||||
for _, source := range session.MediaSources {
|
||||
if source.ID == routeID {
|
||||
if mediaSourceIDsEqual(source.ID, routeID) {
|
||||
cp := session
|
||||
sourceCopy := source
|
||||
return &cp, &sourceCopy, true
|
||||
|
||||
@@ -19,6 +19,7 @@ type itemsQuery struct {
|
||||
namePrefix string
|
||||
maxOfficialRating string
|
||||
parentLibraryID int
|
||||
parentItemID string
|
||||
specificIDs []string
|
||||
itemTypes []string
|
||||
genreName string
|
||||
@@ -32,6 +33,7 @@ type itemsQuery struct {
|
||||
personID int64
|
||||
isPlayed *bool // nil = not specified
|
||||
imageTypeLimit *int // nil = not specified
|
||||
requireBackdrop bool // true when ImageTypes includes Backdrop (filter, not just a hint)
|
||||
mediaTypes []string
|
||||
mediaTypesSet map[string]bool
|
||||
mediaTypesExplicit bool
|
||||
@@ -55,6 +57,8 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
|
||||
if parentID := strings.TrimSpace(q.Get("ParentId")); parentID != "" {
|
||||
if libraryID, err := codec.DecodeIntID(EncodedIDLibrary, parentID); err == nil {
|
||||
result.parentLibraryID = int(libraryID)
|
||||
} else if contentID, itemErr := decodeItemID(codec, parentID); itemErr == nil && contentID != "" {
|
||||
result.parentItemID = contentID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +115,17 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
|
||||
}
|
||||
}
|
||||
|
||||
// ImageTypes acts as a filter: clients (e.g. Wholphin genre cards) request
|
||||
// ImageTypes=Backdrop and assume every returned item has a backdrop. Only
|
||||
// Backdrop is enforced — the catalog browse path can filter on backdrop_path.
|
||||
for _, raw := range q.Values("ImageTypes") {
|
||||
for part := range strings.SplitSeq(raw, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(part), "Backdrop") {
|
||||
result.requireBackdrop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mediaTypesRaw := q.Values("MediaTypes")
|
||||
result.mediaTypes = parseMediaTypes(mediaTypesRaw)
|
||||
result.mediaTypesExplicit = len(mediaTypesRaw) > 0 && strings.TrimSpace(strings.Join(mediaTypesRaw, "")) != ""
|
||||
@@ -211,6 +226,9 @@ func buildBrowseParams(query itemsQuery) url.Values {
|
||||
params.Set("is_played", "false")
|
||||
}
|
||||
}
|
||||
if query.requireBackdrop {
|
||||
params.Set("require_backdrop", "true")
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseItemsQuery_ImageTypesBackdropFilter(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
cases := []struct {
|
||||
name string
|
||||
rawQuery string
|
||||
wantRequire bool
|
||||
}{
|
||||
{"backdrop requested", "ImageTypes=Backdrop", true},
|
||||
{"lowercase param and value", "imagetypes=backdrop", true},
|
||||
{"backdrop among several", "ImageTypes=Primary,Backdrop,Logo", true},
|
||||
{"bracket array variant", "ImageTypes[]=Backdrop", true},
|
||||
{"primary only does not filter", "ImageTypes=Primary", false},
|
||||
{"absent does not filter", "Limit=1", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/Items?"+tc.rawQuery, nil)
|
||||
query := parseItemsQuery(req, codec)
|
||||
if query.requireBackdrop != tc.wantRequire {
|
||||
t.Fatalf("requireBackdrop = %v, want %v", query.requireBackdrop, tc.wantRequire)
|
||||
}
|
||||
params := buildBrowseParams(query)
|
||||
gotParam := params.Get("require_backdrop") == "true"
|
||||
if gotParam != tc.wantRequire {
|
||||
t.Fatalf("require_backdrop param = %q, want emitted=%v", params.Get("require_backdrop"), tc.wantRequire)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID"))
|
||||
playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId")
|
||||
if playSessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "PlaySessionId is required")
|
||||
return
|
||||
@@ -906,7 +906,7 @@ func audioSelectionChanged(session *PlaybackSession, mediaSourceID string, incom
|
||||
return true
|
||||
}
|
||||
for _, source := range session.MediaSources {
|
||||
if mediaSourceID != "" && source.ID != mediaSourceID {
|
||||
if mediaSourceID != "" && !mediaSourceIDsEqual(source.ID, mediaSourceID) {
|
||||
continue
|
||||
}
|
||||
if source.SelectedAudioStreamIndex == nil {
|
||||
@@ -925,7 +925,7 @@ func (h *PlaybackHandler) setSelectedAudioStream(playSessionID, mediaSourceID st
|
||||
if mediaSourceID != "" {
|
||||
sourceIndex = -1
|
||||
for index := range current.MediaSources {
|
||||
if current.MediaSources[index].ID == mediaSourceID {
|
||||
if mediaSourceIDsEqual(current.MediaSources[index].ID, mediaSourceID) {
|
||||
sourceIndex = index
|
||||
break
|
||||
}
|
||||
@@ -1061,7 +1061,7 @@ func (h *PlaybackHandler) createStaticPlaySession(ctx context.Context, session *
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) resolvePlaybackRoute(r *http.Request, compatSession *Session, routeID, mediaSourceID string) (*PlaybackSession, *PlaybackMediaSource, error) {
|
||||
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
|
||||
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
|
||||
playSession, ok := h.playbackStore.Get(playSessionID)
|
||||
if !ok || playSession.CompatToken != compatSession.Token {
|
||||
return nil, nil, ErrSessionNotFound
|
||||
@@ -1099,7 +1099,7 @@ func findMediaSource(session *PlaybackSession, mediaSourceID string) *PlaybackMe
|
||||
return nil
|
||||
}
|
||||
for _, source := range session.MediaSources {
|
||||
if source.ID == mediaSourceID {
|
||||
if mediaSourceIDsEqual(source.ID, mediaSourceID) {
|
||||
copy := source
|
||||
return ©
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user