refactor: centralize criteria sort parsing and extract smart playlist logic (#5415)

* test: add tests for recordingdate alias resolution in smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: update FieldInfo structure and simplify fieldMap initialization

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move sort parsing logic from persistence to criteria package

Extracted sort field parsing, validation, and direction handling from
persistence/criteria_sql.go into model/criteria/sort.go. The new
OrderByFields method on Criteria parses the Sort/Order strings into
validated SortField structs (field name + direction), resolving aliases
and handling +/- prefixes and order inversion. The persistence layer now
consumes these parsed fields and only handles SQL expression mapping.
This centralizes sort parsing to enforce consistent implementations.

* refactor: standardize field access in smartPlaylistCriteria structure

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: add ResolveLimit method to Criteria

Moved the percentage-limit resolution logic from playlist_repository
into Criteria.ResolveLimit, replacing the 3-line mutate-after-query
pattern with a single method call. The method preserves LimitPercent
rather than zeroing it, since IsPercentageLimit already returns false
once Limit is set, making the clear redundant and lossy.

* refactor: improve child playlist loading and error handling in refresh logic

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: extract smart playlist logic to dedicated files

Moved refreshSmartPlaylist, addSmartPlaylistAnnotationJoins, and
addCriteria methods from playlist_repository.go to a new
smart_playlist_repository.go file. Extracted all smart playlist tests
to smart_playlist_repository_test.go. Added DeferCleanup to the
"valid rules" test to fix ordering flakiness when Ginkgo randomizes
test execution across files.

* refactor: break refreshSmartPlaylist into smaller focused methods

Split the monolithic refreshSmartPlaylist method into discrete helpers
for readability: shouldRefreshSmartPlaylist for guard checks,
refreshChildPlaylists for recursive dependency refresh,
resolvePercentageLimit for count-based limit resolution,
buildSmartPlaylistQuery for assembling the SELECT with joins, and
addMediaFileAnnotationJoin to DRY up the repeated annotation join clause.

* refactor: deduplicate child playlist IDs in Criteria

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: simplify withSmartPlaylistOwner to accept model.User

Replaced separate ownerID string and ownerIsAdmin bool parameters with a
single model.User struct, reducing the field count in smartPlaylistCriteria
and making the option function signature clearer. Updated all call sites
and tests accordingly.

* fix: handle empty sort fields and propagate child playlist load errors

OrderByFields now falls back to [{title, asc}] when all user-supplied
sort fields are invalid, preventing empty ORDER BY clauses that would
produce invalid SQL in row_number() window functions. Also restored the
original behavior where a DB error loading child playlists aborts the
parent smart playlist refresh, by making refreshChildPlaylists return a
bool.

* refactor: log warning when no valid sort fields are found

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão 2026-04-26 14:49:59 -04:00 committed by GitHub
parent 0ab10e819f
commit 1bd736dae9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1069 additions and 827 deletions

View File

@ -4,6 +4,7 @@ package criteria
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"slices"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
) )
@ -42,6 +43,16 @@ func (c Criteria) EffectiveLimit(totalCount int64) int {
return 0 return 0
} }
// ResolveLimit converts a percentage-based limit into an absolute Limit using
// the given totalCount. It is a no-op when a fixed Limit is already set or when
// no percentage limit is configured.
func (c *Criteria) ResolveLimit(totalCount int64) {
if !c.IsPercentageLimit() {
return
}
c.Limit = c.EffectiveLimit(totalCount)
}
// IsPercentageLimit returns true when the criteria uses a valid percentage-based // IsPercentageLimit returns true when the criteria uses a valid percentage-based
// limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it). // limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it).
func (c Criteria) IsPercentageLimit() bool { func (c Criteria) IsPercentageLimit() bool {
@ -53,11 +64,14 @@ func (c Criteria) ChildPlaylistIds() []string {
return nil return nil
} }
if parent, ok := c.Expression.(conjunction); ok { parent, ok := c.Expression.(conjunction)
return parent.ChildPlaylistIds() if !ok {
return nil
} }
return nil ids := parent.ChildPlaylistIds()
slices.Sort(ids)
return slices.Compact(ids)
} }
func (c Criteria) MarshalJSON() ([]byte, error) { func (c Criteria) MarshalJSON() ([]byte, error) {

View File

@ -177,6 +177,39 @@ var _ = Describe("Criteria", func() {
}) })
}) })
Describe("ResolveLimit", func() {
It("resolves percentage to absolute limit preserving LimitPercent", func() {
c := Criteria{LimitPercent: 10}
c.ResolveLimit(450)
gomega.Expect(c.Limit).To(gomega.Equal(45))
})
It("does nothing when Limit is already set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
c.ResolveLimit(1000)
gomega.Expect(c.Limit).To(gomega.Equal(50))
})
It("does nothing when no limit is configured", func() {
c := Criteria{}
c.ResolveLimit(1000)
gomega.Expect(c.Limit).To(gomega.Equal(0))
})
It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() {
c := Criteria{LimitPercent: 1}
c.ResolveLimit(5)
gomega.Expect(c.Limit).To(gomega.Equal(1))
})
It("is idempotent when called twice", func() {
c := Criteria{LimitPercent: 10}
c.ResolveLimit(450)
c.ResolveLimit(450)
gomega.Expect(c.Limit).To(gomega.Equal(45))
})
})
Describe("IsPercentageLimit", func() { Describe("IsPercentageLimit", func() {
It("returns true when LimitPercent is set and Limit is 0", func() { It("returns true when LimitPercent is set and Limit is 0", func() {
c := Criteria{LimitPercent: 10} c := Criteria{LimitPercent: 10}
@ -269,5 +302,19 @@ var _ = Describe("Criteria", func() {
ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds() ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty()) gomega.Expect(ids).To(gomega.BeEmpty())
}) })
It("deduplicates repeated playlist IDs", func() {
sharedID := uuid.NewString()
goObj = Criteria{
Expression: All{
InPlaylist{"id": sharedID},
Any{
InPlaylist{"id": sharedID},
NotInPlaylist{"id": sharedID},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.Equal([]string{sharedID}))
})
}) })
}) })

