Files
silo-server/internal/jellycompat/query.go
T

503 lines
15 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
specificIDs []string
itemTypes []string
genreName string
isFavorite bool
isResumable bool
hasItemTypeFilter bool // true when IncludeItemTypes 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
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)
}
}
if ids := strings.TrimSpace(q.Get("Ids")); ids != "" {
parts := strings.SplitSeq(ids, ",")
for part := range parts {
if decoded, err := decodeItemID(codec, strings.TrimSpace(part)); err == nil && decoded != "" {
result.specificIDs = append(result.specificIDs, decoded)
}
}
}
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)
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
}
}
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.
fieldsRaw := q.Get("Fields")
result.requestedFields = parseRequestedFields(fieldsRaw)
result.fieldsExplicit = 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)
}
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")
}
}
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 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
}