mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* feat(persistence): add AlbumRepository.GetYears for distinct album years * test(persistence): verify GetYears de-duplicates repeated years Regression test that adds two albums with the same non-zero max_year (2005) and verifies that GetYears() returns that year exactly once, ensuring the SQL DISTINCT clause is applied correctly. Catches any future removal of DISTINCT from the GetYears query. * feat(jellyfin): add legacy /Items/Filters endpoint (genres + years) * feat(jellyfin): add /Studios endpoint from record label tags * refactor(persistence): drop duplicate columns in tagRepository.GetAll * fix(jellyfin): exclude missing albums from filter years GetYears only filtered max_year > 0, so albums whose files were all removed (missing=true, kept when Scanner.PurgeMissing=never) contributed stale years to /Items/Filters. Filter them out like the normal album listings do. Also return an empty slice from the MockAlbumRepo to match the real repository. * feat(jellyfin): filter /Items by Years= * feat(jellyfin): filter /Items by StudioIds= (record labels) * feat(jellyfin): scope filter and studio lists to ParentId library * refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call sites and the duplicated empty-scope guard into two small helpers, so the empty-scope=unrestricted contract lives in one place. Reuse the existing names() helper in the Years= e2e test. * feat(jellyfin): expose record labels as album Studios Add a Studios field to the album BaseItemDto, populated from the record-label tags and gated behind Fields=Studios (matching Jellyfin's ItemFields convention). Studio ids reuse the record-label tag identity, so they round-trip with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios empty for music; Feishin reads it as the album's record label.
227 lines
9.3 KiB
Go
227 lines
9.3 KiB
Go
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})
|
|
}
|
|
|
|
// admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership.
|
|
ctxAdmin := func() context.Context {
|
|
return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil})
|
|
}
|
|
|
|
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}}))
|
|
invoke(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}}))
|
|
invoke(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))
|
|
invoke(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))
|
|
invoke(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))
|
|
invoke(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}}))
|
|
invoke(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("bounds a search the client left unbounded, and clamps an oversized one", 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}}))
|
|
invoke(api.getArtists, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(artistRepo.Options.Max).To(Equal(defaultSearchLimit + 1))
|
|
|
|
w = httptest.NewRecorder()
|
|
r = httptest.NewRequest("GET", "/Artists?SearchTerm=art&Limit=999999", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
|
|
invoke(api.getArtists, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(artistRepo.Options.Max).To(Equal(maxSearchLimit + 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}}))
|
|
invoke(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))
|
|
})
|
|
|
|
It("does not restrict results for an admin user", func() {
|
|
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
|
|
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin())
|
|
invoke(api.getArtists, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
// accessibleLibraryIDs is empty for an admin (Libraries is nil), so
|
|
// ApplyArtistLibraryFilter([]) is a no-op: no library_id restriction is added.
|
|
if artistRepo.Options.Filters == nil {
|
|
return
|
|
}
|
|
sql, _, err := artistRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).NotTo(ContainSubstring("library_artist.library_id"))
|
|
})
|
|
})
|
|
|
|
Describe("getGenres", func() {
|
|
It("lists genres via /Genres", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Genres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
|
|
invoke(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}}))
|
|
invoke(api.getGenres, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
})
|
|
})
|
|
|
|
Describe("getStudios", func() {
|
|
It("scopes results to the user's accessible libraries", func() {
|
|
tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}}))
|
|
invoke(api.getStudios, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, args, err := tagRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("library_tag.library_id"))
|
|
Expect(args).To(ContainElements(1, 2))
|
|
})
|
|
|
|
// An empty scope (admin, or a non-admin with no explicit library grants) must be treated
|
|
// as unrestricted, matching accessibleLibraryIDs' documented contract, not as "match nothing".
|
|
It("does not restrict results for an admin user", func() {
|
|
tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxAdmin())
|
|
invoke(api.getStudios, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(tagRepo.Options.Filters).To(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("getQueryFiltersLegacy", func() {
|
|
It("scopes genres to the user's accessible libraries", func() {
|
|
genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}}))
|
|
invoke(api.getQueryFiltersLegacy, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, args, err := genreRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("library_tag.library_id"))
|
|
Expect(args).To(ContainElements(1, 2))
|
|
})
|
|
|
|
It("does not restrict genres for an admin user", func() {
|
|
genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxAdmin())
|
|
invoke(api.getQueryFiltersLegacy, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(genreRepo.Options.Filters).To(BeNil())
|
|
})
|
|
})
|
|
})
|