mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(jellyfin): filter items by year and record label (#5817)
* 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.
This commit is contained in:
parent
b27d6f61ae
commit
5927e693d1
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
22
server/jellyfin/dto/filters_test.go
Normal file
22
server/jellyfin/dto/filters_test.go
Normal file
@ -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]`))
|
||||
})
|
||||
})
|
||||
@ -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.
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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=<libraryId> (scoping) plus GenreIds=<genreId>.
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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=<libraryId> 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=<libraryId> 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=<artistId> 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))
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
24
tests/mock_tag_repo.go
Normal file
24
tests/mock_tag_repo.go
Normal file
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user