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"
)
var notMissing = squirrel.Eq{"missing": false}
func searchTerm(p *req.Values) string {
return strings.TrimSpace(p.StringOr("searchterm", ""))
}
type itemFilters struct {
favorite *bool
played *bool
}
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
}
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)
}
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}
}
func chained(results []itemsResult, total, skip int) itemsResult {
open := func() (iter.Seq2[dto.BaseItemDto, error], error) {
if len(results) == 0 {
return sliceItems(nil), nil
}
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)
}
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
}
}
func (ir itemsResult) seq() (iter.Seq2[dto.BaseItemDto, error], error) {
if ir.openCursor != nil {
return ir.openCursor()
}
return sliceItems(ir.items), nil
}
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)
})
}
func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res itemsResult) {
api.streamResult(w, r, res, streamItemsArray)
}
func stampItem(it dto.BaseItemDto, serverID string, fields dto.Fields) dto.BaseItemDto {
it.ServerId = serverID
if it.MediaType == "" {
it.MediaType = "Unknown"
}
if it.ImageTags == nil {
it.ImageTags = map[string]string{}
}
if fields.Has("Genres") {
if it.Genres == nil {
it.Genres = []string{}
}
if it.GenreItems == nil {
it.GenreItems = []dto.NameGuidPair{}
}
}
if fields.Has("Tags") && it.Tags == nil {
it.Tags = []string{}
}
return it
}
func requestFields(r *http.Request) dto.Fields {
return dto.ParseFields(req.Params(r).Strings("fields")...)
}
func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult,
write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) {
sid, fields := api.serverID(r.Context()), requestFields(r)
seq, err := res.seq()
if err != nil {
api.internalError(w, r, err)
return
}
stamped := func(yield func(dto.BaseItemDto, error) bool) {
for it, err := range seq {
if err != nil {
yield(dto.BaseItemDto{}, err)
return
}
if !yield(stampItem(it, sid, fields), nil) {
return
}
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := write(w, stamped); err != nil {
log.Error(r.Context(), "Jellyfin API: error streaming response", err)
}
}
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 string
entityParent string
isLibraryParent bool
scopeIDs []int
artistId string
contributingOnly bool
genreIds []string
albumIds []string
years []int
studioIds []string
}
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),
}
}
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
}
ids, ok := decodedQueryIDs(r, "ids")
if !ok {
return itemsQuery{}, model.ErrNotFound
}
genreIds, ok := decodedQueryIDs(r, "genreids")
if !ok {
return itemsQuery{}, model.ErrNotFound
}
albumIds, ok := decodedQueryIDs(r, "albumids")
if !ok {
return itemsQuery{}, model.ErrNotFound
}
studioIds, ok := decodedQueryIDs(r, "studioids")
if !ok {
return itemsQuery{}, model.ErrNotFound
}
q := listParams(p)
q.ids = ids
q.rawTypes = knownItemKinds(p.StringOr("includeitemtypes", ""))
q.parentId = parentId
q.genreIds = genreIds
q.albumIds = albumIds
q.years = parseYears(r)
q.studioIds = studioIds
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)
if q.isLibraryParent && !p.BoolOr("recursive", false) {
q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" })
}
if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent {
if q.parentId == dto.PlaylistsFolderID {
q.types = []string{"Playlist"}
} else if _, err := api.ds.Album().Get(ctx, q.parentId); err == nil {
q.types = []string{"Audio"}
}
}
q.entityParent = q.parentId
if q.isLibraryParent || len(q.types) > 1 {
q.entityParent = ""
}
return q, nil
}
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 {
case len(q.ids) > 0:
return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil
case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"):
return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil
}
if repo, ok := api.playlistTracksRepo(ctx, q); ok {
return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit)
}
if q.search != "" {
q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit)
}
if len(q.types) == 1 {
opts := model.QueryOptions{Offset: q.offset, Max: q.limit}
applySort(&opts, q.types[0], q.sortBy, q.sortOrder)
return api.queryItemsOfType(ctx, q.types[0], opts, q)
}
return api.mergeTypes(ctx, q)
}
func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) {
if q.parentId == "" || q.isLibraryParent || q.parentId == dto.PlaylistsFolderID {
return nil, false
}
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)
}
offset := q.offset
if randomlySorted(q) {
offset = 0
}
return api.mergeTypesPaged(ctx, q, offset)
}
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
}
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
}
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)
}
func (api *Router) mergeTypesPaged(ctx context.Context, q itemsQuery, offset int) (itemsResult, error) {
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 != "" {
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":
return api.listArtists(ctx, opts, q, model.RoleAlbumArtist)
case "MusicGenre":
return api.listGenres(ctx, opts)
case "Playlist":
return api.listPlaylists(ctx, opts, q)
default:
return api.listAlbums(ctx, opts, q)
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func firstDecodedID(s string) (string, bool) {
if s == "" {
return "", true
}
first, _, _ := strings.Cut(s, ",")
return decodeFilterParam(strings.TrimSpace(first))
}
func decodedQueryIDs(r *http.Request, key string) ([]string, bool) {
return dto.DecodeIDs(queryIDs(r, key))
}
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
}
var supportedTypes = map[string]string{
"audio": "Audio", "musicartist": "MusicArtist", "musicalbum": "MusicAlbum", "musicgenre": "MusicGenre", "playlist": "Playlist",
}
var jellyfinItemKinds = map[string]bool{
"aggregatefolder": true, "audio": true, "audiobook": true, "basepluginfolder": true, "book": true,
"boxset": true, "channel": true, "channelfolderitem": true, "collectionfolder": true, "episode": true,
"folder": true, "genre": true, "manualplaylistsfolder": true, "movie": true, "livetvchannel": true,
"livetvprogram": true, "musicalbum": true, "musicartist": true, "musicgenre": true, "musicvideo": true,
"person": true, "photo": true, "photoalbum": true, "playlist": true, "playlistsfolder": true,
"program": true, "recording": true, "season": true, "series": true, "studio": true, "trailer": true,
"tvchannel": true, "tvprogram": true, "userrootfolder": true, "userview": true, "video": true, "year": true,
}
func knownItemKinds(types string) string {
var known []string
for t := range strings.SplitSeq(types, ",") {
if t = strings.TrimSpace(t); jellyfinItemKinds[strings.ToLower(t)] {
known = append(known, t)
}
}
return strings.Join(known, ",")
}
func parseTypes(types string) []string {
if strings.TrimSpace(types) == "" {
return []string{"MusicAlbum"}
}
var recognized []string
for t := range strings.SplitSeq(types, ",") {
if name, ok := supportedTypes[strings.ToLower(strings.TrimSpace(t))]; ok {
recognized = append(recognized, name)
}
}
return slice.Unique(recognized)
}
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
}
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
}
const (
defaultSearchLimit = 100
maxSearchLimit = 2000
)
func clampLimit(limit, def, ceiling int) int {
if limit <= 0 {
return def
}
return min(limit, ceiling)
}
func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) {
fetch := opts
fetch.Max++
rows, err := search(fetch)
if err != nil {
return nil, 0, err
}
total := opts.Offset + len(rows)
if len(rows) > opts.Max {
rows = rows[:opts.Max]
}
return rows, total, nil
}
func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) }
repo := api.ds.Album()
filters := squirrel.And{}
switch {
case q.contributingOnly && q.artistId != "":
filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters)
case firstNonEmpty(q.artistId, q.entityParent) != "":
filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters)
default:
filters = append(filters, notMissing)
}
if len(q.genreIds) > 0 {
filters = append(filters, filter.AlbumsByGenreID(q.genreIds))
}
if len(q.years) > 0 {
filters = append(filters, filter.AlbumsByYears(q.years))
}
if len(q.studioIds) > 0 {
filters = append(filters, filter.ByStudioID(q.studioIds))
}
if q.search == "" {
filters = append(filters, q.filters.predicates()...)
}
opts.Filters = filters
opts = filter.ApplyLibraryFilter(opts, q.scopeIDs)
if q.search != "" {
albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) {
return repo.Search(ctx, q.search, o)
})
if err != nil {
return itemsResult{}, err
}
return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil
}
total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters})
open := streamCursor(func() (func(func(model.Album, error) bool), error) {
return repo.GetCursor(ctx, opts)
}, toItem)
return streamed(open, int(total), opts.Offset), nil
}
func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) }
repo := api.ds.MediaFile()
filters := squirrel.And{}
switch {
case q.artistId != "":
filters = append(filters, filter.SongsByArtistID(q.artistId).Filters)
case q.entityParent != "":
filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters)
default:
filters = append(filters, notMissing)
}
if len(q.albumIds) > 0 {
filters = append(filters, filter.ByAlbumID(q.albumIds))
}
if len(q.genreIds) > 0 {
filters = append(filters, filter.SongsByGenreID(q.genreIds))
}
if len(q.years) > 0 {
filters = append(filters, filter.SongsByYears(q.years))
}
if len(q.studioIds) > 0 {
filters = append(filters, filter.ByStudioID(q.studioIds))
}
if q.search == "" {
filters = append(filters, q.filters.predicates()...)
}
opts.Filters = filters
opts = filter.ApplyLibraryFilter(opts, q.scopeIDs)
if q.search != "" {
mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) {
return repo.Search(ctx, q.search, o)
})
if err != nil {
return itemsResult{}, err
}
return materialized(result(slice.Map(mfs, toItem), total, opts.Offset)), nil
}
if q.artistId == "" && q.entityParent != "" && opts.Sort == "" {
opts.Sort = filter.SongsByAlbum(q.entityParent).Sort
}
total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters})
open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) {
return repo.GetCursorWithArtwork(ctx, opts)
}, toItem)
return streamed(open, int(total), opts.Offset), nil
}
func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) {
repo := api.ds.Artist()
toItem := func(ar model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(ar, q.fields) }
if q.search != "" {
if len(q.scopeIDs) > 0 {
opts.Filters = squirrel.Eq{"library_id": q.scopeIDs}
}
artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) {
return repo.Search(ctx, q.search, o)
})
if err != nil {
return itemsResult{}, err
}
return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil
}
filters := squirrel.And{notMissing}
filters = append(filters, q.filters.predicates()...)
if len(q.genreIds) > 0 {
filters = append(filters, filter.ArtistsByGenreID(q.genreIds))
}
opts.Filters = filters
opts = filter.ArtistsByRole(opts, role)
opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs)
total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters})
open := streamCursor(func() (func(func(model.Artist, error) bool), error) {
return repo.GetCursor(ctx, opts)
}, toItem)
return streamed(open, int(total), opts.Offset), nil
}
func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) {
genres, err := api.ds.Genre().GetAll(ctx, model.QueryOptions{Sort: opts.Sort, Order: opts.Order})
if err != nil {
return itemsResult{}, err
}
items := slice.Map(genres, dto.GenreToBaseItem)
return materialized(result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset)), nil
}
func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
if preds := q.filters.predicates(); len(preds) > 0 {
opts.Filters = squirrel.And(preds)
}
repo := api.ds.Playlist()
total, err := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters})
if err != nil {
return itemsResult{}, err
}
open := streamCursor(func() (func(func(model.Playlist, error) bool), error) {
return repo.GetCursor(ctx, opts)
}, func(p model.Playlist) dto.BaseItemDto { return dto.PlaylistToBaseItem(p, q.fields) })
return streamed(open, int(total), opts.Offset), nil
}
func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) {
if id == dto.PlaylistsFolderID {
return playlistsFolder(), true
}
u, _ := request.UserFrom(ctx)
if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) {
if lib, err := api.ds.Library().Get(ctx, libID); err == nil {
return dto.LibraryToBaseItem(*lib), true
}
}
if al, err := api.ds.Album().Get(ctx, id); err == nil {
if !u.HasLibraryAccess(al.LibraryID) {
return dto.BaseItemDto{}, false
}
return dto.AlbumToBaseItem(*al, fields), true
}
if ar, err := api.ds.Artist().Get(ctx, id); err == nil {
return dto.ArtistToBaseItem(*ar, fields), true
}
if mf, err := api.ds.MediaFile().Get(ctx, id); err == nil {
if !u.HasLibraryAccess(mf.LibraryID) {
return dto.BaseItemDto{}, false
}
return dto.SongToBaseItem(*mf, fields), true
}
if pl, err := api.playlists.Get(ctx, id); err == nil {
return dto.PlaylistToBaseItem(*pl, fields), true
}
if g, err := api.ds.Genre().Get(ctx, id); err == nil {
return dto.GenreToBaseItem(*g), true
}
return dto.BaseItemDto{}, false
}
func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]model.MediaFile {
songs := make(map[string]model.MediaFile, len(ids))
for chunk := range slice.CollectChunks(slices.Values(ids), 500) {
mfs, err := api.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}})
if err != nil {
log.Error(ctx, "Jellyfin API: error fetching songs by id", err)
continue
}
for _, mf := range mfs {
songs[mf.ID] = mf
}
}
return songs
}
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)
}
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)
}
func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
fields := dto.ParseFields(p.Strings("fields")...)
opts := filter.AlbumsByNewest()
opts.Max = p.IntOr("limit", 20)
parentID, ok := decodeFilterParam(p.StringOr("parentid", ""))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
scopeIDs, isLibrary := resolveLibraryScope(ctx, parentID)
if parentID != "" && !isLibrary {
opts.Filters = squirrel.And{opts.Filters, filter.AlbumsByArtistID(parentID).Filters}
}
opts = filter.ApplyLibraryFilter(opts, scopeIDs)
repo := api.ds.Album()
open := streamCursor(func() (func(func(model.Album, error) bool), error) {
return repo.GetCursor(ctx, opts)
}, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) })
api.writeItemsArray(w, r, streamed(open, 0, 0))
}
func result(items []dto.BaseItemDto, total, start int) dto.QueryResult {
if items == nil {
items = []dto.BaseItemDto{}
}
return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start}
}
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))
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)
}
first, _, _ := strings.Cut(order, ",")
if strings.EqualFold(first, "Descending") {
opts.Order = "desc"
}
}
var sortColumnsByType = map[string]map[string]string{
"Audio": {
"sortname": "title", "name": "title",
"album": "album",
"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",
"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",
},
}
func sortColumn(itemType, sortBy string) (string, bool) {
col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)]
return col, ok
}