🏡 index : quine-global/quintodrome.git

package jellyfin

import (
	"context"
	"errors"
	"io"
	"iter"
	"net/http"
	"slices"
	"strconv"
	"strings"

	"github.com/Masterminds/squirrel"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/server/filter"
	"github.com/navidrome/navidrome/server/jellyfin/dto"
	"github.com/navidrome/navidrome/utils/req"
	"github.com/navidrome/navidrome/utils/slice"
	"golang.org/x/sync/errgroup"
)

// notMissing excludes items whose backing files are all gone ("missing" is a real column on
// album, artist and media_file).
var notMissing = squirrel.Eq{"missing": false}

// searchTerm trims, so a whitespace-only term is not a search: doSearch would read it as "match
// everything" and materialize the library, where the unfiltered path streams.
func searchTerm(p *req.Values) string {
	return strings.TrimSpace(p.StringOr("searchterm", ""))
}

// itemFilters is the parsed Filters=... list together with the standalone isFavorite/isPlayed params
// clients may send instead. A nil field means the client asked for no filtering on that dimension.
type itemFilters struct {
	favorite *bool
	played   *bool
}

// parseItemFilters reads the standalone params first and lets the Filters list win, matching real
// Jellyfin. Tokens with no Navidrome equivalent (Likes, IsFolder, IsResumable) are dropped.
func parseItemFilters(p *req.Values) itemFilters {
	f := itemFilters{favorite: p.BoolPtr("isfavorite"), played: p.BoolPtr("isplayed")}
	for token := range strings.SplitSeq(p.StringOr("filters", ""), ",") {
		switch strings.TrimSpace(token) {
		case "IsFavorite", "IsFavoriteOrLikes":
			f.favorite = new(true)
		case "IsPlayed":
			f.played = new(true)
		case "IsUnplayed":
			f.played = new(false)
		}
	}
	return f
}

// predicates renders the filters as annotation-column conditions. The negative cases have to match
// NULL as well: annotations are LEFT JOINed, so an item nobody has touched has no row at all.
func (f itemFilters) predicates() []squirrel.Sqlizer {
	var out []squirrel.Sqlizer
	if f.favorite != nil {
		if *f.favorite {
			out = append(out, squirrel.Eq{"starred": true})
		} else {
			out = append(out, squirrel.Or{squirrel.Eq{"starred": nil}, squirrel.Eq{"starred": false}})
		}
	}
	if f.played != nil {
		if *f.played {
			out = append(out, squirrel.Gt{"play_count": 0})
		} else {
			out = append(out, squirrel.Or{squirrel.Eq{"play_count": nil}, squirrel.Eq{"play_count": 0}})
		}
	}
	return out
}

func (api *Router) getItems(w http.ResponseWriter, r *http.Request) {
	res, err := api.queryItems(r.Context(), r)
	if err != nil {
		if errors.Is(err, model.ErrNotFound) {
			http.Error(w, "Not Found", http.StatusNotFound)
			return
		}
		api.internalError(w, r, err)
		return
	}
	api.ok(w, r, res)
}

// itemsResult is the outcome of a collection query: a materialized page, or a cursor opener so a
// full-library response never builds every DTO at once. Exactly one of items/openCursor is set.
//
// openCursor is deferred rather than opened here: it must run after the ServerId lookup, which
// writes to the DB on first use and would deadlock against an open reader, but before the first
// response byte, so a failed open is still a clean error rather than a truncated 200.
type itemsResult struct {
	items      []dto.BaseItemDto
	openCursor func() (iter.Seq2[dto.BaseItemDto, error], error)
	total      int
	start      int
}

func materialized(q dto.QueryResult) itemsResult {
	return itemsResult{items: q.Items, total: q.TotalRecordCount, start: q.StartIndex}
}

func streamed(open func() (iter.Seq2[dto.BaseItemDto, error], error), total, start int) itemsResult {
	return itemsResult{openCursor: open, total: total, start: start}
}

// chained streams several results back to back, skipping the first skip items — the unbounded
// multi-type merge, where paginate(items, offset, 0) is just the concatenation minus its head.
func chained(results []itemsResult, total, skip int) itemsResult {
	open := func() (iter.Seq2[dto.BaseItemDto, error], error) {
		if len(results) == 0 {
			return sliceItems(nil), nil
		}
		// Only the first opens eagerly (so the usual failure is still a clean error); the rest open as
		// the stream reaches them, so only one cursor pins a DB connection at a time.
		first, err := results[0].seq()
		if err != nil {
			return nil, err
		}
		return func(yield func(dto.BaseItemDto, error) bool) {
			n := 0
			emit := func(seq iter.Seq2[dto.BaseItemDto, error]) bool {
				for it, err := range seq {
					if err != nil {
						yield(dto.BaseItemDto{}, err)
						return false
					}
					if n < skip {
						n++
						continue
					}
					if !yield(it, nil) {
						return false
					}
				}
				return true
			}
			if !emit(first) {
				return
			}
			for _, res := range results[1:] {
				seq, err := res.seq()
				if err != nil {
					yield(dto.BaseItemDto{}, err)
					return
				}
				if !emit(seq) {
					return
				}
			}
		}, nil
	}
	return streamed(open, total, skip)
}

// streamCursor builds a deferred opener that maps each row as it's yielded. It takes the cursor's
// underlying func type, so callers wrap repo.GetCursor for the named type to infer T.
func streamCursor[T any](openCursor func() (func(func(T, error) bool), error), toItem func(T) dto.BaseItemDto) func() (iter.Seq2[dto.BaseItemDto, error], error) {
	return func() (iter.Seq2[dto.BaseItemDto, error], error) {
		cursor, err := openCursor()
		if err != nil {
			return nil, err
		}
		return func(yield func(dto.BaseItemDto, error) bool) {
			for row, err := range cursor {
				if err != nil {
					yield(dto.BaseItemDto{}, err)
					return
				}
				if !yield(toItem(row), nil) {
					return
				}
			}
		}, nil
	}
}

// seq returns the items as one sequence, opening the cursor if there is one.
func (ir itemsResult) seq() (iter.Seq2[dto.BaseItemDto, error], error) {
	if ir.openCursor != nil {
		return ir.openCursor()
	}
	return sliceItems(ir.items), nil
}

// collect drains the result into a slice, for the merge that combines types before paginating.
func (ir itemsResult) collect() ([]dto.BaseItemDto, error) {
	if ir.openCursor == nil {
		return ir.items, nil
	}
	seq, err := ir.openCursor()
	if err != nil {
		return nil, err
	}
	var out []dto.BaseItemDto
	for it, err := range seq {
		if err != nil {
			return nil, err
		}
		out = append(out, it)
	}
	return out, nil
}

func (api *Router) writeItems(w http.ResponseWriter, r *http.Request, res itemsResult) {
	api.streamResult(w, r, res, func(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error {
		return streamItemsEnvelope(w, items, res.total, res.start)
	})
}

// writeItemsArray writes the bare-array shape (/Items/Latest), which has no QueryResult envelope.
func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res itemsResult) {
	api.streamResult(w, r, res, streamItemsArray)
}

// stampItem fills in what real Jellyfin puts on every item it returns, so a client that requires a
// key never meets an item without it: ServerId, MediaType, ImageTags, and the Fields-gated lists.
func stampItem(it dto.BaseItemDto, serverID string, fields dto.Fields) dto.BaseItemDto {
	it.ServerId = serverID
	if it.MediaType == "" {
		it.MediaType = "Unknown"
	}
	if it.ImageTags == nil {
		it.ImageTags = map[string]string{}
	}
	if fields.Has("Genres") {
		if it.Genres == nil {
			it.Genres = []string{}
		}
		if it.GenreItems == nil {
			it.GenreItems = []dto.NameGuidPair{}
		}
	}
	if fields.Has("Tags") && it.Tags == nil {
		it.Tags = []string{}
	}
	return it
}

// requestFields parses the Fields param, which gates what stampItem and the mappers attach.
func requestFields(r *http.Request) dto.Fields {
	return dto.ParseFields(req.Params(r).Strings("fields")...)
}