View File

@ -2,90 +2,83 @@ package criteria
import "strings" import "strings"
// FieldInfo describes a criteria field without tying it to persistence details. // FieldInfo contains semantic metadata about a criteria field
type FieldInfo struct { type FieldInfo struct {
Name string Name string
IsTag bool IsTag bool
IsRole bool IsRole bool
Numeric bool Numeric bool
alias string
} }
var fieldMap = map[string]*fieldMetadata{ var fieldMap = map[string]FieldInfo{
"title": {name: "title"}, "title": {Name: "title"},
"album": {name: "album"}, "album": {Name: "album"},
"hascoverart": {name: "hascoverart"}, "hascoverart": {Name: "hascoverart"},
"tracknumber": {name: "tracknumber"}, "tracknumber": {Name: "tracknumber"},
"discnumber": {name: "discnumber"}, "discnumber": {Name: "discnumber"},
"year": {name: "year"}, "year": {Name: "year"},
"date": {name: "date", alias: "recordingdate"}, "date": {Name: "date", alias: "recordingdate"},
"originalyear": {name: "originalyear"}, "originalyear": {Name: "originalyear"},
"originaldate": {name: "originaldate"}, "originaldate": {Name: "originaldate"},
"releaseyear": {name: "releaseyear"}, "releaseyear": {Name: "releaseyear"},
"releasedate": {name: "releasedate"}, "releasedate": {Name: "releasedate"},
"size": {name: "size"}, "size": {Name: "size"},
"compilation": {name: "compilation"}, "compilation": {Name: "compilation"},
"missing": {name: "missing"}, "missing": {Name: "missing"},
"explicitstatus": {name: "explicitstatus"}, "explicitstatus": {Name: "explicitstatus"},
"dateadded": {name: "dateadded"}, "dateadded": {Name: "dateadded"},
"datemodified": {name: "datemodified"}, "datemodified": {Name: "datemodified"},
"discsubtitle": {name: "discsubtitle"}, "discsubtitle": {Name: "discsubtitle"},
"comment": {name: "comment"}, "comment": {Name: "comment"},
"lyrics": {name: "lyrics"}, "lyrics": {Name: "lyrics"},
"sorttitle": {name: "sorttitle"}, "sorttitle": {Name: "sorttitle"},
"sortalbum": {name: "sortalbum"}, "sortalbum": {Name: "sortalbum"},
"sortartist": {name: "sortartist"}, "sortartist": {Name: "sortartist"},
"sortalbumartist": {name: "sortalbumartist"}, "sortalbumartist": {Name: "sortalbumartist"},
"albumcomment": {name: "albumcomment"}, "albumcomment": {Name: "albumcomment"},
"catalognumber": {name: "catalognumber"}, "catalognumber": {Name: "catalognumber"},
"filepath": {name: "filepath"}, "filepath": {Name: "filepath"},
"filetype": {name: "filetype"}, "filetype": {Name: "filetype"},
"codec": {name: "codec"}, "codec": {Name: "codec"},
"duration": {name: "duration"}, "duration": {Name: "duration"},
"bitrate": {name: "bitrate"}, "bitrate": {Name: "bitrate"},
"bitdepth": {name: "bitdepth"}, "bitdepth": {Name: "bitdepth"},
"samplerate": {name: "samplerate"}, "samplerate": {Name: "samplerate"},
"bpm": {name: "bpm"}, "bpm": {Name: "bpm"},
"channels": {name: "channels"}, "channels": {Name: "channels"},
"loved": {name: "loved"}, "loved": {Name: "loved"},
"dateloved": {name: "dateloved"}, "dateloved": {Name: "dateloved"},
"lastplayed": {name: "lastplayed"}, "lastplayed": {Name: "lastplayed"},
"daterated": {name: "daterated"}, "daterated": {Name: "daterated"},
"playcount": {name: "playcount"}, "playcount": {Name: "playcount"},
"rating": {name: "rating"}, "rating": {Name: "rating"},
"averagerating": {name: "averagerating", numeric: true}, "averagerating": {Name: "averagerating", Numeric: true},
"albumrating": {name: "albumrating"}, "albumrating": {Name: "albumrating"},
"albumloved": {name: "albumloved"}, "albumloved": {Name: "albumloved"},
"albumplaycount": {name: "albumplaycount"}, "albumplaycount": {Name: "albumplaycount"},
"albumlastplayed": {name: "albumlastplayed"}, "albumlastplayed": {Name: "albumlastplayed"},
"albumdateloved": {name: "albumdateloved"}, "albumdateloved": {Name: "albumdateloved"},
"albumdaterated": {name: "albumdaterated"}, "albumdaterated": {Name: "albumdaterated"},
"artistrating": {name: "artistrating"}, "artistrating": {Name: "artistrating"},
"artistloved": {name: "artistloved"}, "artistloved": {Name: "artistloved"},
"artistplaycount": {name: "artistplaycount"}, "artistplaycount": {Name: "artistplaycount"},
"artistlastplayed": {name: "artistlastplayed"}, "artistlastplayed": {Name: "artistlastplayed"},
"artistdateloved": {name: "artistdateloved"}, "artistdateloved": {Name: "artistdateloved"},
"artistdaterated": {name: "artistdaterated"}, "artistdaterated": {Name: "artistdaterated"},
"mbz_album_id": {name: "mbz_album_id"}, "mbz_album_id": {Name: "mbz_album_id"},
"mbz_album_artist_id": {name: "mbz_album_artist_id"}, "mbz_album_artist_id": {Name: "mbz_album_artist_id"},
"mbz_artist_id": {name: "mbz_artist_id"}, "mbz_artist_id": {Name: "mbz_artist_id"},
"mbz_recording_id": {name: "mbz_recording_id"}, "mbz_recording_id": {Name: "mbz_recording_id"},
"mbz_release_track_id": {name: "mbz_release_track_id"}, "mbz_release_track_id": {Name: "mbz_release_track_id"},
"mbz_release_group_id": {name: "mbz_release_group_id"}, "mbz_release_group_id": {Name: "mbz_release_group_id"},
"library_id": {name: "library_id", numeric: true}, "library_id": {Name: "library_id", Numeric: true},
// Backward compatibility: albumtype is an alias for the releasetype tag. // Backward compatibility: albumtype is an alias for the releasetype tag.
"albumtype": {name: "releasetype", isTag: true}, "albumtype": {Name: "releasetype", IsTag: true},
"random": {name: "random"}, "random": {Name: "random"},
"value": {name: "value"}, "value": {Name: "value"},
}
type fieldMetadata struct {
name string
isRole bool
isTag bool
alias string
numeric bool
} }
// AllFieldNames returns the names of all registered criteria fields. // AllFieldNames returns the names of all registered criteria fields.
@ -100,15 +93,7 @@ func AllFieldNames() []string {
// LookupField returns semantic metadata for a criteria field name. // LookupField returns semantic metadata for a criteria field name.
func LookupField(name string) (FieldInfo, bool) { func LookupField(name string) (FieldInfo, bool) {
f, ok := fieldMap[strings.ToLower(name)] f, ok := fieldMap[strings.ToLower(name)]
if !ok { return f, ok
return FieldInfo{}, false
}
return FieldInfo{
Name: f.name,
IsTag: f.isTag,
IsRole: f.isRole,
Numeric: f.numeric,
}, true
} }
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in // AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
@ -119,7 +104,7 @@ func AddRoles(roles []string) {
if _, ok := fieldMap[name]; ok { if _, ok := fieldMap[name]; ok {
continue continue
} }
fieldMap[name] = &fieldMetadata{name: name, isRole: true} fieldMap[name] = FieldInfo{Name: name, IsRole: true}
} }
} }
@ -138,7 +123,7 @@ func AddTagNames(tagNames []string) {
} }
} }
if _, ok := fieldMap[name]; !ok { if _, ok := fieldMap[name]; !ok {
fieldMap[name] = &fieldMetadata{name: name, isTag: true} fieldMap[name] = FieldInfo{Name: name, IsTag: true}
} }
} }
} }
@ -148,9 +133,10 @@ func AddNumericTags(tagNames []string) {
for _, tagName := range tagNames { for _, tagName := range tagNames {
name := strings.ToLower(tagName) name := strings.ToLower(tagName)
if fm, ok := fieldMap[name]; ok { if fm, ok := fieldMap[name]; ok {
fm.numeric = true fm.Numeric = true
fieldMap[name] = fm
} else { } else {
fieldMap[name] = &fieldMetadata{name: name, isTag: true, numeric: true} fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true}
} }
} }
} }

