mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
* feat: Add support for referencing playlists using paths Signed-off-by: David <dvedvick@gmail.com> * feat: Support relative playlist paths in smartlists Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): protect against nil panic Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): refreshing child playlists Signed-off-by: David <dvedvick@gmail.com> * chore(smartplaylists): log field parsing error Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): handle empty playlist paths Signed-off-by: David <dvedvick@gmail.com> * refactor(smartplaylists): make NormalizeChildPaths non-mutating Signed-off-by: David <dvedvick@gmail.com> * fix(smartplaylists): stop warning on every inPlaylist rule without the looked-up field Rules that reference a playlist by id have no path field, and the reverse, so the warning fired on every refresh. The log call also had a bad argument count. * fix(smartplaylists): ignore empty inPlaylist id and path references An empty path matched every playlist without a file path, including the referencing playlist itself, so the refresh recursed until the stack overflowed. An empty id also shadowed a valid path in the same rule. * fix(smartplaylists): match inPlaylist paths in both NFC and NFD forms A playlist path is stored in the Unicode form the filesystem reports, which can differ from the form typed in the .nsp file. The exact comparison then found no playlist for names with accents. * fix(smartplaylists): keep all criteria fields when normalizing child paths The field-by-field copy dropped RefreshDelay. * fix(smartplaylists): clean absolute inPlaylist path references Only relative references were cleaned, so an absolute reference such as /music/./child.nsp never matched the stored /music/child.nsp. * fix(smartplaylists): stop infinite recursion on playlists that reference each other Two smart playlists referencing each other, by id or by path, recursed until the stack overflowed and the server died. The refresh now tracks visited playlists. * fix(smartplaylists): resolve inPlaylist path references with OS-native separators Playlist.Path is OS-native, but references in a .nsp file use forward slashes. On Windows they never matched, and a leading slash was not seen as absolute. The specs now build OS-native paths, so they also run on Windows. * fix(smartplaylists): warn when a relative inPlaylist path cannot be resolved A playlist created in the UI has no file path, so a relative reference silently matched nothing. * refactor(smartplaylists): simplify child playlist reference handling Share one extractor for child ids and paths, return only the normalized rules instead of a playlist copy, and resolve each path reference in a single switch. * test(smartplaylists): store the Unicode child path in OS-native form Playlist.Path is OS-native, so on Windows the forward-slash fixture never matched the normalized reference. --------- Signed-off-by: David <dvedvick@gmail.com> Co-authored-by: Deluan Quintão <deluan@navidrome.org>
165 lines
4.7 KiB
Go
165 lines
4.7 KiB
Go
// Package criteria implements the smart playlist criteria DSL.
|
|
package criteria
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/utils"
|
|
)
|
|
|
|
type Expression interface {
|
|
fields() map[string]any
|
|
}
|
|
|
|
type Criteria struct {
|
|
Expression
|
|
Sort string
|
|
Order string
|
|
Limit int
|
|
LimitPercent int
|
|
Offset int
|
|
RefreshDelay time.Duration // 0 = use conf.Server.SmartPlaylistRefreshDelay
|
|
}
|
|
|
|
// EffectiveLimit resolves the effective limit for a query. If a fixed Limit is
|
|
// set it takes precedence. Otherwise, if LimitPercent is set, the limit is
|
|
// computed as a percentage of totalCount (minimum 1 when totalCount > 0).
|
|
// Returns 0 when no limit applies.
|
|
func (c Criteria) EffectiveLimit(totalCount int64) int {
|
|
if c.Limit > 0 {
|
|
return c.Limit
|
|
}
|
|
if c.LimitPercent > 0 && c.LimitPercent <= 100 {
|
|
if totalCount <= 0 {
|
|
return 0
|
|
}
|
|
result := int(totalCount) * c.LimitPercent / 100
|
|
if result < 1 {
|
|
return 1
|
|
}
|
|
return result
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ResolveLimit converts a percentage-based limit into an absolute Limit using
|
|
// the given totalCount. It is a no-op when a fixed Limit is already set or when
|
|
// no percentage limit is configured.
|
|
func (c *Criteria) ResolveLimit(totalCount int64) {
|
|
if !c.IsPercentageLimit() {
|
|
return
|
|
}
|
|
c.Limit = c.EffectiveLimit(totalCount)
|
|
}
|
|
|
|
// IsPercentageLimit returns true when the criteria uses a valid percentage-based
|
|
// limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it).
|
|
func (c Criteria) IsPercentageLimit() bool {
|
|
return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100
|
|
}
|
|
|
|
func (c Criteria) ChildPlaylistIds() []string {
|
|
return c.childPlaylistRefs(conjunction.ChildPlaylistIds)
|
|
}
|
|
|
|
func (c Criteria) ChildPlaylistPaths() []string {
|
|
return c.childPlaylistRefs(conjunction.ChildPlaylistPaths)
|
|
}
|
|
|
|
func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string {
|
|
parent, ok := c.Expression.(conjunction)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
refs := extract(parent)
|
|
slices.Sort(refs)
|
|
return slices.Compact(refs)
|
|
}
|
|
|
|
func (c Criteria) MarshalJSON() ([]byte, error) {
|
|
aux := struct {
|
|
All []Expression `json:"all,omitempty"`
|
|
Any []Expression `json:"any,omitempty"`
|
|
Sort string `json:"sort,omitempty"`
|
|
Order string `json:"order,omitempty"`
|
|
Limit int `json:"limit,omitempty"`
|
|
LimitPercent int `json:"limitPercent,omitempty"`
|
|
Offset int `json:"offset,omitempty"`
|
|
RefreshDelay string `json:"refreshDelay,omitempty"`
|
|
}{
|
|
Sort: c.Sort,
|
|
Order: c.Order,
|
|
Limit: c.Limit,
|
|
LimitPercent: c.LimitPercent,
|
|
Offset: c.Offset,
|
|
}
|
|
if c.RefreshDelay > 0 {
|
|
aux.RefreshDelay = utils.FormatDuration(c.RefreshDelay)
|
|
}
|
|
switch rules := c.Expression.(type) {
|
|
case Any:
|
|
aux.Any = rules
|
|
case All:
|
|
aux.All = rules
|
|
default:
|
|
aux.All = All{rules}
|
|
}
|
|
return json.Marshal(aux)
|
|
}
|
|
|
|
func (c *Criteria) UnmarshalJSON(data []byte) error {
|
|
var aux struct {
|
|
All optionalConjunction `json:"all"`
|
|
Any optionalConjunction `json:"any"`
|
|
Sort string `json:"sort"`
|
|
Order string `json:"order"`
|
|
Limit int `json:"limit"`
|
|
LimitPercent int `json:"limitPercent"`
|
|
Offset int `json:"offset"`
|
|
RefreshDelay string `json:"refreshDelay"`
|
|
}
|
|
if err := json.Unmarshal(data, &aux); err != nil {
|
|
return err
|
|
}
|
|
// A Criteria has a single top-level group. Reject files that provide both keys
|
|
// (even when one is [] or null) rather than silently dropping one of them.
|
|
if aux.All.present && aux.Any.present {
|
|
return errors.New("invalid criteria json: 'all' and 'any' cannot both be used at the top level; nest one inside the other instead")
|
|
}
|
|
if len(aux.Any.rules) > 0 {
|
|
c.Expression = Any(aux.Any.rules)
|
|
} else if len(aux.All.rules) > 0 {
|
|
c.Expression = All(aux.All.rules)
|
|
} else {
|
|
return errors.New("invalid criteria json. missing rules (key 'all' or 'any')")
|
|
}
|
|
c.Sort = aux.Sort
|
|
c.Order = aux.Order
|
|
c.Limit = aux.Limit
|
|
c.Offset = aux.Offset
|
|
|
|
if aux.RefreshDelay != "" {
|
|
d, err := utils.ParseDuration(aux.RefreshDelay)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid refreshDelay: %w", err)
|
|
}
|
|
c.RefreshDelay = d
|
|
}
|
|
|
|
// Clamp LimitPercent to [0, 100]
|
|
if aux.LimitPercent < 0 {
|
|
log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent)
|
|
aux.LimitPercent = 0
|
|
} else if aux.LimitPercent > 100 {
|
|
log.Warn("limitPercent value out of range, clamping to 100", "value", aux.LimitPercent)
|
|
aux.LimitPercent = 100
|
|
}
|
|
c.LimitPercent = aux.LimitPercent
|
|
return nil
|
|
}
|