feat(smartplaylists): relax playlist visibility in inPlaylist/notInPlaylist rules (#5411)

* test(e2e): add end-to-end tests for smart playlists functionality

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

* fix: enforce playlist visibility in smart playlist InPlaylist/NotInPlaylist rules

Previously, the InPlaylist/NotInPlaylist smart playlist criteria only
allowed referencing public playlists, regardless of who owned the smart
playlist. This was too restrictive for owners referencing their own
private playlists and for admins who should have unrestricted access.

The fix passes the smart playlist owner's identity and admin status into
the criteria SQL builder, so that: admins can reference any playlist,
regular users can reference public playlists plus their own private ones,
and inaccessible referenced playlists produce a warning instead of a hard
error. Also prevents recursive refresh of child playlists the owner
cannot access.

* test(e2e): clarify user roles and fix playlist visibility tests

Renamed testUser/otherUser to adminUser/regularUser to make the admin
vs regular user distinction explicit in test code. Fixed three playlist
visibility tests that were evaluating as admin (bypassing all access
checks) instead of as a regular user, so the public playlist path is
now actually exercised. All playlist operator tests now use explicit
evaluateRuleAs calls with the appropriate user role.

* fix: sync rulesSQL criteria after limitPercent resolution

The rulesSQL struct captures a copy of rules at creation time. When
limitPercent is resolved later, rules.Limit is updated but rulesSQL
still holds the stale value. This caused percentage-based smart playlist
limits to be silently ignored. Fix by updating rulesSQL.criteria after
the resolution.

* refactor: convert inList to a method on smartPlaylistCriteria

The inList function already receives ownerID and ownerIsAdmin from the
smartPlaylistCriteria caller. Making it a method lets it access those
fields directly from the receiver, simplifying the signature and staying
consistent with exprSQL which was already converted to a method.

* refactor: simplify function signatures by removing type parameters in criteria_sql.go

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão 2026-04-25 14:59:06 -04:00 committed by GitHub
parent 251cc71e2d
commit ca09070a6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 176 additions and 67 deletions

View file

@ -8,7 +8,7 @@ import (
"strings"
"time"
squirrel "github.com/Masterminds/squirrel"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/criteria"
)
@ -32,11 +32,24 @@ type smartPlaylistField struct {
}
type smartPlaylistCriteria struct {
criteria criteria.Criteria
criteria criteria.Criteria
ownerID string
ownerIsAdmin bool
}
func newSmartPlaylistCriteria(c criteria.Criteria) smartPlaylistCriteria {
return smartPlaylistCriteria{criteria: c}
func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria {
cSQL := smartPlaylistCriteria{criteria: c}
for _, opt := range opts {
opt(&cSQL)
}
return cSQL
}
func withSmartPlaylistOwner(ownerID string, ownerIsAdmin bool) func(*smartPlaylistCriteria) {
return func(c *smartPlaylistCriteria) {
c.ownerID = ownerID
c.ownerIsAdmin = ownerIsAdmin
}
}
var smartPlaylistFields = map[string]smartPlaylistField{
@ -109,15 +122,15 @@ func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
if c.criteria.Expression == nil {
return squirrel.Expr("1 = 1"), nil
}
return exprSQL(c.criteria.Expression)
return c.exprSQL(c.criteria.Expression)
}
func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
switch e := expr.(type) {
case criteria.All:
and := squirrel.And{}
for _, child := range e {
cond, err := exprSQL(child)
cond, err := c.exprSQL(child)
if err != nil {
return nil, err
}
@ -127,7 +140,7 @@ func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
case criteria.Any:
or := squirrel.Or{}
for _, child := range e {
cond, err := exprSQL(child)
cond, err := c.exprSQL(child)
if err != nil {
return nil, err
}
@ -171,15 +184,15 @@ func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
case criteria.NotInTheLast:
return periodExpr(e, true)
case criteria.InPlaylist:
return inList(e, false)
return c.inList(e, false)
case criteria.NotInPlaylist:
return inList(e, true)
return c.inList(e, true)
default:
return nil, fmt.Errorf("unknown criteria expression type %T", expr)
}
}
func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) {
func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, squirrel.Eq{"value": value}, true), nil
}
@ -190,7 +203,7 @@ func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) {
return squirrel.NotEq(fields), nil
}
func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {
func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil
}
@ -201,7 +214,7 @@ func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel
return makeCond(fields), nil
}
func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirrel.Sqlizer, error) {
func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil
}
@ -223,7 +236,7 @@ func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirre
return lk, nil
}
func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) {
func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) {
fields, err := sqlFields(values)
if err != nil {
return nil, err
@ -242,7 +255,7 @@ func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) {
return and, nil
}
func periodExpr[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) {
func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
fields, err := sqlFields(values)
if err != nil {
return nil, err
@ -271,18 +284,26 @@ func startOfPeriod(numDays int64, from time.Time) string {
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
}
func inList[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) {
func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
playlistID, ok := values["id"].(string)
if !ok {
return nil, errors.New("playlist id not given")
}
filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}}
if !c.ownerIsAdmin {
if c.ownerID == "" {
filters = append(filters, squirrel.Eq{"playlist.public": 1})
} else {
filters = append(filters, squirrel.Or{
squirrel.Eq{"playlist.public": 1},
squirrel.Eq{"playlist.owner_id": c.ownerID},
})
}
}
subQuery := squirrel.Select("media_file_id").
From("playlist_tracks pl").
LeftJoin("playlist on pl.playlist_id = playlist.id").
Where(squirrel.And{
squirrel.Eq{"pl.playlist_id": playlistID},
squirrel.Eq{"playlist.public": 1},
})
Where(filters)
subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
if err != nil {
return nil, err
@ -334,7 +355,7 @@ func (e roleCond) ToSql() (string, []any, error) {
return cond, args, err
}
func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, bool) {
func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) {
if len(values) != 1 {
return "", nil, criteria.FieldInfo{}, false
}
@ -345,7 +366,7 @@ func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo,
return "", nil, criteria.FieldInfo{}, false
}
func sqlFields[T ~map[string]any](values T) (map[string]any, error) {
func sqlFields(values map[string]any) (map[string]any, error) {
fields := make(map[string]any, len(values))
for field, value := range values {
info, ok := criteria.LookupField(field)

View file

@ -59,6 +59,34 @@ var _ = Describe("Smart playlist criteria SQL", func() {
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%"),
)
Describe("playlist permissions", func() {
It("allows public or same-owner playlist references for regular users", func() {
sqlizer, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
withSmartPlaylistOwner("owner-id", false),
).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND (playlist.public = ? OR playlist.owner_id = ?)))"))
Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id"))
})
It("allows all playlist references for admins", func() {
sqlizer, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
withSmartPlaylistOwner("admin-id", true),
).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ?))"))
Expect(args).To(HaveExactElements("deadbeef-dead-beef"))
})
})
It("builds relative date expressions", func() {
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where()
Expect(err).ToNot(HaveOccurred())

View file

@ -53,14 +53,14 @@ var (
snapshotPath string
snapshotTables []string
testUser = model.User{
adminUser = model.User{
ID: "sp-test-user-1",
UserName: "sptestuser",
Name: "SP Test User",
IsAdmin: true,
}
otherUser = model.User{
regularUser = model.User{
ID: "sp-test-user-2",
UserName: "spotheruser",
Name: "SP Other User",
@ -147,25 +147,36 @@ func findMediaFileByTitle(title string) string {
}
func evaluateRule(jsonRule string) []string {
titles := evaluateRuleOrdered(jsonRule)
titles := evaluateRuleOrderedAs(adminUser, jsonRule)
sort.Strings(titles)
return titles
}
func evaluateRuleOrdered(jsonRule string) []string {
return evaluateRuleOrderedAs(adminUser, jsonRule)
}
func evaluateRuleAs(owner model.User, jsonRule string) []string {
titles := evaluateRuleOrderedAs(owner, jsonRule)
sort.Strings(titles)
return titles
}
func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string {
userCtx := request.WithUser(GinkgoT().Context(), owner)
var rules criteria.Criteria
err := json.Unmarshal([]byte(jsonRule), &rules)
Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule)
pls := &model.Playlist{
Name: "test-smart-playlist",
OwnerID: testUser.ID,
OwnerID: owner.ID,
Rules: &rules,
}
err = ds.Playlist(ctx).Put(pls)
err = ds.Playlist(userCtx).Put(pls)
Expect(err).ToNot(HaveOccurred())
loaded, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
titles := make([]string, len(loaded.Tracks))
@ -198,12 +209,20 @@ func createPrivatePlaylist(owner model.User, titles ...string) string {
}
func createPublicSmartPlaylist(owner model.User, jsonRule string) string {
return createSmartPlaylist(owner, true, jsonRule)
}
func createPrivateSmartPlaylist(owner model.User, jsonRule string) string {
return createSmartPlaylist(owner, false, jsonRule)
}
func createSmartPlaylist(owner model.User, public bool, jsonRule string) string {
var rules criteria.Criteria
Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed())
pls := &model.Playlist{
Name: "ref-smart-playlist",
OwnerID: owner.ID,
Public: true,
Public: public,
Rules: &rules,
}
Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
@ -211,7 +230,7 @@ func createPublicSmartPlaylist(owner model.User, jsonRule string) string {
}
var _ = BeforeSuite(func() {
ctx = request.WithUser(GinkgoT().Context(), testUser)
ctx = request.WithUser(GinkgoT().Context(), adminUser)
tmpDir := GinkgoT().TempDir()
dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db")
snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot")
@ -226,28 +245,28 @@ var _ = BeforeSuite(func() {
initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())}
userWithPass := testUser
userWithPass := adminUser
userWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed())
otherUserWithPass := otherUser
otherUserWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&otherUserWithPass)).To(Succeed())
regularUserWithPass := regularUser
regularUserWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&regularUserWithPass)).To(Succeed())
lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())
Expect(initDS.User(ctx).SetUserLibraries(testUser.ID, []int{lib.ID})).To(Succeed())
Expect(initDS.User(ctx).SetUserLibraries(otherUser.ID, []int{lib.ID})).To(Succeed())
Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
loadedUser, err := initDS.User(ctx).FindByUsername(testUser.UserName)
loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
Expect(err).ToNot(HaveOccurred())
testUser.Libraries = loadedUser.Libraries
adminUser.Libraries = loadedUser.Libraries
loadedOther, err := initDS.User(ctx).FindByUsername(otherUser.UserName)
loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName)
Expect(err).ToNot(HaveOccurred())
otherUser.Libraries = loadedOther.Libraries
regularUser.Libraries = loadedOther.Libraries
ctx = request.WithUser(GinkgoT().Context(), testUser)
ctx = request.WithUser(GinkgoT().Context(), adminUser)
buildTestFS()
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
@ -315,7 +334,7 @@ func restoreDB() {
}
func setupTestDB() {
ctx = request.WithUser(GinkgoT().Context(), testUser)
ctx = request.WithUser(GinkgoT().Context(), adminUser)
DeferCleanup(configtest.SetupConfig())
conf.Server.MusicFolder = "fake:///music"
conf.Server.DevExternalScanner = false

View file

@ -1,8 +1,10 @@
package e2e
import (
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/sirupsen/logrus"
)
var _ = Describe("Smart Playlists", func() {
@ -245,46 +247,82 @@ var _ = Describe("Smart Playlists", func() {
Describe("Playlist operators", func() {
It("matches tracks in a public regular playlist", func() {
refID := createPublicPlaylist(testUser, "Come Together", "So What")
results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`)
refID := createPublicPlaylist(adminUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Come Together", "So What"))
})
It("matches tracks not in a public regular playlist", func() {
refID := createPublicPlaylist(testUser, "Come Together", "So What")
results := evaluateRule(`{"all":[{"notInPlaylist":{"id":"` + refID + `"}}]}`)
refID := createPublicPlaylist(adminUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog",
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("recursively refreshes a referenced smart playlist owned by the same user", func() {
smartBID := createPublicSmartPlaylist(testUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`)
smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
Expect(results).To(ConsistOf("So What"))
})
It("does not refresh a referenced smart playlist owned by another user", func() {
smartBID := createPublicSmartPlaylist(otherUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`)
smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
Expect(results).To(BeEmpty())
})
It("does not match tracks from a private playlist", func() {
refID := createPrivatePlaylist(testUser, "Come Together", "So What")
results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`)
Expect(results).To(BeEmpty())
It("does not refresh a playlist or its children when an admin views another user's smart playlist", func() {
smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(loadedA.Tracks).To(BeEmpty())
Expect(loadedA.EvaluatedAt).To(BeNil())
loadedB, err := ds.Playlist(ctx).Get(smartBID)
Expect(err).ToNot(HaveOccurred())
Expect(loadedB.EvaluatedAt).To(BeNil())
})
It("matches tracks in a public playlist owned by another user", func() {
refID := createPublicPlaylist(otherUser, "Bohemian Rhapsody")
results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`)
It("matches tracks from a private playlist owned by the same user", func() {
refID := createPrivatePlaylist(regularUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Come Together", "So What"))
})
It("allows admin-owned smart playlists to reference private playlists owned by other users", func() {
refID := createPrivatePlaylist(regularUser, "Bohemian Rhapsody")
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Bohemian Rhapsody"))
})
It("does not match tracks from a private playlist owned by another user", func() {
refID := createPrivatePlaylist(otherUser, "Bohemian Rhapsody")
results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`)
It("does not match tracks from a private playlist owned by another regular user", func() {
refID := createPrivatePlaylist(adminUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(BeEmpty())
})
It("warns when a referenced playlist is inaccessible to the smart playlist owner", func() {
hook, cleanup := tests.LogHook()
defer cleanup()
refID := createPrivatePlaylist(adminUser, "Come Together")
results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
Expect(hook.LastEntry()).ToNot(BeNil())
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
Expect(hook.LastEntry().Message).To(Equal("Referenced playlist is not accessible to smart playlist owner"))
Expect(hook.LastEntry().Data).To(HaveKeyWithValue("childId", refID))
})
It("matches tracks in a public playlist owned by another user", func() {
refID := createPublicPlaylist(adminUser, "Bohemian Rhapsody")
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Bohemian Rhapsody"))
})
})
})

View file

@ -14,7 +14,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/pocketbase/dbx"
)
@ -228,13 +227,17 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
// Re-populate playlist based on Smart Playlist criteria
rules := *pls.Rules
rulesSQL := newSmartPlaylistCriteria(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
}
@ -283,10 +286,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
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, 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
@ -337,15 +341,14 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j
return sq
}
func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) (SelectBuilder, error) {
cSQL := newSmartPlaylistCriteria(c)
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 c.Limit > 0 {
sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset))
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)