// streamResult stamps every item (see stampItem). The cursor opens before the first byte, so a
// failed open is still a clean 500.
func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult,
	write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) {
	sid, fields := api.serverID(r.Context()), requestFields(r)
	seq, err := res.seq()
	if err != nil {
		api.internalError(w, r, err)
		return
	}
	stamped := func(yield func(dto.BaseItemDto, error) bool) {
		for it, err := range seq {
			if err != nil {
				yield(dto.BaseItemDto{}, err)
				return
			}
			if !yield(stampItem(it, sid, fields), nil) {
				return
			}
		}
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if err := write(w, stamped); err != nil {
		log.Error(r.Context(), "Jellyfin API: error streaming response", err)
	}
}

// itemsQuery is a parsed /Items request, so the dispatch and every listXxx take one value instead
// of a long positional parameter list.
type itemsQuery struct {
	fields    dto.Fields
	ids       []string
	rawTypes  string
	types     []string
	search    string
	sortBy    string
	sortOrder string
	offset    int
	limit     int
	filters   itemFilters
	// parentId scopes the query. entityParent is the same id only when it names an entity (an artist
	// for MusicAlbum, an album for Audio) rather than a library.
	parentId        string
	entityParent    string
	isLibraryParent bool
	scopeIDs        []int
	// artistId selects that artist's own discography; contributingOnly means albums they merely
	// appear on (Jellyfin's "Featured On"), which must exclude that discography.
	artistId         string
	contributingOnly bool
	genreIds         []string
	albumIds         []string
	years            []int
	studioIds        []string
}

// listParams reads the itemsQuery fields that come straight from query params.
func listParams(p *req.Values) itemsQuery {
	return itemsQuery{
		fields:    dto.ParseFields(p.Strings("fields")...),
		search:    searchTerm(p),
		sortBy:    p.StringOr("sortby", ""),
		sortOrder: p.StringOr("sortorder", ""),
		offset:    p.IntOr("startindex", 0),
		limit:     p.IntOr("limit", 0),
		filters:   parseItemFilters(p),
	}
}

// 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). A non-empty id param that
// fails to decode reports model.ErrNotFound rather than silently dropping the filter (see decodeFilterParam).
func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQuery, error) {
	p := req.Params(r)
	parentId, ok := decodeFilterParam(p.StringOr("parentid", ""))
	if !ok {
		return itemsQuery{}, model.ErrNotFound
	}
	// Any malformed entry in one of these id lists must 404, not silently drop out of the filter
	// (see dto.DecodeIDs) — an all-malformed list would otherwise widen the query to everything.
	ids, ok := decodedQueryIDs(r, "ids")
	if !ok {
		return itemsQuery{}, model.ErrNotFound
	}
	// Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre.
	genreIds, ok := decodedQueryIDs(r, "genreids")
	if !ok {
		return itemsQuery{}, model.ErrNotFound
	}
	// Feishin fetches an album's tracks with AlbumIds instead of ParentId.
	albumIds, ok := decodedQueryIDs(r, "albumids")
	if !ok {
		return itemsQuery{}, model.ErrNotFound
	}
	studioIds, ok := decodedQueryIDs(r, "studioids")
	if !ok {
		return itemsQuery{}, model.ErrNotFound
	}
	q := listParams(p)
	q.ids = ids
	q.rawTypes = knownItemKinds(p.StringOr("includeitemtypes", ""))
	q.parentId = parentId
	q.genreIds = genreIds
	q.albumIds = albumIds
	q.years = parseYears(r)
	q.studioIds = studioIds
	// An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping
	// plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist.
	albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", ""))
	contributingScope := p.StringOr("contributingartistids", "")
	artistId, ok := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope))
	if !ok {
		return itemsQuery{}, model.ErrNotFound
	}
	q.artistId = artistId
	q.contributingOnly = albumArtistScope == "" && contributingScope != ""

	q.types = parseTypes(q.rawTypes)
	q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId)

	// Recursive=false asks for direct children only, and no track is a library's direct child.
	// Finamp's sync probes a library this way, and every track is a wrong, unbounded answer.
	if q.isLibraryParent && !p.BoolOr("recursive", false) {
		q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" })
	}

	// With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks
	// (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse
	// its albums).
	if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent {
		if q.parentId == dto.PlaylistsFolderID {
			// Browsing into the synthetic playlists folder lists the user's playlists.
			q.types = []string{"Playlist"}
		} else if _, err := api.ds.Album().Get(ctx, q.parentId); err == nil {
			q.types = []string{"Audio"}
		}
	}
	// ParentId-as-entity-id only makes sense for a single type; a multi-type query has no natural
	// parent entity, so there ParentId is only library scoping.
	q.entityParent = q.parentId
	if q.isLibraryParent || len(q.types) > 1 {
		q.entityParent = ""
	}
	return q, nil
}

// queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via
// the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites
// screen requests).
func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) {
	q, err := api.parseItemsQuery(ctx, r)
	if err != nil {
		return itemsResult{}, err
	}
	switch {
	// /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch.
	case len(q.ids) > 0:
		return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil
	// A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items.
	case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"):
		return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil
	}
	if repo, ok := api.playlistTracksRepo(ctx, q); ok {
		return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit)
	}
	if q.search != "" {
		q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit)
	}
	if len(q.types) == 1 {
		opts := model.QueryOptions{Offset: q.offset, Max: q.limit}
		applySort(&opts, q.types[0], q.sortBy, q.sortOrder)
		return api.queryItemsOfType(ctx, q.types[0], opts, q)
	}
	return api.mergeTypes(ctx, q)
}

// playlistTracksRepo resolves a playlist parent, whatever IncludeItemTypes says: Jellify opens a
// playlist with ParentId=<playlist>&IncludeItemTypes=Audio, and routing that through listSongs would
// treat the playlist id as an album id and return nothing.
//
// ok is false when ParentId isn't a visible playlist, so the caller falls through to the type
// dispatch: ParentId is usually an album or artist.
func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) {
	if q.parentId == "" || q.isLibraryParent || q.parentId == dto.PlaylistsFolderID {
		return nil, false
	}
	// Tracks enforces visibility.
	repo, err := api.playlists.Tracks(ctx, q.parentId)
	return repo, err == nil
}

func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) {
	if q.limit == 0 {
		return api.mergeTypesStreaming(ctx, q)
	}
	// A random page doesn't stack on the previous one (the order reshuffles each request), so serving
	// from 0 is an equivalent fresh draw and avoids materializing offset+limit rows per type.
	offset := q.offset
	if randomlySorted(q) {
		offset = 0
	}
	return api.mergeTypesPaged(ctx, q, offset)
}

// randomlySorted reports whether every merged type resolves to a random sort — the case where a page
// is an independent draw, so the offset can be collapsed to 0. Resolving via applySort (rather than
// matching the raw SortBy) keeps this in step with how each type's sort is actually chosen.
func randomlySorted(q itemsQuery) bool {
	for _, itemType := range q.types {
		var opts model.QueryOptions
		applySort(&opts, itemType, q.sortBy, q.sortOrder)
		if opts.Sort != "random" {
			return false
		}
	}
	return true
}

// mergeTypesStreaming keeps the unbounded path lazy: chaining the per-type cursors yields their rows
// in order minus the first offset, without pulling every row into memory.
func (api *Router) mergeTypesStreaming(ctx context.Context, q itemsQuery) (itemsResult, error) {
	var results []itemsResult
	total := 0
	for _, itemType := range q.types {
		res, err := api.queryTypeWindow(ctx, itemType, 0, q)
		if err != nil {
			return itemsResult{}, err
		}
		results = append(results, res)
		total += res.total
	}
	return chained(results, total, q.offset), nil
}

// queryTypeWindow queries one type for the merge paths, capping it to window rows with the sort applied.
func (api *Router) queryTypeWindow(ctx context.Context, itemType string, window int, q itemsQuery) (itemsResult, error) {
	var opts model.QueryOptions
	opts.Max = window
	applySort(&opts, itemType, q.sortBy, q.sortOrder)
	return api.queryItemsOfType(ctx, itemType, opts, q)
}

// mergeTypesPaged runs each type's query concurrently, then round-robins the per-type rows so the limited page
// is a mix rather than one type's rows followed by the next.
func (api *Router) mergeTypesPaged(ctx context.Context, q itemsQuery, offset int) (itemsResult, error) {
	// Each per-type query needs at most offset+limit rows (worst case: one type fills the whole window).
	window := offset + q.limit
	if q.search != "" {
		window = min(window, maxSearchLimit)
	}
	lists := make([][]dto.BaseItemDto, len(q.types))
	totals := make([]int, len(q.types))
	g, ctx := errgroup.WithContext(ctx)
	for i, itemType := range q.types {
		g.Go(func() error {
			res, err := api.queryTypeWindow(ctx, itemType, window, q)
			if err != nil {
				return err
			}
			items, err := res.collect()
			if err != nil {
				return err
			}
			lists[i] = items
			totals[i] = res.total
			return nil
		})
	}
	if err := g.Wait(); err != nil {
		return itemsResult{}, err
	}
	total := 0
	for _, t := range totals {
		total += t
	}
	items := interleave(lists)
	if q.search != "" {
		// Past the window the merged order isn't the true one, so drop it rather than serve another
		// type's rows. The total is what's pageable overall, so a client paging on it won't stop early.
		items = items[:min(window, len(items))]
		total = min(total, maxSearchLimit)
	}
	return materialized(result(paginate(items, offset, q.limit), total, q.offset)), nil
}

func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
	switch itemType {
	case "Audio":
		return api.listSongs(ctx, opts, q)
	case "MusicArtist":
		// The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists.
		return api.listArtists(ctx, opts, q, model.RoleAlbumArtist)
	case "MusicGenre":
		return api.listGenres(ctx, opts)
	case "Playlist":
		return api.listPlaylists(ctx, opts, q)
	default: // MusicAlbum
		return api.listAlbums(ctx, opts, q)
	}
}

// firstNonEmpty returns the first non-empty string, or "".
func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if v != "" {
			return v
		}
	}
	return ""
}

// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list, reporting
// whether it decoded successfully (see decodeFilterParam).
func firstDecodedID(s string) (string, bool) {
	if s == "" {
		return "", true
	}
	first, _, _ := strings.Cut(s, ",")
	return decodeFilterParam(strings.TrimSpace(first))
}

// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if
// any entry is malformed, so a dropped entry can't shrink the list into an empty, no-op filter.
func decodedQueryIDs(r *http.Request, key string) ([]string, bool) {
	return dto.DecodeIDs(queryIDs(r, key))
}

// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params.
func parseYears(r *http.Request) []int {
	var years []int
	for _, v := range queryIDs(r, "years") {
		if y, err := strconv.Atoi(v); err == nil && y > 0 {
			years = append(years, y)
		}
	}
	return years
}

// supportedTypes maps lowercased IncludeItemTypes names (Jellyfin binds them case-insensitively)
// to the item types Navidrome serves.
var supportedTypes = map[string]string{
	"audio": "Audio", "musicartist": "MusicArtist", "musicalbum": "MusicAlbum", "musicgenre": "MusicGenre", "playlist": "Playlist",
}

// jellyfinItemKinds lists Jellyfin's BaseItemKind names, lowercased.
var jellyfinItemKinds = map[string]bool{
	"aggregatefolder": true, "audio": true, "audiobook": true, "basepluginfolder": true, "book": true,
	"boxset": true, "channel": true, "channelfolderitem": true, "collectionfolder": true, "episode": true,
	"folder": true, "genre": true, "manualplaylistsfolder": true, "movie": true, "livetvchannel": true,
	"livetvprogram": true, "musicalbum": true, "musicartist": true, "musicgenre": true, "musicvideo": true,
	"person": true, "photo": true, "photoalbum": true, "playlist": true, "playlistsfolder": true,
	"program": true, "recording": true, "season": true, "series": true, "studio": true, "trailer": true,
	"tvchannel": true, "tvprogram": true, "userrootfolder": true, "userview": true, "video": true, "year": true,
}

// knownItemKinds drops IncludeItemTypes entries that aren't BaseItemKind names, as Jellyfin's binder
// does, so an all-unknown list (JellyBox sends "music") behaves like an absent one.
func knownItemKinds(types string) string {
	var known []string
	for t := range strings.SplitSeq(types, ",") {
		if t = strings.TrimSpace(t); jellyfinItemKinds[strings.ToLower(t)] {
			known = append(known, t)
		}
	}
	return strings.Join(known, ",")
}

// parseTypes returns the supported entries in IncludeItemTypes in order. Only an absent param
// defaults to albums, so ParentId=<artistId> still browses that artist's albums.
func parseTypes(types string) []string {
	if strings.TrimSpace(types) == "" {
		return []string{"MusicAlbum"}
	}
	var recognized []string
	for t := range strings.SplitSeq(types, ",") {
		if name, ok := supportedTypes[strings.ToLower(strings.TrimSpace(t))]; ok {
			recognized = append(recognized, name)
		}
	}
	// Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query.
	return slice.Unique(recognized)
}

// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only
// (single-type queries push Offset/Max down to SQL instead).
func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto {
	if offset >= len(items) {
		return []dto.BaseItemDto{}
	}
	items = items[offset:]
	if limit > 0 && limit < len(items) {
		items = items[:limit]
	}
	return items
}

// interleave merges per-type item lists round-robin: one item from each list in turn, preserving
// each list's own order, so no single type dominates the head of a mixed-type result.
func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto {
	total, maxLen := 0, 0
	for _, l := range lists {
		total += len(l)
		maxLen = max(maxLen, len(l))
	}
	out := make([]dto.BaseItemDto, 0, total)
	for i := 0; i < maxLen; i++ {
		for _, l := range lists {
			if i < len(l) {
				out = append(out, l[i])
			}
		}
	}
	return out
}

// Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without
// the ceiling, Limit=999999 still materializes every match.
const (
	defaultSearchLimit = 100
	maxSearchLimit     = 2000
)

// clampLimit bounds a client-supplied limit, 0 or less meaning it sent none, so it can't drive an
// oversized allocation or provider fetch (flagged by CodeQL as a user-controlled allocation size).
//
// Searches clamp their Limit here rather than in searchPage, which also sees mergeTypes' larger
// offset+limit window: bounding that would truncate each type before the merged page is cut.
func clampLimit(limit, def, ceiling int) int {
	if limit <= 0 {
		return def
	}
	return min(limit, ceiling)
}

// searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the
// Search API returns no match count and CountAll can't see the search term. offset+len(rows) is
// exact once matches end (and a growing lower bound before), so paging terminates at the last match.
func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) {
	fetch := opts
	fetch.Max++
	rows, err := search(fetch)
	if err != nil {
		return nil, 0, err
	}
	total := opts.Offset + len(rows)
	if len(rows) > opts.Max {
		rows = rows[:opts.Max]
	}
	return rows, total, nil
}

