mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
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:
parent
ae7e81e33f
commit
85132240e0
13 changed files with 351 additions and 6 deletions
|
|
@ -1,6 +1,12 @@
|
|||
package utils
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TimeNewest(times ...time.Time) time.Time {
|
||||
newest := time.Time{}
|
||||
|
|
@ -11,3 +17,59 @@ func TimeNewest(times ...time.Time) time.Time {
|
|||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
var durationDayWeekRe = regexp.MustCompile(`-?\d+(?:\.\d+)?[dw]`)
|
||||
|
||||
// ParseDuration is time.ParseDuration extended with d (24h) and w (168h) units.
|
||||
// Negative durations are rejected.
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
expanded := durationDayWeekRe.ReplaceAllStringFunc(s, func(match string) string {
|
||||
value, err := strconv.ParseFloat(match[:len(match)-1], 64)
|
||||
if err != nil {
|
||||
return match
|
||||
}
|
||||
hours := value * 24
|
||||
if match[len(match)-1] == 'w' {
|
||||
hours = value * 24 * 7
|
||||
}
|
||||
return strconv.FormatFloat(hours, 'f', -1, 64) + "h"
|
||||
})
|
||||
d, err := time.ParseDuration(expanded)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q: %w", s, err)
|
||||
}
|
||||
if d < 0 {
|
||||
return 0, fmt.Errorf("negative duration not allowed: %q", s)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// FormatDuration renders whole w/d multiples with those units, falling back to
|
||||
// time.Duration.String for the sub-day remainder, so ParseDuration round-trips.
|
||||
func FormatDuration(d time.Duration) string {
|
||||
if d < 24*time.Hour {
|
||||
return formatSubDay(d)
|
||||
}
|
||||
var b strings.Builder
|
||||
weekDuration := 7 * 24 * time.Hour
|
||||
if weeks := d / weekDuration; weeks > 0 {
|
||||
b.WriteString(strconv.Itoa(int(weeks)) + "w")
|
||||
d %= weekDuration
|
||||
}
|
||||
dayDuration := 24 * time.Hour
|
||||
if days := d / dayDuration; days > 0 {
|
||||
b.WriteString(strconv.Itoa(int(days)) + "d")
|
||||
d %= dayDuration
|
||||
}
|
||||
if d > 0 {
|
||||
b.WriteString(formatSubDay(d))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatSubDay(d time.Duration) string {
|
||||
if d >= time.Hour && d%time.Hour == 0 {
|
||||
return strconv.Itoa(int(d/time.Hour)) + "h"
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,3 +26,74 @@ var _ = Describe("TimeNewest", func() {
|
|||
Expect(utils.TimeNewest(t1, t2, t3)).To(Equal(t2))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ParseDuration", func() {
|
||||
DescribeTable("parses valid durations",
|
||||
func(input string, expected time.Duration) {
|
||||
d, err := utils.ParseDuration(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(d).To(Equal(expected))
|
||||
},
|
||||
Entry("standard Go units", "90m", 90*time.Minute),
|
||||
Entry("hours", "12h", 12*time.Hour),
|
||||
Entry("days", "1d", 24*time.Hour),
|
||||
Entry("weeks", "1w", 7*24*time.Hour),
|
||||
Entry("multiple days", "3d", 72*time.Hour),
|
||||
Entry("mixed day and hours", "1d12h", 36*time.Hour),
|
||||
Entry("mixed week, day and hours", "1w2d3h", (7*24+2*24+3)*time.Hour),
|
||||
Entry("fractional days", "0.5d", 12*time.Hour),
|
||||
)
|
||||
|
||||
DescribeTable("rejects invalid durations",
|
||||
func(input string) {
|
||||
_, err := utils.ParseDuration(input)
|
||||
Expect(err).To(HaveOccurred())
|
||||
},
|
||||
Entry("empty string", ""),
|
||||
Entry("not a duration", "tomorrow"),
|
||||
Entry("bare number", "42"),
|
||||
Entry("unit only", "d"),
|
||||
Entry("unknown unit", "5y"),
|
||||
)
|
||||
|
||||
DescribeTable("rejects negative durations",
|
||||
func(input string) {
|
||||
_, err := utils.ParseDuration(input)
|
||||
Expect(err).To(MatchError(ContainSubstring("negative duration")))
|
||||
},
|
||||
Entry("negative days", "-1d"),
|
||||
Entry("negative weeks", "-0.5w"),
|
||||
Entry("negative Go units", "-30m"),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("FormatDuration", func() {
|
||||
DescribeTable("formats durations using the largest whole units",
|
||||
func(input time.Duration, expected string) {
|
||||
Expect(utils.FormatDuration(input)).To(Equal(expected))
|
||||
},
|
||||
Entry("whole weeks", 7*24*time.Hour, "1w"),
|
||||
Entry("whole days", 24*time.Hour, "1d"),
|
||||
Entry("multiple days", 72*time.Hour, "3d"),
|
||||
Entry("day and hours", 36*time.Hour, "1d12h"),
|
||||
Entry("week, day and hours", (7*24+2*24+3)*time.Hour, "1w2d3h"),
|
||||
Entry("hours only", 12*time.Hour, "12h"),
|
||||
Entry("sub-hour", 90*time.Minute, "1h30m0s"),
|
||||
Entry("zero", time.Duration(0), "0s"),
|
||||
)
|
||||
|
||||
DescribeTable("round-trips through ParseDuration",
|
||||
func(input string) {
|
||||
d, err := utils.ParseDuration(input)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
formatted := utils.FormatDuration(d)
|
||||
d2, err := utils.ParseDuration(formatted)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(d2).To(Equal(d))
|
||||
},
|
||||
Entry("1d", "1d"),
|
||||
Entry("1w", "1w"),
|
||||
Entry("1d12h", "1d12h"),
|
||||
Entry("90m", "90m"),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue