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
43 changes: 40 additions & 3 deletions api/smart_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
package api

import (
"strconv"
"math"
"strings"
"time"

Expand Down Expand Up @@ -44,6 +44,20 @@ var rbFieldMap = map[string]string{
"dateCreated": "date_created",
}

// rbField looks up a SmartList PropertyName, which rekordbox matches in any
// letter case (smart-xml-matrix, rekordbox 7.2.19: GENRE works as genre).
func rbField(property string) (string, bool) {
if field, ok := rbFieldMap[property]; ok {
return field, true
}
for name, field := range rbFieldMap {
if strings.EqualFold(name, property) {
return field, true
}
}
return "", false
}

func isNumericField(field string) bool {
switch field {
case "bpm", "year", "rating", "play_count", "duration_sec", "color":
Expand All @@ -66,6 +80,29 @@ func rbDate(s string) string {
return t.UTC().Format(time.RFC3339)
}

// rbInt reads a numeric rule value as rekordbox does (link-spec
// smart-numeric-matrix and smart-numeric-boundaries, rekordbox 7.2.19): an
// optionally signed run of leading digits, so 0.5 is 0 and blank or other
// text is 0, saturating at the int32 limits.
func rbInt(s string) int64 {
s = strings.TrimLeft(s, " \t\r\n")
neg := false
if s != "" && (s[0] == '-' || s[0] == '+') {
neg = s[0] == '-'
s = s[1:]
}
var n int64
for i := 0; i < len(s) && s[i] >= '0' && s[i] <= '9'; i++ {
if n = n*10 + int64(s[i]-'0'); n > math.MaxInt32+1 {
n = math.MaxInt32 + 1
}
}
if neg {
return max(-n, math.MinInt32)
}
return min(n, math.MaxInt32)
}

// smartRulesFromRekordbox translates a parsed rekordbox SmartList into the
// app's SmartRules. Returns the rules and the number of conditions that
// could not be mapped (unsupported property/operator) so the caller can log
Expand Down Expand Up @@ -99,7 +136,7 @@ func smartRulesFromRekordbox(rb *library.RBSmartList) (rules *SmartRules, mapped
// 11=ends-with. BPM values are stored ×100; myTag values arrive already
// resolved to the tag name by the dump helper.
func rbCondToSmart(c library.RBSmartCond) (SmartCond, bool) {
field, ok := rbFieldMap[c.Property]
field, ok := rbField(c.Property)
if !ok {
return SmartCond{}, false
}
Expand All @@ -109,7 +146,7 @@ func rbCondToSmart(c library.RBSmartCond) (SmartCond, bool) {

// num parses a rekordbox numeric value string, scaling BPM down by 100.
num := func(s string) float64 {
f, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
f := float64(rbInt(s))
if field == "bpm" {
f /= 100
}
Expand Down
42 changes: 42 additions & 0 deletions api/smart_import_numeric_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package api

import (
"testing"
"time"

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

// Numeric rule values and stored numbers compare as rekordbox's do (link-spec
// smart-numeric-matrix and smart-numeric-boundaries, rekordbox 7.2.19).
func TestSmartImportNumericLikeRekordbox(t *testing.T) {
for in, want := range map[string]int64{
"0.5": 0, "1.5": 1, "-1": -1, "": 0, "not-a-number": 0, " 7": 7,
"2147483647": 2147483647, "2147483648": 2147483647, "4294967296": 2147483647,
"-99999999999": -2147483648,
} {
if got := rbInt(in); got != want {
t.Errorf("rbInt(%q) = %d, want %d", in, got, want)
}
}
match := func(prop string, op int, left, right string, tr *library.Track) bool {
rules, _, _ := smartRulesFromRekordbox(&library.RBSmartList{Logical: 1,
Conditions: []library.RBSmartCond{{Property: prop, Operator: op, Left: left, Right: right}}})
return rules.Match(tr, nil, time.Now())
}
// A stored rating of -1 (255 in a byte) is below zero.
if !match("rating", 4, "0", "", &library.Track{Rating: 255}) {
t.Errorf("rating 255 (signed -1) should be less than 0")
}
// A negative stored BPM matches no rule, not even its complement.
neg := &library.Track{BPM: -0.01}
if match("bpm", 2, "100", "", neg) || match("bpm", 5, "-100", "100", neg) {
t.Errorf("a negative BPM should match no BPM rule")
}
// 0.5 truncates to 0.
if !match("bpm", 1, "0.5", "", &library.Track{BPM: 0}) {
t.Errorf("BPM equal 0.5 should match a zero BPM")
}
}
7 changes: 5 additions & 2 deletions api/smart_playlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,14 @@ func (c SmartCond) match(t *library.Track, tags TagLookup, now time.Time) bool {
func evalLeaf(field, op string, value any, t *library.Track, tags TagLookup, now time.Time) bool {
switch field {
case "bpm":
return numericMatch(op, value, t.BPM)
// rekordbox's smart playlists skip a track whose stored BPM reads
// as negative (link-spec smart-numeric-boundaries, rekordbox 7.2.19).
return t.BPM >= 0 && numericMatch(op, value, t.BPM)
case "year":
return numericMatch(op, value, float64(t.Year))
case "rating":
return numericMatch(op, value, float64(t.Rating))
// rekordbox reads the rating as a signed byte.
return numericMatch(op, value, float64(int8(t.Rating)))
case "play_count":
return numericMatch(op, value, float64(t.PlayCount))
case "duration_sec":
Expand Down
11 changes: 9 additions & 2 deletions dbserver/menuitem.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package dbserver

import (
"fmt"
"math"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -287,9 +288,15 @@ func (h *Handler) applyTrackDetail(m *menuItem, t *library.Track) {
m.Label2 += " - " + t.Key
}
case "key":
// rekordbox composes "<key> - <bpm> bpm" even for an empty key.
// rekordbox composes "<key> - <bpm> bpm" even for an empty key, but
// shows the key alone when the tempo is zero, and a stored negative
// tempo by its magnitude (link-spec smart-numeric-boundaries,
// rekordbox 7.2.19).
m.ParentID = h.trackKeyID(t)
m.Label2 = fmt.Sprintf("%s - %.1f bpm", t.Key, t.BPM)
m.Label2 = t.Key
if raw := math.Round(t.BPM * 100); raw != 0 {
m.Label2 = fmt.Sprintf("%s - %.1f bpm", t.Key, math.Abs(raw)/100)
}
case "artist":
m.ParentID = h.catID("artist", t.Artist)
m.Label2 = t.Artist
Expand Down
17 changes: 17 additions & 0 deletions dbserver/songinfo_display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,23 @@ func TestKeyColumnEmptyKey(t *testing.T) {
}
}

// The KEY column shows the key alone for a zero tempo and a stored negative
// tempo by its magnitude (link-spec smart-numeric-boundaries).
func TestKeyColumnZeroAndNegativeTempo(t *testing.T) {
h := &Handler{menu: keyColumnMenu{}}
for _, tc := range []struct {
bpm float64
want string
}{
{0, "Am"}, {0.015, "Am - 0.0 bpm"}, {-0.01, "Am - 0.0 bpm"},
{-21474836.48, "Am - 21474836.5 bpm"}, {128, "Am - 128.0 bpm"},
} {
if got := h.trackToStdItem(&library.Track{ID: 1, Key: "Am", BPM: tc.bpm}).Label2; got != tc.want {
t.Errorf("BPM %v: %q, want %q", tc.bpm, got, tc.want)
}
}
}

// keyColumnMenu is a MenuSource whose track lists show the KEY column.
type keyColumnMenu struct{}

Expand Down
87 changes: 87 additions & 0 deletions library/rekordbox_dump_parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package library

import (
"encoding/json"
"os/exec"
"testing"
)

// The dump helper reads SmartList XML and numeric columns as rekordbox does
// (link-spec smart-xml-matrix and smart-numeric-boundaries, rekordbox
// 7.2.19). Runs the helper's pure functions under python3 with sqlcipher3
// stubbed out, so no database or key is needed.
func TestDumpHelperParsesLikeRekordbox(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 not available")
}
const script = `
import importlib.util, json, sys, types
sys.modules["sqlcipher3"] = types.ModuleType("sqlcipher3")
spec = importlib.util.spec_from_file_location("dump", "../tools/rekordbox_dump.py")
dump = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dump)
house = '<CONDITION PropertyName="genre" Operator="1" ValueUnit="" ValueLeft="Fixture House" ValueRight=""/>'
canonical = '<NODE Id="1" LogicalOperator="1">' + house + '</NODE>'
def rule(x):
r = dump.parse_smartlist(x, {})
if r is None:
return None
return [r["logical"]] + [c["operator"] for c in r["conditions"]] + [c["left"] for c in r["conditions"]]
out = {
"canonical": rule(canonical),
"lowercase": rule(canonical.replace("NODE", "node").replace("CONDITION", "condition")),
"mismatched_close": rule(canonical.replace("</NODE>", "</NODES>")),
"two_roots": rule(canonical + canonical.replace("House", "Techno")),
"trailing": rule(canonical + "trailing"),
"nul_junk": rule(canonical + "\0junk"),
"duplicate_value": rule(canonical.replace('ValueLeft="Fixture House"', 'ValueLeft="Fixture House" ValueLeft="Fixture Techno"')),
"duplicate_logical": rule(canonical.replace('LogicalOperator="1"', 'LogicalOperator="2" LogicalOperator="1"')),
"invalid_logical": rule(canonical.replace('LogicalOperator="1"', 'LogicalOperator="wat"')),
"entity": rule(canonical.replace("Fixture House", "Fixture&#x20;House")),
"bom": rule("\ufeff" + canonical),
"leading_text": rule("leading" + canonical),
"unclosed": rule(canonical[:-len("</NODE>")]),
"unknown_entity": rule(canonical.replace("Fixture House", "Fixture &bogus; House")),
"wrapper_root": rule("<ROOT>" + canonical + "</ROOT>"),
"ints": [dump.db_int(v) for v in (None, 0.5, 1.5, -0.5, -1, 2**31, 2**32)],
"u16": [dump.db_int(v, 16, False) for v in (-1, 2**31 - 1, 2**31, 2**32 - 1, 2**32)],
"s8": [dump.db_int(v, 8) for v in (-1, 2**31 - 1, 2**31, 1.5)],
}
json.dump(out, sys.stdout)
`
raw, err := exec.Command("python3", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("python3: %v\n%s", err, raw)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("decode %s: %v", raw, err)
}
want := map[string]string{
"canonical": `[1,1,"Fixture House"]`,
"lowercase": `[1,1,"Fixture House"]`,
"mismatched_close": `[1,1,"Fixture House"]`,
"two_roots": `[1,1,"Fixture House"]`,
"trailing": `[1,1,"Fixture House"]`,
"nul_junk": `[1,1,"Fixture House"]`,
"duplicate_value": `[1,1,"Fixture House"]`,
"duplicate_logical": `[2,1,"Fixture House"]`,
"invalid_logical": `[1,1,"Fixture House"]`,
"entity": `[1,1,"Fixture House"]`,
"bom": `null`,
"leading_text": `null`,
"unclosed": `null`,
"unknown_entity": `null`,
"wrapper_root": `[1]`,
"ints": `[0,0,1,0,-1,-2147483648,0]`,
"u16": `[65535,65535,0,65535,0]`,
"s8": `[-1,-1,0,1]`,
}
for k, w := range want {
g, _ := json.Marshal(got[k])
if string(g) != w {
t.Errorf("%s = %s, want %s", k, g, w)
}
}
}
Loading
Loading