func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
	toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) }
	repo := api.ds.Album()
	filters := squirrel.And{}
	// For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's
	// albums"; contributingArtistIds means "albums they only appear on" (Featured On).
	switch {
	case q.contributingOnly && q.artistId != "":
		filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters)
	case firstNonEmpty(q.artistId, q.entityParent) != "":
		filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters)
	default:
		filters = append(filters, notMissing)
	}
	if len(q.genreIds) > 0 {
		filters = append(filters, filter.AlbumsByGenreID(q.genreIds))
	}
	if len(q.years) > 0 {
		filters = append(filters, filter.AlbumsByYears(q.years))
	}
	if len(q.studioIds) > 0 {
		filters = append(filters, filter.ByStudioID(q.studioIds))
	}
	// Not on the search path: its first FTS phase selects rowids with no annotation join, so a
	// starred/play_count predicate there is "no such column" rather than a filter.
	if q.search == "" {
		filters = append(filters, q.filters.predicates()...)
	}
	opts.Filters = filters
	opts = filter.ApplyLibraryFilter(opts, q.scopeIDs)

	if q.search != "" {
		albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) {
			return repo.Search(ctx, q.search, o)
		})
		if err != nil {
			return itemsResult{}, err
		}
		return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil
	}
	total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters})
	open := streamCursor(func() (func(func(model.Album, error) bool), error) {
		return repo.GetCursor(ctx, opts)
	}, toItem)
	return streamed(open, int(total), opts.Offset), nil
}

func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
	toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) }
	repo := api.ds.MediaFile()
	filters := squirrel.And{}
	// For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's.
	switch {
	case q.artistId != "":
		filters = append(filters, filter.SongsByArtistID(q.artistId).Filters)
	case q.entityParent != "":
		filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters)
	default:
		filters = append(filters, notMissing)
	}
	if len(q.albumIds) > 0 {
		filters = append(filters, filter.ByAlbumID(q.albumIds))
	}
	if len(q.genreIds) > 0 {
		filters = append(filters, filter.SongsByGenreID(q.genreIds))
	}
	if len(q.years) > 0 {
		filters = append(filters, filter.SongsByYears(q.years))
	}
	if len(q.studioIds) > 0 {
		filters = append(filters, filter.ByStudioID(q.studioIds))
	}
	// Not on the search path: its first FTS phase selects rowids with no annotation join, so a
	// starred/play_count predicate there is "no such column" rather than a filter.
	if q.search == "" {
		filters = append(filters, q.filters.predicates()...)
	}
	opts.Filters = filters
	opts = filter.ApplyLibraryFilter(opts, q.scopeIDs)

	if q.search != "" {
		mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) {
			return repo.Search(ctx, q.search, o)
		})
		if err != nil {
			return itemsResult{}, err
		}
		return materialized(result(slice.Map(mfs, toItem), total, opts.Offset)), nil
	}
	// When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an
	// explicit client SortBy still wins, since applySort already set opts.Sort.
	if q.artistId == "" && q.entityParent != "" && opts.Sort == "" {
		opts.Sort = filter.SongsByAlbum(q.entityParent).Sort
	}
	// A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows.
	total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters})
	open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) {
		return repo.GetCursorWithArtwork(ctx, opts)
	}, toItem)
	return streamed(open, int(total), opts.Offset), nil
}

// listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views,
// RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical.
// genreIds isn't applied to search — a name lookup, like role (see below).
func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) {
	repo := api.ds.Artist()
	toItem := func(ar model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(ar, q.fields) }

	// Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a
	// search scope (artists have no library_id column). A compound or join-based filter
	// (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build
	// filters differently. Role isn't applied to search for the same reason — it's a name lookup.
	if q.search != "" {
		if len(q.scopeIDs) > 0 {
			opts.Filters = squirrel.Eq{"library_id": q.scopeIDs}
		}
		artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) {
			return repo.Search(ctx, q.search, o)
		})
		if err != nil {
			return itemsResult{}, err
		}
		return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil
	}

	filters := squirrel.And{notMissing}
	filters = append(filters, q.filters.predicates()...)
	if len(q.genreIds) > 0 {
		filters = append(filters, filter.ArtistsByGenreID(q.genreIds))
	}
	opts.Filters = filters
	opts = filter.ArtistsByRole(opts, role)
	opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs)
	total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters})
	open := streamCursor(func() (func(func(model.Artist, error) bool), error) {
		return repo.GetCursor(ctx, opts)
	}, toItem)
	return streamed(open, int(total), opts.Offset), nil
}

