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:
Deluan Quintão 2026-07-19 12:39:31 -04:00 committed by GitHub
parent b27d6f61ae
commit 5927e693d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 435 additions and 26 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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 {

View File

@ -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)

View File

@ -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,
})
}

View File

@ -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())
})
})
})

View File

@ -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"`
}

View 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]`))
})
})

View File

@ -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.

View File

@ -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())
})
})

View File

@ -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())
})
})
})

View File

@ -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{

View File

@ -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))
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -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
View 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
}