* fix(jellycompat): parse repeated Fields query params
parseItemsQuery read the Fields parameter via q.Get("Fields"), which
returns only the first value when a client sends Fields as repeated
query params (Fields=A&Fields=B&...) instead of comma-separated in a
single param (Fields=A,B,C).
The jellyfin-sdk-kotlin (used by Wholphin) sends repeated params. When
such a request listed a detail-only field like MediaSources after other
fields — e.g. the episode-playlist request
/Shows/{id}/Episodes?Fields=PrimaryImageAspectRatio&...&Fields=MediaSources&...
silo saw only the first value (PrimaryImageAspectRatio), so
needsDetailFields stayed false, the request took the list path, and the
response came back without MediaSources. Clients then could not start
playback of the returned episodes ("no media sources").
Join all repeated Fields values before splitting on commas so field
order and delimiter style no longer matter. Comma-separated single-param
clients (e.g. VidHub) are unaffected.
* fix(jellycompat): stop advertising CanDownload and stub ThemeSongs
Wholphin (jellyfin-sdk-kotlin) audit surfaced two reachable gaps:
- mapping.go set CanDownload=true on every playable item while no
/Items/{id}/Download route exists, sending clients that honor the flag
(e.g. Wholphin's screensaver/slideshow) into 404s. Advertise false until
a download route exists.
- GET /Items/{id}/ThemeSongs 404'd, so enabling theme songs in Wholphin
silently failed on every detail page. Stub it with an empty
ThemeMediaResult. This cannot reuse the generic item stub: the SDK
models OwnerId as non-nullable, so the response must include it even
when empty.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(architecture): add Wholphin endpoint coverage audit
Cross-references every Jellyfin endpoint the Wholphin client can call
against the routes jellycompat serves, with gating evidence for each
missing-but-unreachable endpoint and prioritized recommendations.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
556 lines
18 KiB
Go
556 lines
18 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
)
|
|
|
|
type itemsQuery struct {
|
|
limit int
|
|
startIndex int
|
|
enableTotalRecordCount bool
|
|
searchTerm string
|
|
namePrefix string
|
|
maxOfficialRating string
|
|
parentLibraryID int
|
|
parentItemID string
|
|
parentCollectionID string
|
|
specificIDs []string
|
|
specificCollectionIDs []string
|
|
itemTypes []string
|
|
genreName string
|
|
isFavorite bool
|
|
isResumable bool
|
|
hasItemTypeFilter bool // true when IncludeItemTypes was present in the request
|
|
wantsBoxSets bool // true when IncludeItemTypes contains BoxSet
|
|
wantsViews bool // true when IncludeItemTypes contains CollectionFolder
|
|
sortExplicit bool // true when SortBy was present in the request
|
|
needsDetailFields bool // true when requested Fields include detail-level data (e.g. MediaSources)
|
|
itemType string
|
|
sort string
|
|
order string
|
|
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
|
|
requestedFields map[string]bool // parsed from Fields param
|
|
fieldsExplicit bool // true when Fields was in the request
|
|
}
|
|
|
|
func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
|
|
q := newCaseInsensitiveQuery(r.URL.Query())
|
|
result := itemsQuery{
|
|
limit: parsePositiveInt(q.Get("Limit"), 24),
|
|
startIndex: parsePositiveInt(q.Get("StartIndex"), 0),
|
|
enableTotalRecordCount: parseBool(q.Get("EnableTotalRecordCount"), true),
|
|
searchTerm: strings.TrimSpace(q.Get("SearchTerm")),
|
|
namePrefix: strings.TrimSpace(firstNonEmpty(q.Get("NameStartsWith"), q.Get("StartsWith"))),
|
|
maxOfficialRating: strings.TrimSpace(q.Get("MaxOfficialRating")),
|
|
sort: mapSortBy(q.Get("SortBy")),
|
|
order: mapSortOrder(q.Get("SortOrder")),
|
|
}
|
|
|
|
if parentID := strings.TrimSpace(q.Get("ParentId")); parentID != "" {
|
|
if libraryID, err := codec.DecodeIntID(EncodedIDLibrary, parentID); err == nil {
|
|
result.parentLibraryID = int(libraryID)
|
|
} else if collectionID, collErr := codec.DecodeStringID(EncodedIDCollection, parentID); collErr == nil && collectionID != "" {
|
|
result.parentCollectionID = collectionID
|
|
} else if contentID, itemErr := decodeItemID(codec, parentID); itemErr == nil && contentID != "" {
|
|
result.parentItemID = contentID
|
|
}
|
|
}
|
|
|
|
if ids := strings.TrimSpace(q.Get("Ids")); ids != "" {
|
|
parts := strings.SplitSeq(ids, ",")
|
|
for part := range parts {
|
|
raw := strings.TrimSpace(part)
|
|
if decoded, err := decodeItemID(codec, raw); err == nil && decoded != "" {
|
|
result.specificIDs = append(result.specificIDs, decoded)
|
|
} else if collectionID, collErr := codec.DecodeStringID(EncodedIDCollection, raw); collErr == nil && collectionID != "" {
|
|
result.specificCollectionIDs = append(result.specificCollectionIDs, collectionID)
|
|
}
|
|
}
|
|
}
|
|
if genreIDs := strings.TrimSpace(firstNonEmpty(q.Get("GenreIds"), q.Get("GenreItems"))); genreIDs != "" {
|
|
for part := range strings.SplitSeq(genreIDs, ",") {
|
|
decoded, err := codec.DecodeStringID(EncodedIDGenre, strings.TrimSpace(part))
|
|
if err == nil && decoded != "" {
|
|
result.genreName = decoded
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if personIDs := strings.TrimSpace(q.Get("PersonIds")); personIDs != "" {
|
|
for part := range strings.SplitSeq(personIDs, ",") {
|
|
trimmed := strings.TrimSpace(part)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if decoded, err := codec.DecodeIntID(EncodedIDPerson, trimmed); err == nil && decoded > 0 {
|
|
result.personID = decoded
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
rawItemTypes := q.Values("IncludeItemTypes")
|
|
result.hasItemTypeFilter = len(rawItemTypes) > 0 && strings.TrimSpace(strings.Join(rawItemTypes, "")) != ""
|
|
result.itemTypes = mapIncludeItemTypes(rawItemTypes)
|
|
result.wantsBoxSets = includeItemTypesContain(rawItemTypes, "boxset")
|
|
result.wantsViews = includeItemTypesContain(rawItemTypes, "collectionfolder")
|
|
result.sortExplicit = strings.TrimSpace(q.Get("SortBy")) != ""
|
|
if len(result.itemTypes) > 0 {
|
|
result.itemType = result.itemTypes[0]
|
|
}
|
|
result.isFavorite = hasFilter(q.Get("Filters"), "IsFavorite") || parseBool(q.Get("IsFavorite"), false)
|
|
result.isResumable = hasFilter(q.Get("Filters"), "IsResumable")
|
|
|
|
// IsPlayed filter.
|
|
if isPlayedRaw := q.Get("IsPlayed"); isPlayedRaw != "" {
|
|
val := strings.EqualFold(isPlayedRaw, "true") || isPlayedRaw == "1"
|
|
result.isPlayed = &val
|
|
}
|
|
|
|
// ImageTypeLimit.
|
|
if itlRaw := q.Get("ImageTypeLimit"); itlRaw != "" {
|
|
if itl, err := strconv.Atoi(itlRaw); err == nil {
|
|
result.imageTypeLimit = &itl
|
|
}
|
|
}
|
|
|
|
// 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, "")) != ""
|
|
if len(result.mediaTypes) > 0 {
|
|
result.mediaTypesSet = make(map[string]bool, len(result.mediaTypes))
|
|
for _, mediaType := range result.mediaTypes {
|
|
result.mediaTypesSet[mediaType] = true
|
|
}
|
|
}
|
|
|
|
// Fields — parse requested fields and track if explicitly present.
|
|
// Clients send Fields two ways: comma-separated in a single param (VidHub:
|
|
// Fields=A,B,C) OR as repeated params (Wholphin / jellyfin-sdk-kotlin:
|
|
// Fields=A&Fields=B&Fields=C). q.Get returns only the FIRST repeated value,
|
|
// which silently dropped every field after the first — so a Wholphin
|
|
// playlist request (Fields=PrimaryImageAspectRatio&...&Fields=MediaSources)
|
|
// never triggered the detail path and came back without MediaSources,
|
|
// breaking next-episode playback. Join all values, then split on commas.
|
|
fieldsRaw := strings.Join(q.Values("Fields"), ",")
|
|
result.requestedFields = parseRequestedFields(fieldsRaw)
|
|
result.fieldsExplicit = strings.TrimSpace(fieldsRaw) != ""
|
|
result.needsDetailFields = requestedFieldsNeedDetail(result.requestedFields)
|
|
|
|
// Diagnostic: when the request stays on the list path, emit a Debug log
|
|
// listing any requested Fields that mapping.go's itemFromList does not
|
|
// populate AND that are not in the detail-required allowlist. Those
|
|
// fields are silently dropped from the response. Operators can grep for
|
|
// "jellycompat unsatisfied fields" to discover client/server feature
|
|
// drift (e.g., a client asking for RemoteTrailers without also asking
|
|
// for Chapters/MediaSources/People to trigger detail).
|
|
if !result.needsDetailFields {
|
|
if missing := unsatisfiedListFields(result.requestedFields); len(missing) > 0 {
|
|
slog.DebugContext(r.Context(), "jellycompat unsatisfied fields",
|
|
"path", r.URL.Path,
|
|
"fields", missing,
|
|
"hint", "list-path response will omit these; add a detail-required field (e.g. MediaSources) to switch paths")
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// parseSuggestionsQuery parses the Jellyfin Suggestions endpoint parameters.
|
|
// The Suggestions API uses "type" (not "IncludeItemTypes") to filter item types.
|
|
func parseSuggestionsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
|
|
q := newCaseInsensitiveQuery(r.URL.Query())
|
|
result := itemsQuery{
|
|
limit: parsePositiveInt(q.Get("Limit"), 10),
|
|
startIndex: parsePositiveInt(q.Get("StartIndex"), 0),
|
|
}
|
|
// The Suggestions endpoint uses "type" rather than "IncludeItemTypes".
|
|
// The bracket variant (type[]) is handled by caseInsensitiveQuery.Values.
|
|
typeValues := q.Values("Type")
|
|
mapped := mapIncludeItemTypes(typeValues)
|
|
if len(mapped) > 0 {
|
|
result.itemType = mapped[0]
|
|
result.itemTypes = mapped
|
|
}
|
|
return result
|
|
}
|
|
|
|
func buildLatestBrowseParams(query itemsQuery) url.Values {
|
|
params := buildBrowseParams(query)
|
|
// "recently_added" sorts by mil.first_seen_at, which the
|
|
// idx_item_libraries_folder_seen_content index orders for free —
|
|
// vs "created_at" which forces a full-library top-N heapsort.
|
|
params.Set("sort", "recently_added")
|
|
params.Set("order", "desc")
|
|
return params
|
|
}
|
|
|
|
func buildBrowseParams(query itemsQuery) url.Values {
|
|
params := url.Values{}
|
|
params.Set("limit", strconv.Itoa(query.limit))
|
|
params.Set("offset", strconv.Itoa(query.startIndex))
|
|
if len(query.itemTypes) > 0 {
|
|
params.Set("type", strings.Join(query.itemTypes, ","))
|
|
}
|
|
if query.parentLibraryID > 0 {
|
|
params.Set("library_id", strconv.Itoa(query.parentLibraryID))
|
|
}
|
|
if query.genreName != "" {
|
|
params.Set("genre", query.genreName)
|
|
}
|
|
if query.namePrefix != "" {
|
|
params.Set("name_prefix", query.namePrefix)
|
|
}
|
|
if query.sort != "" {
|
|
params.Set("sort", query.sort)
|
|
}
|
|
if query.order != "" {
|
|
params.Set("order", query.order)
|
|
}
|
|
params.Set("include_total", strconv.FormatBool(query.enableTotalRecordCount))
|
|
if query.personID > 0 {
|
|
params.Set("person_id", strconv.FormatInt(query.personID, 10))
|
|
}
|
|
if query.maxOfficialRating != "" {
|
|
params.Set("max_content_rating", query.maxOfficialRating)
|
|
}
|
|
if query.isPlayed != nil {
|
|
if *query.isPlayed {
|
|
params.Set("is_played", "true")
|
|
} else {
|
|
params.Set("is_played", "false")
|
|
}
|
|
}
|
|
if query.requireBackdrop {
|
|
params.Set("require_backdrop", "true")
|
|
}
|
|
return params
|
|
}
|
|
|
|
func favoriteItemsNeedBrowseFilters(query itemsQuery) bool {
|
|
return query.parentLibraryID > 0 ||
|
|
query.genreName != "" ||
|
|
query.namePrefix != "" ||
|
|
query.maxOfficialRating != "" ||
|
|
query.sort != "" ||
|
|
query.order != "" ||
|
|
query.personID > 0 ||
|
|
query.isPlayed != nil ||
|
|
len(query.specificIDs) > 0
|
|
}
|
|
|
|
// favoriteBrowseFiltersSupportedBySQL reports whether the favorite items query
|
|
// can be served by the catalog.BrowseFavorites single-query SQL path. Filters
|
|
// that require joining user_progress (isPlayed) or item_people (personID) are
|
|
// not supported by that path; specific-ID intersections are also routed to the
|
|
// legacy two-query fallback to keep the SQL plan simple. Sorts outside
|
|
// catalog.IsBrowseFavoritesSortSupported (e.g. random, rating_imdb) would
|
|
// silently fall back to added_at in BrowseFavorites — fall back to the legacy
|
|
// path so client-requested ordering is preserved.
|
|
func favoriteBrowseFiltersSupportedBySQL(query itemsQuery) bool {
|
|
if query.isPlayed != nil {
|
|
return false
|
|
}
|
|
if query.personID > 0 {
|
|
return false
|
|
}
|
|
if len(query.specificIDs) > 0 {
|
|
return false
|
|
}
|
|
if !catalog.IsBrowseFavoritesSortSupported(query.sort) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parsePositiveInt(raw string, fallback int) int {
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value < 0 {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func parseBool(s string, defaultVal bool) bool {
|
|
if s == "" {
|
|
return defaultVal
|
|
}
|
|
return strings.EqualFold(s, "true") || s == "1"
|
|
}
|
|
|
|
func mapIncludeItemTypes(rawValues []string) []string {
|
|
if len(rawValues) == 0 {
|
|
return nil
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
result := make([]string, 0, len(rawValues))
|
|
for _, raw := range rawValues {
|
|
for part := range strings.SplitSeq(raw, ",") {
|
|
var mapped string
|
|
switch strings.ToLower(strings.TrimSpace(part)) {
|
|
case "movie", "movies":
|
|
mapped = "movie"
|
|
case "series", "tvshows", "show":
|
|
mapped = "series"
|
|
case "episode", "episodes":
|
|
mapped = "episode"
|
|
case "season", "seasons":
|
|
mapped = "season"
|
|
}
|
|
if mapped == "" || seen[mapped] {
|
|
continue
|
|
}
|
|
seen[mapped] = true
|
|
result = append(result, mapped)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// includeItemTypesContain reports whether a raw IncludeItemTypes value list
|
|
// contains the given (lowercase) type, before mapIncludeItemTypes drops
|
|
// entries it cannot map to catalog types (e.g. BoxSet).
|
|
func includeItemTypesContain(rawValues []string, target string) bool {
|
|
for _, raw := range rawValues {
|
|
for part := range strings.SplitSeq(raw, ",") {
|
|
if strings.ToLower(strings.TrimSpace(part)) == target {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseMediaTypes(rawValues []string) []string {
|
|
if len(rawValues) == 0 {
|
|
return nil
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
result := make([]string, 0, len(rawValues))
|
|
for _, raw := range rawValues {
|
|
for part := range strings.SplitSeq(raw, ",") {
|
|
mediaType := strings.ToLower(strings.TrimSpace(part))
|
|
if mediaType == "" || seen[mediaType] {
|
|
continue
|
|
}
|
|
seen[mediaType] = true
|
|
result = append(result, mediaType)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func mapSortBy(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(strings.Split(raw, ",")[0])) {
|
|
case "sortname", "name":
|
|
return "sort_title"
|
|
case "datecreated":
|
|
return "created_at"
|
|
case "premiered", "premieredate":
|
|
return "release_date"
|
|
case "productionyear":
|
|
return "year"
|
|
case "communityrating":
|
|
return "rating_imdb"
|
|
case "random":
|
|
return "random"
|
|
case "dateplayed", "datelastcontentadded":
|
|
return "created_at"
|
|
default:
|
|
return "created_at"
|
|
}
|
|
}
|
|
|
|
func mapSortOrder(raw string) string {
|
|
if strings.EqualFold(raw, "Ascending") {
|
|
return "asc"
|
|
}
|
|
return "desc"
|
|
}
|
|
|
|
func parseRequestedFields(raw string) map[string]bool {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
fields := map[string]bool{}
|
|
for part := range strings.SplitSeq(raw, ",") {
|
|
key := strings.ToLower(strings.TrimSpace(part))
|
|
if key != "" {
|
|
fields[key] = true
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// fieldsRequiringDetail enumerates the (case-insensitive) Jellyfin Fields
|
|
// values that genuinely require a per-item GetItemDetail call. All other
|
|
// fields can be served by browse-level joins; do NOT add fields here just
|
|
// to be safe — every entry causes an N+1 amplification (one detail fetch
|
|
// per result item, e.g. ~525 queries for /Shows/{id}/Episodes on a
|
|
// 500-episode series).
|
|
//
|
|
// Keys must be lowercase. parseRequestedFields normalizes incoming Fields
|
|
// to lowercase before storing them in the requestedFields map.
|
|
//
|
|
// mediasources is listed because BrowseRepository does not yet project
|
|
// per-file media metadata. When the LATERAL JOIN against media_files lands
|
|
// (catalog SQL performance overhaul plan §3.2 part b), it can be removed.
|
|
var fieldsRequiringDetail = map[string]struct{}{
|
|
"people": {},
|
|
"chapters": {},
|
|
"mediastreams": {},
|
|
"mediasources": {},
|
|
}
|
|
|
|
// fieldsServedByList enumerates Fields values that mapping.go's itemFromList
|
|
// can populate — gated by `if allFields || fields[X]` blocks. Anything outside
|
|
// this set AND outside fieldsRequiringDetail is silently dropped from
|
|
// list-path responses (no detail fetch is triggered to fill it).
|
|
//
|
|
// Keep aligned with mapping.go itemFromList. When you add a new
|
|
// `fields[X]`-gated branch there, add the lowercase key here too.
|
|
var fieldsServedByList = map[string]struct{}{
|
|
"overview": {},
|
|
"genres": {},
|
|
"etag": {},
|
|
"sortname": {},
|
|
"studios": {},
|
|
"taglines": {},
|
|
"tags": {},
|
|
"productionlocations": {},
|
|
"criticrating": {},
|
|
"mediasourcecount": {},
|
|
"providerids": {},
|
|
}
|
|
|
|
func requestedFieldsNeedDetail(fields map[string]bool) bool {
|
|
for field := range fields {
|
|
key := strings.ToLower(strings.TrimSpace(field))
|
|
if _, ok := fieldsRequiringDetail[key]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// unsatisfiedListFields returns the (sorted, lowercased) Fields values that
|
|
// will be silently dropped on list-path responses — neither populated by the
|
|
// browse mapper nor recognized as a detail-required field that would trigger
|
|
// a per-item GetItemDetail fetch. Returns nil when the request will fall
|
|
// into the detail path (which serves all fields) or when no fields are
|
|
// unserved. Diagnostic only — does not change response behavior.
|
|
func unsatisfiedListFields(fields map[string]bool) []string {
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
if requestedFieldsNeedDetail(fields) {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for field := range fields {
|
|
key := strings.ToLower(strings.TrimSpace(field))
|
|
if key == "" || key == "*" {
|
|
continue
|
|
}
|
|
if _, ok := fieldsServedByList[key]; ok {
|
|
continue
|
|
}
|
|
if _, ok := fieldsRequiringDetail[key]; ok {
|
|
continue
|
|
}
|
|
out = append(out, key)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func hasFilter(raw, target string) bool {
|
|
for part := range strings.SplitSeq(raw, ",") {
|
|
if strings.EqualFold(strings.TrimSpace(part), target) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// caseInsensitiveQuery wraps url.Values with case-insensitive key lookup.
|
|
// Jellyfin clients use inconsistent casing (PascalCase, camelCase, lowercase).
|
|
type caseInsensitiveQuery struct {
|
|
index map[string]string // lowercase key → original key
|
|
raw url.Values
|
|
}
|
|
|
|
func newCaseInsensitiveQuery(values url.Values) caseInsensitiveQuery {
|
|
index := make(map[string]string, len(values))
|
|
for key := range values {
|
|
lower := strings.ToLower(key)
|
|
if _, exists := index[lower]; !exists {
|
|
index[lower] = key
|
|
}
|
|
}
|
|
return caseInsensitiveQuery{index: index, raw: values}
|
|
}
|
|
|
|
func (q caseInsensitiveQuery) Get(key string) string {
|
|
lower := strings.ToLower(key)
|
|
if orig, ok := q.index[lower]; ok {
|
|
return q.raw.Get(orig)
|
|
}
|
|
// Jellyfin SDKs may send "key[]" for array params — try bracket variant.
|
|
if orig, ok := q.index[lower+"[]"]; ok {
|
|
return q.raw.Get(orig)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (q caseInsensitiveQuery) Values(key string) []string {
|
|
lower := strings.ToLower(key)
|
|
if orig, ok := q.index[lower]; ok {
|
|
return q.raw[orig]
|
|
}
|
|
// Jellyfin SDKs may send "key[]" for array params — try bracket variant.
|
|
if orig, ok := q.index[lower+"[]"]; ok {
|
|
return q.raw[orig]
|
|
}
|
|
return nil
|
|
}
|