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.
245 lines
7.0 KiB
Go
245 lines
7.0 KiB
Go
package filter
|
|
|
|
import (
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/persistence"
|
|
)
|
|
|
|
type Options = model.QueryOptions
|
|
|
|
var defaultFilters = Eq{"missing": false}
|
|
|
|
func addDefaultFilters(options Options) Options {
|
|
if options.Filters == nil {
|
|
options.Filters = defaultFilters
|
|
} else {
|
|
options.Filters = And{defaultFilters, options.Filters}
|
|
}
|
|
return options
|
|
}
|
|
|
|
func AlbumsByNewest() Options {
|
|
return addDefaultFilters(addDefaultFilters(Options{Sort: "recently_added", Order: "desc"}))
|
|
}
|
|
|
|
func AlbumsByRecent() Options {
|
|
return addDefaultFilters(Options{Sort: "playDate", Order: "desc", Filters: Gt{"play_date": time.Time{}}})
|
|
}
|
|
|
|
func AlbumsByFrequent() Options {
|
|
return addDefaultFilters(Options{Sort: "playCount", Order: "desc", Filters: Gt{"play_count": 0}})
|
|
}
|
|
|
|
func AlbumsByRandom() Options {
|
|
return addDefaultFilters(Options{Sort: "random"})
|
|
}
|
|
|
|
func AlbumsByName() Options {
|
|
return addDefaultFilters(Options{Sort: "name"})
|
|
}
|
|
|
|
func AlbumsByArtist() Options {
|
|
return addDefaultFilters(Options{Sort: "artist"})
|
|
}
|
|
|
|
func AlbumsByArtistID(artistId string) Options {
|
|
filters := []Sqlizer{
|
|
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}),
|
|
}
|
|
if conf.Server.Subsonic.ArtistParticipations {
|
|
filters = append(filters,
|
|
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}),
|
|
)
|
|
}
|
|
return addDefaultFilters(Options{
|
|
Sort: "max_year",
|
|
Filters: Or(filters),
|
|
})
|
|
}
|
|
|
|
// AlbumsByContributingArtistID matches albums where the artist performs on a track but is not the
|
|
// album artist — Jellyfin's "Featured On". The disjoint complement of AlbumsByArtistID, so an
|
|
// artist's own discography never leaks into it.
|
|
func AlbumsByContributingArtistID(artistId string) Options {
|
|
return addDefaultFilters(Options{
|
|
Sort: "max_year",
|
|
Filters: And{
|
|
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}),
|
|
persistence.NotExists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}),
|
|
},
|
|
})
|
|
}
|
|
|
|
func AlbumsByYear(fromYear, toYear int) Options {
|
|
orderOption := ""
|
|
if fromYear > toYear {
|
|
fromYear, toYear = toYear, fromYear
|
|
orderOption = "desc"
|
|
}
|
|
return addDefaultFilters(Options{
|
|
Sort: "max_year",
|
|
Order: orderOption,
|
|
Filters: Or{
|
|
And{
|
|
GtOrEq{"min_year": fromYear},
|
|
LtOrEq{"min_year": toYear},
|
|
},
|
|
And{
|
|
GtOrEq{"max_year": fromYear},
|
|
LtOrEq{"max_year": toYear},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func SongsByAlbum(albumId string) Options {
|
|
return addDefaultFilters(Options{
|
|
Filters: Eq{"album_id": albumId},
|
|
Sort: "album",
|
|
})
|
|
}
|
|
|
|
// SongsByArtistID matches media files where the artist participates as album or track artist, in
|
|
// album order. Semi-joins media_file_artists; scanning the participants JSON is ~10x slower at scale.
|
|
func SongsByArtistID(artistId string) Options {
|
|
return addDefaultFilters(Options{
|
|
Sort: "album",
|
|
Filters: Expr(
|
|
"media_file.id IN (SELECT media_file_id FROM media_file_artists WHERE artist_id = ? AND role IN (?, ?))",
|
|
artistId, model.RoleArtist.String(), model.RoleAlbumArtist.String()),
|
|
})
|
|
}
|
|
|
|
func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options {
|
|
options := Options{}
|
|
ff := And{}
|
|
if genre != "" {
|
|
ff = append(ff, filterByGenre(genre))
|
|
}
|
|
if fromYear != 0 {
|
|
ff = append(ff, GtOrEq{"year": fromYear})
|
|
}
|
|
if toYear != 0 {
|
|
ff = append(ff, LtOrEq{"year": toYear})
|
|
}
|
|
options.Filters = ff
|
|
return addDefaultFilters(options)
|
|
}
|
|
|
|
func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options {
|
|
if len(musicFolderIds) == 0 {
|
|
return opts
|
|
}
|
|
|
|
libraryFilter := Eq{"library_id": musicFolderIds}
|
|
if opts.Filters == nil {
|
|
opts.Filters = libraryFilter
|
|
} else {
|
|
opts.Filters = And{opts.Filters, libraryFilter}
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
// ApplyArtistLibraryFilter applies a filter to the given Options to ensure that only artists
|
|
// that are associated with the specified music folders are included in the results.
|
|
func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options {
|
|
if len(musicFolderIds) == 0 {
|
|
return opts
|
|
}
|
|
|
|
artistLibraryFilter := Eq{"library_artist.library_id": musicFolderIds}
|
|
if opts.Filters == nil {
|
|
opts.Filters = artistLibraryFilter
|
|
} else {
|
|
opts.Filters = And{opts.Filters, artistLibraryFilter}
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
// ArtistsByRole restricts an artist query to artists appearing in the given role (album artist,
|
|
// performer, composer, ...) via library_artist.stats. An unknown role is ignored (no filter).
|
|
func ArtistsByRole(opts Options, role model.Role) Options {
|
|
if _, ok := model.AllRoles[role.String()]; !ok {
|
|
return opts
|
|
}
|
|
roleFilter := Expr("JSON_EXTRACT(library_artist.stats, '$." + role.String() + ".m') IS NOT NULL")
|
|
if opts.Filters == nil {
|
|
opts.Filters = roleFilter
|
|
} else {
|
|
opts.Filters = And{opts.Filters, roleFilter}
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func ByGenre(genre string) Options {
|
|
return addDefaultFilters(Options{
|
|
Sort: "name",
|
|
Filters: filterByGenre(genre),
|
|
})
|
|
}
|
|
|
|
// ByGenreID matches items (albums or songs) tagged with any of the given genre tag ids.
|
|
func ByGenreID(genreIds []string) Sqlizer {
|
|
return genreTagFilter(Eq{"value": genreIds})
|
|
}
|
|
|
|
// ByAlbumID matches media files belonging to any of the given albums.
|
|
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 {
|
|
return Expr(
|
|
`artist.id IN (SELECT jt.value FROM album, json_tree(album.participants, '$.albumartist') jt
|
|
WHERE jt.atom IS NOT NULL AND ?)`,
|
|
genreTagFilter(Eq{"value": genreIds}),
|
|
)
|
|
}
|
|
|
|
// 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 {
|
|
return genreTagFilter(Like{"value": genre})
|
|
}
|
|
|
|
func ByRating() Options {
|
|
return addDefaultFilters(Options{Sort: "rating", Order: "desc", Filters: Gt{"rating": 0}})
|
|
}
|
|
|
|
func ByStarred() Options {
|
|
return addDefaultFilters(Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}})
|
|
}
|
|
|
|
func ArtistsByStarred() Options {
|
|
return Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}}
|
|
}
|