mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
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:
parent
0ab10e819f
commit
1bd736dae9
@ -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) {
|
||||||
|
|||||||
@ -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}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
62
model/criteria/sort.go
Normal 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
103
model/criteria/sort_test.go
Normal 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"}))
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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:])
|
parts = append(parts, mapped+" "+dir)
|
||||||
}
|
}
|
||||||
sortField := strings.ToLower(part)
|
return strings.Join(parts, ", ")
|
||||||
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, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
203
persistence/smart_playlist_repository.go
Normal file
203
persistence/smart_playlist_repository.go
Normal 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
|
||||||
|
}
|
||||||
531
persistence/smart_playlist_repository_test.go
Normal file
531
persistence/smart_playlist_repository_test.go
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user