Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions api/smart_key_notation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// 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 {
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(tr, 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
{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)
}
}
}
66 changes: 59 additions & 7 deletions api/smart_playlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"
"unicode"

"github.com/vynulldev/vynull/analysis"
"github.com/vynulldev/vynull/library"
)

Expand Down Expand Up @@ -226,18 +227,69 @@ 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)
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
// 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)
}

// 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 {
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"),
Expand Down
43 changes: 37 additions & 6 deletions api/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11140,13 +11140,17 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
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.
Expand Down Expand Up @@ -11311,10 +11315,23 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
}
return `<input type="number" data-sm-val value="${v}" style="width:100px">`;
}
if (meta.type === 'key' && SMART_KEY_TEXT_OPS.includes(op)) {
return `<input type="text" data-sm-val value="${escape(c.value || '')}" style="min-width:100px">`;
}
if (meta.type === 'key') {
return `<select data-sm-val>${SMART_KEYS.map(k =>
`<option value="${k}" ${k === c.value ? 'selected' : ''}>${k}</option>`
).join('')}</select>`;
// 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);
// 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 =>
`<option value="${k}" ${k === v ? 'selected' : ''}>${escape(keyBoth(k))}</option>`);
if (v && !SMART_KEYS.includes(v)) {
const p = KEY_PAIRS[v.trim().toUpperCase()];
opts.unshift(`<option value="${escape(v)}" selected>${escape(v)}${p ? ' · ' + p[0] : ''}</option>`);
}
return `<select data-sm-val>${opts.join('')}</select>`;
}
if (meta.type === 'tag') {
const tags = (allTagsCache || []);
Expand Down Expand Up @@ -11601,10 +11618,24 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
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;
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;
}
Expand Down
Loading