62
model/criteria/sort.go Normal file
View File

@ -0,0 +1,62 @@
package criteria
import (
"strings"
"github.com/navidrome/navidrome/log"
)
type SortField struct {
Field string
Desc bool
}
func (c Criteria) OrderByFields() []SortField {
sortValue := c.Sort
if sortValue == "" {
sortValue = "title"
}
order := strings.ToLower(strings.TrimSpace(c.Order))
if order != "" && order != "asc" && order != "desc" {
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
order = ""
}
parts := strings.Split(sortValue, ",")
fields := make([]SortField, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
desc := false
if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") {
desc = strings.HasPrefix(part, "-")
part = strings.TrimSpace(part[1:])
}
info, ok := LookupField(part)
if !ok {
log.Error("Invalid field in 'sort' field", "sort", part)
continue
}
if order == "desc" {
desc = !desc
}
fields = append(fields, SortField{Field: info.Name, Desc: desc})
}
if len(fields) == 0 {
log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue)
return []SortField{{Field: "title", Desc: false}}
}
return fields
}
func (c Criteria) SortFieldNames() []string {
sortFields := c.OrderByFields()
names := make([]string, len(sortFields))
for i, sf := range sortFields {
names[i] = sf.Field
}
return names
}

103
model/criteria/sort_test.go Normal file
View File

@ -0,0 +1,103 @@
package criteria
import (
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = Describe("OrderByFields", func() {
It("defaults to title ascending when Sort is empty", func() {
c := Criteria{}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("parses a single field", func() {
c := Criteria{Sort: "title"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("parses descending prefix", func() {
c := Criteria{Sort: "-rating"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "rating", Desc: true}}))
})
It("parses ascending prefix", func() {
c := Criteria{Sort: "+title"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("parses multiple comma-separated fields", func() {
c := Criteria{Sort: "title,-rating"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "title", Desc: false},
{Field: "rating", Desc: true},
}))
})
It("inverts directions when Order is desc", func() {
c := Criteria{Sort: "-date,title", Order: "desc"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "date", Desc: false},
{Field: "title", Desc: true},
}))
})
It("skips invalid fields", func() {
c := Criteria{Sort: "bogus,title"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("falls back to title when all fields are invalid", func() {
c := Criteria{Sort: "bogus,invalid"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("resolves tag aliases (albumtype -> releasetype)", func() {
c := Criteria{Sort: "albumtype"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "releasetype", Desc: false}}))
})
It("resolves field aliases (recordingdate -> date)", func() {
AddTagNames([]string{"recordingdate"})
c := Criteria{Sort: "recordingdate"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "date", Desc: false}}))
})
It("handles the random field", func() {
c := Criteria{Sort: "random"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "random", Desc: false}}))
})
It("ignores invalid Order value", func() {
c := Criteria{Sort: "-title", Order: "invalid"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: true}}))
})
It("handles whitespace in fields", func() {
c := Criteria{Sort: " title , -rating "}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "title", Desc: false},
{Field: "rating", Desc: true},
}))
})
It("skips empty parts from trailing commas", func() {
c := Criteria{Sort: "title,,rating,"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "title", Desc: false},
{Field: "rating", Desc: false},
}))
})
})
var _ = Describe("SortFieldNames", func() {
It("returns canonical field names", func() {
c := Criteria{Sort: "title,-rating,albumtype"}
gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title", "rating", "releasetype"}))
})
It("defaults to title when Sort is empty", func() {
c := Criteria{}
gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title"}))
})
})

View File

@ -9,7 +9,7 @@ import (
"time" "time"
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/criteria"
) )
@ -32,23 +32,21 @@ type smartPlaylistField struct {
} }
type smartPlaylistCriteria struct { type smartPlaylistCriteria struct {
criteria criteria.Criteria criteria.Criteria
ownerID string owner model.User
ownerIsAdmin bool
} }
func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria { func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria {
cSQL := smartPlaylistCriteria{criteria: c} cSQL := smartPlaylistCriteria{Criteria: c}
for _, opt := range opts { for _, opt := range opts {
opt(&cSQL) opt(&cSQL)
} }
return cSQL return cSQL
} }
func withSmartPlaylistOwner(ownerID string, ownerIsAdmin bool) func(*smartPlaylistCriteria) { func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) {
return func(c *smartPlaylistCriteria) { return func(c *smartPlaylistCriteria) {
c.ownerID = ownerID c.owner = owner
c.ownerIsAdmin = ownerIsAdmin
} }
} }
@ -119,10 +117,10 @@ var smartPlaylistFields = map[string]smartPlaylistField{
} }
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
if c.criteria.Expression == nil { if c.Criteria.Expression == nil {
return squirrel.Expr("1 = 1"), nil return squirrel.Expr("1 = 1"), nil
} }
return c.exprSQL(c.criteria.Expression) return c.exprSQL(c.Criteria.Expression)
} }
func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
@ -290,13 +288,13 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir
return nil, errors.New("playlist id not given") return nil, errors.New("playlist id not given")
} }
filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}}
if !c.ownerIsAdmin { if !c.owner.IsAdmin {
if c.ownerID == "" { if c.owner.ID == "" {
filters = append(filters, squirrel.Eq{"playlist.public": 1}) filters = append(filters, squirrel.Eq{"playlist.public": 1})
} else { } else {
filters = append(filters, squirrel.Or{ filters = append(filters, squirrel.Or{
squirrel.Eq{"playlist.public": 1}, squirrel.Eq{"playlist.public": 1},
squirrel.Eq{"playlist.owner_id": c.ownerID}, squirrel.Eq{"playlist.owner_id": c.owner.ID},
}) })
} }
} }
@ -404,7 +402,7 @@ func fieldJoinType(name string) smartPlaylistJoinType {
func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
var joins smartPlaylistJoinType var joins smartPlaylistJoinType
_ = criteria.Walk(c.criteria.Expression, func(expr criteria.Expression) error { _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error {
for field := range criteria.Fields(expr) { for field := range criteria.Fields(expr) {
joins |= fieldJoinType(field) joins |= fieldJoinType(field)
} }
@ -415,69 +413,27 @@ func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType {
joins := c.ExpressionJoins() joins := c.ExpressionJoins()
for _, sortField := range sortFields(c.criteria.Sort) { for _, name := range c.Criteria.SortFieldNames() {
joins |= fieldJoinType(sortField) joins |= fieldJoinType(name)
} }
return joins return joins
} }
func (c smartPlaylistCriteria) OrderBy() string { func (c smartPlaylistCriteria) OrderBy() string {
sortValue := c.criteria.Sort sortFields := c.Criteria.OrderByFields()
if sortValue == "" { parts := make([]string, 0, len(sortFields))
sortValue = "title" for _, sf := range sortFields {
} mapped, ok := sortExpr(sf.Field)
if !ok {
order := strings.ToLower(strings.TrimSpace(c.criteria.Order))
if order != "" && order != "asc" && order != "desc" {
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.criteria.Order)
order = ""
}
parts := strings.Split(sortValue, ",")
fields := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue continue
} }
dir := "asc" dir := "asc"
if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { if sf.Desc {
if strings.HasPrefix(part, "-") { dir = "desc"
dir = "desc"
}
part = strings.TrimSpace(part[1:])
} }
sortField := strings.ToLower(part) parts = append(parts, mapped+" "+dir)
mapped, ok := sortExpr(sortField)
if !ok {
log.Error("Invalid field in 'sort' field", "sort", sortField)
continue
}
if order == "desc" {
if dir == "asc" {
dir = "desc"
} else {
dir = "asc"
}
}
fields = append(fields, mapped+" "+dir)
} }
return strings.Join(fields, ", ") return strings.Join(parts, ", ")
}
func sortFields(sortValue string) []string {
if sortValue == "" {
sortValue = "title"
}
parts := strings.Split(sortValue, ",")
fields := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(part), "+-"))
if part != "" {
fields = append(fields, part)
}
}
return fields
} }
func sortExpr(sortField string) (string, bool) { func sortExpr(sortField string) (string, bool) {

View File

@ -3,6 +3,7 @@ package persistence
import ( import (
"time" "time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@ -11,7 +12,7 @@ import (
var _ = Describe("Smart playlist criteria SQL", func() { var _ = Describe("Smart playlist criteria SQL", func() {
BeforeEach(func() { BeforeEach(func() {
criteria.AddRoles([]string{"artist", "composer", "producer"}) criteria.AddRoles([]string{"artist", "composer", "producer"})
criteria.AddTagNames([]string{"genre", "mood", "releasetype"}) criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"})
criteria.AddNumericTags([]string{"rate"}) criteria.AddNumericTags([]string{"rate"})
}) })
@ -54,6 +55,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6),
Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"),
Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"),
Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"),
Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
@ -63,7 +65,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
It("allows public or same-owner playlist references for regular users", func() { It("allows public or same-owner playlist references for regular users", func() {
sqlizer, err := newSmartPlaylistCriteria( sqlizer, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
withSmartPlaylistOwner("owner-id", false), withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}),
).Where() ).Where()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -76,7 +78,7 @@ var _ = Describe("Smart playlist criteria SQL", func() {
It("allows all playlist references for admins", func() { It("allows all playlist references for admins", func() {
sqlizer, err := newSmartPlaylistCriteria( sqlizer, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
withSmartPlaylistOwner("admin-id", true), withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}),
).Where() ).Where()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())

View File

@ -197,6 +197,11 @@ var _ = Describe("Smart Playlists", func() {
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
}) })
It("resolves recordingdate alias to the date column", func() {
results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`)
Expect(results).To(ConsistOf("So What"))
})
}) })
Describe("Logic operators", func() { Describe("Logic operators", func() {

View File

@ -11,7 +11,6 @@ import (
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@ -201,161 +200,6 @@ func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) Selec
Columns(r.tableName+".*", "user.user_name as owner_name") Columns(r.tableName+".*", "user.user_name as owner_name")
} }
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
// Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config
if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) {
return false
}
// Never refresh other users' playlists
usr := loggedUser(r.ctx)
if pls.OwnerID != usr.ID {
log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
return false
}
log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
start := time.Now()
// Remove old tracks
del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
_, err := r.executeSQL(del)
if err != nil {
log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Re-populate playlist based on Smart Playlist criteria
rules := *pls.Rules
rulesSQL := newSmartPlaylistCriteria(rules, withSmartPlaylistOwner(pls.OwnerID, usr.IsAdmin))
// If the playlist depends on other playlists, recursively refresh them first
childPlaylistIds := rules.ChildPlaylistIds()
for _, id := range childPlaylistIds {
childPls, err := r.Get(id)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID)
continue
}
log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err)
return false
}
r.refreshSmartPlaylist(childPls)
}
orderBy := rulesSQL.OrderBy()
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file").LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
requiredJoins := rulesSQL.RequiredJoins()
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID)
// Only include media files from libraries the user has access to
sq = r.applyLibraryFilter(sq, "media_file")
// Resolve percentage-based limit to an absolute number before applying criteria
if rules.IsPercentageLimit() {
// Use only expression-based joins for the COUNT query (sort joins are unnecessary)
exprJoins := rulesSQL.ExpressionJoins()
countSq := Select("count(*) as count").From("media_file").
LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID)
countSq = r.applyLibraryFilter(countSq, "media_file")
cond, err := rulesSQL.Where()
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
countSq = countSq.Where(cond)
var res struct{ Count int64 }
err = r.queryOne(countSq, &res)
if err != nil {
log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err)
return false
}
resolvedLimit := rules.EffectiveLimit(res.Count)
log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit)
rules.Limit = resolvedLimit
rules.LimitPercent = 0
rulesSQL.criteria = rules
}
// Apply the criteria rules
sq, err = r.addCriteria(sq, rulesSQL)
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
_, err = r.executeSQL(insSql)
if err != nil {
log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Update playlist stats
err = r.refreshCounters(pls)
if err != nil {
log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Update when the playlist was last refreshed (for cache purposes)
now := time.Now()
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
_, err = r.executeSQL(updSql)
if err != nil {
log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
return false
}
pls.EvaluatedAt = &now
log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
return true
}
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
if joins.has(smartPlaylistJoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", userID)
}
if joins.has(smartPlaylistJoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", userID)
}
return sq
}
func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) {
cond, err := cSQL.Where()
if err != nil {
return sql, err
}
sql = sql.Where(cond)
if cSQL.criteria.Limit > 0 {
sql = sql.Limit(uint64(cSQL.criteria.Limit)).Offset(uint64(cSQL.criteria.Offset))
}
if order := cSQL.OrderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql, nil
}
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
ids := make([]string, len(tracks)) ids := make([]string, len(tracks))
for i := range tracks { for i := range tracks {

View File

@ -1,17 +1,11 @@
package persistence package persistence
import ( import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
) )
var _ = Describe("PlaylistRepository", func() { var _ = Describe("PlaylistRepository", func() {
@ -128,379 +122,6 @@ var _ = Describe("PlaylistRepository", func() {
}) })
}) })
Context("Smart Playlists", func() {
var rules *criteria.Criteria
BeforeEach(func() {
rules = &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "love"},
},
}
})
Context("valid rules", func() {
Specify("Put/Get", func() {
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
savedPls, err := repo.Get(newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(savedPls.Rules).To(Equal(rules))
})
})
Context("invalid rules", func() {
It("fails to Put it in the DB", func() {
rules = &criteria.Criteria{
// This is invalid because "contains" cannot have multiple fields
Expression: criteria.All{
criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
},
}
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
})
})
Context("child smart playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
When("refresh delay has expired", func() {
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
// Nested playlist has not been evaluated yet
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsRead.EvaluatedAt).To(BeNil())
// Getting parent with refresh should recursively refresh the nested playlist
pls, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.EvaluatedAt).ToNot(BeNil())
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Parent should have tracks from the nested playlist
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
// Nested playlist should now have been refreshed (EvaluatedAt set)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
})
})
When("refresh delay has not expired", func() {
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
childEvaluatedAt := time.Now().Add(-30 * time.Minute)
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
// Parent has no EvaluatedAt, so it WILL refresh, but the child should not
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
// Getting parent with refresh should NOT recursively refresh the nested playlist
parent, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
// Parent should have been refreshed (its EvaluatedAt was nil)
Expect(parent.EvaluatedAt).ToNot(BeNil())
Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Nested playlist should NOT have been refreshed (still within delay window)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second))
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
})
})
})
})
Describe("Playlist Track Sorting", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
Expect(repo.Delete(testPlaylistID)).To(BeNil())
testPlaylistID = ""
}
})
It("sorts tracks correctly by album (disc and track number)", func() {
By("creating a playlist with multi-disc album tracks in arbitrary order")
newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
// Add tracks in intentionally scrambled order
newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("retrieving tracks sorted by album")
tracksRepo := repo.Tracks(newPls.ID, false)
tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
Expect(err).ToNot(HaveOccurred())
By("verifying tracks are sorted by disc number then track number")
Expect(tracks).To(HaveLen(4))
// Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
})
})
Describe("Smart Playlists with Album/Artist Annotation Criteria", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
})
It("matches tracks from starred albums using albumLoved", func() {
// albumRadioactivity (ID "103") is starred in test fixtures
// Songs in album 103: 1003, 1004, 1005, 1006
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006"))
})
It("matches tracks from starred artists using artistLoved", func() {
// artistBeatles (ID "3") is starred in test fixtures
// Songs with ArtistID "3": 1001, 1002, 3002
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "3002"))
})
It("matches tracks with combined album and artist criteria", func() {
// albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006)
// artistLoved=true → songs with artist 3 (1001, 1002)
// Using Any: union of both sets
rules := &criteria.Criteria{
Expression: criteria.Any{
criteria.Is{"albumLoved": true},
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002"))
})
It("returns no tracks when no albums/artists match", func() {
// No album has rating 5 in fixtures
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumRating": 5},
},
}
newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(BeEmpty())
})
})
Describe("Smart Playlists with Tag Criteria", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var songWithGrouping, songWithoutGrouping model.MediaFile
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
// Register 'grouping' as a valid tag for smart playlists
criteria.AddTagNames([]string{"grouping"})
// Create a song with the grouping tag
songWithGrouping = model.MediaFile{
ID: "test-grouping-1",
Title: "Song With Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "test/grouping/song1.mp3",
Tags: model.Tags{
"grouping": []string{"My Crate"},
},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
// Create a song without the grouping tag
songWithoutGrouping = model.MediaFile{
ID: "test-grouping-2",
Title: "Song Without Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "test/grouping/song2.mp3",
Tags: model.Tags{},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
})
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test media files
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
})
It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
By("creating a smart playlist that checks if grouping tag has any value")
// This is the workaround for issue #4728: using 'contains' with empty string
// generates SQL: value LIKE '%%' which matches any non-empty string
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track with grouping tag is matched")
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
})
It("excludes tracks with a tag value using 'notContains' with empty string", func() {
By("creating a smart playlist that checks if grouping tag is NOT set")
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.NotContains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying the track with grouping is NOT in the playlist")
for _, track := range pls.Tracks {
Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
}
By("verifying the track without grouping IS in the playlist")
var foundWithoutGrouping bool
for _, track := range pls.Tracks {
if track.MediaFileID == songWithoutGrouping.ID {
foundWithoutGrouping = true
break
}
}
Expect(foundWithoutGrouping).To(BeTrue())
})
})
Describe("Track Deletion and Renumbering", func() { Describe("Track Deletion and Renumbering", func() {
var testPlaylistID string var testPlaylistID string
@ -573,136 +194,4 @@ var _ = Describe("PlaylistRepository", func() {
Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"}))
}) })
}) })
Describe("Smart Playlists Library Filtering", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var lib2ID int
var restrictedUserID string
var uniqueLibPath string
BeforeEach(func() {
db := GetDBXBuilder()
// Generate unique IDs for this test run
uniqueSuffix := time.Now().Format("20060102150405.000")
restrictedUserID = "restricted-user-" + uniqueSuffix
uniqueLibPath = "/music/lib2-" + uniqueSuffix
// Create a second library with unique name and path to avoid conflicts with other tests
_, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath)
Expect(err).ToNot(HaveOccurred())
err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID)
Expect(err).ToNot(HaveOccurred())
// Create a restricted user with access only to library 1
_, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID)
Expect(err).ToNot(HaveOccurred())
_, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID)
Expect(err).ToNot(HaveOccurred())
// Create test media files in each library
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, db)
// Song in library 1 (accessible by restricted user)
songLib1 := model.MediaFile{
ID: "lib1-song",
Title: "Song in Lib1",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "lib1/song.mp3",
LibraryID: 1,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib1)).To(Succeed())
// Song in library 2 (NOT accessible by restricted user)
songLib2 := model.MediaFile{
ID: "lib2-song",
Title: "Song in Lib2",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "lib2/song.mp3",
LibraryID: lib2ID,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib2)).To(Succeed())
})
AfterEach(func() {
db := GetDBXBuilder()
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test data
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute()
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute()
_, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute()
_, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute()
_, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID)
})
It("should only include tracks from libraries the user has access to (issue #4738)", func() {
db := GetDBXBuilder()
ctx := log.NewContext(GinkgoT().Context())
// Create the smart playlist as the restricted user
restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false}
ctx = request.WithUser(ctx, restrictedUser)
restrictedRepo := NewPlaylistRepository(ctx, db)
// Create a smart playlist that matches all songs
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Gt{"playCount": -1}, // Matches everything
},
}
newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules}
Expect(restrictedRepo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track from library 1 is in the playlist")
var foundLib1Song, foundLib2Song bool
for _, track := range pls.Tracks {
if track.MediaFileID == "lib1-song" {
foundLib1Song = true
}
if track.MediaFileID == "lib2-song" {
foundLib2Song = true
}
}
Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist")
Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist")
By("verifying playlist_tracks table only contains the accessible track")
var playlistTracksCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount)
Expect(err).ToNot(HaveOccurred())
// Count should only include tracks visible to the user (lib1-song)
// The count may include other test songs from library 1, but NOT lib2-song
var lib2TrackCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount)
Expect(err).ToNot(HaveOccurred())
Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks")
By("verifying SongCount matches visible tracks")
Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks")
})
})
}) })

View File

@ -0,0 +1,203 @@
package persistence
import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated
// based on their rules. The main method is refreshSmartPlaylist, which evaluates the criteria and updates the playlist
// tracks accordingly. It also handles refreshing dependent playlists when a smart playlist references other playlists
// in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and
// configured refresh delay.
// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly.
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
usr := loggedUser(r.ctx)
if !r.shouldRefreshSmartPlaylist(pls, usr) {
return false
}
log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
start := time.Now()
del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
if _, err := r.executeSQL(del); err != nil {
log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr))
if !r.refreshChildPlaylists(pls, rulesSQL) {
return false
}
if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil {
return false
}
sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID)
sq, err := r.addCriteria(sq, rulesSQL)
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
if _, err = r.executeSQL(insSql); err != nil {
log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
if err = r.refreshCounters(pls); err != nil {
log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
return false
}
now := time.Now()
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
if _, err = r.executeSQL(updSql); err != nil {
log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
return false
}
pls.EvaluatedAt = &now
log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
return true
}
// shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated
// time, and ownership.
func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool {
if !pls.IsSmartPlaylist() {
return false
}
if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay {
return false
}
if pls.OwnerID != usr.ID {
log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
return false
}
return true
}
// refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria.
// Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort.
func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool {
childPlaylistIds := rulesSQL.ChildPlaylistIds()
if len(childPlaylistIds) == 0 {
return true
}
childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}})
if err != nil {
log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err)
return false
}
found := make(map[string]struct{}, len(childPlaylists))
for i := range childPlaylists {
found[childPlaylists[i].ID] = struct{}{}
r.refreshSmartPlaylist(&childPlaylists[i])
}
for _, id := range childPlaylistIds {
if _, ok := found[id]; !ok {
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID)
}
}
return true
}
// resolvePercentageLimit calculates the actual limit for a smart playlist criteria that uses a percentage-based limit.
func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error {
if !rulesSQL.IsPercentageLimit() {
return nil
}
exprJoins := rulesSQL.ExpressionJoins()
countSq := Select("count(*) as count").From("media_file")
countSq = r.addMediaFileAnnotationJoin(countSq, userID)
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID)
countSq = r.applyLibraryFilter(countSq, "media_file")
cond, err := rulesSQL.Where()
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return err
}
countSq = countSq.Where(cond)
var res struct{ Count int64 }
if err = r.queryOne(countSq, &res); err != nil {
log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err)
return err
}
rulesSQL.ResolveLimit(res.Count)
log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit)
return nil
}
// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria,
// including necessary joins for annotations and library filtering.
func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder {
orderBy := rulesSQL.OrderBy()
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file")
sq = r.addMediaFileAnnotationJoin(sq, userID)
requiredJoins := rulesSQL.RequiredJoins()
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID)
sq = r.applyLibraryFilter(sq, "media_file")
return sq
}
// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include
// user-specific annotations in the smart playlist criteria evaluation.
func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder {
return sq.LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", userID)
}
// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on
// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation.
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
if joins.has(smartPlaylistJoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", userID)
}
if joins.has(smartPlaylistJoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", userID)
}
return sq
}
// addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the
// smart playlist criteria.
func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) {
cond, err := cSQL.Where()
if err != nil {
return sql, err
}
sql = sql.Where(cond)
if cSQL.Criteria.Limit > 0 {
sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset))
}
if order := cSQL.OrderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql, nil
}

View File

@ -0,0 +1,531 @@
package persistence
import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("PlaylistRepository - Smart Playlists", func() {
var repo model.PlaylistRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
})
Context("Smart Playlists", func() {
var rules *criteria.Criteria
BeforeEach(func() {
rules = &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "love"},
},
}
})
Context("valid rules", func() {
Specify("Put/Get", func() {
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(newPls.ID) })
savedPls, err := repo.Get(newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(savedPls.Rules).To(Equal(rules))
})
})
Context("invalid rules", func() {
It("fails to Put it in the DB", func() {
rules = &criteria.Criteria{
// This is invalid because "contains" cannot have multiple fields
Expression: criteria.All{
criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
},
}
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
})
})
Context("child smart playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
When("refresh delay has expired", func() {
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
// Nested playlist has not been evaluated yet
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsRead.EvaluatedAt).To(BeNil())
// Getting parent with refresh should recursively refresh the nested playlist
pls, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.EvaluatedAt).ToNot(BeNil())
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Parent should have tracks from the nested playlist
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
// Nested playlist should now have been refreshed (EvaluatedAt set)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
})
})
When("refresh delay has not expired", func() {
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
childEvaluatedAt := time.Now().Add(-30 * time.Minute)
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
// Parent has no EvaluatedAt, so it WILL refresh, but the child should not
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
// Getting parent with refresh should NOT recursively refresh the nested playlist
parent, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
// Parent should have been refreshed (its EvaluatedAt was nil)
Expect(parent.EvaluatedAt).ToNot(BeNil())
Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Nested playlist should NOT have been refreshed (still within delay window)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second))
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
})
})
})
})
Describe("Playlist Track Sorting", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
Expect(repo.Delete(testPlaylistID)).To(BeNil())
testPlaylistID = ""
}
})
It("sorts tracks correctly by album (disc and track number)", func() {
By("creating a playlist with multi-disc album tracks in arbitrary order")
newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
// Add tracks in intentionally scrambled order
newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("retrieving tracks sorted by album")
tracksRepo := repo.Tracks(newPls.ID, false)
tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
Expect(err).ToNot(HaveOccurred())
By("verifying tracks are sorted by disc number then track number")
Expect(tracks).To(HaveLen(4))
// Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
})
})
Describe("Smart Playlists with Album/Artist Annotation Criteria", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
})
It("matches tracks from starred albums using albumLoved", func() {
// albumRadioactivity (ID "103") is starred in test fixtures
// Songs in album 103: 1003, 1004, 1005, 1006
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006"))
})
It("matches tracks from starred artists using artistLoved", func() {
// artistBeatles (ID "3") is starred in test fixtures
// Songs with ArtistID "3": 1001, 1002, 3002
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "3002"))
})
It("matches tracks with combined album and artist criteria", func() {
// albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006)
// artistLoved=true → songs with artist 3 (1001, 1002)
// Using Any: union of both sets
rules := &criteria.Criteria{
Expression: criteria.Any{
criteria.Is{"albumLoved": true},
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002"))
})
It("returns no tracks when no albums/artists match", func() {
// No album has rating 5 in fixtures
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumRating": 5},
},
}
newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(BeEmpty())
})
})
Describe("Smart Playlists with Tag Criteria", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var songWithGrouping, songWithoutGrouping model.MediaFile
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
// Register 'grouping' as a valid tag for smart playlists
criteria.AddTagNames([]string{"grouping"})
// Create a song with the grouping tag
songWithGrouping = model.MediaFile{
ID: "test-grouping-1",
Title: "Song With Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "test/grouping/song1.mp3",
Tags: model.Tags{
"grouping": []string{"My Crate"},
},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
// Create a song without the grouping tag
songWithoutGrouping = model.MediaFile{
ID: "test-grouping-2",
Title: "Song Without Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "test/grouping/song2.mp3",
Tags: model.Tags{},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
})
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test media files
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
})
It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
By("creating a smart playlist that checks if grouping tag has any value")
// This is the workaround for issue #4728: using 'contains' with empty string
// generates SQL: value LIKE '%%' which matches any non-empty string
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track with grouping tag is matched")
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
})
It("excludes tracks with a tag value using 'notContains' with empty string", func() {
By("creating a smart playlist that checks if grouping tag is NOT set")
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.NotContains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying the track with grouping is NOT in the playlist")
for _, track := range pls.Tracks {
Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
}
By("verifying the track without grouping IS in the playlist")
var foundWithoutGrouping bool
for _, track := range pls.Tracks {
if track.MediaFileID == songWithoutGrouping.ID {
foundWithoutGrouping = true
break
}
}
Expect(foundWithoutGrouping).To(BeTrue())
})
})
Describe("Smart Playlists Library Filtering", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var lib2ID int
var restrictedUserID string
var uniqueLibPath string
BeforeEach(func() {
db := GetDBXBuilder()
// Generate unique IDs for this test run
uniqueSuffix := time.Now().Format("20060102150405.000")
restrictedUserID = "restricted-user-" + uniqueSuffix
uniqueLibPath = "/music/lib2-" + uniqueSuffix
// Create a second library with unique name and path to avoid conflicts with other tests
_, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath)
Expect(err).ToNot(HaveOccurred())
err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID)
Expect(err).ToNot(HaveOccurred())
// Create a restricted user with access only to library 1
_, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID)
Expect(err).ToNot(HaveOccurred())
_, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID)
Expect(err).ToNot(HaveOccurred())
// Create test media files in each library
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, db)
// Song in library 1 (accessible by restricted user)
songLib1 := model.MediaFile{
ID: "lib1-song",
Title: "Song in Lib1",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "lib1/song.mp3",
LibraryID: 1,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib1)).To(Succeed())
// Song in library 2 (NOT accessible by restricted user)
songLib2 := model.MediaFile{
ID: "lib2-song",
Title: "Song in Lib2",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "lib2/song.mp3",
LibraryID: lib2ID,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib2)).To(Succeed())
})
AfterEach(func() {
db := GetDBXBuilder()
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test data
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute()
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute()
_, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute()
_, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute()
_, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID)
})
It("should only include tracks from libraries the user has access to (issue #4738)", func() {
db := GetDBXBuilder()
ctx := log.NewContext(GinkgoT().Context())
// Create the smart playlist as the restricted user
restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false}
ctx = request.WithUser(ctx, restrictedUser)
restrictedRepo := NewPlaylistRepository(ctx, db)
// Create a smart playlist that matches all songs
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Gt{"playCount": -1}, // Matches everything
},
}
newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules}
Expect(restrictedRepo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track from library 1 is in the playlist")
var foundLib1Song, foundLib2Song bool
for _, track := range pls.Tracks {
if track.MediaFileID == "lib1-song" {
foundLib1Song = true
}
if track.MediaFileID == "lib2-song" {
foundLib2Song = true
}
}
Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist")
Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist")
By("verifying playlist_tracks table only contains the accessible track")
var playlistTracksCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount)
Expect(err).ToNot(HaveOccurred())
// Count should only include tracks visible to the user (lib1-song)
// The count may include other test songs from library 1, but NOT lib2-song
var lib2TrackCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount)
Expect(err).ToNot(HaveOccurred())
Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks")
By("verifying SongCount matches visible tracks")
Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks")
})
})
})