navidrome/server/jellyfin/items.go
Deluan Quintão c66ef04dd3
refactor(jellyfin): emit real 128-bit GUIDs as item ids (#5942)
* test(jellyfin): use canonical ids in dto fixtures

Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.

* test(jellyfin): use canonical ids in handler fixtures

Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape. Playlist entry positions
stay decimal, matching the integer playlist_tracks.id column.

* test(jellyfin): use canonical ids in e2e fixtures

Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.

* test(jellyfin): use canonical ids in audiomuse fixtures

audiomuse_test.go passes ids as bare function args (mf(id, ...), call(query, user)) rather than
via ID: struct-literal fields, so the original grep-built file list missed it. Same conversion as
the rest of the fixtures: fake labels through id.NewHash via testID.

* test(jellyfin): convert remaining nonexistent-id sentinels in e2e tests

Reviewer swept for enc("literal") sites the brief's dto.EncodeID grep missed. These "does not
exist" fixtures must stay well-formed GUIDs under the strict codec, or the test degrades from
"resolves to nothing" to "empty path segment".

* refactor(jellyfin): emit real 128-bit GUIDs as item ids

Navidrome ids are now a canonical 22-char base62 encoding of exactly 128 bits, so they map
losslessly onto Jellyfin GUIDs. Previously the API hex-encoded the id string itself, producing
44 hex chars where Jellyfin uses 32.

Integer library ids, the synthetic playlists folder, and playlist entry positions (a
playlist_tracks.id, an integer column) aren't 128-bit values, so they get a reserved GUID space
tagged by kind. DecodeID is now strict: malformed input returns an empty string instead of
passing through unchanged.

BREAKING: Jellyfin clients see entirely new item ids.

* fix(jellyfin): 404 malformed playlist ids instead of silently creating

updatePlaylist decoded a malformed playlistId to "", the same sentinel core/playlists.Create
uses to mean "make a new playlist" — the overload createPlaylist deliberately relies on. A
malformed id now 404s before reaching Create.

Also tightens id-codec test fixtures: several tests set chi params to a raw canonical id, which
now decodes to "" and only passed because the fakes ignore the id argument; and a batch of
not-found sentinels now use well-formed-but-nonexistent GUIDs so they exercise the intended path
instead of the malformed-id path. READMEs "lossless" claim softened to note the reserved space.

* refactor(jellyfin): drop the id truncation workaround

Finamp's saved-queue packing keeps the first 16 bytes of each item id. That was lossy only
because our ids were 44 hex chars; now they are 32, so the packing round-trips exactly and the
server-side prefix recovery is dead code.

Removes an indexed range scan per restored queue and the ambiguous-prefix path that could
resolve to the wrong item.

* fix(jellyfin): emit ServerId and PlaySessionId in Jellyfin's id format

Jellyfin serializes GUIDs without dashes; ServerId was emitting the dashed UUID form. A
ServerId persisted before this change is normalized on read rather than rewritten.

PlaySessionId was emitting a raw internal id instead of the encoded form.

BREAKING: the ServerId change makes clients treat the server as new, so users re-login once.

* fix(jellyfin): 404 on undecodable id filters instead of widening the query

DecodeID collapsed an absent param and an undecodable one into the empty string, and downstream
an empty id means no filter. A client sending a stale pre-upgrade id therefore had its filter
silently dropped: ParentId, ArtistIds and AlbumArtistIds each returned the whole library instead
of a scoped result. Every existing client hits this on first launch after the id format changes.

Scalar id params now distinguish the two cases and report not-found. List-valued params already
failed closed. EncodeID logs a diagnostic when a non-empty id is not canonical, which should not
happen post-migration and would otherwise ship an unaddressable item silently.

* test(jellyfin): drop comments that restate the spec names

* refactor(jellyfin): decode reserved GUIDs from bytes, not hex strings

DecodeID already had the 16 decoded bytes, then re-derived the kind tag and payload by slicing the
hex string and parsing it a second time. Reading them off the byte slice matches how the format is
specified and removes the duplicate parse.

Bounding the payload inside encodeReserved gives both encoders the 32-char guarantee, which only
EncodePlaylistEntryID enforced before.

Playlist entries now decode through DecodePlaylistEntryID, which rejects other kinds. The tag was
being encoded and then discarded, so a song id passed as an EntryId reached RemoveTracks as a
playlist_tracks position.

Drops the per-field log.Warn from EncodeID: it sat in a leaf codec without a ctx and would emit
once per item per request on exactly the bad-data population it was meant to surface.

* refactor(jellyfin): make DecodeID report whether the id was decodable

DecodeID returned the empty string for both an absent param and an undecodable one, and
downstream an empty id means no filter. That conflation is what let a stale id widen /Items to
the whole library; it had been patched at two call sites, leaving three different policies for an
undecodable id in one package and ~16 handlers correct only because a repo Get("") happens to fail.

Returning (string, bool) makes the ambiguity unrepresentable, and the compiler forces each of the
~22 sites to decide. URL params share one itemIDParam helper that 404s; id lists go through
DecodeIDs, which is all-or-nothing because dropping bad entries would empty a list and make its
len() > 0 filter gate vanish — the original bug by another route.

A well-formed but unknown id is still 200 with zero results; only malformed ids 404. Malformed
ids now also 404 on the image and similar/instant-mix routes, which previously answered with a
placeholder or an empty list.
2026-08-12 19:02:34 -04:00

985 lines
35 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", ""))
}
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
favOnly bool
// 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
}
// 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 := itemsQuery{
fields: dto.ParseFields(p.Strings("fields")...),
ids: ids,
rawTypes: p.StringOr("includeitemtypes", ""),
search: searchTerm(p),
sortBy: p.StringOr("sortby", ""),
sortOrder: p.StringOr("sortorder", ""),
offset: p.IntOr("startindex", 0),
limit: p.IntOr("limit", 0),
// Clients express "favorites only" two ways: Filters=IsFavorite and the standalone
// isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter).
favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false),
parentId: parentId,
genreIds: genreIds,
albumIds: albumIds,
years: parseYears(r),
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))
}
if q.favOnly {
filters = append(filters, filter.ByStarred().Filters)
}
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))
}
if q.favOnly {
filters = append(filters, filter.ByStarred().Filters)
}
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
}
if q.favOnly {
opts.Filters = filter.ArtistsByStarred().Filters
} else {
opts.Filters = notMissing
}
if len(q.genreIds) > 0 {
opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(q.genreIds)}
}
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 q.favOnly {
starred := squirrel.Eq{"starred": true}
if opts.Filters == nil {
opts.Filters = starred
} else {
opts.Filters = squirrel.And{opts.Filters, starred}
}
}
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 translates Jellyfin's SortBy/SortOrder into a valid model.QueryOptions sort key for the
// item type. Clients send SortBy as a comma-separated fallback list (e.g. "DateCreated,SortName");
// this uses the first recognized key. An unrecognized SortBy is left untouched (the repo's default),
// not passed through raw where it could produce an invalid ORDER BY.
func applySort(opts *model.QueryOptions, itemType, sortBy, order string) {
for key := range strings.SplitSeq(sortBy, ",") {
if col, ok := sortColumn(itemType, strings.TrimSpace(key)); ok {
opts.Sort = col
break
}
}
if strings.EqualFold(order, "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",
// 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",
"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
}