feat(playlists): add SmartPlaylistEvaluator for background evaluation

Implements the CacheWarmer pattern: a background goroutine processes
batched playlist IDs, evaluating smart playlist criteria and populating
tracks asynchronously. Includes a noop implementation for CLI/test use.

Part of #4539
This commit is contained in:
Deluan 2026-03-23 20:00:49 -04:00
parent 759ab26b19
commit 7ce0d3e79f
2 changed files with 124 additions and 0 deletions

107
core/playlists/evaluator.go Normal file
View File

@ -0,0 +1,107 @@
package playlists
import (
"context"
"maps"
"slices"
"sync"
"time"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// SmartPlaylistEvaluator evaluates smart playlists in the background.
// Call Enqueue to queue a playlist for evaluation. The evaluation happens
// asynchronously in a background goroutine.
type SmartPlaylistEvaluator interface {
Enqueue(playlistID string)
}
func NewSmartPlaylistEvaluator(ds model.DataStore) SmartPlaylistEvaluator {
e := &smartPlaylistEvaluator{
ds: ds,
buffer: make(map[string]struct{}),
wakeSignal: make(chan struct{}, 1),
}
go e.run()
return e
}
type smartPlaylistEvaluator struct {
ds model.DataStore
buffer map[string]struct{}
mutex sync.Mutex
wakeSignal chan struct{}
}
func (e *smartPlaylistEvaluator) Enqueue(playlistID string) {
e.mutex.Lock()
defer e.mutex.Unlock()
e.buffer[playlistID] = struct{}{}
e.sendWakeSignal()
}
func (e *smartPlaylistEvaluator) sendWakeSignal() {
select {
case e.wakeSignal <- struct{}{}:
default:
}
}
func (e *smartPlaylistEvaluator) run() {
for {
e.waitSignal(10 * time.Second)
e.mutex.Lock()
if len(e.buffer) == 0 {
e.mutex.Unlock()
continue
}
batch := slices.Collect(maps.Keys(e.buffer))
e.buffer = make(map[string]struct{})
e.mutex.Unlock()
e.processBatch(batch)
}
}
func (e *smartPlaylistEvaluator) waitSignal(timeout time.Duration) {
select {
case <-time.After(timeout):
case <-e.wakeSignal:
}
}
func (e *smartPlaylistEvaluator) processBatch(batch []string) {
log.Debug("Evaluating smart playlists in background", "count", len(batch))
for _, id := range batch {
e.doEvaluate(id)
}
}
func (e *smartPlaylistEvaluator) doEvaluate(id string) {
// Use admin context so userFilter() returns all playlists.
// Evaluate() internally uses pls.OwnerID for annotation JOINs.
ctx := auth.WithAdminUser(context.TODO(), e.ds)
start := time.Now()
err := e.ds.Playlist(ctx).Evaluate(id)
if err != nil {
log.Error("Error evaluating smart playlist in background", "id", id, err)
return
}
log.Debug("Background smart playlist evaluation complete", "id", id, "elapsed", time.Since(start))
}
// NoopSmartPlaylistEvaluator returns an evaluator that does nothing.
// Used in CLI scan and test contexts.
func NoopSmartPlaylistEvaluator() SmartPlaylistEvaluator {
return &noopSmartPlaylistEvaluator{}
}
type noopSmartPlaylistEvaluator struct{}
func (n *noopSmartPlaylistEvaluator) Enqueue(string) {}

View File

@ -0,0 +1,17 @@
package playlists_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/navidrome/navidrome/core/playlists"
)
var _ = Describe("SmartPlaylistEvaluator", func() {
Describe("NoopSmartPlaylistEvaluator", func() {
It("does not panic when enqueuing", func() {
noop := playlists.NoopSmartPlaylistEvaluator()
Expect(func() { noop.Enqueue("some-id") }).ToNot(Panic())
})
})
})