diff --git a/model/album.go b/model/album.go index 16727ea39..0afeb2760 100644 --- a/model/album.go +++ b/model/album.go @@ -144,6 +144,7 @@ type AlbumRepository interface { Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) GetCursor(...QueryOptions) (AlbumCursor, error) + GetYears(libraryIDs ...int) ([]int, error) // The following methods are used exclusively by the scanner: Touch(ids ...string) error diff --git a/model/tag.go b/model/tag.go index 02ccac05d..1bc011495 100644 --- a/model/tag.go +++ b/model/tag.go @@ -153,6 +153,7 @@ func (t Tags) Add(name TagName, v string) { type TagRepository interface { Add(libraryID int, tags ...Tag) error UpdateCounts() error + GetAll(name TagName, options ...QueryOptions) (TagList, error) } type TagName string diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 53e8efe32..2bb541003 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "encoding/json" + "errors" "fmt" "iter" "maps" @@ -262,6 +263,20 @@ func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumC return wrapAlbumCursor(cursor), nil } +func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { + cond := And{Gt{"max_year": 0}, Eq{"missing": false}} + if len(libraryIDs) > 0 { + cond = append(cond, Eq{"library_id": libraryIDs}) + } + sq := r.applyLibraryFilter(Select("distinct max_year").From("album").Where(cond).OrderBy("max_year")) + years := []int{} + err := r.queryAllSlice(sq, &years) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + return years, nil +} + func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { var from dbx.NullStringMap err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 761255b7d..465617b10 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "errors" "fmt" + "sort" "time" "github.com/Masterminds/squirrel" @@ -851,6 +852,65 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetYears", func() { + It("returns distinct album years ascending, excluding zero", func() { + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // Sorted ascending, no duplicates, no zero-year entries. + Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue()) + Expect(years).ToNot(ContainElement(0)) + for i := 1; i < len(years); i++ { + Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct + } + }) + + It("deduplicates repeated years", func() { + // Regression test: verify that DISTINCT is applied in the SQL. + // Insert two albums with the same non-zero max_year (2005). + album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005} + album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005} + Expect(albumRepo.Put(album1)).To(Succeed()) + Expect(albumRepo.Put(album2)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + + // Count occurrences of 2005 in the result + count := 0 + for _, y := range years { + if y == 2005 { + count++ + } + } + Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it") + }) + + It("scopes years to the given libraries", func() { + all, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // A library with no albums yields no years. + scoped, err := albumRepo.GetYears(99999) + Expect(err).ToNot(HaveOccurred()) + Expect(scoped).To(BeEmpty()) + Expect(all).ToNot(BeEmpty()) + }) + + It("excludes years that belong only to missing albums", func() { + gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true} + Expect(albumRepo.Put(gone)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + Expect(years).ToNot(ContainElement(1911)) + }) + }) + Describe("wrapAlbumCursor", func() { It("does not panic when the cursor yields a dbAlbum with nil Album", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index 5bb8b3832..f2093c9d8 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -74,13 +74,20 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; return nil } +func (r *tagRepository) GetAll(name model.TagName, options ...model.QueryOptions) (model.TagList, error) { + sq := r.newSelect(options...).Where(Eq{"tag.tag_name": name}) + res := model.TagList{} + err := r.queryAll(sq, &res) + return res, err +} + func (r *tagRepository) purgeUnused() error { - del := Delete(r.tableName).Where(` + del := Delete(r.tableName).Where(` id not in (select jt.value from album left join json_tree(album.tags, '$') as jt where atom is not null and key = 'id' - UNION + UNION select jt.value from media_file left join json_tree(media_file.tags, '$') as jt where atom is not null diff --git a/server/filter/filters.go b/server/filter/filters.go index 5fa53daba..067f7b1f9 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -194,6 +194,16 @@ func ByAlbumID(albumIds []string) Sqlizer { return Eq{"album_id": albumIds} } +// AlbumsByYears matches albums whose production year (max_year) is in years. +func AlbumsByYears(years []int) Sqlizer { + return Eq{"max_year": years} +} + +// SongsByYears matches media files whose year is in years. +func SongsByYears(years []int) Sqlizer { + return Eq{"year": years} +} + // ArtistsByGenreID matches artists credited as album artist on an album with any of the given // genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. func ArtistsByGenreID(genreIds []string) Sqlizer { @@ -204,10 +214,17 @@ func ArtistsByGenreID(genreIds []string) Sqlizer { ) } -// genreTagFilter builds an EXISTS over the genre entries in the tags JSON, matching each entry -// against cond (its name via Like, or its tag id via Eq/IN). Shared by the name- and id-based lookups. -func genreTagFilter(cond Sqlizer) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.genre")`, And{NotEq{"atom": nil}, cond}) +// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each +// entry against cond (its name via Like, or its tag id via Eq/IN). +func tagIDFilter(tagName string, cond Sqlizer) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond}) +} + +func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) } + +// ByStudioID matches items (albums or songs) whose record-label tag id is in ids. +func ByStudioID(ids []string) Sqlizer { + return tagIDFilter("recordlabel", Eq{"value": ids}) } func filterByGenre(genre string) Sqlizer { diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 0f79011c7..1f46c08b4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -146,6 +146,8 @@ func (api *Router) routes() http.Handler { r.Get("/items/{itemId}/instantmix", api.getInstantMix) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) + r.Get("/studios", api.getStudios) + r.Get("/items/filters", api.getQueryFiltersLegacy) r.Post("/playlists", api.createPlaylist) r.Get("/playlists/{playlistId}", api.getPlaylist) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index fae21fc0b..29fe48c2b 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" ) // getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists @@ -27,7 +28,7 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) - scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + scopeIDs, _ := parentIDScope(ctx, r) // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. // Finamp's artist tab sends GenreIds when a genre filter is active. q := itemsQuery{ @@ -59,3 +60,44 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { } api.ok(w, r, res) } + +// getStudios handles GET /Studios, exposing record labels (Jellyfin's audio "studio" source) as +// Studio items, scoped to ParentId's library when accessible. +func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + scope, _ := parentIDScope(ctx, r) + opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} + labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts) + if err != nil { + api.internalError(w, r, err) + return + } + items := slice.Map(labels, dto.StudioToBaseItem) + offset, max := p.IntOr("startindex", 0), p.IntOr("limit", 0) + api.ok(w, r, result(paginate(items, offset, max), len(items), offset)) +} + +// getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's +// library when accessible. Tags/OfficialRatings have no music source, so they are always empty. +func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + scope, _ := parentIDScope(ctx, r) + genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} + genres, err := api.ds.Genre(ctx).GetAll(genreOpts) + if err != nil { + api.internalError(w, r, err) + return + } + years, err := api.ds.Album(ctx).GetYears(scope...) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, dto.QueryFiltersLegacy{ + Genres: slice.Map(genres, func(g model.Genre) string { return g.Name }), + Tags: []string{}, + OfficialRatings: []string{}, + Years: years, + }) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 660e5d293..e346b56cb 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -175,4 +175,52 @@ var _ = Describe("Browsing", func() { 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()) + }) + }) }) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index c8f7129cb..9b2c35c69 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -71,6 +71,7 @@ type BaseItemDto struct { ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` Genres []string `json:"Genres,omitempty"` GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + Studios []NameGuidPair `json:"Studios,omitempty"` NormalizationGain *float64 `json:"NormalizationGain,omitempty"` AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` @@ -288,3 +289,12 @@ type LyricLineCue struct { Start int64 `json:"Start"` End *int64 `json:"End,omitempty"` } + +// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present; +// clients (jellyfin-web) render each unconditionally. +type QueryFiltersLegacy struct { + Genres []string `json:"Genres"` + Tags []string `json:"Tags"` + OfficialRatings []string `json:"OfficialRatings"` + Years []int `json:"Years"` +} diff --git a/server/jellyfin/dto/filters_test.go b/server/jellyfin/dto/filters_test.go new file mode 100644 index 000000000..ad3367264 --- /dev/null +++ b/server/jellyfin/dto/filters_test.go @@ -0,0 +1,22 @@ +package dto + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("QueryFiltersLegacy", func() { + It("marshals all four keys, empty ones as [] not null", func() { + b, err := json.Marshal(QueryFiltersLegacy{ + Genres: []string{"Rock"}, Tags: []string{}, OfficialRatings: []string{}, Years: []int{1999}, + }) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"Genres":["Rock"]`)) + Expect(j).To(ContainSubstring(`"Tags":[]`)) + Expect(j).To(ContainSubstring(`"OfficialRatings":[]`)) + Expect(j).To(ContainSubstring(`"Years":[1999]`)) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 036dfbd91..444a25cac 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -200,7 +200,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { return item } -func AlbumToBaseItem(al model.Album) BaseItemDto { +func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { item := BaseItemDto{ Name: al.Name, Id: EncodeID(al.ID), @@ -232,6 +232,14 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios + // list and StudioIds= filter, so a client can display and click through to filter by label. + if fields.Has("Studios") { + for _, label := range al.Tags.Values(model.TagRecordLabel) { + id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID) + item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id}) + } + } // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same // convention as tracks; clients read it off the album item as NormalizationGain. item.NormalizationGain = al.RGAlbumGain @@ -264,6 +272,15 @@ func GenreToBaseItem(g model.Genre) BaseItemDto { } } +func StudioToBaseItem(t model.Tag) BaseItemDto { + return BaseItemDto{ + Name: t.TagValue, + Id: EncodeID(t.ID), + Type: "Studio", + BackdropImageTags: []string{}, + } +} + // PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. func PlaylistToBaseItem(p model.Playlist) BaseItemDto { // Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover. diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index c986e7f81..c11f38eaf 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -244,7 +244,7 @@ var _ = Describe("mappers", func() { It("maps an album to a MusicAlbum folder item", func() { al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} - item := AlbumToBaseItem(al) + item := AlbumToBaseItem(al, nil) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("alb-1"))) @@ -260,9 +260,22 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("populates album Studios from record-label tags only when Fields=Studios", func() { + al := model.Album{ID: "alb-2", Name: "Alb2"} + al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}} + + Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty()) + + item := AlbumToBaseItem(al, ParseFields("Studios")) + Expect(item.Studios).To(Equal([]NameGuidPair{ + {Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)}, + {Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)}, + })) + }) + It("sets NormalizationGain on the album from its ReplayGain", func() { al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} - b, err := json.Marshal(AlbumToBaseItem(al)) + b, err := json.Marshal(AlbumToBaseItem(al, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) // Real Jellyfin never sets AlbumNormalizationGain on an album item. @@ -270,7 +283,7 @@ var _ = Describe("mappers", func() { }) It("omits NormalizationGain when the album has no ReplayGain", func() { - b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) @@ -293,6 +306,13 @@ var _ = Describe("mappers", func() { Expect(item.Name).To(Equal("Rock")) }) + It("maps a tag to a Studio BaseItemDto", func() { + item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"}) + Expect(item.Type).To(Equal("Studio")) + Expect(item.Name).To(Equal("Blue Note")) + Expect(item.Id).To(Equal(EncodeID("t1"))) + }) + Describe("premiereDate", func() { // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. It("serializes a full date", func() { @@ -321,9 +341,9 @@ var _ = Describe("mappers", func() { }) It("is set on albums from their date, falling back to MaxYear", func() { - Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) - Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) - Expect(AlbumToBaseItem(model.Album{ID: "a3"}).PremiereDate).To(BeNil()) + Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil()) }) }) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index a0632994e..be6d6bb5d 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -2,6 +2,7 @@ package e2e import ( "net/http" + "sort" "time" "github.com/navidrome/navidrome/server/jellyfin/dto" @@ -210,6 +211,44 @@ var _ = Describe("Browsing", func() { }) }) + Describe("year filtering (Years=)", func() { + It("filters items by Years=", func() { + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Years=1959")) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + + songs := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Years=1959")) + for _, it := range songs.Items { + Expect(it.ProductionYear).ToNot(BeNil()) + Expect(*it.ProductionYear).To(Equal(1959)) + } + Expect(songs.Items).ToNot(BeEmpty()) + }) + }) + + Describe("studio filtering (StudioIds=)", func() { + It("filters items by StudioIds=", func() { + studios := queryResult(get("/Studios")) + var columbiaID string + for _, it := range studios.Items { + if it.Name == "Columbia" { + columbiaID = it.Id + } + } + Expect(columbiaID).ToNot(BeEmpty()) + + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + columbiaID)) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + }) + + It("returns filter lists scoped to a ParentId library", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Years).To(ContainElements(1959, 1965)) + studios := queryResult(get("/Studios?ParentId=" + enc("1"))) + Expect(names(studios.Items)).To(ContainElement("Columbia")) + }) + }) + // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. Describe("genre filtering (GenreIds)", func() { lib1 := enc("1") @@ -482,5 +521,31 @@ var _ = Describe("Browsing", func() { Expect(q.Items).To(HaveLen(1)) Expect(q.TotalRecordCount).To(Equal(3)) }) + + It("returns record labels as Studio items", func() { + q := queryResult(get("/Studios")) + names := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Studio")) + names = append(names, it.Name) + } + Expect(names).To(ContainElement("Columbia")) + }) + }) + + Describe("GET /Items/Filters", func() { + It("returns legacy query filters with genres, years, and empty tags/ratings", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Genres).To(ContainElements("Rock", "Jazz")) + Expect(filters.Years).To(ContainElements(1959, 1965, 1969, 1971)) + // Verify ascending sort by checking it equals itself sorted. + sorted := make([]int, len(filters.Years)) + copy(sorted, filters.Years) + sort.Ints(sorted) + Expect(filters.Years).To(Equal(sorted)) + Expect(filters.Tags).To(BeEmpty()) + Expect(filters.OfficialRatings).To(BeEmpty()) + }) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 5a4c8c031..31d98d1d9 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -111,7 +111,7 @@ func buildTestFS() storagetest.FakeFS { abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) - kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz", "label": "Columbia"}) singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) return harness.CreateFS(fstest.MapFS{ diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index c3c217cfc..7cf2a17ba 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -222,6 +222,8 @@ type itemsQuery struct { contributingOnly bool genreIds []string albumIds []string + years []int + studioIds []string } // parseItemsQuery also resolves the entity types (inferring them from the parent when @@ -245,7 +247,9 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. genreIds: decodedQueryIDs(r, "genreids"), // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds: decodedQueryIDs(r, "albumids"), + albumIds: decodedQueryIDs(r, "albumids"), + years: parseYears(r), + studioIds: decodedQueryIDs(r, "studioids"), } // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. @@ -414,6 +418,17 @@ func decodedQueryIDs(r *http.Request, key string) []string { return slice.Map(queryIDs(r, key), dto.DecodeID) } +// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. +func parseYears(r *http.Request) []int { + var years []int + for _, v := range queryIDs(r, "years") { + if y, err := strconv.Atoi(v); err == nil && y > 0 { + years = append(years, y) + } + } + return years +} + // parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to // {"MusicAlbum"} when none are recognized (so ParentId= browses that artist's albums). func parseTypes(types string) []string { @@ -481,6 +496,7 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO } func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) } repo := api.ds.Album(ctx) filters := squirrel.And{} // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's @@ -496,6 +512,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.AlbumsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -509,12 +531,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if err != nil { return itemsResult{}, err } - return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil + return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil } total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -537,6 +559,12 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.SongsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -665,7 +693,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if !u.HasLibraryAccess(al.LibraryID) { return dto.BaseItemDto{}, false } - return dto.AlbumToBaseItem(*al), true + return dto.AlbumToBaseItem(*al, fields), true } if ar, err := api.ds.Artist(ctx).Get(id); err == nil { // TODO: an artist spans multiple libraries (library_artist), so there's no single @@ -754,13 +782,15 @@ func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { // /Items/Latest, and why it writes directly instead of going through api.ok. func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + p := req.Params(r) + fields := dto.ParseFields(p.Strings("fields")...) opts := filter.AlbumsByNewest() - opts.Max = req.Params(r).IntOr("limit", 20) + opts.Max = p.IntOr("limit", 20) opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) repo := api.ds.Album(ctx) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) }) api.writeItemsArray(w, r, streamed(open, 0, 0)) } diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index 2e486c36f..2830f1105 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -2,11 +2,14 @@ package jellyfin import ( "context" + "net/http" "strconv" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" ) // accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty @@ -30,6 +33,20 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, return accessibleLibraryIDs(ctx), false } +// parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). +func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) { + return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", ""))) +} + +// libraryScopeFilter restricts a tag query to the given library scope. Empty scope means +// unrestricted (see accessibleLibraryIDs), so it returns nil rather than an impossible IN (). +func libraryScopeFilter(scope []int) squirrel.Sqlizer { + if len(scope) == 0 { + return nil + } + return squirrel.Eq{"library_tag.library_id": scope} +} + // libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. // Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. func libraryView(lib model.Library) dto.BaseItemDto { diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 085080850..661fb95cc 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -177,7 +177,7 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto. } seen[s.AlbumID] = true if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { - items = append(items, dto.AlbumToBaseItem(*al)) + items = append(items, dto.AlbumToBaseItem(*al, nil)) if len(items) >= limit { break } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 03dfed879..cc7d66b4d 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -209,4 +209,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { return nil } +func (m *MockAlbumRepo) GetYears(libraryIDs ...int) ([]int, error) { + if m.Err { + return nil, errors.New("error") + } + return []int{}, nil +} + var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 754f0c084..e016a28de 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -65,7 +65,7 @@ func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { if db.RealDS != nil { return db.RealDS.Tag(ctx) } - db.MockedTag = struct{ model.TagRepository }{} + db.MockedTag = &MockTagRepo{} return db.MockedTag } diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 122ccc278..50796efc0 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -5,8 +5,9 @@ import ( ) type MockedGenreRepo struct { - Error error - Data map[string]model.Genre + Error error + Data map[string]model.Genre + Options model.QueryOptions } func (r *MockedGenreRepo) init() { @@ -15,7 +16,10 @@ func (r *MockedGenreRepo) init() { } } -func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) { +func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, error) { + if len(options) > 0 { + r.Options = options[0] + } if r.Error != nil { return nil, r.Error } diff --git a/tests/mock_tag_repo.go b/tests/mock_tag_repo.go new file mode 100644 index 000000000..a59035ea6 --- /dev/null +++ b/tests/mock_tag_repo.go @@ -0,0 +1,24 @@ +package tests + +import ( + "github.com/navidrome/navidrome/model" +) + +// MockTagRepo records the QueryOptions passed to GetAll, mirroring MockArtistRepo, so tests can +// assert on which filters a caller attached (e.g. a library scope). +type MockTagRepo struct { + model.TagRepository + Data model.TagList + Options model.QueryOptions + Err error +} + +func (r *MockTagRepo) GetAll(_ model.TagName, options ...model.QueryOptions) (model.TagList, error) { + if len(options) > 0 { + r.Options = options[0] + } + if r.Err != nil { + return nil, r.Err + } + return r.Data, nil +}