navidrome/server/jellyfin/items.go

280 lines
9.1 KiB
Go

package jellyfin
import (
"context"
"net/http"
"strings"
"github.com/Masterminds/squirrel"
"github.com/go-chi/chi/v5"
"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. It's hand-rolled rather than
// pulled from a filter.XxxByName()-style builder because none of those return just this
// condition; "missing" is a real column on album, artist and media_file (see persistence/).
var notMissing = squirrel.Eq{"missing": false}
func (api *Router) getItems(w http.ResponseWriter, r *http.Request) {
res, err := api.queryItems(r.Context(), r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
api.ok(w, r, res)
}
// queryItems is the universal /Items dispatcher: it picks a target entity from
// IncludeItemTypes (falling back to MusicAlbum) and delegates to the matching listXxx.
func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryResult, error) {
p := req.Params(r)
parentId := p.StringOr("ParentId", "")
search := p.StringOr("SearchTerm", "")
favOnly := strings.Contains(p.StringOr("Filters", ""), "IsFavorite")
itemType := firstType(p.StringOr("IncludeItemTypes", ""))
opts := model.QueryOptions{Offset: p.IntOr("StartIndex", 0), Max: p.IntOr("Limit", 0)}
applySort(&opts, itemType, p.StringOr("SortBy", ""), p.StringOr("SortOrder", ""))
scopeIDs, isLibraryParent := resolveLibraryScope(ctx, parentId)
entityParent := parentId
if isLibraryParent {
entityParent = ""
}
switch itemType {
case "Audio":
return api.listSongs(ctx, opts, entityParent, scopeIDs, search, favOnly)
case "MusicArtist":
return api.listArtists(ctx, opts, scopeIDs, search, favOnly)
case "MusicGenre":
return api.listGenres(ctx, opts)
default: // MusicAlbum
return api.listAlbums(ctx, opts, entityParent, scopeIDs, search, favOnly)
}
}
// firstType picks the first recognized entry in the (possibly comma-separated)
// IncludeItemTypes, defaulting to MusicAlbum otherwise. This makes ParentId=<artistId>
// (no explicit type) browse into that artist's albums, matching the UserViews ->
// artists -> albums -> songs hierarchy; callers browsing into an album are expected to
// pass IncludeItemTypes=Audio explicitly, as Finamp and Jellyfin's own clients do.
func firstType(types string) string {
for t := range strings.SplitSeq(types, ",") {
t = strings.TrimSpace(t)
switch t {
case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre":
return t
}
}
return "MusicAlbum"
}
func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) {
repo := api.ds.Album(ctx)
filters := squirrel.And{}
if parentId != "" {
filters = append(filters, filter.AlbumsByArtistID(parentId).Filters)
} else {
filters = append(filters, notMissing)
}
if fav {
filters = append(filters, filter.ByStarred().Filters)
}
opts.Filters = filters
opts = filter.ApplyLibraryFilter(opts, scopeIDs)
var albums model.Albums
var err error
if search != "" {
albums, err = repo.Search(search, opts)
} else {
albums, err = repo.GetAll(opts)
}
if err != nil {
return dto.QueryResult{}, err
}
total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
return result(slice.Map(albums, dto.AlbumToBaseItem), int(total), opts.Offset), nil
}
func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, parentId string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) {
repo := api.ds.MediaFile(ctx)
filters := squirrel.And{}
if parentId != "" {
filters = append(filters, filter.SongsByAlbum(parentId).Filters)
} else {
filters = append(filters, notMissing)
}
if fav {
filters = append(filters, filter.ByStarred().Filters)
}
opts.Filters = filters
opts = filter.ApplyLibraryFilter(opts, scopeIDs)
var mfs model.MediaFiles
var err error
if search != "" {
mfs, err = repo.Search(search, opts)
} else {
mfs, err = repo.GetAll(opts)
}
if err != nil {
return dto.QueryResult{}, err
}
total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
return result(slice.Map(mfs, dto.SongToBaseItem), int(total), opts.Offset), nil
}
func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) {
repo := api.ds.Artist(ctx)
if fav {
opts.Filters = filter.ArtistsByStarred().Filters
} else {
opts.Filters = notMissing
}
opts = filter.ApplyArtistLibraryFilter(opts, scopeIDs)
var artists model.Artists
var err error
if search != "" {
artists, err = repo.Search(search, opts)
} else {
artists, err = repo.GetAll(opts)
}
if err != nil {
return dto.QueryResult{}, err
}
total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
return result(slice.Map(artists, dto.ArtistToBaseItem), int(total), opts.Offset), nil
}
// listGenres is intentionally unscoped: genres are global tags derived from track metadata,
// not entities that belong to a single library.
func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (dto.QueryResult, error) {
genres, err := api.ds.Genre(ctx).GetAll(opts)
if err != nil {
return dto.QueryResult{}, err
}
return result(slice.Map(genres, dto.GenreToBaseItem), len(genres), opts.Offset), nil
}
// getItem fetches a single entity by id, trying album, artist and song in turn. For albums
// and songs (which each belong to exactly one library) it 404s if the current user lacks
// access to that library, so an id can't be used to probe content outside the user's libraries.
func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := chi.URLParam(r, "itemId")
u, _ := request.UserFrom(ctx)
if al, err := api.ds.Album(ctx).Get(id); err == nil {
if !u.HasLibraryAccess(al.LibraryID) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
api.ok(w, r, dto.AlbumToBaseItem(*al))
return
}
if ar, err := api.ds.Artist(ctx).Get(id); err == nil {
// TODO: an artist can have content in multiple libraries (via library_artist), so
// there's no single LibraryID to check here; access control for artists relies on
// list-time scoping (listArtists) and the persistence layer's defense-in-depth.
api.ok(w, r, dto.ArtistToBaseItem(*ar))
return
}
if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil {
if !u.HasLibraryAccess(mf.LibraryID) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
api.ok(w, r, dto.SongToBaseItem(*mf))
return
}
http.Error(w, "Not Found", http.StatusNotFound)
}
func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
opts := filter.AlbumsByNewest()
opts.Max = req.Params(r).IntOr("Limit", 20)
opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx))
albums, err := api.ds.Album(ctx).GetAll(opts)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
api.ok(w, r, slice.Map(albums, dto.AlbumToBaseItem)) // /Latest returns a bare array
}
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 model.QueryOptions sort key valid
// for the given item type. Each repository maps different logical fields to different (or
// annotation-joined) real columns, so a key valid for one type can be an invalid column for
// another (e.g. media_file has no "name" column, only "title"; artist has no "random" column).
// An unrecognized SortBy is dropped rather than passed through raw, to avoid an invalid ORDER BY.
func applySort(opts *model.QueryOptions, itemType, sortBy, order string) {
switch itemType {
case "Audio":
switch strings.ToLower(sortBy) {
case "sortname", "name":
opts.Sort = "title"
case "album":
opts.Sort = "album"
case "albumartist":
opts.Sort = "album_artist"
case "datecreated":
opts.Sort = "recently_added"
case "playcount":
opts.Sort = "play_count"
case "dateplayed":
opts.Sort = "play_date"
case "random":
opts.Sort = "random"
}
case "MusicArtist":
switch strings.ToLower(sortBy) {
case "sortname", "name":
opts.Sort = "name"
case "albumcount":
opts.Sort = "album_count"
case "songcount":
opts.Sort = "song_count"
case "datecreated":
opts.Sort = "created_at"
case "playcount":
opts.Sort = "play_count"
case "dateplayed":
opts.Sort = "play_date"
}
default: // MusicAlbum
switch strings.ToLower(sortBy) {
case "sortname", "name", "album":
opts.Sort = "name"
case "albumartist":
opts.Sort = "album_artist"
case "datecreated":
opts.Sort = "recently_added"
case "random":
opts.Sort = "random"
case "playcount":
opts.Sort = "play_count"
case "dateplayed":
opts.Sort = "play_date"
case "premieredate", "productionyear":
opts.Sort = "max_year"
}
}
if strings.EqualFold(order, "Descending") {
opts.Order = "desc"
}
}