// listGenres is intentionally unscoped: genres are global tags, not per-library entities. It's also
// the one listXxx that stays materialized: GenreRepository has no CountAll, so the total is the
// length of the full list and paging is in-memory — nothing for a cursor to page over.
func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) {
	genres, err := api.ds.Genre().GetAll(ctx, model.QueryOptions{Sort: opts.Sort, Order: opts.Order})
	if err != nil {
		return itemsResult{}, err
	}
	items := slice.Map(genres, dto.GenreToBaseItem)
	return materialized(result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset)), nil
}

// listPlaylists lists playlists visible to the current user. Visibility (public or owned) is
// enforced by playlistRepository, not scopeIDs.
func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
	if preds := q.filters.predicates(); len(preds) > 0 {
		opts.Filters = squirrel.And(preds)
	}
	repo := api.ds.Playlist()
	total, err := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters})
	if err != nil {
		return itemsResult{}, err
	}
	open := streamCursor(func() (func(func(model.Playlist, error) bool), error) {
		return repo.GetCursor(ctx, opts)
	}, func(p model.Playlist) dto.BaseItemDto { return dto.PlaylistToBaseItem(p, q.fields) })
	return streamed(open, int(total), opts.Offset), nil
}

// resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album,
// artist, song, playlist and genre in turn. Albums and songs report not-found when the user lacks access
// to their library, so an id can't probe content outside the user's libraries.
func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) {
	// The synthetic playlists folder must resolve by the id we advertised, not 404.
	if id == dto.PlaylistsFolderID {
		return playlistsFolder(), true
	}
	u, _ := request.UserFrom(ctx)
	// Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this
	// the home screen and library tabs 404.
	if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) {
		if lib, err := api.ds.Library().Get(ctx, libID); err == nil {
			return dto.LibraryToBaseItem(*lib), true
		}
	}
	if al, err := api.ds.Album().Get(ctx, id); err == nil {
		if !u.HasLibraryAccess(al.LibraryID) {
			return dto.BaseItemDto{}, false
		}
		return dto.AlbumToBaseItem(*al, fields), true
	}
	if ar, err := api.ds.Artist().Get(ctx, id); err == nil {
		// Artist.Get already scopes to the user's libraries via library_artist.
		return dto.ArtistToBaseItem(*ar, fields), true
	}
	if mf, err := api.ds.MediaFile().Get(ctx, id); err == nil {
		if !u.HasLibraryAccess(mf.LibraryID) {
			return dto.BaseItemDto{}, false
		}
		return dto.SongToBaseItem(*mf, fields), true
	}
	// api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through.
	if pl, err := api.playlists.Get(ctx, id); err == nil {
		return dto.PlaylistToBaseItem(*pl, fields), true
	}
	if g, err := api.ds.Genre().Get(ctx, id); err == nil {
		return dto.GenreToBaseItem(*g), true
	}
	return dto.BaseItemDto{}, false
}

// songsByIDs fetches the media files among ids with chunked IN queries instead of a Get per id.
func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]model.MediaFile {
	songs := make(map[string]model.MediaFile, len(ids))
	// Chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, like playqueue's loadTracks.
	for chunk := range slice.CollectChunks(slices.Values(ids), 500) {
		mfs, err := api.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}})
		if err != nil {
			log.Error(ctx, "Jellyfin API: error fetching songs by id", err)
			continue
		}
		for _, mf := range mfs {
			songs[mf.ID] = mf
		}
	}
	return songs
}

// itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids.
func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult {
	u, _ := request.UserFrom(ctx)
	songs := api.songsByIDs(ctx, ids)
	var items []dto.BaseItemDto
	for _, id := range ids {
		var item dto.BaseItemDto
		if mf, ok := songs[id]; ok {
			if !u.HasLibraryAccess(mf.LibraryID) {
				continue
			}
			item = dto.SongToBaseItem(mf, fields)
		} else if item, ok = api.resolveItemByID(ctx, id, fields); !ok {
			continue
		}
		items = append(items, item)
	}
	return result(items, len(items), 0)
}

func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
	id, ok := itemIDParam(w, r, "itemId")
	if !ok {
		return
	}
	fields := dto.ParseFields(req.Params(r).Strings("fields")...)
	if item, ok := api.resolveItemByID(r.Context(), id, fields); ok {
		api.ok(w, r, item)
		return
	}
	http.Error(w, "Not Found", http.StatusNotFound)
}

