From 0b9fbcd04819456fe70cbfde2858be7b5cc3488b Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 5 Jul 2026 15:27:53 -0400 Subject: [PATCH] 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). --- server/jellyfin/api.go | 6 + server/jellyfin/items.go | 252 ++++++++++++++++++++++++++++++++++ server/jellyfin/items_test.go | 171 +++++++++++++++++++++++ tests/mock_artist_repo.go | 7 + 4 files changed, 436 insertions(+) create mode 100644 server/jellyfin/items.go create mode 100644 server/jellyfin/items_test.go diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 1da4c5c21..cb0f6cb3b 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -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 diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go new file mode 100644 index 000000000..7995d1eb2 --- /dev/null +++ b/server/jellyfin/items.go @@ -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= +// (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" + } +} diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go new file mode 100644 index 000000000..8e0e5e110 --- /dev/null +++ b/server/jellyfin/items_test.go @@ -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")) + }) + }) +}) diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index b7a6fb811..81e5dd7f0 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -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]