From 94cf7cea6038b78f69244d7a7fc025218bed7b45 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 17 Oct 2026 22:56:18 +0000 Subject: [PATCH 1/4] smart: match key rules across key notations An import stores every track's key as Camelot ("8A"), but a rekordbox smart rule carries the key name rekordbox showed ("Am"), so "key is Am" matched nothing after an import. Key rules now treat a recognised key name in either notation as that key: equality compares the keys, and contains, starts with, and ends with test the track key's name in each notation. Other text still compares as text, as rekordbox does. --- api/smart_key_notation_test.go | 43 ++++++++++++++++++++++++++ api/smart_playlist.go | 55 ++++++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 api/smart_key_notation_test.go diff --git a/api/smart_key_notation_test.go b/api/smart_key_notation_test.go new file mode 100644 index 0000000..8b2175d --- /dev/null +++ b/api/smart_key_notation_test.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package api + +import ( + "testing" + "time" + + "github.com/vynulldev/vynull/library" +) + +// An import stores keys as Camelot, but rekordbox key rules carry the names +// rekordbox showed: "key is Am" must still find a track stored as 8A, and a +// rule in either notation must find a key stored in the other. +func TestSmartKeyRulesAcrossNotations(t *testing.T) { + match := func(op int, left, key string) bool { + rules, _, _ := smartRulesFromRekordbox(&library.RBSmartList{Logical: 1, + Conditions: []library.RBSmartCond{{Property: "key", Operator: op, Left: left}}}) + return rules.Match(&library.Track{ID: 1, Key: key}, nil, time.Now()) + } + for _, tc := range []struct { + op int + left, key string + want bool + }{ + {1, "Am", "8A", true}, // is + {1, "8A", "Am", true}, // is, the other way round + {1, "Abm", "1A", true}, // an enharmonic spelling + {1, "Am", "8B", false}, // a different key + {2, "Am", "8A", false}, // is not + {2, "Am", "9A", true}, + {8, "m", "8A", true}, // contains, against the classic name Am + {9, "m", "8B", true}, // does not contain: C has no m in either name + {9, "m", "8A", false}, + {10, "8", "8A", true}, // starts with, in Camelot + {1, " Am", "8A", false}, // text with spaces is not a key name + {1, "Alpha", "Alpha", true}, // non-key text compares as text + } { + if got := match(tc.op, tc.left, tc.key); got != tc.want { + t.Errorf("op %d %q vs key %q = %v, want %v", tc.op, tc.left, tc.key, got, tc.want) + } + } +} diff --git a/api/smart_playlist.go b/api/smart_playlist.go index 70cc297..f67bd45 100644 --- a/api/smart_playlist.go +++ b/api/smart_playlist.go @@ -10,6 +10,7 @@ import ( "time" "unicode" + "github.com/vynulldev/vynull/analysis" "github.com/vynulldev/vynull/library" ) @@ -226,18 +227,60 @@ func keyMatch(op string, value any, trackKey string) bool { return false } switch op { - case "is", "eq", "=", "is_not", "ne", "!=": - // Equality compares the key's name like any other text. - return stringMatch(op, value, trackKey) + case "is", "eq", "=": + return sameKey(target, trackKey) + case "is_not", "ne", "!=": + return !sameKey(target, trackKey) case "compatible": return camelotCompatible(trackKey, target) - case "contains", "not_contains", "starts_with", "ends_with": - // Text operators compare the key's name, as rekordbox does. - return stringMatch(op, value, trackKey) + case "contains", "starts_with", "ends_with": + // Text operators compare the key's name, as rekordbox does, in + // either notation: an import stores keys as Camelot, but the rule + // may be written against rekordbox's classic names. + for _, name := range keyNames(trackKey) { + if stringMatch(op, value, name) { + return true + } + } + return false + case "not_contains": + for _, name := range keyNames(trackKey) { + if !stringMatch(op, value, name) { + return false + } + } + return true } return false } +// sameKey reports whether a key rule's value names the track's key. It +// compares the names as text, as rekordbox does, and also as keys in either +// notation, since an import stores keys as Camelot ("8A") while rekordbox +// rules carry the names rekordbox showed ("Am"). +func sameKey(rule, trackKey string) bool { + if stringMatch("is", rule, trackKey) { + return true + } + a, _ := analysis.KeyNamesFrom(rule) + b, _ := analysis.KeyNamesFrom(trackKey) + return a != "" && a == b && rule == strings.TrimSpace(rule) +} + +// keyNames is a track key's name in each notation it has: the stored name +// and, for a recognised key, its Camelot and classic names. +func keyNames(key string) []string { + names := []string{key} + if cam, std := analysis.KeyNamesFrom(key); cam != "" { + for _, n := range []string{cam, std} { + if n != key { + names = append(names, n) + } + } + } + return names +} + // foldText prepares text for comparison the way rekordbox's smart playlists // compare it (link-spec conformance smart text suites): case-insensitive, a // precomposed accented letter equals its base letter ("Álpha" is "alpha"), From ec264cc97c72ebb5ca29f0f29c6a2c189cdde0ca Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 17 Oct 2026 23:00:00 +0000 Subject: [PATCH 2/4] web: keep an imported key rule's key when editing it The smart editor's key picker lists Camelot keys only, so a rule imported with a classic name ("Am") had no matching option: the browser selected the first key, and the editor's sync wrote it into the rule, so opening the playlist changed it to 1A. The rule's own value is now kept as a selected option (with its Camelot name), and the preview compares keys in either notation, as the server does. --- api/web/index.html | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/api/web/index.html b/api/web/index.html index f6ac8cc..1b4b043 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -11312,9 +11312,17 @@

