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
6 changes: 5 additions & 1 deletion api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1242,7 +1242,7 @@ func (s *Server) applyImportBundle(w http.ResponseWriter, bundle *library.Import
// created uncategorized if it doesn't already exist by name, then
// assigned to the imported tracks. Existing per-track tag sets are
// unioned so a re-import doesn't drop tags applied elsewhere.
if s.Tags != nil && incTags && len(tags) > 0 {
if s.Tags != nil && incTags && (len(tags) > 0 || len(bundle.TagCategories) > 0) {
s.importPhase("Importing tags…")
byName := make(map[string]TagInfo)
for _, t := range s.Tags.GetAllTags() {
Expand All @@ -1269,6 +1269,10 @@ func (s *Server) applyImportBundle(w http.ResponseWriter, bundle *library.Import
catByName[name] = id
return id
}
// Columns first, in rekordbox's order, so empty ones exist too.
for _, name := range bundle.TagCategories {
ensureCategory(name)
}
var tagged int
for _, ti := range tags {
catID := ensureCategory(ti.Category)
Expand Down
2 changes: 2 additions & 0 deletions api/smart_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ var rbFieldMap = map[string]string{
"fileName": "file_name",
"filename": "file_name",
"mixName": "mix_name",
"albumArtist": "album_artist",
"producer": "composer",
"myTag": "tag",
"stockDate": "date_added",
"dateCreated": "date_created",
Expand Down
39 changes: 30 additions & 9 deletions api/smart_playlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ func evalLeaf(field, op string, value any, t *library.Track, tags TagLookup, now
return stringMatch(op, value, filepath.Base(t.FilePath))
case "mix_name":
return stringMatch(op, value, t.MixName)
case "album_artist":
return stringMatch(op, value, t.AlbumArtist)
case "composer":
return stringMatch(op, value, t.Composer)
case "remixer":
return stringMatch(op, value, t.Remixer)
case "original_artist":
Expand Down Expand Up @@ -197,7 +201,8 @@ func stringMatch(op string, value any, s string) bool {
case "contains":
return strings.Contains(low, qLow)
case "not_contains":
return !strings.Contains(low, qLow)
// rekordbox's does-not-contain skips empty and missing values.
return low != "" && !strings.Contains(low, qLow)
case "starts_with":
return strings.HasPrefix(low, qLow)
case "ends_with":
Expand All @@ -216,10 +221,9 @@ func keyMatch(op string, value any, trackKey string) bool {
return false
}
switch op {
case "is", "eq", "=":
return strings.EqualFold(trackKey, target)
case "is_not", "ne", "!=":
return !strings.EqualFold(trackKey, target)
case "is", "eq", "=", "is_not", "ne", "!=":
// Equality compares the key's name like any other text.
return stringMatch(op, value, trackKey)
case "compatible":
return camelotCompatible(trackKey, target)
case "contains", "not_contains", "starts_with", "ends_with":
Expand All @@ -230,8 +234,10 @@ func keyMatch(op string, value any, trackKey string) bool {
}

// foldText prepares text for comparison the way rekordbox's smart playlists
// compare it (link-spec conformance smart text suites): case-insensitive, and
// a precomposed accented letter equals its base letter ("Álpha" is "alpha").
// compare it (link-spec conformance smart text suites): case-insensitive, a
// precomposed accented letter equals its base letter ("Álpha" is "alpha"),
// fullwidth forms equal ASCII ("Alpha" is "alpha"), katakana equals
// hiragana, and final sigma equals sigma.
// Text is not otherwise normalized: a base letter followed by a separate
// combining accent ("A\u0301lpha") keeps the accent, and combining marks in a
// different order differ, as in rekordbox.
Expand All @@ -243,9 +249,24 @@ func foldText(s string) string {
r = base // precomposed: keep the base letter only
}
}
b.WriteRune(r)
b.WriteRune(foldWidthKana(r))
}
return strings.ToLower(b.String())
return strings.ReplaceAll(strings.ToLower(b.String()), "ς", "σ")
}

// foldWidthKana folds the forms rekordbox's text matching treats as equal
// (SMART_PLAYLIST_ORACLE, "Text collation"): fullwidth ASCII and the
// ideographic space to their ASCII forms, and katakana to hiragana.
func foldWidthKana(r rune) rune {
switch {
case r >= 0xff01 && r <= 0xff5e:
return r - 0xfee0
case r == 0x3000:
return ' '
case r >= 0x30a1 && r <= 0x30f6:
return r - 0x60
}
return r
}

// camelotCompatible reports whether `a` is a harmonically-compatible
Expand Down
80 changes: 80 additions & 0 deletions api/smart_strings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package api

import (
"slices"
"testing"
"time"

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

// rekordbox's smart playlist text rules, from link-spec's
// smart-string-property-matrix: the same value sequence in every string
// property gives the same member sets.
func TestSmartStringPropertySemantics(t *testing.T) {
values := []string{"Alpha", "alpha", "Álpha", "Álpha", "Alpha", "Alpha Beta", "Beta Alpha", "Beta", "", ""}
now := time.Now()
for _, prop := range []string{"artist", "albumArtist", "producer", "mixName", "key", "comments"} {
var tracks []*library.Track
for i, v := range values {
tr := &library.Track{ID: uint32(13001 + i)}
switch prop {
case "artist":
tr.Artist = v
case "albumArtist":
tr.AlbumArtist = v
case "producer":
tr.Composer = v
case "mixName":
tr.MixName = v
case "key":
tr.Key = v
case "comments":
tr.Comment = v
}
tracks = append(tracks, tr)
}
members := func(op int, val string) []uint32 {
rules, mapped, _ := smartRulesFromRekordbox(&library.RBSmartList{Logical: 1,
Conditions: []library.RBSmartCond{{Property: prop, Operator: op, Left: val}}})
if mapped == 0 {
return nil
}
var ids []uint32
for _, tr := range tracks {
if rules.Match(tr, nil, now) {
ids = append(ids, tr.ID)
}
}
return ids
}
for _, tc := range []struct {
name string
op int
want []uint32
}{
{"equals", 1, []uint32{13001, 13002, 13003, 13004, 13005}},
{"not equal", 2, []uint32{13006, 13007, 13008, 13009, 13010}},
{"contains", 8, []uint32{13001, 13002, 13003, 13004, 13005, 13006, 13007}},
{"does not contain", 9, []uint32{13008}},
{"starts with", 10, []uint32{13001, 13002, 13003, 13004, 13005, 13006}},
{"ends with", 11, []uint32{13001, 13002, 13003, 13004, 13005, 13007}},
} {
if got := members(tc.op, "Alpha"); !slices.Equal(got, tc.want) {
t.Errorf("%s %s Alpha = %v, want %v", prop, tc.name, got, tc.want)
}
}
}
}

func TestFoldText(t *testing.T) {
for in, want := range map[string]string{
"Alpha": "alpha", "Álpha": "alpha", "カタカナ": "かたかな", "ΟΔΟΣ": "οδοσ", "οδος": "οδοσ",
} {
if got := foldText(in); got != want {
t.Errorf("foldText(%q) = %q, want %q", in, got, want)
}
}
}
16 changes: 12 additions & 4 deletions api/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11146,7 +11146,10 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
// edits. {id, name, rules} where id is null for "create new".
let smartEditorState = null;

function openSmartEditor(playlistID) {
async function openSmartEditor(playlistID) {
// Tag rules pick from the tag catalog; load it (fresh: an import may have
// added tags) before rendering, or the tag dropdowns come up empty.
await ensureAllTags(true);
ensureSmartEditor();
const existing = playlistID != null ? playlistsCache.find(p => p.id === playlistID) : null;
smartEditorState = {
Expand Down Expand Up @@ -11302,9 +11305,14 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
if (meta.type === 'tag') {
const tags = (allTagsCache || []);
const v = (c.value || '');
return `<select data-sm-val>${tags.map(t =>
`<option value="${escape(t.name)}" ${t.name === v ? 'selected' : ''}>${escape(t.name)}</option>`
).join('') || '<option value="">— NO TAGS YET —</option>'}</select>`;
// Rules match tag names case-insensitively; select the rule's tag that
// way, and keep a name that isn't in the catalog as its own option so
// saving never swaps the rule's tag for the first one listed.
const known = tags.some(t => t.name.toLowerCase() === v.toLowerCase());
const opts = tags.map(t =>
`<option value="${escape(t.name)}" ${t.name.toLowerCase() === v.toLowerCase() ? 'selected' : ''}>${escape(t.name)}</option>`);
if (v && !known) opts.unshift(`<option value="${escape(v)}" selected>${escape(v)} (not in tag list)</option>`);
return `<select data-sm-val>${opts.join('') || '<option value="">— NO TAGS YET —</option>'}</select>`;
}
if (meta.type === 'date') {
if (op === 'in_last_days') {
Expand Down
58 changes: 54 additions & 4 deletions dbserver/menuitem.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ func (h *Handler) trackToStdItem(t *library.Track) *menuItem {
func newKeyID(key string) uint32 {
camelot, _ := analysis.KeyNamesFrom(key)
if camelot == "" {
// rekordbox reads a key name loosely: a leading root note A-G
// (fullwidth too), an optional sharp or flat, then "m" for minor;
// anything after is ignored ("Alpha" is A major, "Beta" B major), and
// a name that doesn't start with a root note has no index.
if loose := looseKeyName(key); loose != "" && loose != key {
return newKeyID(loose)
}
return 0
}
letter := camelot[len(camelot)-1]
Expand Down Expand Up @@ -280,10 +287,9 @@ func (h *Handler) applyTrackDetail(m *menuItem, t *library.Track) {
m.Label2 += " - " + t.Key
}
case "key":
m.ParentID = h.catID("key", t.Key)
if t.Key != "" {
m.Label2 = fmt.Sprintf("%s - %.1f bpm", t.Key, t.BPM)
}
// rekordbox composes "<key> - <bpm> bpm" even for an empty key.
m.ParentID = h.trackKeyID(t)
m.Label2 = fmt.Sprintf("%s - %.1f bpm", t.Key, t.BPM)
case "artist":
m.ParentID = h.catID("artist", t.Artist)
m.Label2 = t.Artist
Expand Down Expand Up @@ -468,3 +474,47 @@ func trackInfoTitleID(fileType string) uint32 {
return 1
}
}

// trackKeyID is a track's key ID as rekordbox serves it: the imported key row
// ID (which an empty-named key also has), else the KEY menu's ID for the key's
// name, and 0 for a track with no key.
func (h *Handler) trackKeyID(t *library.Track) uint32 {
if t.KeyID != 0 {
return t.KeyID
}
if t.Key == "" {
return 0
}
return h.catID("key", t.Key)
}

// looseKeyName reduces a key name to the part rekordbox reads (see
// newKeyID): root note, accidental and minor flag, or "" when it doesn't
// start with a root note.
func looseKeyName(key string) string {
r := []rune(key)
for i, c := range r {
if c >= 0xff01 && c <= 0xff5e { // fullwidth ASCII
r[i] = c - 0xfee0
}
}
if len(r) == 0 || r[0] < 'A' || r[0] > 'G' {
return ""
}
name := string(r[0])
rest := r[1:]
if len(rest) > 0 {
switch rest[0] {
case '#', '♯':
name += "#"
rest = rest[1:]
case 'b', '♭':
name += "b"
rest = rest[1:]
}
}
if len(rest) > 0 && rest[0] == 'm' {
name += "m"
}
return name
}
36 changes: 36 additions & 0 deletions dbserver/songinfo_display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,39 @@ func TestTruncateUTF16(t *testing.T) {
t.Errorf("under the limit: %q", got)
}
}

// rekordbox reads key names loosely for its 1-24 key index (link-spec
// smart-string-property-matrix): a leading root note, sharp or flat, and
// "m"; real key names are unaffected.
func TestNewKeyIDLoose(t *testing.T) {
for key, want := range map[string]uint32{
"Alpha": 22, "alpha": 0, "Álpha": 0, "Álpha": 22, "Alpha": 22,
"Beta": 2, "": 0, "Am": 15, "F#m": 21, "8A": 15,
} {
if got := newKeyID(key); got != want {
t.Errorf("newKeyID(%q) = %d, want %d", key, got, want)
}
}
}

// The KEY column carries the track's key ID (an empty-named key keeps its
// row ID, no key is 0) and always composes "<key> - <bpm> bpm".
func TestKeyColumnEmptyKey(t *testing.T) {
lib := library.New()
lib.AddTrack(&library.Track{ID: 1, BPM: 120, KeyID: 18009}) // empty-named key row
lib.AddTrack(&library.Track{ID: 2, BPM: 120.5}) // no key
h := &Handler{lib: lib, menu: keyColumnMenu{}}
a, b := h.trackToStdItem(lib.Track(1)), h.trackToStdItem(lib.Track(2))
if a.ParentID != 18009 || a.Label2 != " - 120.0 bpm" {
t.Errorf("empty-named key: %d %q, want 18009 \" - 120.0 bpm\"", a.ParentID, a.Label2)
}
if b.ParentID != 0 || b.Label2 != " - 120.5 bpm" {
t.Errorf("no key: %d %q, want 0 \" - 120.5 bpm\"", b.ParentID, b.Label2)
}
}

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

func (keyColumnMenu) RootMenu() []RootMenuEntry { return DefaultRootMenu() }
func (keyColumnMenu) TrackDetail() string { return "key" }
9 changes: 6 additions & 3 deletions library/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ type ImportBundle struct {
Result *ImportResult
Playlists []PlaylistImport
Tags []TagImport
Colors []ColorImport
Cues []ImportedCue
Assets []ImportedAsset
// TagCategories are the MyTag columns in rekordbox's order, including
// empty ones; created before the tags so they keep that order.
TagCategories []string
Colors []ColorImport
Cues []ImportedCue
Assets []ImportedAsset
// ShareRoot is the share/ root for ANLZ + artwork (a backup-zip extract, or
// a master.db's own library folder). SettingsDir holds the *SETTING.DAT
// blobs. Both are empty when the format carries no such assets.
Expand Down
Loading
Loading