Deluan Quintão 5927e693d1
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.
2026-07-19 12:39:31 -04:00

62 lines
2.2 KiB
Go

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
// slice (non-admin with no libraries) is treated as a no-op/unrestricted by the library filters.
func accessibleLibraryIDs(ctx context.Context) []int {
u, _ := request.UserFrom(ctx)
return u.Libraries.IDs()
}
// resolveLibraryScope handles ParentId's ambiguity: a library id (browsing a UserView) or an
// entity id (artist/album). It's treated as a library only when the user has access; otherwise
// isLibraryParent is false and callers fall through to entity-id handling.
func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, isLibraryParent bool) {
if parentId != "" {
if id, err := strconv.Atoi(parentId); err == nil {
if u, _ := request.UserFrom(ctx); u.HasLibraryAccess(id) {
return []int{id}, true
}
}
}
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 {
return dto.BaseItemDto{
Id: dto.EncodeID(strconv.Itoa(lib.ID)),
Name: lib.Name,
Type: "CollectionFolder",
CollectionType: "music",
IsFolder: true,
BackdropImageTags: []string{},
}
}