RELATED

return ``; } if (meta.type === 'key') { - return ``; + // An imported rekordbox rule may name its key in classic notation + // ("Am"); keep that value as its own option so opening the editor never + // swaps the rule's key for the first one listed. + const v = c.value == null ? '' : String(c.value); + const opts = SMART_KEYS.map(k => + ``); + if (v && !SMART_KEYS.includes(v)) { + const p = KEY_PAIRS[v.trim().toUpperCase()]; + opts.unshift(``); + } + return ``; } if (meta.type === 'tag') { const tags = (allTagsCache || []); @@ -11601,8 +11609,10 @@

RELATED

return false; } if (meta.type === 'key') { - const trackKey = (t.key || '').toUpperCase(); - const target = (v || '').toString().toUpperCase(); + // Compare keys in either notation, as the server does. + const cam = x => { const k = String(x || '').toUpperCase(); return (KEY_PAIRS[k] || [k])[0]; }; + const trackKey = cam(t.key); + const target = cam(v); if (op === 'is') return trackKey === target; if (op === 'is_not') return trackKey !== target; // compatible — quick approximation; server does the authoritative check From 3bb385e199f43aef3916ddefe04c21a12fa650df Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 17 Oct 2026 23:04:02 +0000 Subject: [PATCH 3/4] web: offer rekordbox's key operators in the smart editor rekordbox's key conditions offer equals, not equals, contains, does not contain, starts with and ends with; the editor offered only equals, is not, and Vynull's own is compatible with. It now lists rekordbox's six (keeping is compatible with last), takes a typed value for the text operators, and previews them against the key's name in either notation, as the server matches them. --- api/web/index.html | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/api/web/index.html b/api/web/index.html index 1b4b043..a585c42 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -11140,13 +11140,17 @@

RELATED

number: [['eq','equals'], ['ne','is not'], ['lt','<'], ['lte','≤'], ['gt','>'], ['gte','≥'], ['between','between']], string: [['is','equals'], ['is_not','is not'], ['contains','contains'], ['not_contains','doesn’t contain'], ['starts_with','starts with'], ['ends_with','ends with'], ['is_empty','is empty'], ['not_empty','not empty']], - key: [['is','equals'], ['is_not','is not'], ['compatible','is compatible with']], + // rekordbox's key operators, plus Vynull's harmonic-mixing match. + key: [['is','equals'], ['is_not','is not'], ['contains','contains'], ['not_contains','doesn’t contain'], + ['starts_with','starts with'], ['ends_with','ends with'], ['compatible','is compatible with']], tag: [['has','has tag'], ['not_has','doesn’t have tag'], ['has_any','has any tag'], ['none','has no tags']], date: [['in_last_days','in the last (days)'], ['not_in_last_days','not in the last (days)'], ['in_last_months','in the last (months)'], ['not_in_last_months','not in the last (months)'], ['on','is'], ['not_on','is not'], ['before','before'], ['after','after'], ['between','between']], color: [['eq','is'], ['ne','is not']], }; +// Key operators that test the key's name as text, so take a typed value. +const SMART_KEY_TEXT_OPS = ['contains', 'not_contains', 'starts_with', 'ends_with']; // Date operators whose value is a count of days or months, not a date. const SMART_DATE_COUNT_OPS = ['in_last_days', 'not_in_last_days', 'in_last_months', 'not_in_last_months']; // Camelot key list for key field dropdowns. @@ -11311,6 +11315,9 @@

RELATED

} return ``; } + if (meta.type === 'key' && SMART_KEY_TEXT_OPS.includes(op)) { + return ``; + } if (meta.type === 'key') { // An imported rekordbox rule may name its key in classic notation // ("Am"); keep that value as its own option so opening the editor never @@ -11615,6 +11622,18 @@

RELATED

const target = cam(v); if (op === 'is') return trackKey === target; if (op === 'is_not') return trackKey !== target; + if (SMART_KEY_TEXT_OPS.includes(op)) { + // The key's name in each notation, as the server tests it. + const raw = String(t.key || ''); + const p = KEY_PAIRS[raw.trim().toUpperCase()]; + const names = (p ? [raw, p[0], p[1]] : [raw]).map(n => n.toLowerCase()); + const q = String(v || '').toLowerCase(); + const hit = f => names.some(f); + if (op === 'contains') return hit(n => n.includes(q)); + if (op === 'not_contains') return raw !== '' && !hit(n => n.includes(q)); + if (op === 'starts_with') return hit(n => n.startsWith(q)); + if (op === 'ends_with') return hit(n => n.endsWith(q)); + } // compatible — quick approximation; server does the authoritative check return trackKey === target; } From 14ad61d64657cc33362ee4be111abac2fd679d69 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 17 Oct 2026 23:11:12 +0000 Subject: [PATCH 4/4] smart: compatible-key rules accept either notation "Is compatible with" parsed only Camelot, so a key named in classic notation fell back to comparing text. Both sides now read as keys in either notation. The editor's key dropdown labels each key in both notations, led by the key display setting. --- api/smart_key_notation_test.go | 9 ++++++++- api/smart_playlist.go | 11 ++++++++++- api/web/index.html | 4 +++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/api/smart_key_notation_test.go b/api/smart_key_notation_test.go index 8b2175d..50df179 100644 --- a/api/smart_key_notation_test.go +++ b/api/smart_key_notation_test.go @@ -14,9 +14,14 @@ import ( // rule in either notation must find a key stored in the other. func TestSmartKeyRulesAcrossNotations(t *testing.T) { match := func(op int, left, key string) bool { + tr := &library.Track{ID: 1, Key: key} + if op == 0 { // Vynull's own "is compatible with", which rekordbox lacks + return (&SmartRules{Combinator: "all", Conditions: []SmartCond{ + {Field: "key", Operator: "compatible", Value: left}}}).Match(tr, nil, time.Now()) + } rules, _, _ := smartRulesFromRekordbox(&library.RBSmartList{Logical: 1, Conditions: []library.RBSmartCond{{Property: "key", Operator: op, Left: left}}}) - return rules.Match(&library.Track{ID: 1, Key: key}, nil, time.Now()) + return rules.Match(tr, nil, time.Now()) } for _, tc := range []struct { op int @@ -35,6 +40,8 @@ func TestSmartKeyRulesAcrossNotations(t *testing.T) { {10, "8", "8A", true}, // starts with, in Camelot {1, " Am", "8A", false}, // text with spaces is not a key name {1, "Alpha", "Alpha", true}, // non-key text compares as text + {0, "Am", "9A", true}, // compatible (a neighbour), typed in classic + {0, "Am", "3B", false}, } { if got := match(tc.op, tc.left, tc.key); got != tc.want { t.Errorf("op %d %q vs key %q = %v, want %v", tc.op, tc.left, tc.key, got, tc.want) diff --git a/api/smart_playlist.go b/api/smart_playlist.go index f67bd45..9cef37f 100644 --- a/api/smart_playlist.go +++ b/api/smart_playlist.go @@ -232,7 +232,7 @@ func keyMatch(op string, value any, trackKey string) bool { case "is_not", "ne", "!=": return !sameKey(target, trackKey) case "compatible": - return camelotCompatible(trackKey, target) + return camelotCompatible(camelotName(trackKey), camelotName(target)) case "contains", "starts_with", "ends_with": // Text operators compare the key's name, as rekordbox does, in // either notation: an import stores keys as Camelot, but the rule @@ -267,6 +267,15 @@ func sameKey(rule, trackKey string) bool { return a != "" && a == b && rule == strings.TrimSpace(rule) } +// camelotName is a key's Camelot name when it names a key in either +// notation ("Am" is 8A), else the text unchanged. +func camelotName(key string) string { + if cam, _ := analysis.KeyNamesFrom(key); cam != "" { + return cam + } + return key +} + // keyNames is a track key's name in each notation it has: the stored name // and, for a recognised key, its Camelot and classic names. func keyNames(key string) []string { diff --git a/api/web/index.html b/api/web/index.html index a585c42..9e06c84 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -11323,8 +11323,10 @@

RELATED

// ("Am"); keep that value as its own option so opening the editor never // swaps the rule's key for the first one listed. const v = c.value == null ? '' : String(c.value); + // Each key is stored in Camelot and labelled in both notations, led by + // the user's key display setting ("8A · Am" or "Am · 8A"). const opts = SMART_KEYS.map(k => - ``); + ``); if (v && !SMART_KEYS.includes(v)) { const p = KEY_PAIRS[v.trim().toUpperCase()]; opts.unshift(``);