feat(jellyfin): Items query engine, item detail, and latest

Adds the /Items universal query endpoint, dispatching by IncludeItemTypes
over albums/artists/songs/genres with ParentId, SearchTerm, Filters=IsFavorite,
SortBy/SortOrder and StartIndex/Limit support, plus GET /Items/{itemId} and
/Users/{userId}/Items/Latest. Reuses server/subsonic/filter builders (by
artist/album/starred) instead of hand-rolled squirrel filters, and resolves
sort keys per item type since each repository maps sort names to different
real/aliased columns.

Also adds the missing CountAll to tests.MockArtistRepo, exposed by this task
(the mock embedded a nil ArtistRepository for it and would panic on use).
This commit is contained in:
Deluan 2026-07-05 15:27:53 -04:00
commit 0b9fbcd048
4 changed files with 436 additions and 0 deletions

View file

@ -56,6 +56,12 @@ func (api *Router) routes() http.Handler {
r.Get("/Users/{userId}/Views", api.getUserViews)
r.Get("/Users/Me", api.getCurrentUser)
r.Get("/Users/{userId}", api.getCurrentUser)
r.Get("/Items", api.getItems)
r.Get("/Users/{userId}/Items", api.getItems)
r.Get("/Items/{itemId}", api.getItem)
r.Get("/Users/{userId}/Items/{itemId}", api.getItem)
r.Get("/Users/{userId}/Items/Latest", api.getLatest)
})
return r

252
server/jellyfin/items.go Normal file
View file

@ -0,0 +1,252 @@
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/server/jellyfin/dto"
"github.com/navidrome/navidrome/server/subsonic/filter"
"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", ""))
switch itemType {
case "Audio":
return api.listSongs(ctx, opts, parentId, search, favOnly)
case "MusicArtist":
return api.listArtists(ctx, opts, search, favOnly)
case "MusicGenre":
return api.listGenres(ctx, opts)
default: // MusicAlbum
return api.listAlbums(ctx, opts, parentId, 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, search string, fav bool) (dto.QueryResult, error) {
repo := api.ds.Album(ctx)
filters := squirrel.And{}
if parentId != "" && parentId != musicViewID {
filters = append(filters, filter.AlbumsByArtistID(parentId).Filters)
} else {
filters = append(filters, notMissing)
}
if fav {
filters = append(filters, filter.ByStarred().Filters)
}
opts.Filters = filters
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, search string, fav bool) (dto.QueryResult, error) {
repo := api.ds.MediaFile(ctx)
filters := squirrel.And{}
if parentId != "" && parentId != musicViewID {
filters = append(filters, filter.SongsByAlbum(parentId).Filters)
} else {
filters = append(filters, notMissing)
}
if fav {
filters = append(filters, filter.ByStarred().Filters)
}
opts.Filters = filters
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, search string, fav bool) (dto.QueryResult, error) {
repo := api.ds.Artist(ctx)
if fav {
opts.Filters = filter.ArtistsByStarred().Filters
} else {
opts.Filters = notMissing
}
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
}
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
}
func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := chi.URLParam(r, "itemId")
if al, err := api.ds.Album(ctx).Get(id); err == nil {
api.ok(w, r, dto.AlbumToBaseItem(*al))
return
}
if ar, err := api.ds.Artist(ctx).Get(id); err == nil {
api.ok(w, r, dto.ArtistToBaseItem(*ar))
return
}
if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil {
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)
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"
}
}

View file

@ -0,0 +1,171 @@
package jellyfin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// withChiURLParam simulates chi's routing having captured a path parameter, since these
// tests call handlers directly instead of going through the full router.
func withChiURLParam(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
var _ = Describe("Items", func() {
var api *Router
var ds *tests.MockDataStore
ctxUser := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"})
}
BeforeEach(func() {
ds = &tests.MockDataStore{}
api = &Router{ds: ds}
})
Describe("getItems", func() {
It("lists albums when IncludeItemTypes=MusicAlbum", func() {
ds.Album(nil).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser())
api.getItems(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(2))
Expect(res.Items[0].Type).To(Equal("MusicAlbum"))
Expect(res.TotalRecordCount).To(Equal(2))
})
It("lists an album's songs when ParentId is an album and type is Audio", func() {
ds.Album(nil).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.MediaFile(nil).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", AlbumID: "a1"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId=a1&IncludeItemTypes=Audio", nil).WithContext(ctxUser())
api.getItems(w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Type).To(Equal("Audio"))
Expect(res.Items[0].Id).To(Equal("s1"))
})
It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() {
ds.Album(nil).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId=ar1&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser())
api.getItems(w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
albumRepo := ds.Album(nil).(*tests.MockAlbumRepo)
sql, _, err := albumRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(sql).To(ContainSubstring("json_tree"))
})
It("lists artists when IncludeItemTypes=MusicArtist", func() {
ds.Artist(nil).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser())
api.getItems(w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Type).To(Equal("MusicArtist"))
})
It("lists genres when IncludeItemTypes=MusicGenre", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicGenre", nil).WithContext(ctxUser())
api.getItems(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).NotTo(BeNil())
})
It("applies a starred filter when Filters=IsFavorite", func() {
albumRepo := ds.Album(nil).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser())
api.getItems(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, _, err := albumRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(sql).To(ContainSubstring("starred"))
})
It("forwards SearchTerm to the repo's Search method", func() {
albumRepo := ds.Album(nil).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser())
api.getItems(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
})
It("forwards StartIndex/Limit as Offset/Max", func() {
albumRepo := ds.Album(nil).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser())
api.getItems(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(albumRepo.Options.Offset).To(Equal(5))
Expect(albumRepo.Options.Max).To(Equal(10))
})
})
Describe("getItem", func() {
It("returns an album by id", func() {
ds.Album(nil).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/a1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "a1")
api.getItem(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal("a1"))
Expect(item.Type).To(Equal("MusicAlbum"))
})
It("returns 404 when the id doesn't match any entity", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "missing")
api.getItem(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("getLatest", func() {
It("returns a bare array of the newest albums", func() {
ds.Album(nil).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser())
api.getLatest(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var items []dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed())
Expect(items).To(HaveLen(1))
Expect(items[0].Id).To(Equal("a1"))
})
})
})

View file

@ -145,6 +145,13 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles .
return result, nil
}
func (m *MockArtistRepo) CountAll(...model.QueryOptions) (int64, error) {
if m.Err {
return 0, errors.New("mock repo error")
}
return int64(len(m.Data)), nil
}
func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) {
if len(options) > 0 {
m.Options = options[0]