Files
navidrome-navidrome/persistence/smart_playlist_repository_test.go
T
dualfroz 45e13950f3 fix(smartplaylist): don't let invalid rules hide all playlists - #6316
Empty nested groups were saved as {} and could not be read back, failing
every playlist query. Drop them while parsing, and log and skip unreadable
rules stored in the DB instead of failing the whole listing.

Signed-off-by: DualFroz <me@dualfroz.com>
2026-10-14 19:07:59 +02:00

838 lines
32 KiB
Go

package persistence
import (
"context"
"path/filepath"
"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/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("PlaylistRepository - Smart Playlists", func() {
var repo model.PlaylistRepository
var ctx context.Context
BeforeEach(func() {
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(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(ctx, &newPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) })
savedPls, err := repo.Get(ctx, newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(savedPls.Rules).To(Equal(rules))
})
})
Context("after an evaluation", func() {
It("stamps updated_at and evaluated_at with the same instant", func() {
newPls := model.Playlist{Name: "Evaluated", OwnerID: "userid", Rules: rules}
Expect(repo.Put(ctx, &newPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) })
refreshed, err := repo.GetWithTracks(ctx, newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
stored, err := repo.Get(ctx, newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(stored.EvaluatedAt).ToNot(BeNil())
Expect(stored.UpdatedAt).To(BeTemporally("==", *stored.EvaluatedAt))
Expect(refreshed.UpdatedAt).To(BeTemporally("==", stored.UpdatedAt))
})
})
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(ctx, &newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
})
Context("already stored in the DB", func() {
var broken model.Playlist
BeforeEach(func() {
broken = model.Playlist{Name: "Broken", OwnerID: "userid", Rules: rules}
Expect(repo.Put(ctx, &broken)).To(Succeed())
DeferCleanup(func() { Expect(repo.Delete(ctx, broken.ID)).To(Succeed()) })
_, err := GetDBXBuilder().NewQuery("update playlist set rules = {:rules} where id = {:id}").
Bind(dbx.Params{"rules": `{"all":[{}],"sort":"+lastplayed","limit":10}`, "id": broken.ID}).Execute()
Expect(err).ToNot(HaveOccurred())
})
It("does not hide the other playlists", func() {
all, err := repo.GetAll(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(
ContainElements(plsBest.ID, plsCool.ID, broken.ID))
})
It("returns the playlist without its rules", func() {
pls, err := repo.Get(ctx, broken.ID)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Rules).To(BeNil())
})
})
})
Context("re-imported from disk", func() {
// The scanner re-imports every playlist in a touched folder, and a freshly parsed
// .nsp carries no counters — saving it must not wipe the ones already evaluated.
It("keeps the stored counters when a freshly parsed playlist is saved over it", func() {
rules = &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Antenna"},
},
}
pls := model.Playlist{Name: "Smart", OwnerID: "userid", Rules: rules, Path: "/music/smart.nsp", Sync: true}
Expect(repo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) })
evaluated, err := repo.GetWithTracks(ctx, pls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(evaluated.SongCount).To(BeNumerically(">", 0))
stored, err := repo.Get(ctx, pls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(stored.SongCount).To(Equal(evaluated.SongCount))
reimported := model.Playlist{
ID: pls.ID, Name: pls.Name, OwnerID: "userid", Rules: rules,
Path: pls.Path, Sync: true,
}
Expect(repo.Put(ctx, &reimported)).To(Succeed())
afterImport, err := repo.Get(ctx, pls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(afterImport.SongCount).To(Equal(stored.SongCount))
Expect(afterImport.Duration).To(Equal(stored.Duration))
Expect(afterImport.Size).To(Equal(stored.Size))
})
})
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 [ID]", OwnerID: "userid", Public: true, Rules: childRules}
Expect(repo.Put(ctx, &nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, nestedPls.ID) })
childRules = &criteria.Criteria{
Expression: criteria.All{
criteria.Eq{"artist": "シートベルツ"},
},
}
nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules}
Expect(repo.Put(ctx, &nestedPathPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, nestedPathPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.Any{
criteria.InPlaylist{"id": nestedPls.ID},
criteria.InPlaylist{"path": nestedPathPls.Path},
},
}}
Expect(repo.Put(ctx, &parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) })
// Nested playlist has not been evaluated yet
nestedPlsRead, err := repo.Get(ctx, 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(ctx, 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(2))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
// Nested playlists should now have been refreshed (EvaluatedAt set)
nestedPlsAfterParentGet, err := repo.Get(ctx, nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
nestedPlsAfterParentGet, err = repo.Get(ctx, nestedPathPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
})
})
It("does not recurse forever when two smart playlists reference each other", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
}}
Expect(repo.Put(ctx, &plsA)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, plsA.ID) })
plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}},
}}
Expect(repo.Put(ctx, &plsB)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, plsB.ID) })
plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}}
Expect(repo.Put(ctx, &plsA)).To(Succeed())
_, err := repo.GetWithTracks(ctx, plsA.ID, true, false)
Expect(err).ToNot(HaveOccurred())
})
It("does not treat an empty path as a reference to every playlist without a path", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
}}
Expect(repo.Put(ctx, &bystander)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, bystander.ID) })
parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"path": ""}},
}}
Expect(repo.Put(ctx, &parent)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, parent.ID) })
_, err := repo.GetWithTracks(ctx, parent.ID, true, false)
Expect(err).ToNot(HaveOccurred())
reloaded, err := repo.Get(ctx, bystander.ID)
Expect(err).ToNot(HaveOccurred())
Expect(reloaded.EvaluatedAt).To(BeNil())
})
It("matches a child path stored in a different Unicode normalization form", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
}}
Expect(repo.Put(ctx, &child)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, child.ID) })
parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}},
}}
Expect(repo.Put(ctx, &parent)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, parent.ID) })
pls, err := repo.GetWithTracks(ctx, parent.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
})
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(ctx, &nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, 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(ctx, &parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) })
nestedPlsRead, err := repo.Get(ctx, nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
// Getting parent with refresh should NOT recursively refresh the nested playlist
parent, err := repo.GetWithTracks(ctx, 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(ctx, nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second))
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
})
})
Context("per-playlist refreshDelay", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("does NOT refresh when the per-playlist delay has not elapsed, even if global has", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
evaluatedAt := time.Now().Add(-1 * time.Hour)
rules := &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
RefreshDelay: 24 * time.Hour,
}
pls := model.Playlist{Name: "Frozen Daily", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt}
Expect(repo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) })
got, err := repo.GetWithTracks(ctx, pls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
// Not re-evaluated: EvaluatedAt unchanged, no tracks materialized
Expect(*got.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second))
Expect(got.Tracks).To(BeEmpty())
})
It("refreshes when the per-playlist delay has elapsed, even if global has not", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
evaluatedAt := time.Now().Add(-10 * time.Minute)
rules := &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
RefreshDelay: 5 * time.Minute,
}
pls := model.Playlist{Name: "Fast Refresh", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt}
Expect(repo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) })
got, err := repo.GetWithTracks(ctx, pls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
Expect(got.Tracks).To(HaveLen(1))
Expect(got.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
})
})
})
})
Describe("Playlist Track Sorting", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
Expect(repo.Delete(ctx, 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(ctx, &newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("retrieving tracks sorted by album")
tracksRepo := repo.Tracks(ctx, newPls.ID, false)
tracks, err := tracksRepo.GetAll(ctx, 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(ctx, 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(ctx, &newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(ctx, 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(ctx, &newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(ctx, 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(ctx, &newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(ctx, 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(ctx, &newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(BeEmpty())
})
It("matches loved tracks when loved value is a string in nested group (issue #4826)", func() {
// songComeTogether (ID "1002") is starred in test fixtures
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Any{
criteria.Is{"loved": "true"},
},
},
}
newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules}
Expect(repo.Put(ctx, &newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(ctx, 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(ContainElement("1002"))
Expect(len(pls.Tracks)).To(BeNumerically(">=", 1))
})
It("returns same results for string and bool loved values (issue #4826)", func() {
boolRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Any{
criteria.Is{"loved": true},
},
},
}
boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules}
Expect(repo.Put(ctx, &boolPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, boolPls.ID) })
stringRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Any{
criteria.Is{"loved": "true"},
},
},
}
stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules}
Expect(repo.Put(ctx, &stringPls)).To(Succeed())
testPlaylistID = stringPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
boolResult, err := repo.GetWithTracks(ctx, boolPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
stringResult, err := repo.GetWithTracks(ctx, stringPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
boolIDs := make([]string, len(boolResult.Tracks))
for i, t := range boolResult.Tracks {
boolIDs[i] = t.MediaFileID
}
stringIDs := make([]string, len(stringResult.Tracks))
for i, t := range stringResult.Tracks {
stringIDs[i] = t.MediaFileID
}
Expect(stringIDs).To(ConsistOf(boolIDs))
})
})
Describe("Smart Playlists with Album Aggregate Criteria", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
})
trackIDsOf := func(rules *criteria.Criteria) []string {
newPls := model.Playlist{Name: "Album Aggregates", OwnerID: "userid", Rules: rules}
Expect(repo.Put(ctx, &newPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) })
pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
return slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })
}
It("filters on albumSongCount", func() {
// albumMultiDisc (ID "104") is the only fixture album with SongCount > 3
rules := &criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumSongCount": 3}}}
Expect(trackIDsOf(rules)).To(ConsistOf("2001", "2002", "2003", "2004"))
})
It("sorts by an album field not referenced in the expression (issue #5347)", func() {
// All four tracks share album 104, so the album date ties and disc/track number decide.
rules := &criteria.Criteria{
Expression: criteria.All{criteria.Is{"album": "Multi Disc Album"}},
Sort: "-albumDateAdded,discNumber,trackNumber",
}
Expect(trackIDsOf(rules)).To(HaveExactElements("2002", "2004", "2003", "2001"))
})
})
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(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(ctx, &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(ctx, &songWithoutGrouping)).To(Succeed())
})
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(ctx, 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(ctx, &newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(ctx, 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(ctx, &newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(ctx, 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(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(ctx, &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(ctx, &songLib2)).To(Succeed())
})
AfterEach(func() {
db := GetDBXBuilder()
if testPlaylistID != "" {
_ = repo.Delete(ctx, 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(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(ctx, &newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := restrictedRepo.GetWithTracks(ctx, 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")
})
})
})