mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* feat(persistence): add AlbumRepository.GetYears for distinct album years * test(persistence): verify GetYears de-duplicates repeated years Regression test that adds two albums with the same non-zero max_year (2005) and verifies that GetYears() returns that year exactly once, ensuring the SQL DISTINCT clause is applied correctly. Catches any future removal of DISTINCT from the GetYears query. * feat(jellyfin): add legacy /Items/Filters endpoint (genres + years) * feat(jellyfin): add /Studios endpoint from record label tags * refactor(persistence): drop duplicate columns in tagRepository.GetAll * fix(jellyfin): exclude missing albums from filter years GetYears only filtered max_year > 0, so albums whose files were all removed (missing=true, kept when Scanner.PurgeMissing=never) contributed stale years to /Items/Filters. Filter them out like the normal album listings do. Also return an empty slice from the MockAlbumRepo to match the real repository. * feat(jellyfin): filter /Items by Years= * feat(jellyfin): filter /Items by StudioIds= (record labels) * feat(jellyfin): scope filter and studio lists to ParentId library * refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call sites and the duplicated empty-scope guard into two small helpers, so the empty-scope=unrestricted contract lives in one place. Reuse the existing names() helper in the Years= e2e test. * feat(jellyfin): expose record labels as album Studios Add a Studios field to the album BaseItemDto, populated from the record-label tags and gated behind Fields=Studios (matching Jellyfin's ItemFields convention). Studio ids reuse the record-label tag identity, so they round-trip with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios empty for music; Feishin reads it as the album's record label.
877 lines
32 KiB
Go
877 lines
32 KiB
Go
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=<libraryId> 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=<libraryId> 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=<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 == 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=<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)
|
|
}
|
|
}
|
|
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
|
|
}
|