navidrome/server/filter/filters.go
Deluan 65f6bc2526 feat(pglite): run Navidrome on the embedded PostgreSQL end to end
Replace the 131 SQLite migrations with a single goose migration that creates
the whole schema in PostgreSQL (the user table becomes user_account, json
columns become jsonb, seed rows for the default library and transcodings).
Port the repositories' SQL just far enough for Navidrome to start, scan,
log in and browse artists and albums: json_agg/jsonb_* for the SQLite JSON
functions, lower() for COLLATE NOCASE, ON CONFLICT DO NOTHING, coalesce on
annotations, stricter GROUP BY, explicit casts under the simple protocol,
sequential queries in library.RefreshStats. db/ is now Postgres-only, with
backup/restore stubbed and DevExternalScanner forced off.

Embed the PGlite module like go-taglib: pglite.wasi.gz is go:embed'ed,
decompressed in memory and compiled by wazero with an on-disk compilation
cache, so the module itself is never written to disk. The cluster is
created by initdb at runtime on the first start; initdb runs in a
throwaway wasm instance and the backend in a fresh one, otherwise the next
start cannot find a valid checkpoint. Since initdb creates only template1,
a short-lived backend then runs CREATE DATABASE navidrome and the real
backend starts on it. The build recipe and patches for the -O2, no-wizer
module live in db/pglite/build.
2026-09-04 20:41:52 -04:00

228 lines
6.6 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 {
roles := []model.Role{model.RoleAlbumArtist}
if conf.Server.Subsonic.ArtistParticipations {
roles = append(roles, model.RoleArtist)
}
return addDefaultFilters(Options{
Sort: "max_year",
Filters: persistence.ParticipantIDFilter("album", artistId, roles...),
})
}
// 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.ParticipantIDFilter("album", artistId, model.RoleArtist),
persistence.NotParticipantIDFilter("album", artistId, model.RoleAlbumArtist),
},
})
}
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.
func SongsByArtistID(artistId string) Options {
return addDefaultFilters(Options{
Sort: "album",
Filters: persistence.ParticipantIDFilter("media_file", artistId, model.RoleArtist, model.RoleAlbumArtist),
})
}
func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options {
options := Options{}
ff := And{}
if genre != "" {
ff = append(ff, persistence.SongGenres.ByName(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("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
}
// SongsByGenreID / AlbumsByGenreID (by tag id) and AlbumsByGenre / SongsByGenre (by name, wrapped
// as Options for Subsonic) delegate to the persistence genre filters, which own the join schema.
func SongsByGenreID(genreIds []string) Sqlizer { return persistence.SongGenres.ByID(genreIds) }
func AlbumsByGenreID(genreIds []string) Sqlizer { return persistence.AlbumGenres.ByID(genreIds) }
func AlbumsByGenre(genre string) Options {
return addDefaultFilters(Options{Sort: "name", Filters: persistence.AlbumGenres.ByName(genre)})
}
func SongsByGenre(genre string) Options {
return addDefaultFilters(Options{Sort: "name", Filters: persistence.SongGenres.ByName(genre)})
}
// 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}
}
func ArtistsByGenreID(genreIds []string) Sqlizer {
return persistence.AlbumArtistsByGenreID(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})
}
// 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 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}}
}