mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* test(scanner): fix flaky Windows search_normalized rescan test The 'repopulates a stale search_normalized on a full rescan' spec runs two full scans back-to-back. Whether the second scan refreshes the unchanged artist depends on folderEntry.isOutdated(), which compares folder.updated_at (written during the first scan) against the second scan's library.last_scan_started_at using a strict time.Before(). Both are time.Now() values captured milliseconds apart. On Linux's fine-grained clock they are always distinct, so the test passes. On Windows the coarse wall-clock granularity frequently makes the two timestamps land in the same tick and compare equal, so Before() returns false, the folder is treated as up-to-date and skipped, the artist is never re-persisted, and search_normalized stays empty -- failing the assertion intermittently across unrelated PRs. Backdate the folder's updated_at an hour before the second scan so the comparison is unambiguous on every platform. This is a test-only timing artifact (real rescans never run milliseconds apart on an unchanged library), so no production code changes are needed. * fix(smartplaylist): reject NSP mixing top-level 'any' and 'all' A smart playlist (.nsp) that specified both a top-level "any" and a top-level "all" group was imported by silently keeping only "any" and discarding "all", regardless of key order. The Criteria model holds a single top-level Expression, so it cannot represent both groups, and the parser picked "any" without reporting the dropped rules. Make Criteria.UnmarshalJSON return an error when both keys are present at the top level, so the scanner fails loudly (logging the playlist as invalid) instead of silently losing rules. Users should nest one group inside the other, as shown in the documented examples. Fixes #5757 * fix(smartplaylist): reject top-level any+all by key presence Address code review feedback: the previous guard checked decoded slice lengths, so it only rejected the mixed top-level any/all form when both groups were non-empty. An input like {"any":[],"all":[...]} (or a null group) slipped past and silently used just one group — the same class of silent drop this change set out to prevent. Decode the two keys as json.RawMessage and detect presence by key rather than length, so any file that provides both top-level keys is rejected regardless of whether one group is empty or null. * refactor(smartplaylist): detect top-level any+all via presence type Replace the json.RawMessage + manual double-unmarshal in Criteria.UnmarshalJSON with a small optionalConjunction wrapper whose UnmarshalJSON records that its key was present. Because encoding/json invokes UnmarshalJSON even for a JSON null, this keeps the exact behavior (a present-but-empty or null group still counts, so mixing both top-level keys is rejected) while decoding in a single pass — no raw-message capture, no re-decode, no shadow variables. No behavior change; existing tests pass unchanged.
188 lines
4.1 KiB
Go
188 lines
4.1 KiB
Go
package criteria
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type unmarshalConjunctionType []Expression
|
|
|
|
func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
|
|
var raw []map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
|
|
var es unmarshalConjunctionType
|
|
for _, e := range raw {
|
|
for k, v := range e {
|
|
k = strings.ToLower(k)
|
|
expr := unmarshalExpression(k, v)
|
|
if expr == nil {
|
|
expr = unmarshalConjunction(k, v)
|
|
}
|
|
if expr == nil {
|
|
return fmt.Errorf(`invalid expression key '%s'`, k)
|
|
}
|
|
es = append(es, expr)
|
|
}
|
|
}
|
|
*uc = es
|
|
return nil
|
|
}
|
|
|
|
// optionalConjunction is a top-level "all"/"any" value that remembers whether its
|
|
// key was present at all, so a Criteria providing both can be rejected. encoding/json
|
|
// calls UnmarshalJSON even for a JSON null, so present is set whenever the key appears
|
|
// — including as [] or null — while an absent key leaves it false.
|
|
type optionalConjunction struct {
|
|
present bool
|
|
rules unmarshalConjunctionType
|
|
}
|
|
|
|
func (o *optionalConjunction) UnmarshalJSON(data []byte) error {
|
|
o.present = true
|
|
return json.Unmarshal(data, &o.rules)
|
|
}
|
|
|
|
func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
|
|
m := make(map[string]any)
|
|
err := json.Unmarshal(rawValue, &m)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
normalizeBoolFields(m)
|
|
switch opName {
|
|
case "is":
|
|
return Is(m)
|
|
case "isnot":
|
|
return IsNot(m)
|
|
case "gt":
|
|
return Gt(m)
|
|
case "lt":
|
|
return Lt(m)
|
|
case "contains":
|
|
return Contains(m)
|
|
case "notcontains":
|
|
return NotContains(m)
|
|
case "startswith":
|
|
return StartsWith(m)
|
|
case "endswith":
|
|
return EndsWith(m)
|
|
case "intherange":
|
|
return InTheRange(m)
|
|
case "before":
|
|
return Before(m)
|
|
case "after":
|
|
return After(m)
|
|
case "inthelast":
|
|
return InTheLast(m)
|
|
case "notinthelast":
|
|
return NotInTheLast(m)
|
|
case "inplaylist":
|
|
return InPlaylist(m)
|
|
case "notinplaylist":
|
|
return NotInPlaylist(m)
|
|
case "ismissing":
|
|
normalizeAllBoolFields(m)
|
|
return IsMissing(m)
|
|
case "ispresent":
|
|
normalizeAllBoolFields(m)
|
|
return IsPresent(m)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeAllBoolFields(m map[string]any) {
|
|
for k, v := range m {
|
|
m[k] = normalizeBoolValue(v)
|
|
}
|
|
}
|
|
|
|
func normalizeBoolFields(m map[string]any) {
|
|
for field, value := range m {
|
|
info, ok := LookupField(field)
|
|
if ok && info.Boolean {
|
|
m[field] = normalizeBoolValue(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ToBool coerces a criteria value to a bool, accepting the forms criteria values take: a real bool,
|
|
// a strconv.ParseBool-parseable string, or a JSON number that is exactly 0 or 1. Any other value
|
|
// (other numbers, slices, nil, unparseable strings) returns ok=false so callers can handle it.
|
|
func ToBool(v any) (bool, bool) {
|
|
switch val := v.(type) {
|
|
case bool:
|
|
return val, true
|
|
case string:
|
|
b, err := strconv.ParseBool(val)
|
|
return b, err == nil
|
|
case float64:
|
|
switch val {
|
|
case 1:
|
|
return true, true
|
|
case 0:
|
|
return false, true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// normalizeBoolValue leaves non-boolean values unchanged so they flow through to their own validation.
|
|
func normalizeBoolValue(v any) any {
|
|
if b, ok := ToBool(v); ok {
|
|
return b
|
|
}
|
|
return v
|
|
}
|
|
|
|
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
|
|
var items unmarshalConjunctionType
|
|
err := json.Unmarshal(rawValue, &items)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
switch conjName {
|
|
case "any":
|
|
return Any(items)
|
|
case "all":
|
|
return All(items)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func marshalExpression(name string, value map[string]any) ([]byte, error) {
|
|
if len(value) != 1 {
|
|
return nil, fmt.Errorf(`invalid %s expression length %d for values %v`, name, len(value), value)
|
|
}
|
|
b := strings.Builder{}
|
|
b.WriteString(`{"` + name + `":{`)
|
|
for f, v := range value {
|
|
j, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b.WriteString(`"` + f + `":`)
|
|
b.Write(j)
|
|
break
|
|
}
|
|
b.WriteString("}}")
|
|
return []byte(b.String()), nil
|
|
}
|
|
|
|
func marshalConjunction(name string, conj []Expression) ([]byte, error) {
|
|
aux := struct {
|
|
All []Expression `json:"all,omitempty"`
|
|
Any []Expression `json:"any,omitempty"`
|
|
}{}
|
|
if name == "any" {
|
|
aux.Any = conj
|
|
} else {
|
|
aux.All = conj
|
|
}
|
|
return json.Marshal(aux)
|
|
}
|