From 65b92168f9f1c33e04973841efe70fdb39b4be41 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 5 Jul 2026 16:10:07 -0400 Subject: [PATCH] feat(jellyfin): Artists and Genres endpoints Add /Artists, /Artists/AlbumArtists, /Genres and /MusicGenres. Artists listing is library-scoped (defaulting to the user's accessible libraries, narrowed by an accessible ParentId), delegating access control to listArtists/ApplyArtistLibraryFilter. Genres are global and unscoped, matching the ML decision already made for listGenres. --- server/jellyfin/api.go | 5 ++ server/jellyfin/browsing.go | 50 +++++++++++ server/jellyfin/browsing_test.go | 139 +++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 server/jellyfin/browsing.go create mode 100644 server/jellyfin/browsing_test.go diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index cb0f6cb3b..8ec57bb1f 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -62,6 +62,11 @@ func (api *Router) routes() http.Handler { r.Get("/Items/{itemId}", api.getItem) r.Get("/Users/{userId}/Items/{itemId}", api.getItem) r.Get("/Users/{userId}/Items/Latest", api.getLatest) + + r.Get("/Artists", api.getArtists) + r.Get("/Artists/AlbumArtists", api.getArtists) + r.Get("/Genres", api.getGenres) + r.Get("/MusicGenres", api.getGenres) }) return r diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go new file mode 100644 index 000000000..95840f0ac --- /dev/null +++ b/server/jellyfin/browsing.go @@ -0,0 +1,50 @@ +package jellyfin + +import ( + "net/http" + "strconv" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/req" +) + +// getArtists handles /Artists and /Artists/AlbumArtists. It defaults to the user's +// accessible libraries but narrows to a single one when ParentId names a library the +// user can access, matching how queryItems treats ParentId as a UserView id. +func (api *Router) getArtists(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("StartIndex", 0), Max: p.IntOr("Limit", 0)} + applySort(&opts, "MusicArtist", p.StringOr("SortBy", ""), p.StringOr("SortOrder", "")) + + scopeIDs := accessibleLibraryIDs(ctx) + if parentId := p.StringOr("ParentId", ""); parentId != "" { + if libID, err := strconv.Atoi(parentId); err == nil { + if u, _ := request.UserFrom(ctx); u.HasLibraryAccess(libID) { + scopeIDs = []int{libID} + } + } + } + + res, err := api.listArtists(ctx, opts, scopeIDs, p.StringOr("SearchTerm", ""), false) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + api.ok(w, r, res) +} + +// getGenres handles /Genres and /MusicGenres. Genres are global (see listGenres), so no +// library scoping is applied here. +func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("StartIndex", 0), Max: p.IntOr("Limit", 0)} + res, err := api.listGenres(ctx, opts) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + api.ok(w, r, res) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go new file mode 100644 index 000000000..bcb5b1c36 --- /dev/null +++ b/server/jellyfin/browsing_test.go @@ -0,0 +1,139 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "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" +) + +var _ = Describe("Browsing", func() { + var api *Router + var ds *tests.MockDataStore + ctxUser := func(libs model.Libraries) context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + api = &Router{ds: ds} + }) + + Describe("getArtists", func() { + It("lists artists via /Artists", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + api.getArtists(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)) + Expect(res.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("handles /Artists/AlbumArtists the same way", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + api.getArtists(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("scopes results to the user's accessible libraries", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs)) + api.getArtists(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes to a single library when ParentId is an accessible library id", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists?ParentId=2", nil).WithContext(ctxUser(libs)) + api.getArtists(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(2)) + Expect(args).NotTo(ContainElement(1)) + }) + + It("does not let ParentId= narrow the scope", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}} // no access to library 99 + r := httptest.NewRequest("GET", "/Artists?ParentId=99", nil).WithContext(ctxUser(libs)) + api.getArtists(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(1)) + Expect(args).NotTo(ContainElement(99)) + }) + + It("forwards SearchTerm to the repo's Search method", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + api.getArtists(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() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + api.getArtists(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Offset).To(Equal(5)) + Expect(artistRepo.Options.Max).To(Equal(10)) + }) + }) + + Describe("getGenres", func() { + It("lists genres via /Genres", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Genres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + api.getGenres(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("handles /MusicGenres the same way", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/MusicGenres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + api.getGenres(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) +})