mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* feat(persistence): add AlbumRepository.GetYears for distinct album years * test(persistence): verify GetYears de-duplicates repeated years Regression test that adds two albums with the same non-zero max_year (2005) and verifies that GetYears() returns that year exactly once, ensuring the SQL DISTINCT clause is applied correctly. Catches any future removal of DISTINCT from the GetYears query. * feat(jellyfin): add legacy /Items/Filters endpoint (genres + years) * feat(jellyfin): add /Studios endpoint from record label tags * refactor(persistence): drop duplicate columns in tagRepository.GetAll * fix(jellyfin): exclude missing albums from filter years GetYears only filtered max_year > 0, so albums whose files were all removed (missing=true, kept when Scanner.PurgeMissing=never) contributed stale years to /Items/Filters. Filter them out like the normal album listings do. Also return an empty slice from the MockAlbumRepo to match the real repository. * feat(jellyfin): filter /Items by Years= * feat(jellyfin): filter /Items by StudioIds= (record labels) * feat(jellyfin): scope filter and studio lists to ParentId library * refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call sites and the duplicated empty-scope guard into two small helpers, so the empty-scope=unrestricted contract lives in one place. Reuse the existing names() helper in the Years= e2e test. * feat(jellyfin): expose record labels as album Studios Add a Studios field to the album BaseItemDto, populated from the record-label tags and gated behind Fields=Studios (matching Jellyfin's ItemFields convention). Studio ids reuse the record-label tag identity, so they round-trip with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios empty for music; Feishin reads it as the album's record label.
104 lines
3.6 KiB
Go
104 lines
3.6 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"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
|
|
// handles GET /Artists/AlbumArtists (album artists only). Distinct roles, so composers/arrangers
|
|
// don't appear identically in both.
|
|
func (api *Router) getArtists(w http.ResponseWriter, r *http.Request) {
|
|
api.listArtistsByRole(w, r, model.RoleArtist)
|
|
}
|
|
|
|
func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) {
|
|
api.listArtistsByRole(w, r, model.RoleAlbumArtist)
|
|
}
|
|
|
|
// listArtistsByRole is the shared body of the /Artists* handlers, scoping to ParentId's library
|
|
// when accessible (like queryItems) or all accessible libraries otherwise.
|
|
func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)}
|
|
applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", ""))
|
|
|
|
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{
|
|
scopeIDs: scopeIDs,
|
|
genreIds: decodedQueryIDs(r, "genreids"),
|
|
search: searchTerm(p),
|
|
}
|
|
if q.search != "" {
|
|
opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit)
|
|
}
|
|
|
|
res, err := api.listArtists(ctx, opts, q, role)
|
|
if err != nil {
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
api.ok(w, r, res)
|
|
}
|
|
|
|
// getGenres handles /Genres and /MusicGenres. Genres are global, so no library scoping applies.
|
|
func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)}
|
|
res, err := api.listGenres(ctx, opts)
|
|
if err != nil {
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
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,
|
|
})
|
|
}
|