503 lines
15 KiB
Go
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
|
|
}
|