package jellyfin import ( "context" "io" "iter" "net/http" "slices" "strconv" "strings" "github.com/Masterminds/squirrel" "github.com/go-chi/chi/v5" "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" ) // 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 { 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). func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { p := req.Params(r) q := itemsQuery{ fields: dto.ParseFields(p.Strings("fields")...), ids: decodedQueryIDs(r, "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: dto.DecodeID(p.StringOr("parentid", "")), // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. genreIds: decodedQueryIDs(r, "genreids"), // Feishin fetches an album's tracks with AlbumIds instead of ParentId. albumIds: decodedQueryIDs(r, "albumids"), years: parseYears(r), studioIds: decodedQueryIDs(r, "studioids"), } // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) contributingScope := p.StringOr("contributingartistids", "") q.artistId = firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) 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 == 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 } // 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 := api.parseItemsQuery(ctx, r) 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=&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 == 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) { // Each per-type query needs at most offset+limit rows (the worst case where one type fills the // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. window := 0 if q.limit > 0 { window = q.offset + q.limit } // A search can't stream, so the window is what each type materializes and StartIndex would drive // it without bound. Only below the window are the merged rows the true order, hence the clip // below too. Non-search stays unbounded in StartIndex: a known gap, fixable with per-type counts. if q.search != "" { window = min(window, maxSearchLimit) } var results []itemsResult total := 0 for _, itemType := range q.types { var opts model.QueryOptions opts.Max = window applySort(&opts, itemType, q.sortBy, q.sortOrder) res, err := api.queryItemsOfType(ctx, itemType, opts, q) if err != nil { return itemsResult{}, err } results = append(results, res) total += res.total } if q.limit == 0 { // No cap above, so merging in memory would pull every row of every type. The merged page is // just their rows in order minus the first offset — what chaining the cursors yields. return chained(results, total, q.offset), nil } var items []dto.BaseItemDto for _, res := range results { typeItems, err := res.collect() if err != nil { return itemsResult{}, err } items = append(items, typeItems...) } 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, not this page, or a client paging on it // would stop after the first page. items = items[:min(window, len(items))] total = min(total, maxSearchLimit) } return materialized(result(paginate(items, q.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. func firstDecodedID(s string) string { if s == "" { return "" } first, _, _ := strings.Cut(s, ",") return dto.DecodeID(strings.TrimSpace(first)) } // decodedQueryIDs reads an id-list param in both client spellings (see queryIDs), decoding each id. func decodedQueryIDs(r *http.Request, key string) []string { return slice.Map(queryIDs(r, key), dto.DecodeID) } // 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= 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) } } 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 } // 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.ByGenreID(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.ByGenreID(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.GetCursor(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) // 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, dto.ArtistToBaseItem), 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) }, dto.ArtistToBaseItem) 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) }, dto.PlaylistToBaseItem) return streamed(open, int(total), opts.Offset), nil } // resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, // artist, song and playlist 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 == 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), 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), 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. // A Finamp-truncated id is resolved by prefix but echoed as requested — Finamp matches restored // queue items against its stored (truncated) ids. func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult { u, _ := request.UserFrom(ctx) fullIDs := api.resolveItemIDs(ctx, ids) songs := api.songsByIDs(ctx, fullIDs) var items []dto.BaseItemDto for i, id := range fullIDs { 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 } if id != ids[i] { item.Id = dto.EncodeID(ids[i]) } items = append(items, item) } return result(items, len(items), 0) } func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) 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 := dto.DecodeID(chi.URLParam(r, "itemId")) 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. Each repository maps // logical fields to different real columns (e.g. media_file has "title" not "name"; artist has no // "random"). 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", }, "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", }, "Playlist": { "sortname": "name", "name": "name", "datecreated": "created_at", }, } // 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 }