// deleteItem handles DELETE /Items/{id}. Only playlists are deletable here (albums/songs come from
// scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership.
func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id, ok := itemIDParam(w, r, "itemId")
	if !ok {
		return
	}
	if err := api.playlists.Delete(ctx, id); err != nil {
		api.playlistError(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// getLatest returns a bare array, not a QueryResult envelope — real Jellyfin's shape for
// /Items/Latest, and why it writes directly instead of going through api.ok.
func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	p := req.Params(r)
	fields := dto.ParseFields(p.Strings("fields")...)
	opts := filter.AlbumsByNewest()
	opts.Max = p.IntOr("limit", 20)
	parentID, ok := decodeFilterParam(p.StringOr("parentid", ""))
	if !ok {
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}
	// A ParentId naming neither a library nor an artist (a stale id, an album) narrows to nothing
	// rather than widening back to every library.
	scopeIDs, isLibrary := resolveLibraryScope(ctx, parentID)
	if parentID != "" && !isLibrary {
		opts.Filters = squirrel.And{opts.Filters, filter.AlbumsByArtistID(parentID).Filters}
	}
	opts = filter.ApplyLibraryFilter(opts, scopeIDs)
	repo := api.ds.Album()
	open := streamCursor(func() (func(func(model.Album, error) bool), error) {
		return repo.GetCursor(ctx, opts)
	}, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) })
	api.writeItemsArray(w, r, streamed(open, 0, 0))
}

func result(items []dto.BaseItemDto, total, start int) dto.QueryResult {
	if items == nil {
		items = []dto.BaseItemDto{}
	}
	return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start}
}

// applySort keeps every recognized SortBy key, so secondary keys break ties as Jellyfin intends.
// Unrecognized keys are skipped, not passed through raw where they could make an invalid ORDER BY.
func applySort(opts *model.QueryOptions, itemType, sortBy, order string) {
	var cols []string
	for key := range strings.SplitSeq(sortBy, ",") {
		col, ok := sortColumn(itemType, strings.TrimSpace(key))
		// The repo matches random by exact string equality, so it can only ever sort alone.
		if !ok || slices.Contains(cols, col) || (col == "random" && len(cols) > 0) {
			continue
		}
		cols = append(cols, col)
		if col == "random" {
			break
		}
	}
	switch {
	case len(cols) > 0:
		opts.Sort = strings.Join(cols, ", ")
	case sortBy != "":
		log.Debug("Jellyfin API: no usable SortBy key, falling back to the default order",
			"itemType", itemType, "sortBy", sortBy)
	}
	// Jellyfin allows a per-key SortOrder list, which one Order can't express; honor the first value
	// for every key, as Jellyfin does for keys past the end of the list.
	first, _, _ := strings.Cut(order, ",")
	if strings.EqualFold(first, "Descending") {
		opts.Order = "desc"
	}
}

// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type (repos map logical fields
// to different real columns, e.g. media_file has "title" not "name").
var sortColumnsByType = map[string]map[string]string{
	"Audio": {
		"sortname": "title", "name": "title",
		"album": "album",
		// Finamp's album view sorts by ParentIndexNumber,IndexNumber (disc, track); Navidrome's
		// "album" sort key is disc+track order within an album, so map both to it.
		"indexnumber":       "album",
		"parentindexnumber": "album",
		"artist":            "artist",
		"albumartist":       "album_artist",
		"datecreated":       "recently_added",
		"playcount":         "play_count",
		"dateplayed":        "play_date",
		"communityrating":   "rating",
		"random":            "random",
		"runtime":           "duration",
		"runtimeticks":      "duration",
		// Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear.
		"premieredate":   "year",
		"productionyear": "year",
	},
	"MusicArtist": {
		"sortname": "name", "name": "name",
		"albumcount":      "album_count",
		"songcount":       "song_count",
		"datecreated":     "created_at",
		"playcount":       "play_count",
		"dateplayed":      "play_date",
		"communityrating": "rating",
		"random":          "random",
	},
	"MusicAlbum": {
		"sortname": "name", "name": "name", "album": "name",
		"artist":          "artist",
		"albumartist":     "album_artist",
		"datecreated":     "recently_added",
		"random":          "random",
		"playcount":       "play_count",
		"dateplayed":      "play_date",
		"communityrating": "rating",
		"runtime":         "duration",
		"runtimeticks":    "duration",
		"premieredate":    "max_year", "productionyear": "max_year",
	},
	"MusicGenre": {
		"sortname": "name", "name": "name",
		"random": "random",
	},
	"Playlist": {
		"sortname": "name", "name": "name",
		"datecreated": "created_at",
		"random":      "random",
	},
}

// sortColumn maps a single (non comma-list) Jellyfin SortBy key to the repo sort key for
// itemType, reporting false when it isn't recognized for that type.
func sortColumn(itemType, sortBy string) (string, bool) {
	col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)]
	return col, ok
}