mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-15 22:07:08 +02:00
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.
This commit is contained in:
parent
c7d1f69c1b
commit
65b92168f9
3 changed files with 194 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
50
server/jellyfin/browsing.go
Normal file
50
server/jellyfin/browsing.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
139
server/jellyfin/browsing_test.go
Normal file
139
server/jellyfin/browsing_test.go
Normal file
|
|
@ -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=<inaccessible library id> 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue