diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go index faa31ec0d..fb49e43c7 100644 --- a/server/jellyfin/dto/fields.go +++ b/server/jellyfin/dto/fields.go @@ -7,12 +7,15 @@ import "strings" // omits those unless the client asks for them. type Fields map[string]struct{} -// ParseFields splits the comma-separated Fields param into a lowercased set. -func ParseFields(csv string) Fields { +// ParseFields builds a lowercased set from the Fields param. It accepts each value comma-separated +// (Fields=a,b) and across repeated params (Fields=a&Fields=b), both of which real Jellyfin honors. +func ParseFields(values ...string) Fields { f := Fields{} - for name := range strings.SplitSeq(csv, ",") { - if name = strings.TrimSpace(strings.ToLower(name)); name != "" { - f[name] = struct{}{} + for _, csv := range values { + for name := range strings.SplitSeq(csv, ",") { + if name = strings.TrimSpace(strings.ToLower(name)); name != "" { + f[name] = struct{}{} + } } } return f diff --git a/server/jellyfin/dto/fields_test.go b/server/jellyfin/dto/fields_test.go new file mode 100644 index 000000000..59d06734d --- /dev/null +++ b/server/jellyfin/dto/fields_test.go @@ -0,0 +1,26 @@ +package dto + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseFields", func() { + It("parses a single comma-separated value", func() { + f := ParseFields("Genres,MediaSources") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + }) + + It("parses fields spread across repeated params", func() { + f := ParseFields("Genres", "MediaSources", "SortName") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + Expect(f.Has("SortName")).To(BeTrue()) + }) + + It("returns an empty set for no values", func() { + Expect(ParseFields()).To(BeEmpty()) + Expect(ParseFields("")).To(BeEmpty()) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 18565c635..382ec9406 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -62,6 +62,16 @@ var _ = Describe("Browsing", func() { } }) + // Clients (Finamp, Feishin) send Fields as repeated params rather than one comma-separated + // value; real Jellyfin accepts both, so a later Fields=MediaSources must still take effect. + It("honors MediaSources when Fields is sent as repeated params", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=Genres&Fields=MediaSources")) + Expect(q.Items).ToNot(BeEmpty()) + for _, it := range q.Items { + Expect(it.MediaSources).To(HaveLen(1)) + } + }) + It("lists all album artists", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) Expect(q.TotalRecordCount).To(Equal(4)) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index b9372c397..6ed24b056 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -224,13 +224,20 @@ type itemsQuery struct { albumIds []string } +// parseFields reads the Fields param, accepting both repeated params (Fields=a&Fields=b) and a +// comma-separated value (Fields=a,b), matching real Jellyfin. StringOr would keep only one value. +func parseFields(p *req.Values) dto.Fields { + values, _ := p.Strings("fields") + return dto.ParseFields(values...) +} + // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because // normalizeQueryKeys folded them (Jellyfin binds case-insensitively). func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { p := req.Params(r) q := itemsQuery{ - fields: dto.ParseFields(p.StringOr("fields", "")), + fields: parseFields(p), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), @@ -730,7 +737,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + fields := parseFields(req.Params(r)) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) return diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 084ff2975..4d3959a79 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { return } p := req.Params(r) - fields := dto.ParseFields(p.StringOr("fields", "")) + fields := parseFields(p) res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err)