diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 2f7b73885..c75f6467b 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -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) diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 9fa5ff104..e06a4eccb 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -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()) diff --git a/core/playlists/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go similarity index 85% rename from core/playlists/e2e/e2e_suite_test.go rename to persistence/e2e/e2e_suite_test.go index e9a717d0a..ea0d0fea8 100644 --- a/core/playlists/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -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(®ularUserWithPass)).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 diff --git a/core/playlists/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go similarity index 76% rename from core/playlists/e2e/smartplaylist_test.go rename to persistence/e2e/smartplaylist_test.go index 6e31c6787..658030de5 100644 --- a/core/playlists/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -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")) + }) + }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 6bf3ded30..11e76fa8b 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -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)