Deluan Quintão 7a11ca69bb
fix(jellyfin): honor the Filters, SortBy and MaxHeight params clients actually send (#5981)
* fix(jellyfin): honor Filters=IsFavorite on /Artists and /Artists/AlbumArtists

listArtistsByRole hand-built its itemsQuery and never set favOnly, so the
favorites filter was silently dropped on both artist routes while /Items
honored it. Finamp's home screen asks for favorite artists once per load and
was served the entire artist list instead: 10,298 artists, 6.15 MB, 2.7s on
a real library, and the wrong data on screen.

Extract the favOnly parsing that parseItemsQuery already did into
parseFavOnly and use it in both places. listArtists now adds the starred
predicate to notMissing rather than replacing it, matching listAlbums and
listSongs, so a favorite artist whose files are gone stays excluded.

* fix(jellyfin): map SortBy=Runtime to duration for albums and songs

sortColumnsByType had no runtime/runtimeticks key for any type, so Finamp's
"Duration" sort silently misbehaved in two different ways.

Albums: Finamp sends a bare SortBy=Runtime. Nothing matched, opts.Sort stayed
empty, and applyOptions skips OrderBy entirely when Sort is empty — so the
query ran with no ORDER BY at all and Ascending and Descending returned
identical lists.

Songs: Finamp sends SortBy=Runtime,AlbumArtist,Album,SortName. applySort takes
the first *recognized* key, so Runtime was skipped and the list came back
sorted by album artist while looking correct.

Both repos already accept a duration sort (mediafile_repository maps it
explicitly; album_repository falls through to the column name), so no
migration is needed. Sorting 97k songs by duration costs a temp B-tree
(~114ms on a prod-sized copy) — the same cost the Subsonic and UI duration
sorts already pay, and correct where the previous behaviour was merely fast.

* fix(jellyfin): apply the played/unplayed filters and MaxHeight image bound

Filters was matched with a substring test for IsFavorite, so every other token
Jellyfin defines was silently dropped and the response kept rows it should
have excluded. Finamp sends Filters=IsUnplayed in normal use.

Replace the bool with a parsed itemFilters carrying nullable favorite and
played flags, so isFavorite=false and isPlayed=false are real filters rather
than indistinguishable from an absent param. Standalone params are read first
and the Filters list overrides them, the precedence real Jellyfin has.
IsFavoriteOrLikes now maps to favorites deliberately instead of by substring
accident; Likes, Dislikes, IsFolder, IsNotFolder and IsResumable have no
Navidrome equivalent and are dropped rather than half-applied. The negative
cases match NULL as well, since annotations are LEFT JOINed and an untouched
item has no row.

getItemImage read only maxwidth, so a client sending just MaxHeight got the
full-size original: measured against a real cover, maxHeight=100 returned
82,570 bytes where maxWidth=100 returned 3,316. Use the tighter of the two
bounds.

* refactor(jellyfin): share the plain-param parser between /Items and /Artists

listArtistsByRole hand-listed the itemsQuery fields it happened to need, which
is exactly how the favorites filter went missing: the literal has been amended
in four of the five commits that touched it. Extract listParams for the fields
that come straight from query params so both paths read one parser, and the
next supported param reaches every list path instead of only /Items.

Also from the cleanup pass: collapse imageSize to a single clamped comparison
and read its bounds through req.Params like the rest of the package, which
drops the strconv import; build the artist and playlist filter lists with the
flat append shape the album and song paths already use, instead of re-wrapping
opts.Filters into a nested And per predicate; drop a nil guard in
listPlaylists that no caller can reach, since both paths into queryItemsOfType
build QueryOptions without Filters.

applySort now logs when no SortBy key resolves at all — a miss inside a
fallback list is normal, but none matching means a silently ignored sort, the
failure mode that hid the Runtime bug. Its doc comment records why the
remaining keys cannot simply be joined.

Folds three duplicated test bodies into the tables that already parameterize
them, and covers the artist-parent album branch, which reaches notMissing
through filter.AlbumsByArtistID rather than the default branch.

* docs(jellyfin): correct how applySort describes Jellyfin's SortBy semantics

The comment claimed SortBy is a comma-separated fallback list. It is not:
RequestHelpers.GetOrderBy (10.10) builds one (ItemSortBy, SortOrder) pair per
key, so Jellyfin orders by every key in turn. Navidrome applies only the first
recognized one, which is a real divergence — secondary keys never break ties —
not the intended reading of the parameter.

The assertion that the keys cannot be joined was also wrong. buildSortOrder
does split its input on commas; what it maps is the whole string, so joining
raw Jellyfin key names misses the mappings. Mapping each key first and joining
the results would work, which makes multi-key sorting a real option rather
than a blocked one. Documenting the current behaviour as a known divergence
until then.

* fix(jellyfin): order by every recognized SortBy key, not just the first

Jellyfin orders by each SortBy key in turn, so "DatePlayed,SortName" means
break ties by name. Navidrome applied only the first recognized key and dropped
the rest, which is 28% of the sort traffic on a real server (23 of 82 requests
in 12h carry 2-5 keys). Most were harmless because the primary key dominates,
but PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName came back
unordered within a year.

The keys cannot simply be joined: sortMapping keyed on the whole Sort string,
so a joined value missed every mapping and fell through to raw column names.
Make it resolve a comma list per part, but only when every part is a known key
— the four existing callers that pass raw column lists (core/matcher,
core/lyrics, core/maintenance, subsonic/browsing) all carry a part that is not
a mapping key, several with their own direction, so they keep falling through
exactly as before. Verified each one.

applySort now collects every recognized key, skipping duplicates so
ParentIndexNumber,IndexNumber does not repeat a column. random stays alone: the
repo matches it by exact string equality, so joining it would both break that
path and emit a bare 'random' column into the ORDER BY.

Verified against a prod-sized copy: every multi-key combination seen in real
traffic returns 200, and a secondary key now changes the order within a tied
year for songs. Albums are unchanged there, because their max_year mapping
already ended in ", name".

* fix(persistence): resolve sort mappings exactly once

Making sortMapping resolve a comma list per part broke an invariant it had
been relying on: idempotence. sanitizeSort mapped the sort key up front and
applyOptions then ran buildSortOrder over the result, so sortMapping was
already being handed its own output. That was harmless only while a mapped
value could never look like a key list.

media_file's rated_at maps to "rating, rated_at", and both parts are keys, so
the second pass expanded it to "rating, rating, rated_at". Found by
round-tripping every mapping in all four repositories; it was the only
collision, and the duplicate sort key was benign in SQL, but any future mapping
of that shape would silently change meaning.

sanitizeSort now validates without resolving, leaving buildSortOrder as the
single mapping point. The generated SQL is unchanged — the whole suite passes
apart from the two specs that asserted the old return value, which are updated
and joined by a round-trip guard covering exactly the rated_at shape.

Also use the paren-aware splitFunc that buildSortOrder already uses, so an
expression carrying commas inside its parentheses cannot be split apart.

* refactor(jellyfin,persistence): flatten the sort resolution paths

Cleanup pass over the branch, no behavior change.

sortMapping loses the len(parts)>1 guard, which existed only to pick between
two identical toSnakeCase exits; the single-key case now falls through the same
loop. lookupSortMapping hands back the snake_case form it had to derive so the
fallback stops recomputing it — toSnakeCase is two regexps, and on a miss it was
running twice per call. sanitizeSort now asks lookupSortMapping instead of
probing the map itself, so "is this a known sort key" has one answer; the two
had already drifted, since sanitizeSort tried one casing where the resolver
tries three.

applySort folds the nested random branch into the skip condition and the two
trailing length tests into one switch. setSortMappings documents the invariant
the comma-list rule depends on, where someone adding a mapping will read it.

The README line describing SortBy still said only the first key applied, which
the commit before last made false.

Tests: the twelve near-identical sorting specs become one DescribeTable of
(itemType, SortBy, want) triples, 124 lines to 36, and the applyOptions
round-trip assertion collapses to the buildSortOrder call its sibling uses.

* fix(jellyfin): keep annotation filters out of search, resolve sorts per part

Two findings from the Codex review on #5981.

The played/unplayed filters turned working requests into 500s when combined
with SearchTerm. Search runs a two-phase FTS query whose first phase selects
rowids with no annotation join, so a starred or play_count predicate there is
"no such column", not a filter. Measured against master: MusicAlbum with
SearchTerm and Filters=IsUnplayed went 200 -> 500, likewise IsPlayed and the
Audio equivalents. listAlbums and listSongs now skip those predicates on the
search path, matching what listArtists already did. That also clears the same
500 master already had for Filters=IsFavorite with SearchTerm.

sortMapping resolved a comma list only while every part was a known key, so a
list mixing a plain column with a mapped key kept neither: MusicAlbum
SortBy=Runtime,SortName arrives as "duration, name", and duration is a plain
album column, so name stayed raw instead of expanding to order_album_name.
Albums whose name differs from its sort form — 1,366 of 6,987 on a real
library — then ordered by the wrong secondary key, and PreferSortTags was
ignored. Each part is now resolved on its own, which is what setSortMappings
already documents for a single field. Verified every in-tree caller that passes
a raw column list still produces its original ORDER BY.

Codex also asked for the artist search path to apply the same filters. It
would 500 for the reason above, and wrapping the library scope in a compound
filter makes requestedLibraryIDs stop recognizing it, silently widening the
search past the requested ParentId.

* fix(jellyfin): honor the first SortOrder value for a multi-key sort

applySort compared the whole SortOrder string with "Descending", so a per-key
list like SortOrder=Descending,Ascending failed the match and every key,
including the primary, sorted ascending — the exact opposite of the request.
Take the first comma-separated value, which Jellyfin also uses for any key past
the end of the SortOrder list. True per-key directions can't be expressed
through the single opts.Sort string and are left out; no observed client sends
a SortOrder list.
2026-08-19 08:36:44 -04:00

1049 lines
38 KiB
Go

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)
}
// streamResult stamps every item's ServerId (constant per request, so it's set here rather than in
// each mapper). 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 := api.serverID(r.Context())
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
}
it.ServerId = sid
if !yield(it, 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 = 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(ctx).Get(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(q.rawTypes, "ManualPlaylistsFolder"):
return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil
}
if repo, ok := api.playlistTracksRepo(ctx, q); ok {
return api.playlistTrackPage(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
}
// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to
// {"MusicAlbum"} when none are recognized (so ParentId=<artistId> browses that artist's albums).
func parseTypes(types string) []string {
var recognized []string
for t := range strings.SplitSeq(types, ",") {
t = strings.TrimSpace(t)
switch t {
case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist":
recognized = append(recognized, t)
}
}
// Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query.
recognized = slice.Unique(recognized)
if len(recognized) == 0 {
return []string{"MusicAlbum"}
}
return 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(ctx)
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(q.search, o)
})
if err != nil {
return itemsResult{}, err
}
return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil
}
total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
open := streamCursor(func() (func(func(model.Album, error) bool), error) {
return repo.GetCursor(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(ctx)
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(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(model.QueryOptions{Filters: opts.Filters})
open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) {
return repo.GetCursorWithArtwork(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(ctx)
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(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(model.QueryOptions{Filters: opts.Filters})
open := streamCursor(func() (func(func(model.Artist, error) bool), error) {
return repo.GetCursor(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(ctx).GetAll(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(ctx)
total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
if err != nil {
return itemsResult{}, err
}
open := streamCursor(func() (func(func(model.Playlist, error) bool), error) {
return repo.GetCursor(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) {
for _, lib := range u.Libraries {
if lib.ID == libID {
return libraryView(lib), true
}
}
// Admin bypass: Libraries is empty but all access is granted, so fetch the real library.
if lib, err := api.ds.Library(ctx).Get(libID); err == nil {
return libraryView(*lib), true
}
}
if al, err := api.ds.Album(ctx).Get(id); err == nil {
if !u.HasLibraryAccess(al.LibraryID) {
return dto.BaseItemDto{}, false
}
return dto.AlbumToBaseItem(*al, fields), true
}
if ar, err := api.ds.Artist(ctx).Get(id); err == nil {
// TODO: an artist spans multiple libraries (library_artist), so there's no single
// LibraryID to gate here; artist access relies on list-time scoping and persistence.
return dto.ArtistToBaseItem(*ar, fields), true
}
if mf, err := api.ds.MediaFile(ctx).Get(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(ctx).Get(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(ctx).GetAll(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)
opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx))
repo := api.ds.Album(ctx)
open := streamCursor(func() (func(func(model.Album, error) bool), error) {
return repo.GetCursor(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
}