feat(playlists): add Evaluate method to PlaylistRepository

Refactor refreshSmartPlaylist into guard checks + core evaluation logic
(doRefreshSmartPlaylist). Add public Evaluate method that bypasses
ownership/delay guards for system-level evaluation (e.g., background
processing after scanner import).

Part of #4539
This commit is contained in:
Deluan 2026-03-23 19:59:10 -04:00
parent 356b0716b6
commit 759ab26b19
4 changed files with 79 additions and 4 deletions

View File

@ -131,6 +131,7 @@ type PlaylistRepository interface {
Delete(id string) error
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
GetPlaylists(mediaFileId string) (Playlists, error)
Evaluate(id string) error
}
type PlaylistTrack struct {

View File

@ -215,6 +215,10 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
return false
}
return r.doRefreshSmartPlaylist(pls, usr.ID)
}
func (r *playlistRepository) doRefreshSmartPlaylist(pls *model.Playlist, userID string) bool {
log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
start := time.Now()
@ -244,11 +248,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
From("media_file").LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
" AND annotation.user_id = ?)", userID)
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
requiredJoins := rules.RequiredJoins()
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID)
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID)
// Only include media files from libraries the user has access to
sq = r.applyLibraryFilter(sq, "media_file")
@ -261,8 +265,8 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID)
" AND annotation.user_id = ?)", userID)
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID)
countSq = r.applyLibraryFilter(countSq, "media_file")
countSq = countSq.Where(rules)
@ -310,6 +314,18 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
return true
}
func (r *playlistRepository) Evaluate(id string) error {
pls, err := r.Get(id)
if err != nil {
return err
}
if !pls.IsSmartPlaylist() {
return nil
}
r.doRefreshSmartPlaylist(pls, pls.OwnerID)
return nil
}
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder {
if joins.Has(criteria.JoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+

View File

@ -254,6 +254,57 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("Evaluate", func() {
var testPlaylistID string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
})
It("evaluates a smart playlist and sets EvaluatedAt and SongCount", func() {
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
newPls := model.Playlist{Name: "Evaluate Test", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
Expect(repo.Evaluate(newPls.ID)).To(Succeed())
saved, err := repo.Get(newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(saved.EvaluatedAt).ToNot(BeNil())
Expect(*saved.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
Expect(saved.SongCount).To(BeNumerically(">", 0))
})
It("is a no-op for non-smart playlists", func() {
newPls := model.Playlist{Name: "Regular Playlist", OwnerID: "userid"}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
Expect(repo.Evaluate(newPls.ID)).To(Succeed())
saved, err := repo.Get(newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(saved.EvaluatedAt).To(BeNil())
})
It("returns ErrNotFound for a non-existent playlist ID", func() {
err := repo.Evaluate("nonexistent-id")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Playlist Track Sorting", func() {
var testPlaylistID string

View File

@ -108,4 +108,11 @@ func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
return int64(len(m.Data)), nil
}
func (m *MockPlaylistRepo) Evaluate(_ string) error {
if m.Err {
return errors.New("error")
}
return nil
}
var _ model.PlaylistRepository = (*MockPlaylistRepo)(nil)