Files
silo-server/internal/jellycompat/query.go
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* feat(observability): OpenTelemetry logs+traces with secret redaction

Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing
stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with
no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged.

Bootstrap (internal/telemetry):
- Setup() builds one shared resource, a TracerProvider (parent-based trace-id
  ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator
  from env. It installs NO MeterProvider — metrics stay on Prometheus, and the
  built-in no-op global MeterProvider keeps the trace instrumentation libs from
  double-emitting. Shutdown is deferred with a flush timeout.
- Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the
  shared LevelVar and best-effort so a failing collector can't break the console
  or DB branches. stderr + opslog stay untouched.

Secret redaction (internal/logredact):
- A slog.Handler masks secret-keyed attributes (password, token, api_key,
  authorization, cookie, ...) — including .With-bound attrs, nested groups,
  secret-keyed group subtrees, and values behind a LogValuer — on the console
  and OTLP sinks, with a no-op fast path when a record has no secret keys.
  opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one
  marker list.

Rotation is infra-managed (no custom file sink): container runtime for stderr,
collector/backend for OTLP, opslog partition-pruning for the DB. Documented in
docs/architecture/observability.md.

Verification: go build ./..., go vet, gofmt -l — clean; go test
./internal/telemetry/ ./internal/logredact/ -race pass.

AI-use disclosure: implemented with AI assistance (Claude Code), including
adversarial reviews that hardened the bootstrap and fixed two redaction leak
paths; reviewed by the author.

* refactor(observability): slog context+component sweep, sloglint gate (phase 3)

Part of #265. Builds on the OTel bootstrap + redaction commit.

Standardizes every log call site onto the context-carrying slog variants so
records correlate with the active OpenTelemetry trace, and locks the standard
in with a machine gate so future code (human- or AI-authored) can't drift back.

- Call-site sweep: converted the remaining slog.<Level>(...) calls to the
  slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope
  (background/init calls with no ctx are left as-is), across 183 files. Applied
  via a type-aware AST codemod. Log levels and message strings are preserved
  verbatim; a component attr (canonical per-package name) is added to direct
  package-level slog calls. Bound-logger calls keep their existing .With
  bindings. The main.go and telemetry package conversions rode with their file
  in the previous commit to keep each file within a single commit.
- Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg,
  key-naming-case=snake, no-mixed-args. After the sweep all four report zero
  violations repo-wide (tests included), so make lint / CI now blocks any
  regression to the non-context form. The gate ships with the sweep because it
  cannot be green until the legacy sites are converted.

Metrics remain on Prometheus; no behavior change to /metrics or Grafana.

Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4
rules) 0 violations repo-wide; log levels verified unchanged.

AI-use disclosure: implemented with AI assistance (Claude Code), including the
codemod; reviewed by the author.

* fix(observability): honor per-signal OTLP protocol and secret WithGroup names

Two Codex review findings on PR #290:

- telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the
  generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector
  setups (e.g. HTTP logs + gRPC traces) build the right exporter.
- logredact: entering a group whose name is secret-bearing (e.g.
  WithGroup("authorization")) now masks every leaf in that subtree,
  matching how slog.Group("authorization", ...) is masked as a whole.

* fix(observability): address review feedback on telemetry bootstrap

- Telemetry setup failure no longer kills boot: Setup returns usable
  no-op providers alongside the error and main logs and continues with
  telemetry disabled, honoring the best-effort contract.
- Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_*
  variants); unsupported values fall back to parentbased_traceidratio.
- Attach node identity as semconv service.instance.id instead of the
  non-semconv node.name.
- Rename opslog retention-scope log attrs to target_component/target_level
  so they no longer collide with the canonical component routing key, and
  tag those lines with component=opslog.
- Fix stale levelGated comment casing; use WarnContext in the telemetry
  shutdown defer; document the LogValuer double-resolve on the redaction
  slow path.

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>
2026-07-09 08:53:52 -04:00

617 lines
20 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
parentSeasonID string
parentCollectionID string
specificIDs []string
specificCollectionIDs []string
itemTypes []string
genreName string
isFavorite bool
isResumable bool
hasItemTypeFilter bool // true when IncludeItemTypes or ExcludeItemTypes 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
startItemID string // raw encoded ID from StartItemId param
adjacentTo string // raw encoded ID from AdjacentTo param
}
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 seasonID, seasonErr := codec.DecodeStringID(EncodedIDSeason, parentID); seasonErr == nil && seasonID != "" {
// A season ParentId means "list this season's episodes". The codec's
// kind tagging already keeps Season and Item IDs distinct; decoding
// Season first is defensive in case that separation ever changes.
result.parentSeasonID = seasonID
} 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")
rawExcludedItemTypes := q.Values("ExcludeItemTypes")
result.hasItemTypeFilter = hasNonEmptyValues(rawItemTypes) || hasNonEmptyValues(rawExcludedItemTypes)
result.itemTypes = effectiveItemTypes(rawItemTypes, rawExcludedItemTypes)
result.wantsBoxSets = includeItemTypesContain(rawItemTypes, "boxset")
result.wantsViews = includeItemTypesContain(rawItemTypes, "collectionfolder")
result.sortExplicit = strings.TrimSpace(q.Get("SortBy")) != ""
if result.sort == "latest_episode_added" && !itemTypesOnlySeries(result.itemTypes) {
result.sort = "created_at"
}
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)
result.startItemID = strings.TrimSpace(q.Get("StartItemId"))
result.adjacentTo = strings.TrimSpace(q.Get("AdjacentTo"))
// 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", "component", "jellycompat",
"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
}
func effectiveItemTypes(rawIncluded, rawExcluded []string) []string {
included := mapIncludeItemTypes(rawIncluded)
excluded := mapIncludeItemTypes(rawExcluded)
if len(excluded) == 0 {
return included
}
base := included
if len(base) == 0 && !hasNonEmptyValues(rawIncluded) {
base = compatVideoTypeList
}
if len(base) == 0 {
return nil
}
excludedSet := make(map[string]struct{}, len(excluded))
for _, itemType := range excluded {
excludedSet[itemType] = struct{}{}
}
result := make([]string, 0, len(base))
for _, itemType := range base {
if _, skip := excludedSet[itemType]; skip {
continue
}
result = append(result, itemType)
}
return result
}
func hasNonEmptyValues(values []string) bool {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return true
}
}
return false
}
func itemTypesOnlySeries(itemTypes []string) bool {
return len(itemTypes) == 1 && itemTypes[0] == "series"
}
// 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":
return "created_at"
case "datelastcontentadded":
// Jellyfin's standard "Latest" sort for TV libraries: shows ordered
// by their most recently added episode (issue #202).
return "latest_episode_added"
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
}