mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* fix(jellyfin): honor Filters=IsFavorite on /Artists and /Artists/AlbumArtists listArtistsByRole hand-built its itemsQuery and never set favOnly, so the favorites filter was silently dropped on both artist routes while /Items honored it. Finamp's home screen asks for favorite artists once per load and was served the entire artist list instead: 10,298 artists, 6.15 MB, 2.7s on a real library, and the wrong data on screen. Extract the favOnly parsing that parseItemsQuery already did into parseFavOnly and use it in both places. listArtists now adds the starred predicate to notMissing rather than replacing it, matching listAlbums and listSongs, so a favorite artist whose files are gone stays excluded. * fix(jellyfin): map SortBy=Runtime to duration for albums and songs sortColumnsByType had no runtime/runtimeticks key for any type, so Finamp's "Duration" sort silently misbehaved in two different ways. Albums: Finamp sends a bare SortBy=Runtime. Nothing matched, opts.Sort stayed empty, and applyOptions skips OrderBy entirely when Sort is empty — so the query ran with no ORDER BY at all and Ascending and Descending returned identical lists. Songs: Finamp sends SortBy=Runtime,AlbumArtist,Album,SortName. applySort takes the first *recognized* key, so Runtime was skipped and the list came back sorted by album artist while looking correct. Both repos already accept a duration sort (mediafile_repository maps it explicitly; album_repository falls through to the column name), so no migration is needed. Sorting 97k songs by duration costs a temp B-tree (~114ms on a prod-sized copy) — the same cost the Subsonic and UI duration sorts already pay, and correct where the previous behaviour was merely fast. * fix(jellyfin): apply the played/unplayed filters and MaxHeight image bound Filters was matched with a substring test for IsFavorite, so every other token Jellyfin defines was silently dropped and the response kept rows it should have excluded. Finamp sends Filters=IsUnplayed in normal use. Replace the bool with a parsed itemFilters carrying nullable favorite and played flags, so isFavorite=false and isPlayed=false are real filters rather than indistinguishable from an absent param. Standalone params are read first and the Filters list overrides them, the precedence real Jellyfin has. IsFavoriteOrLikes now maps to favorites deliberately instead of by substring accident; Likes, Dislikes, IsFolder, IsNotFolder and IsResumable have no Navidrome equivalent and are dropped rather than half-applied. The negative cases match NULL as well, since annotations are LEFT JOINed and an untouched item has no row. getItemImage read only maxwidth, so a client sending just MaxHeight got the full-size original: measured against a real cover, maxHeight=100 returned 82,570 bytes where maxWidth=100 returned 3,316. Use the tighter of the two bounds. * refactor(jellyfin): share the plain-param parser between /Items and /Artists listArtistsByRole hand-listed the itemsQuery fields it happened to need, which is exactly how the favorites filter went missing: the literal has been amended in four of the five commits that touched it. Extract listParams for the fields that come straight from query params so both paths read one parser, and the next supported param reaches every list path instead of only /Items. Also from the cleanup pass: collapse imageSize to a single clamped comparison and read its bounds through req.Params like the rest of the package, which drops the strconv import; build the artist and playlist filter lists with the flat append shape the album and song paths already use, instead of re-wrapping opts.Filters into a nested And per predicate; drop a nil guard in listPlaylists that no caller can reach, since both paths into queryItemsOfType build QueryOptions without Filters. applySort now logs when no SortBy key resolves at all — a miss inside a fallback list is normal, but none matching means a silently ignored sort, the failure mode that hid the Runtime bug. Its doc comment records why the remaining keys cannot simply be joined. Folds three duplicated test bodies into the tables that already parameterize them, and covers the artist-parent album branch, which reaches notMissing through filter.AlbumsByArtistID rather than the default branch. * docs(jellyfin): correct how applySort describes Jellyfin's SortBy semantics The comment claimed SortBy is a comma-separated fallback list. It is not: RequestHelpers.GetOrderBy (10.10) builds one (ItemSortBy, SortOrder) pair per key, so Jellyfin orders by every key in turn. Navidrome applies only the first recognized one, which is a real divergence — secondary keys never break ties — not the intended reading of the parameter. The assertion that the keys cannot be joined was also wrong. buildSortOrder does split its input on commas; what it maps is the whole string, so joining raw Jellyfin key names misses the mappings. Mapping each key first and joining the results would work, which makes multi-key sorting a real option rather than a blocked one. Documenting the current behaviour as a known divergence until then. * fix(jellyfin): order by every recognized SortBy key, not just the first Jellyfin orders by each SortBy key in turn, so "DatePlayed,SortName" means break ties by name. Navidrome applied only the first recognized key and dropped the rest, which is 28% of the sort traffic on a real server (23 of 82 requests in 12h carry 2-5 keys). Most were harmless because the primary key dominates, but PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName came back unordered within a year. The keys cannot simply be joined: sortMapping keyed on the whole Sort string, so a joined value missed every mapping and fell through to raw column names. Make it resolve a comma list per part, but only when every part is a known key — the four existing callers that pass raw column lists (core/matcher, core/lyrics, core/maintenance, subsonic/browsing) all carry a part that is not a mapping key, several with their own direction, so they keep falling through exactly as before. Verified each one. applySort now collects every recognized key, skipping duplicates so ParentIndexNumber,IndexNumber does not repeat a column. random stays alone: the repo matches it by exact string equality, so joining it would both break that path and emit a bare 'random' column into the ORDER BY. Verified against a prod-sized copy: every multi-key combination seen in real traffic returns 200, and a secondary key now changes the order within a tied year for songs. Albums are unchanged there, because their max_year mapping already ended in ", name". * fix(persistence): resolve sort mappings exactly once Making sortMapping resolve a comma list per part broke an invariant it had been relying on: idempotence. sanitizeSort mapped the sort key up front and applyOptions then ran buildSortOrder over the result, so sortMapping was already being handed its own output. That was harmless only while a mapped value could never look like a key list. media_file's rated_at maps to "rating, rated_at", and both parts are keys, so the second pass expanded it to "rating, rating, rated_at". Found by round-tripping every mapping in all four repositories; it was the only collision, and the duplicate sort key was benign in SQL, but any future mapping of that shape would silently change meaning. sanitizeSort now validates without resolving, leaving buildSortOrder as the single mapping point. The generated SQL is unchanged — the whole suite passes apart from the two specs that asserted the old return value, which are updated and joined by a round-trip guard covering exactly the rated_at shape. Also use the paren-aware splitFunc that buildSortOrder already uses, so an expression carrying commas inside its parentheses cannot be split apart. * refactor(jellyfin,persistence): flatten the sort resolution paths Cleanup pass over the branch, no behavior change. sortMapping loses the len(parts)>1 guard, which existed only to pick between two identical toSnakeCase exits; the single-key case now falls through the same loop. lookupSortMapping hands back the snake_case form it had to derive so the fallback stops recomputing it — toSnakeCase is two regexps, and on a miss it was running twice per call. sanitizeSort now asks lookupSortMapping instead of probing the map itself, so "is this a known sort key" has one answer; the two had already drifted, since sanitizeSort tried one casing where the resolver tries three. applySort folds the nested random branch into the skip condition and the two trailing length tests into one switch. setSortMappings documents the invariant the comma-list rule depends on, where someone adding a mapping will read it. The README line describing SortBy still said only the first key applied, which the commit before last made false. Tests: the twelve near-identical sorting specs become one DescribeTable of (itemType, SortBy, want) triples, 124 lines to 36, and the applyOptions round-trip assertion collapses to the buildSortOrder call its sibling uses. * fix(jellyfin): keep annotation filters out of search, resolve sorts per part Two findings from the Codex review on #5981. The played/unplayed filters turned working requests into 500s when combined with SearchTerm. Search runs a two-phase FTS query whose first phase selects rowids with no annotation join, so a starred or play_count predicate there is "no such column", not a filter. Measured against master: MusicAlbum with SearchTerm and Filters=IsUnplayed went 200 -> 500, likewise IsPlayed and the Audio equivalents. listAlbums and listSongs now skip those predicates on the search path, matching what listArtists already did. That also clears the same 500 master already had for Filters=IsFavorite with SearchTerm. sortMapping resolved a comma list only while every part was a known key, so a list mixing a plain column with a mapped key kept neither: MusicAlbum SortBy=Runtime,SortName arrives as "duration, name", and duration is a plain album column, so name stayed raw instead of expanding to order_album_name. Albums whose name differs from its sort form — 1,366 of 6,987 on a real library — then ordered by the wrong secondary key, and PreferSortTags was ignored. Each part is now resolved on its own, which is what setSortMappings already documents for a single field. Verified every in-tree caller that passes a raw column list still produces its original ORDER BY. Codex also asked for the artist search path to apply the same filters. It would 500 for the reason above, and wrapping the library scope in a compound filter makes requestedLibraryIDs stop recognizing it, silently widening the search past the requested ParentId. * fix(jellyfin): honor the first SortOrder value for a multi-key sort applySort compared the whole SortOrder string with "Descending", so a per-key list like SortOrder=Descending,Ascending failed the match and every key, including the primary, sorted ascending — the exact opposite of the request. Take the first comma-separated value, which Jellyfin also uses for any key past the end of the SortOrder list. True per-key directions can't be expressed through the single opts.Sort string and are left out; no observed client sends a SortOrder list.
1049 lines
38 KiB
Go
1049 lines
38 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"iter"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/filter"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/utils/req"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// notMissing excludes items whose backing files are all gone ("missing" is a real column on
|
|
// album, artist and media_file).
|
|
var notMissing = squirrel.Eq{"missing": false}
|
|
|
|
// searchTerm trims, so a whitespace-only term is not a search: doSearch would read it as "match
|
|
// everything" and materialize the library, where the unfiltered path streams.
|
|
func searchTerm(p *req.Values) string {
|
|
return strings.TrimSpace(p.StringOr("searchterm", ""))
|
|
}
|
|
|
|
// itemFilters is the parsed Filters=... list together with the standalone isFavorite/isPlayed params
|
|
// clients may send instead. A nil field means the client asked for no filtering on that dimension.
|
|
type itemFilters struct {
|
|
favorite *bool
|
|
played *bool
|
|
}
|
|
|
|
// parseItemFilters reads the standalone params first and lets the Filters list win, matching real
|
|
// Jellyfin. Tokens with no Navidrome equivalent (Likes, IsFolder, IsResumable) are dropped.
|
|
func parseItemFilters(p *req.Values) itemFilters {
|
|
f := itemFilters{favorite: p.BoolPtr("isfavorite"), played: p.BoolPtr("isplayed")}
|
|
for token := range strings.SplitSeq(p.StringOr("filters", ""), ",") {
|
|
switch strings.TrimSpace(token) {
|
|
case "IsFavorite", "IsFavoriteOrLikes":
|
|
f.favorite = new(true)
|
|
case "IsPlayed":
|
|
f.played = new(true)
|
|
case "IsUnplayed":
|
|
f.played = new(false)
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
// predicates renders the filters as annotation-column conditions. The negative cases have to match
|
|
// NULL as well: annotations are LEFT JOINed, so an item nobody has touched has no row at all.
|
|
func (f itemFilters) predicates() []squirrel.Sqlizer {
|
|
var out []squirrel.Sqlizer
|
|
if f.favorite != nil {
|
|
if *f.favorite {
|
|
out = append(out, squirrel.Eq{"starred": true})
|
|
} else {
|
|
out = append(out, squirrel.Or{squirrel.Eq{"starred": nil}, squirrel.Eq{"starred": false}})
|
|
}
|
|
}
|
|
if f.played != nil {
|
|
if *f.played {
|
|
out = append(out, squirrel.Gt{"play_count": 0})
|
|
} else {
|
|
out = append(out, squirrel.Or{squirrel.Eq{"play_count": nil}, squirrel.Eq{"play_count": 0}})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (api *Router) getItems(w http.ResponseWriter, r *http.Request) {
|
|
res, err := api.queryItems(r.Context(), r)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
api.ok(w, r, res)
|
|
}
|
|
|
|
// itemsResult is the outcome of a collection query: a materialized page, or a cursor opener so a
|
|
// full-library response never builds every DTO at once. Exactly one of items/openCursor is set.
|
|
//
|
|
// openCursor is deferred rather than opened here: it must run after the ServerId lookup, which
|
|
// writes to the DB on first use and would deadlock against an open reader, but before the first
|
|
// response byte, so a failed open is still a clean error rather than a truncated 200.
|
|
type itemsResult struct {
|
|
items []dto.BaseItemDto
|
|
openCursor func() (iter.Seq2[dto.BaseItemDto, error], error)
|
|
total int
|
|
start int
|
|
}
|
|
|
|
func materialized(q dto.QueryResult) itemsResult {
|
|
return itemsResult{items: q.Items, total: q.TotalRecordCount, start: q.StartIndex}
|
|
}
|
|
|
|
func streamed(open func() (iter.Seq2[dto.BaseItemDto, error], error), total, start int) itemsResult {
|
|
return itemsResult{openCursor: open, total: total, start: start}
|
|
}
|
|
|
|
// chained streams several results back to back, skipping the first skip items — the unbounded
|
|
// multi-type merge, where paginate(items, offset, 0) is just the concatenation minus its head.
|
|
func chained(results []itemsResult, total, skip int) itemsResult {
|
|
open := func() (iter.Seq2[dto.BaseItemDto, error], error) {
|
|
if len(results) == 0 {
|
|
return sliceItems(nil), nil
|
|
}
|
|
// Only the first opens eagerly (so the usual failure is still a clean error); the rest open as
|
|
// the stream reaches them, so only one cursor pins a DB connection at a time.
|
|
first, err := results[0].seq()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return func(yield func(dto.BaseItemDto, error) bool) {
|
|
n := 0
|
|
emit := func(seq iter.Seq2[dto.BaseItemDto, error]) bool {
|
|
for it, err := range seq {
|
|
if err != nil {
|
|
yield(dto.BaseItemDto{}, err)
|
|
return false
|
|
}
|
|
if n < skip {
|
|
n++
|
|
continue
|
|
}
|
|
if !yield(it, nil) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
if !emit(first) {
|
|
return
|
|
}
|
|
for _, res := range results[1:] {
|
|
seq, err := res.seq()
|
|
if err != nil {
|
|
yield(dto.BaseItemDto{}, err)
|
|
return
|
|
}
|
|
if !emit(seq) {
|
|
return
|
|
}
|
|
}
|
|
}, nil
|
|
}
|
|
return streamed(open, total, skip)
|
|
}
|
|
|
|
// streamCursor builds a deferred opener that maps each row as it's yielded. It takes the cursor's
|
|
// underlying func type, so callers wrap repo.GetCursor for the named type to infer T.
|
|
func streamCursor[T any](openCursor func() (func(func(T, error) bool), error), toItem func(T) dto.BaseItemDto) func() (iter.Seq2[dto.BaseItemDto, error], error) {
|
|
return func() (iter.Seq2[dto.BaseItemDto, error], error) {
|
|
cursor, err := openCursor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return func(yield func(dto.BaseItemDto, error) bool) {
|
|
for row, err := range cursor {
|
|
if err != nil {
|
|
yield(dto.BaseItemDto{}, err)
|
|
return
|
|
}
|
|
if !yield(toItem(row), nil) {
|
|
return
|
|
}
|
|
}
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// seq returns the items as one sequence, opening the cursor if there is one.
|
|
func (ir itemsResult) seq() (iter.Seq2[dto.BaseItemDto, error], error) {
|
|
if ir.openCursor != nil {
|
|
return ir.openCursor()
|
|
}
|
|
return sliceItems(ir.items), nil
|
|
}
|
|
|
|
// collect drains the result into a slice, for the merge that combines types before paginating.
|
|
func (ir itemsResult) collect() ([]dto.BaseItemDto, error) {
|
|
if ir.openCursor == nil {
|
|
return ir.items, nil
|
|
}
|
|
seq, err := ir.openCursor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []dto.BaseItemDto
|
|
for it, err := range seq {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (api *Router) writeItems(w http.ResponseWriter, r *http.Request, res itemsResult) {
|
|
api.streamResult(w, r, res, func(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error {
|
|
return streamItemsEnvelope(w, items, res.total, res.start)
|
|
})
|
|
}
|
|
|
|
// writeItemsArray writes the bare-array shape (/Items/Latest), which has no QueryResult envelope.
|
|
func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res itemsResult) {
|
|
api.streamResult(w, r, res, streamItemsArray)
|
|
}
|
|
|
|
// streamResult stamps every item's ServerId (constant per request, so it's set here rather than in
|
|
// each mapper). The cursor opens before the first byte, so a failed open is still a clean 500.
|
|
func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult,
|
|
write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) {
|
|
sid := api.serverID(r.Context())
|
|
seq, err := res.seq()
|
|
if err != nil {
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
stamped := func(yield func(dto.BaseItemDto, error) bool) {
|
|
for it, err := range seq {
|
|
if err != nil {
|
|
yield(dto.BaseItemDto{}, err)
|
|
return
|
|
}
|
|
it.ServerId = sid
|
|
if !yield(it, nil) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
if err := write(w, stamped); err != nil {
|
|
log.Error(r.Context(), "Jellyfin API: error streaming response", err)
|
|
}
|
|
}
|
|
|
|
// itemsQuery is a parsed /Items request, so the dispatch and every listXxx take one value instead
|
|
// of a long positional parameter list.
|
|
type itemsQuery struct {
|
|
fields dto.Fields
|
|
ids []string
|
|
rawTypes string
|
|
types []string
|
|
search string
|
|
sortBy string
|
|
sortOrder string
|
|
offset int
|
|
limit int
|
|
filters itemFilters
|
|
// parentId scopes the query. entityParent is the same id only when it names an entity (an artist
|
|
// for MusicAlbum, an album for Audio) rather than a library.
|
|
parentId string
|
|
entityParent string
|
|
isLibraryParent bool
|
|
scopeIDs []int
|
|
// artistId selects that artist's own discography; contributingOnly means albums they merely
|
|
// appear on (Jellyfin's "Featured On"), which must exclude that discography.
|
|
artistId string
|
|
contributingOnly bool
|
|
genreIds []string
|
|
albumIds []string
|
|
years []int
|
|
studioIds []string
|
|
}
|
|
|
|
// listParams reads the itemsQuery fields that come straight from query params.
|
|
func listParams(p *req.Values) itemsQuery {
|
|
return itemsQuery{
|
|
fields: dto.ParseFields(p.Strings("fields")...),
|
|
search: searchTerm(p),
|
|
sortBy: p.StringOr("sortby", ""),
|
|
sortOrder: p.StringOr("sortorder", ""),
|
|
offset: p.IntOr("startindex", 0),
|
|
limit: p.IntOr("limit", 0),
|
|
filters: parseItemFilters(p),
|
|
}
|
|
}
|
|
|
|
// parseItemsQuery also resolves the entity types (inferring them from the parent when
|
|
// IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because
|
|
// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). A non-empty id param that
|
|
// fails to decode reports model.ErrNotFound rather than silently dropping the filter (see decodeFilterParam).
|
|
func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQuery, error) {
|
|
p := req.Params(r)
|
|
parentId, ok := decodeFilterParam(p.StringOr("parentid", ""))
|
|
if !ok {
|
|
return itemsQuery{}, model.ErrNotFound
|
|
}
|
|
// Any malformed entry in one of these id lists must 404, not silently drop out of the filter
|
|
// (see dto.DecodeIDs) — an all-malformed list would otherwise widen the query to everything.
|
|
ids, ok := decodedQueryIDs(r, "ids")
|
|
if !ok {
|
|
return itemsQuery{}, model.ErrNotFound
|
|
}
|
|
// Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre.
|
|
genreIds, ok := decodedQueryIDs(r, "genreids")
|
|
if !ok {
|
|
return itemsQuery{}, model.ErrNotFound
|
|
}
|
|
// Feishin fetches an album's tracks with AlbumIds instead of ParentId.
|
|
albumIds, ok := decodedQueryIDs(r, "albumids")
|
|
if !ok {
|
|
return itemsQuery{}, model.ErrNotFound
|
|
}
|
|
studioIds, ok := decodedQueryIDs(r, "studioids")
|
|
if !ok {
|
|
return itemsQuery{}, model.ErrNotFound
|
|
}
|
|
q := listParams(p)
|
|
q.ids = ids
|
|
q.rawTypes = p.StringOr("includeitemtypes", "")
|
|
q.parentId = parentId
|
|
q.genreIds = genreIds
|
|
q.albumIds = albumIds
|
|
q.years = parseYears(r)
|
|
q.studioIds = studioIds
|
|
// An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping
|
|
// plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist.
|
|
albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", ""))
|
|
contributingScope := p.StringOr("contributingartistids", "")
|
|
artistId, ok := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope))
|
|
if !ok {
|
|
return itemsQuery{}, model.ErrNotFound
|
|
}
|
|
q.artistId = artistId
|
|
q.contributingOnly = albumArtistScope == "" && contributingScope != ""
|
|
|
|
q.types = parseTypes(q.rawTypes)
|
|
q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId)
|
|
|
|
// Recursive=false asks for direct children only, and no track is a library's direct child.
|
|
// Finamp's sync probes a library this way, and every track is a wrong, unbounded answer.
|
|
if q.isLibraryParent && !p.BoolOr("recursive", false) {
|
|
q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" })
|
|
}
|
|
|
|
// With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks
|
|
// (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse
|
|
// its albums).
|
|
if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent {
|
|
if q.parentId == dto.PlaylistsFolderID {
|
|
// Browsing into the synthetic playlists folder lists the user's playlists.
|
|
q.types = []string{"Playlist"}
|
|
} else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil {
|
|
q.types = []string{"Audio"}
|
|
}
|
|
}
|
|
// ParentId-as-entity-id only makes sense for a single type; a multi-type query has no natural
|
|
// parent entity, so there ParentId is only library scoping.
|
|
q.entityParent = q.parentId
|
|
if q.isLibraryParent || len(q.types) > 1 {
|
|
q.entityParent = ""
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
// queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via
|
|
// the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites
|
|
// screen requests).
|
|
func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) {
|
|
q, err := api.parseItemsQuery(ctx, r)
|
|
if err != nil {
|
|
return itemsResult{}, err
|
|
}
|
|
switch {
|
|
// /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch.
|
|
case len(q.ids) > 0:
|
|
return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil
|
|
// A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items.
|
|
case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"):
|
|
return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil
|
|
}
|
|
if repo, ok := api.playlistTracksRepo(ctx, q); ok {
|
|
return api.playlistTrackPage(repo, q.fields, q.offset, q.limit)
|
|
}
|
|
if q.search != "" {
|
|
q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit)
|
|
}
|
|
if len(q.types) == 1 {
|
|
opts := model.QueryOptions{Offset: q.offset, Max: q.limit}
|
|
applySort(&opts, q.types[0], q.sortBy, q.sortOrder)
|
|
return api.queryItemsOfType(ctx, q.types[0], opts, q)
|
|
}
|
|
return api.mergeTypes(ctx, q)
|
|
}
|
|
|
|
// playlistTracksRepo resolves a playlist parent, whatever IncludeItemTypes says: Jellify opens a
|
|
// playlist with ParentId=<playlist>&IncludeItemTypes=Audio, and routing that through listSongs would
|
|
// treat the playlist id as an album id and return nothing.
|
|
//
|
|
// ok is false when ParentId isn't a visible playlist, so the caller falls through to the type
|
|
// dispatch: ParentId is usually an album or artist.
|
|
func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) {
|
|
if q.parentId == "" || q.isLibraryParent || q.parentId == dto.PlaylistsFolderID {
|
|
return nil, false
|
|
}
|
|
// Tracks enforces visibility.
|
|
repo, err := api.playlists.Tracks(ctx, q.parentId)
|
|
return repo, err == nil
|
|
}
|
|
|
|
func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) {
|
|
if q.limit == 0 {
|
|
return api.mergeTypesStreaming(ctx, q)
|
|
}
|
|
// A random page doesn't stack on the previous one (the order reshuffles each request), so serving
|
|
// from 0 is an equivalent fresh draw and avoids materializing offset+limit rows per type.
|
|
offset := q.offset
|
|
if randomlySorted(q) {
|
|
offset = 0
|
|
}
|
|
return api.mergeTypesPaged(ctx, q, offset)
|
|
}
|
|
|
|
// randomlySorted reports whether every merged type resolves to a random sort — the case where a page
|
|
// is an independent draw, so the offset can be collapsed to 0. Resolving via applySort (rather than
|
|
// matching the raw SortBy) keeps this in step with how each type's sort is actually chosen.
|
|
func randomlySorted(q itemsQuery) bool {
|
|
for _, itemType := range q.types {
|
|
var opts model.QueryOptions
|
|
applySort(&opts, itemType, q.sortBy, q.sortOrder)
|
|
if opts.Sort != "random" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// mergeTypesStreaming keeps the unbounded path lazy: chaining the per-type cursors yields their rows
|
|
// in order minus the first offset, without pulling every row into memory.
|
|
func (api *Router) mergeTypesStreaming(ctx context.Context, q itemsQuery) (itemsResult, error) {
|
|
var results []itemsResult
|
|
total := 0
|
|
for _, itemType := range q.types {
|
|
res, err := api.queryTypeWindow(ctx, itemType, 0, q)
|
|
if err != nil {
|
|
return itemsResult{}, err
|
|
}
|
|
results = append(results, res)
|
|
total += res.total
|
|
}
|
|
return chained(results, total, q.offset), nil
|
|
}
|
|
|
|
// queryTypeWindow queries one type for the merge paths, capping it to window rows with the sort applied.
|
|
func (api *Router) queryTypeWindow(ctx context.Context, itemType string, window int, q itemsQuery) (itemsResult, error) {
|
|
var opts model.QueryOptions
|
|
opts.Max = window
|
|
applySort(&opts, itemType, q.sortBy, q.sortOrder)
|
|
return api.queryItemsOfType(ctx, itemType, opts, q)
|
|
}
|
|
|
|
// mergeTypesPaged runs each type's query concurrently, then round-robins the per-type rows so the limited page
|
|
// is a mix rather than one type's rows followed by the next.
|
|
func (api *Router) mergeTypesPaged(ctx context.Context, q itemsQuery, offset int) (itemsResult, error) {
|
|
// Each per-type query needs at most offset+limit rows (worst case: one type fills the whole window).
|
|
window := offset + q.limit
|
|
if q.search != "" {
|
|
window = min(window, maxSearchLimit)
|
|
}
|
|
lists := make([][]dto.BaseItemDto, len(q.types))
|
|
totals := make([]int, len(q.types))
|
|
g, ctx := errgroup.WithContext(ctx)
|
|
for i, itemType := range q.types {
|
|
g.Go(func() error {
|
|
res, err := api.queryTypeWindow(ctx, itemType, window, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items, err := res.collect()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lists[i] = items
|
|
totals[i] = res.total
|
|
return nil
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
return itemsResult{}, err
|
|
}
|
|
total := 0
|
|
for _, t := range totals {
|
|
total += t
|
|
}
|
|
items := interleave(lists)
|
|
if q.search != "" {
|
|
// Past the window the merged order isn't the true one, so drop it rather than serve another
|
|
// type's rows. The total is what's pageable overall, so a client paging on it won't stop early.
|
|
items = items[:min(window, len(items))]
|
|
total = min(total, maxSearchLimit)
|
|
}
|
|
return materialized(result(paginate(items, offset, q.limit), total, q.offset)), nil
|
|
}
|
|
|
|
func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
|
|
switch itemType {
|
|
case "Audio":
|
|
return api.listSongs(ctx, opts, q)
|
|
case "MusicArtist":
|
|
// The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists.
|
|
return api.listArtists(ctx, opts, q, model.RoleAlbumArtist)
|
|
case "MusicGenre":
|
|
return api.listGenres(ctx, opts)
|
|
case "Playlist":
|
|
return api.listPlaylists(ctx, opts, q)
|
|
default: // MusicAlbum
|
|
return api.listAlbums(ctx, opts, q)
|
|
}
|
|
}
|
|
|
|
// firstNonEmpty returns the first non-empty string, or "".
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list, reporting
|
|
// whether it decoded successfully (see decodeFilterParam).
|
|
func firstDecodedID(s string) (string, bool) {
|
|
if s == "" {
|
|
return "", true
|
|
}
|
|
first, _, _ := strings.Cut(s, ",")
|
|
return decodeFilterParam(strings.TrimSpace(first))
|
|
}
|
|
|
|
// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if
|
|
// any entry is malformed, so a dropped entry can't shrink the list into an empty, no-op filter.
|
|
func decodedQueryIDs(r *http.Request, key string) ([]string, bool) {
|
|
return dto.DecodeIDs(queryIDs(r, key))
|
|
}
|
|
|
|
// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params.
|
|
func parseYears(r *http.Request) []int {
|
|
var years []int
|
|
for _, v := range queryIDs(r, "years") {
|
|
if y, err := strconv.Atoi(v); err == nil && y > 0 {
|
|
years = append(years, y)
|
|
}
|
|
}
|
|
return years
|
|
}
|
|
|
|
// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to
|
|
// {"MusicAlbum"} when none are recognized (so ParentId=<artistId> browses that artist's albums).
|
|
func parseTypes(types string) []string {
|
|
var recognized []string
|
|
for t := range strings.SplitSeq(types, ",") {
|
|
t = strings.TrimSpace(t)
|
|
switch t {
|
|
case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist":
|
|
recognized = append(recognized, t)
|
|
}
|
|
}
|
|
// Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query.
|
|
recognized = slice.Unique(recognized)
|
|
if len(recognized) == 0 {
|
|
return []string{"MusicAlbum"}
|
|
}
|
|
return recognized
|
|
}
|
|
|
|
// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only
|
|
// (single-type queries push Offset/Max down to SQL instead).
|
|
func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto {
|
|
if offset >= len(items) {
|
|
return []dto.BaseItemDto{}
|
|
}
|
|
items = items[offset:]
|
|
if limit > 0 && limit < len(items) {
|
|
items = items[:limit]
|
|
}
|
|
return items
|
|
}
|
|
|
|
// interleave merges per-type item lists round-robin: one item from each list in turn, preserving
|
|
// each list's own order, so no single type dominates the head of a mixed-type result.
|
|
func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto {
|
|
total, maxLen := 0, 0
|
|
for _, l := range lists {
|
|
total += len(l)
|
|
maxLen = max(maxLen, len(l))
|
|
}
|
|
out := make([]dto.BaseItemDto, 0, total)
|
|
for i := 0; i < maxLen; i++ {
|
|
for _, l := range lists {
|
|
if i < len(l) {
|
|
out = append(out, l[i])
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without
|
|
// the ceiling, Limit=999999 still materializes every match.
|
|
const (
|
|
defaultSearchLimit = 100
|
|
maxSearchLimit = 2000
|
|
)
|
|
|
|
// clampLimit bounds a client-supplied limit, 0 or less meaning it sent none, so it can't drive an
|
|
// oversized allocation or provider fetch (flagged by CodeQL as a user-controlled allocation size).
|
|
//
|
|
// Searches clamp their Limit here rather than in searchPage, which also sees mergeTypes' larger
|
|
// offset+limit window: bounding that would truncate each type before the merged page is cut.
|
|
func clampLimit(limit, def, ceiling int) int {
|
|
if limit <= 0 {
|
|
return def
|
|
}
|
|
return min(limit, ceiling)
|
|
}
|
|
|
|
// searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the
|
|
// Search API returns no match count and CountAll can't see the search term. offset+len(rows) is
|
|
// exact once matches end (and a growing lower bound before), so paging terminates at the last match.
|
|
func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) {
|
|
fetch := opts
|
|
fetch.Max++
|
|
rows, err := search(fetch)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
total := opts.Offset + len(rows)
|
|
if len(rows) > opts.Max {
|
|
rows = rows[:opts.Max]
|
|
}
|
|
return rows, total, nil
|
|
}
|
|
|
|
func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
|
|
toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) }
|
|
repo := api.ds.Album(ctx)
|
|
filters := squirrel.And{}
|
|
// For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's
|
|
// albums"; contributingArtistIds means "albums they only appear on" (Featured On).
|
|
switch {
|
|
case q.contributingOnly && q.artistId != "":
|
|
filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters)
|
|
case firstNonEmpty(q.artistId, q.entityParent) != "":
|
|
filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters)
|
|
default:
|
|
filters = append(filters, notMissing)
|
|
}
|
|
if len(q.genreIds) > 0 {
|
|
filters = append(filters, filter.AlbumsByGenreID(q.genreIds))
|
|
}
|
|
if len(q.years) > 0 {
|
|
filters = append(filters, filter.AlbumsByYears(q.years))
|
|
}
|
|
if len(q.studioIds) > 0 {
|
|
filters = append(filters, filter.ByStudioID(q.studioIds))
|
|
}
|
|
// Not on the search path: its first FTS phase selects rowids with no annotation join, so a
|
|
// starred/play_count predicate there is "no such column" rather than a filter.
|
|
if q.search == "" {
|
|
filters = append(filters, q.filters.predicates()...)
|
|
}
|
|
opts.Filters = filters
|
|
opts = filter.ApplyLibraryFilter(opts, q.scopeIDs)
|
|
|
|
if q.search != "" {
|
|
albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) {
|
|
return repo.Search(q.search, o)
|
|
})
|
|
if err != nil {
|
|
return itemsResult{}, err
|
|
}
|
|
return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil
|
|
}
|
|
total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
|
|
open := streamCursor(func() (func(func(model.Album, error) bool), error) {
|
|
return repo.GetCursor(opts)
|
|
}, toItem)
|
|
return streamed(open, int(total), opts.Offset), nil
|
|
}
|
|
|
|
func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
|
|
toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) }
|
|
repo := api.ds.MediaFile(ctx)
|
|
filters := squirrel.And{}
|
|
// For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's.
|
|
switch {
|
|
case q.artistId != "":
|
|
filters = append(filters, filter.SongsByArtistID(q.artistId).Filters)
|
|
case q.entityParent != "":
|
|
filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters)
|
|
default:
|
|
filters = append(filters, notMissing)
|
|
}
|
|
if len(q.albumIds) > 0 {
|
|
filters = append(filters, filter.ByAlbumID(q.albumIds))
|
|
}
|
|
if len(q.genreIds) > 0 {
|
|
filters = append(filters, filter.SongsByGenreID(q.genreIds))
|
|
}
|
|
if len(q.years) > 0 {
|
|
filters = append(filters, filter.SongsByYears(q.years))
|
|
}
|
|
if len(q.studioIds) > 0 {
|
|
filters = append(filters, filter.ByStudioID(q.studioIds))
|
|
}
|
|
// Not on the search path: its first FTS phase selects rowids with no annotation join, so a
|
|
// starred/play_count predicate there is "no such column" rather than a filter.
|
|
if q.search == "" {
|
|
filters = append(filters, q.filters.predicates()...)
|
|
}
|
|
opts.Filters = filters
|
|
opts = filter.ApplyLibraryFilter(opts, q.scopeIDs)
|
|
|
|
if q.search != "" {
|
|
mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) {
|
|
return repo.Search(q.search, o)
|
|
})
|
|
if err != nil {
|
|
return itemsResult{}, err
|
|
}
|
|
return materialized(result(slice.Map(mfs, toItem), total, opts.Offset)), nil
|
|
}
|
|
// When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an
|
|
// explicit client SortBy still wins, since applySort already set opts.Sort.
|
|
if q.artistId == "" && q.entityParent != "" && opts.Sort == "" {
|
|
opts.Sort = filter.SongsByAlbum(q.entityParent).Sort
|
|
}
|
|
// A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows.
|
|
total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
|
|
open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) {
|
|
return repo.GetCursorWithArtwork(opts)
|
|
}, toItem)
|
|
return streamed(open, int(total), opts.Offset), nil
|
|
}
|
|
|
|
// listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views,
|
|
// RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical.
|
|
// genreIds isn't applied to search — a name lookup, like role (see below).
|
|
func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) {
|
|
repo := api.ds.Artist(ctx)
|
|
toItem := func(ar model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(ar, q.fields) }
|
|
|
|
// Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a
|
|
// search scope (artists have no library_id column). A compound or join-based filter
|
|
// (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build
|
|
// filters differently. Role isn't applied to search for the same reason — it's a name lookup.
|
|
if q.search != "" {
|
|
if len(q.scopeIDs) > 0 {
|
|
opts.Filters = squirrel.Eq{"library_id": q.scopeIDs}
|
|
}
|
|
artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) {
|
|
return repo.Search(q.search, o)
|
|
})
|
|
if err != nil {
|
|
return itemsResult{}, err
|
|
}
|
|
return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil
|
|
}
|
|
|
|
filters := squirrel.And{notMissing}
|
|
filters = append(filters, q.filters.predicates()...)
|
|
if len(q.genreIds) > 0 {
|
|
filters = append(filters, filter.ArtistsByGenreID(q.genreIds))
|
|
}
|
|
opts.Filters = filters
|
|
opts = filter.ArtistsByRole(opts, role)
|
|
opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs)
|
|
total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
|
|
open := streamCursor(func() (func(func(model.Artist, error) bool), error) {
|
|
return repo.GetCursor(opts)
|
|
}, toItem)
|
|
return streamed(open, int(total), opts.Offset), nil
|
|
}
|
|
|
|
// listGenres is intentionally unscoped: genres are global tags, not per-library entities. It's also
|
|
// the one listXxx that stays materialized: GenreRepository has no CountAll, so the total is the
|
|
// length of the full list and paging is in-memory — nothing for a cursor to page over.
|
|
func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) {
|
|
genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: opts.Sort, Order: opts.Order})
|
|
if err != nil {
|
|
return itemsResult{}, err
|
|
}
|
|
items := slice.Map(genres, dto.GenreToBaseItem)
|
|
return materialized(result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset)), nil
|
|
}
|
|
|
|
// listPlaylists lists playlists visible to the current user. Visibility (public or owned) is
|
|
// enforced by playlistRepository, not scopeIDs.
|
|
func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
|
|
if preds := q.filters.predicates(); len(preds) > 0 {
|
|
opts.Filters = squirrel.And(preds)
|
|
}
|
|
repo := api.ds.Playlist(ctx)
|
|
total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
|
|
if err != nil {
|
|
return itemsResult{}, err
|
|
}
|
|
open := streamCursor(func() (func(func(model.Playlist, error) bool), error) {
|
|
return repo.GetCursor(opts)
|
|
}, func(p model.Playlist) dto.BaseItemDto { return dto.PlaylistToBaseItem(p, q.fields) })
|
|
return streamed(open, int(total), opts.Offset), nil
|
|
}
|
|
|
|
// resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album,
|
|
// artist, song, playlist and genre in turn. Albums and songs report not-found when the user lacks access
|
|
// to their library, so an id can't probe content outside the user's libraries.
|
|
func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) {
|
|
// The synthetic playlists folder must resolve by the id we advertised, not 404.
|
|
if id == dto.PlaylistsFolderID {
|
|
return playlistsFolder(), true
|
|
}
|
|
u, _ := request.UserFrom(ctx)
|
|
// Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this
|
|
// the home screen and library tabs 404.
|
|
if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) {
|
|
for _, lib := range u.Libraries {
|
|
if lib.ID == libID {
|
|
return libraryView(lib), true
|
|
}
|
|
}
|
|
// Admin bypass: Libraries is empty but all access is granted, so fetch the real library.
|
|
if lib, err := api.ds.Library(ctx).Get(libID); err == nil {
|
|
return libraryView(*lib), true
|
|
}
|
|
}
|
|
if al, err := api.ds.Album(ctx).Get(id); err == nil {
|
|
if !u.HasLibraryAccess(al.LibraryID) {
|
|
return dto.BaseItemDto{}, false
|
|
}
|
|
return dto.AlbumToBaseItem(*al, fields), true
|
|
}
|
|
if ar, err := api.ds.Artist(ctx).Get(id); err == nil {
|
|
// TODO: an artist spans multiple libraries (library_artist), so there's no single
|
|
// LibraryID to gate here; artist access relies on list-time scoping and persistence.
|
|
return dto.ArtistToBaseItem(*ar, fields), true
|
|
}
|
|
if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil {
|
|
if !u.HasLibraryAccess(mf.LibraryID) {
|
|
return dto.BaseItemDto{}, false
|
|
}
|
|
return dto.SongToBaseItem(*mf, fields), true
|
|
}
|
|
// api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through.
|
|
if pl, err := api.playlists.Get(ctx, id); err == nil {
|
|
return dto.PlaylistToBaseItem(*pl, fields), true
|
|
}
|
|
if g, err := api.ds.Genre(ctx).Get(id); err == nil {
|
|
return dto.GenreToBaseItem(*g), true
|
|
}
|
|
return dto.BaseItemDto{}, false
|
|
}
|
|
|
|
// songsByIDs fetches the media files among ids with chunked IN queries instead of a Get per id.
|
|
func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]model.MediaFile {
|
|
songs := make(map[string]model.MediaFile, len(ids))
|
|
// Chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, like playqueue's loadTracks.
|
|
for chunk := range slice.CollectChunks(slices.Values(ids), 500) {
|
|
mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}})
|
|
if err != nil {
|
|
log.Error(ctx, "Jellyfin API: error fetching songs by id", err)
|
|
continue
|
|
}
|
|
for _, mf := range mfs {
|
|
songs[mf.ID] = mf
|
|
}
|
|
}
|
|
return songs
|
|
}
|
|
|
|
// itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids.
|
|
func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult {
|
|
u, _ := request.UserFrom(ctx)
|
|
songs := api.songsByIDs(ctx, ids)
|
|
var items []dto.BaseItemDto
|
|
for _, id := range ids {
|
|
var item dto.BaseItemDto
|
|
if mf, ok := songs[id]; ok {
|
|
if !u.HasLibraryAccess(mf.LibraryID) {
|
|
continue
|
|
}
|
|
item = dto.SongToBaseItem(mf, fields)
|
|
} else if item, ok = api.resolveItemByID(ctx, id, fields); !ok {
|
|
continue
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return result(items, len(items), 0)
|
|
}
|
|
|
|
func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := itemIDParam(w, r, "itemId")
|
|
if !ok {
|
|
return
|
|
}
|
|
fields := dto.ParseFields(req.Params(r).Strings("fields")...)
|
|
if item, ok := api.resolveItemByID(r.Context(), id, fields); ok {
|
|
api.ok(w, r, item)
|
|
return
|
|
}
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
}
|
|
|
|
// deleteItem handles DELETE /Items/{id}. Only playlists are deletable here (albums/songs come from
|
|
// scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership.
|
|
func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
id, ok := itemIDParam(w, r, "itemId")
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := api.playlists.Delete(ctx, id); err != nil {
|
|
api.playlistError(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// getLatest returns a bare array, not a QueryResult envelope — real Jellyfin's shape for
|
|
// /Items/Latest, and why it writes directly instead of going through api.ok.
|
|
func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
fields := dto.ParseFields(p.Strings("fields")...)
|
|
opts := filter.AlbumsByNewest()
|
|
opts.Max = p.IntOr("limit", 20)
|
|
opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx))
|
|
repo := api.ds.Album(ctx)
|
|
open := streamCursor(func() (func(func(model.Album, error) bool), error) {
|
|
return repo.GetCursor(opts)
|
|
}, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) })
|
|
api.writeItemsArray(w, r, streamed(open, 0, 0))
|
|
}
|
|
|
|
func result(items []dto.BaseItemDto, total, start int) dto.QueryResult {
|
|
if items == nil {
|
|
items = []dto.BaseItemDto{}
|
|
}
|
|
return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start}
|
|
}
|
|
|
|
// applySort keeps every recognized SortBy key, so secondary keys break ties as Jellyfin intends.
|
|
// Unrecognized keys are skipped, not passed through raw where they could make an invalid ORDER BY.
|
|
func applySort(opts *model.QueryOptions, itemType, sortBy, order string) {
|
|
var cols []string
|
|
for key := range strings.SplitSeq(sortBy, ",") {
|
|
col, ok := sortColumn(itemType, strings.TrimSpace(key))
|
|
// The repo matches random by exact string equality, so it can only ever sort alone.
|
|
if !ok || slices.Contains(cols, col) || (col == "random" && len(cols) > 0) {
|
|
continue
|
|
}
|
|
cols = append(cols, col)
|
|
if col == "random" {
|
|
break
|
|
}
|
|
}
|
|
switch {
|
|
case len(cols) > 0:
|
|
opts.Sort = strings.Join(cols, ", ")
|
|
case sortBy != "":
|
|
log.Debug("Jellyfin API: no usable SortBy key, falling back to the default order",
|
|
"itemType", itemType, "sortBy", sortBy)
|
|
}
|
|
// Jellyfin allows a per-key SortOrder list, which one Order can't express; honor the first value
|
|
// for every key, as Jellyfin does for keys past the end of the list.
|
|
first, _, _ := strings.Cut(order, ",")
|
|
if strings.EqualFold(first, "Descending") {
|
|
opts.Order = "desc"
|
|
}
|
|
}
|
|
|
|
// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type (repos map logical fields
|
|
// to different real columns, e.g. media_file has "title" not "name").
|
|
var sortColumnsByType = map[string]map[string]string{
|
|
"Audio": {
|
|
"sortname": "title", "name": "title",
|
|
"album": "album",
|
|
// Finamp's album view sorts by ParentIndexNumber,IndexNumber (disc, track); Navidrome's
|
|
// "album" sort key is disc+track order within an album, so map both to it.
|
|
"indexnumber": "album",
|
|
"parentindexnumber": "album",
|
|
"artist": "artist",
|
|
"albumartist": "album_artist",
|
|
"datecreated": "recently_added",
|
|
"playcount": "play_count",
|
|
"dateplayed": "play_date",
|
|
"communityrating": "rating",
|
|
"random": "random",
|
|
"runtime": "duration",
|
|
"runtimeticks": "duration",
|
|
// Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear.
|
|
"premieredate": "year",
|
|
"productionyear": "year",
|
|
},
|
|
"MusicArtist": {
|
|
"sortname": "name", "name": "name",
|
|
"albumcount": "album_count",
|
|
"songcount": "song_count",
|
|
"datecreated": "created_at",
|
|
"playcount": "play_count",
|
|
"dateplayed": "play_date",
|
|
"communityrating": "rating",
|
|
"random": "random",
|
|
},
|
|
"MusicAlbum": {
|
|
"sortname": "name", "name": "name", "album": "name",
|
|
"artist": "artist",
|
|
"albumartist": "album_artist",
|
|
"datecreated": "recently_added",
|
|
"random": "random",
|
|
"playcount": "play_count",
|
|
"dateplayed": "play_date",
|
|
"communityrating": "rating",
|
|
"runtime": "duration",
|
|
"runtimeticks": "duration",
|
|
"premieredate": "max_year", "productionyear": "max_year",
|
|
},
|
|
"MusicGenre": {
|
|
"sortname": "name", "name": "name",
|
|
"random": "random",
|
|
},
|
|
"Playlist": {
|
|
"sortname": "name", "name": "name",
|
|
"datecreated": "created_at",
|
|
"random": "random",
|
|
},
|
|
}
|
|
|
|
// sortColumn maps a single (non comma-list) Jellyfin SortBy key to the repo sort key for
|
|
// itemType, reporting false when it isn't recognized for that type.
|
|
func sortColumn(itemType, sortBy string) (string, bool) {
|
|
col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)]
|
|
return col, ok
|
|
}
|