fix(jellyfin): honor repeated Fields query params (#5811)

The Fields param was read with StringOr, which keeps only one value, so a
request sending it as repeated params (Fields=Genres&Fields=MediaSources) — as
Finamp and Feishin do — lost all but the first. Field-gated data like
MediaSources was then omitted for Audio items even though the client asked for
it; the comma-separated form happened to work because ParseFields splits on
commas. Real Jellyfin accepts both forms.

ParseFields is now variadic and a parseFields helper reads every repeated value
via req.Values.Strings, applied to the item list, single-item, and playlist
endpoints.
This commit is contained in:
Deluan Quintão 2026-07-18 18:59:08 -04:00 committed by GitHub
parent 09ac342f5a
commit 1e82f515c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 54 additions and 8 deletions

View File

@ -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

View File

@ -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())
})
})

View File

@ -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))

View File

@ -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

View File

@ -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)