feat(smartplaylist): per-playlist refreshDelay for stable daily/weekly playlists (#5790)

* feat(utils): add ParseDuration/FormatDuration with day and week units

* feat(criteria): add per-playlist refreshDelay to smart playlist rules

* feat(smartplaylist): honor per-playlist refreshDelay in refresh gate

* feat(subsonic): compute smart playlist validUntil from effective refresh delay

* fix(playlists): reset smart playlist evaluation window when rules change via API

* refactor(utils): flatten FormatDuration recursion, single-pass duration regex

* fix(playlists): address PR review feedback

- Reset EvaluatedAt to nil instead of zero-time on rules change and NSP
  re-import, so getPlaylist(s) never reports year-1 Changed/validUntil in
  the window between an edit and the next owner read
- Parse negative d/w durations so they are rejected with the consistent
  "negative duration" error instead of "unknown unit"
- Quote input in the negative-duration error, matching the parse error
- Gate per-playlist RefreshDelay behind IsSmartPlaylist, matching its doc
This commit is contained in:
Deluan Quintão 2026-07-16 22:20:35 -04:00 • committed by GitHub
commit 85132240e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 351 additions and 6 deletions

View file

@ -8,7 +8,6 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
@ -187,7 +186,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
newPls.EvaluatedAt = &time.Time{}
newPls.EvaluatedAt = nil // force re-evaluation on next read
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID

View file

@ -135,6 +135,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod
}
if rulesChanged {
current.Rules = entity.Rules
current.EvaluatedAt = nil // force re-evaluation on next read
}
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
current.Sync = entity.Sync

View file

@ -314,6 +314,38 @@ var _ = Describe("REST Adapter", func() {
Expect(mockPlsRepo.Last.Public).To(BeTrue())
})
It("resets EvaluatedAt when rules change", func() {
evaluatedAt := time.Now().Add(-1 * time.Hour)
mockPlsRepo.Data["smart-reset"] = &model.Playlist{
ID: "smart-reset",
Name: "Smart",
OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
EvaluatedAt: &evaluatedAt,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}}
err := repo.Update("smart-reset", &model.Playlist{Rules: newRules}, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil())
})
It("keeps EvaluatedAt when rules are not changed", func() {
evaluatedAt := time.Now().Add(-1 * time.Hour)
mockPlsRepo.Data["smart-keep"] = &model.Playlist{
ID: "smart-keep",
Name: "Smart",
OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}},
EvaluatedAt: &evaluatedAt,
}
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Update("smart-keep", &model.Playlist{Name: "Renamed Smart"}, "name")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil())
Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second))
})
It("updates name and rules together (smart-playlist Edit form)", func() {
mockPlsRepo.Data["smart-edit"] = &model.Playlist{
ID: "smart-edit",