mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 03:19:38 +00:00
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:
parent
251cc71e2d
commit
ca09070a6c
5 changed files with 176 additions and 67 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue