navidrome/server/jellyfin/items.go
Deluan Quintão b293b96256
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00

1106 lines
40 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)
}
// 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
}