From 4ab8e0629b0e9f54a3cf1446a579947afbdb4d61 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Fri, 16 Oct 2026 20:14:15 +0000 Subject: [PATCH 1/2] smart playlists: match rekordbox's string rules across every text property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit link-spec's smart-string-property-matrix (rekordbox 7.2.19) runs the same value sequence (Alpha, alpha, Álpha precomposed and decomposed, fullwidth Alpha, Alpha Beta, Beta Alpha, Beta, empty, missing) through every text property; every property gives the same member sets. We matched none of its 104 cases. - Text matching ignores fullwidth versus ASCII forms, katakana versus hiragana and final sigma, as rekordbox's does, besides case and precomposed accents. - "Does not contain" skips empty and missing values; "not equal" keeps them. - Key equality compares the key's name like any other text, not as an exact string. - albumArtist (the album's artist), producer (ComposerID) and mixName (Subtitle) are imported and matched; they matched nothing before. The Key column also follows rekordbox: rows carry the track's own key ID from the import (an empty-named key keeps its row ID, no key is 0), the text is " - bpm" even for an empty key, and the 1-24 key index reads a non-standard key name loosely, as rekordbox does ("Alpha" is A major); real key names are unaffected. Tests cover the member sets for six properties, the text folding, the loose key index and the empty-key column. --- api/smart_import.go | 2 + api/smart_playlist.go | 39 +++++++++++---- api/smart_strings_test.go | 80 +++++++++++++++++++++++++++++++ dbserver/menuitem.go | 58 ++++++++++++++++++++-- dbserver/songinfo_display_test.go | 36 ++++++++++++++ library/import_masterdb.go | 7 +++ library/track.go | 9 +++- tools/rekordbox_dump.py | 10 +++- 8 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 api/smart_strings_test.go diff --git a/api/smart_import.go b/api/smart_import.go index 96c7a35..0b5a80c 100644 --- a/api/smart_import.go +++ b/api/smart_import.go @@ -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", diff --git a/api/smart_playlist.go b/api/smart_playlist.go index 9741fc7..9be9296 100644 --- a/api/smart_playlist.go +++ b/api/smart_playlist.go @@ -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": @@ -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": @@ -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": @@ -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. @@ -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 diff --git a/api/smart_strings_test.go b/api/smart_strings_test.go new file mode 100644 index 0000000..a3d11d7 --- /dev/null +++ b/api/smart_strings_test.go @@ -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) + } + } +} diff --git a/dbserver/menuitem.go b/dbserver/menuitem.go index f22c615..f8fd597 100644 --- a/dbserver/menuitem.go +++ b/dbserver/menuitem.go @@ -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] @@ -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 " - 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 @@ -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 +} diff --git a/dbserver/songinfo_display_test.go b/dbserver/songinfo_display_test.go index 9a3e0e3..9c9f5c8 100644 --- a/dbserver/songinfo_display_test.go +++ b/dbserver/songinfo_display_test.go @@ -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 " - 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" } diff --git a/library/import_masterdb.go b/library/import_masterdb.go index 09deea7..ef40178 100644 --- a/library/import_masterdb.go +++ b/library/import_masterdb.go @@ -151,6 +151,9 @@ type masterDBTrack struct { // the REMIXER and ORIGINAL ARTIST category drills resolve. Remixer string `json:"remixer"` OriginalArtist string `json:"original_artist"` + MixName string `json:"mix_name"` + AlbumArtist string `json:"album_artist"` + Composer string `json:"composer"` RemixerID string `json:"remixer_id"` OrigArtistID string `json:"orig_artist_id"` } @@ -617,6 +620,10 @@ func masterDBToLibrary(t *masterDBTrack, path string) *Track { AutoLoadHotCue: t.HotCueAutoLoad != "", Remixer: t.Remixer, OriginalArtist: t.OriginalArtist, + MixName: t.MixName, + KeyID: parseRBID(t.KeyID), + AlbumArtist: t.AlbumArtist, + Composer: t.Composer, } } diff --git a/library/track.go b/library/track.go index 3c2bd13..05bd9cf 100644 --- a/library/track.go +++ b/library/track.go @@ -59,7 +59,14 @@ type Track struct { OriginalArtist string `json:"original_artist"` Remixer string `json:"remixer"` MixName string `json:"mix_name"` // mix/version name - DateAdded time.Time `json:"date_added"` + // AlbumArtist and Composer come from a rekordbox import (the album's + // artist; ComposerID, which rekordbox's smart playlists call producer). + AlbumArtist string `json:"album_artist,omitempty"` + Composer string `json:"composer,omitempty"` + // KeyID is the track's rekordbox key row ID, from an import (0 when the + // track has no key relation). It tells an empty-named key from no key. + KeyID uint32 `json:"key_id,omitempty"` + DateAdded time.Time `json:"date_added"` // DateCreated is rekordbox's separate DateCreated, from an import; smart // playlist rules on dateCreated test it (zero when unknown). DateCreated time.Time `json:"date_created,omitempty"` diff --git a/tools/rekordbox_dump.py b/tools/rekordbox_dump.py index 024aeee..9a64cdb 100644 --- a/tools/rekordbox_dump.py +++ b/tools/rekordbox_dump.py @@ -138,7 +138,8 @@ def main(): k.ScaleName AS Key, l.Name AS Label, c.GenreID, c.ArtistID, c.AlbumID, c.KeyID, c.LabelID, rmx.Name AS Remixer, oa.Name AS OrigArtist, - c.RemixerID, c.OrgArtistID + c.RemixerID, c.OrgArtistID, + c.Subtitle AS MixName, aa.Name AS AlbumArtist, cp.Name AS Composer FROM djmdContent c LEFT JOIN djmdArtist a ON c.ArtistID = a.ID LEFT JOIN djmdAlbum al ON c.AlbumID = al.ID @@ -147,6 +148,8 @@ def main(): LEFT JOIN djmdLabel l ON c.LabelID = l.ID LEFT JOIN djmdArtist rmx ON c.RemixerID = rmx.ID LEFT JOIN djmdArtist oa ON c.OrgArtistID = oa.ID + LEFT JOIN djmdArtist aa ON al.AlbumArtistID = aa.ID + LEFT JOIN djmdArtist cp ON c.ComposerID = cp.ID WHERE COALESCE(c.rb_local_deleted, 0) = 0 """) cols = [d[0] for d in cur.description] @@ -199,6 +202,11 @@ def main(): "label_id": str(d.get("LabelID") or ""), "remixer": d.get("Remixer") or "", "original_artist": d.get("OrigArtist") or "", + # Smart playlist text fields: mixName (Subtitle), albumArtist (the + # album's artist) and producer (ComposerID). + "mix_name": d.get("MixName") or "", + "album_artist": d.get("AlbumArtist") or "", + "composer": d.get("Composer") or "", "remixer_id": str(d.get("RemixerID") or ""), "orig_artist_id": str(d.get("OrgArtistID") or ""), # Paths are relative to the rekordbox `share/` root, e.g. From a6fd8d1bb177caf2b7a6954430a9217957597125 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Fri, 16 Oct 2026 22:28:56 +0000 Subject: [PATCH 2/2] import: keep every MyTag in rekordbox's order, and load tags in the smart editor MyTags imported from a rekordbox library came out incomplete and shuffled. djmdMyTag holds both the columns (Genre, Components, ...) and the tags under them, and the importer told them apart by track count: a row with no tracks was taken for a column. So tags nobody had assigned yet were dropped, as were empty columns, and tags were numbered in the order tracks happened to reference them rather than rekordbox's. - The dump exports each row's Seq and column flag (Attribute 1), skips deleted tags and assignments, and lists a track once per tag. - The import keeps every live column and tag, used or not, ordered as rekordbox shows them: columns by Seq, then each column's tags by Seq. Columns are created first, so an empty one exists too. Imported smart playlists with tag rules also opened in the web UI with empty tag dropdowns: the editor read a tag catalog loaded only by the TAGS tab. It now loads the catalog before opening, selects a rule's tag case-insensitively, and keeps a tag name that isn't in the catalog rather than replacing it on save. Checked against a real rekordbox backup: four columns, including an empty one, and all 47 live tags in rekordbox's order, two of them unused. --- api/api.go | 6 +++- api/web/index.html | 16 ++++++++--- library/import.go | 9 ++++-- library/import_masterdb.go | 53 +++++++++++++++++++++++++++-------- library/import_mytags_test.go | 49 ++++++++++++++++++++++++++++++++ tools/rekordbox_dump.py | 27 ++++++++++++------ 6 files changed, 131 insertions(+), 29 deletions(-) create mode 100644 library/import_mytags_test.go diff --git a/api/api.go b/api/api.go index c2ad3ef..df2d961 100644 --- a/api/api.go +++ b/api/api.go @@ -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() { @@ -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) diff --git a/api/web/index.html b/api/web/index.html index dc57438..557e662 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -11146,7 +11146,10 @@

RELATED

// 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 = { @@ -11302,9 +11305,14 @@

RELATED

if (meta.type === 'tag') { const tags = (allTagsCache || []); const v = (c.value || ''); - return ``; + // 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 => + ``); + if (v && !known) opts.unshift(``); + return ``; } if (meta.type === 'date') { if (op === 'in_last_days') { diff --git a/library/import.go b/library/import.go index 5aa9e27..9246179 100644 --- a/library/import.go +++ b/library/import.go @@ -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. diff --git a/library/import_masterdb.go b/library/import_masterdb.go index ef40178..2150e6d 100644 --- a/library/import_masterdb.go +++ b/library/import_masterdb.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "time" @@ -206,8 +207,17 @@ type MasterDBTag struct { Name string `json:"name"` ParentID string `json:"parent_id"` TrackIDs []string `json:"track_ids"` + // Seq orders tags within a column, and columns among themselves. + Seq int `json:"seq"` + // IsCategory marks a column (rekordbox Attribute 1) rather than a tag. + IsCategory bool `json:"is_category"` } +// isCategory reports whether a djmdMyTag row is a column (Genre, +// Components, ...) rather than a tag: its Attribute flag, or, for dumps +// without it, a top-level row. +func (t MasterDBTag) isCategory() bool { return t.IsCategory || t.ParentID == "root" } + // ImportRekordboxMasterDB shells out to tools/rekordbox_dump.py to read // the encrypted master.db and then merges the result into the library. // Requires Python 3 + the `sqlcipher3` package. The key is the SQLCipher @@ -320,27 +330,46 @@ func ImportMasterDBDump(lib *Library, dump *MasterDBDump) (*ImportBundle, error) } } - // Resolve each MyTag's rekordbox ContentIDs to library Track.IDs. - // djmdMyTag is a 2-level tree: category rows (Genre, Components, Mood, …) - // are parents, and the actual tags are children whose ParentID points at - // their category. Category rows carry no track assignments, so they drop - // out of the leaf list below; we use them only to label each tag's - // category. tagsByID lets a tag look up its parent category's name. + // MyTags. djmdMyTag is a 2-level tree: columns (Genre, Components, …) at + // the top and the tags under them. Every live column and tag is imported, + // in rekordbox's order (columns by Seq, then each column's tags by Seq), + // whether or not any track carries it: rekordbox keeps unused tags and + // empty columns, and smart playlists can name them. tagsByID := make(map[string]MasterDBTag, len(dump.Tags)) + var cats, leaves []MasterDBTag for _, mt := range dump.Tags { tagsByID[mt.ID] = mt + if mt.isCategory() { + cats = append(cats, mt) + } else { + leaves = append(leaves, mt) + } } - tags := make([]TagImport, 0, len(dump.Tags)) - for _, mt := range dump.Tags { + sort.SliceStable(cats, func(i, j int) bool { return cats[i].Seq < cats[j].Seq }) + catSeq := func(mt MasterDBTag) int { + if p, ok := tagsByID[mt.ParentID]; ok { + return p.Seq + } + return 1 << 30 // tags outside any column last + } + sort.SliceStable(leaves, func(i, j int) bool { + if a, b := catSeq(leaves[i]), catSeq(leaves[j]); a != b { + return a < b + } + return leaves[i].Seq < leaves[j].Seq + }) + tagCategories := make([]string, 0, len(cats)) + for _, c := range cats { + tagCategories = append(tagCategories, c.Name) + } + tags := make([]TagImport, 0, len(leaves)) + for _, mt := range leaves { var ids []uint32 for _, cid := range mt.TrackIDs { if id, ok := idMap[cid]; ok { ids = append(ids, id) } } - if len(ids) == 0 { - continue // category/parent row, or an unused tag — nothing to apply - } category := "" if parent, ok := tagsByID[mt.ParentID]; ok { category = parent.Name @@ -376,7 +405,7 @@ func ImportMasterDBDump(lib *Library, dump *MasterDBDump) (*ImportBundle, error) } lib.FinalizeBulk() - return &ImportBundle{Result: res, Playlists: playlists, Tags: tags, Colors: colors, Assets: assets, Cues: cues}, nil + return &ImportBundle{Result: res, Playlists: playlists, Tags: tags, TagCategories: tagCategories, Colors: colors, Assets: assets, Cues: cues}, nil } // masterDBPlaylistTree turns the flat djmdPlaylist rows into a nested diff --git a/library/import_mytags_test.go b/library/import_mytags_test.go new file mode 100644 index 0000000..2f451ca --- /dev/null +++ b/library/import_mytags_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package library + +import ( + "slices" + "testing" +) + +// MyTag import keeps every live column and tag in rekordbox's order (columns +// by Seq, then each column's tags by Seq), including tags no track carries and +// empty columns. Unused tags were dropped: the importer took "no tracks" to +// mean "a column". +func TestImportMyTags(t *testing.T) { + lib := New() + dump := &MasterDBDump{ + Tracks: []masterDBTrack{{ID: "100", Title: "One", FilePath: "/music/one.mp3"}}, + Tags: []MasterDBTag{ + {ID: "2", Name: "Situation", ParentID: "root", Seq: 2, IsCategory: true}, + {ID: "1", Name: "Genre", ParentID: "root", Seq: 1, IsCategory: true}, + {ID: "4", Name: "Untitled Column", ParentID: "root", Seq: 4}, // older dump: no flag + {ID: "11", Name: "Deep House", ParentID: "1", Seq: 4, TrackIDs: []string{"100"}}, + {ID: "12", Name: "Acid House", ParentID: "1", Seq: 3}, // unused + {ID: "13", Name: "Trap", ParentID: "1", Seq: 1, TrackIDs: []string{"100"}}, + {ID: "21", Name: "Intro", ParentID: "2", Seq: 9}, + }, + } + bundle, err := ImportMasterDBDump(lib, dump) + if err != nil { + t.Fatal(err) + } + if want := []string{"Genre", "Situation", "Untitled Column"}; !slices.Equal(bundle.TagCategories, want) { + t.Errorf("columns = %v, want %v", bundle.TagCategories, want) + } + var names, cats []string + for _, tg := range bundle.Tags { + names = append(names, tg.Name) + cats = append(cats, tg.Category) + } + if want := []string{"Trap", "Acid House", "Deep House", "Intro"}; !slices.Equal(names, want) { + t.Errorf("tags = %v, want %v", names, want) + } + if want := []string{"Genre", "Genre", "Genre", "Situation"}; !slices.Equal(cats, want) { + t.Errorf("tag columns = %v, want %v", cats, want) + } + if len(bundle.Tags[1].TrackIDs) != 0 || len(bundle.Tags[0].TrackIDs) != 1 { + t.Errorf("track assignments: %+v", bundle.Tags) + } +} diff --git a/tools/rekordbox_dump.py b/tools/rekordbox_dump.py index 9a64cdb..ebf03fa 100644 --- a/tools/rekordbox_dump.py +++ b/tools/rekordbox_dump.py @@ -261,20 +261,29 @@ def main(): entry["smart"] = parse_smartlist(smartlist, tags_by_id) out["playlists"].append(entry) - # MyTags + assignments + # MyTags + assignments. djmdMyTag holds both the columns (Attribute 1, + # ParentID "root": Genre, Components, ...) and the tags under them; Seq + # orders each level as rekordbox shows it. Deleted rows are skipped, and a + # track assigned twice to a tag is listed once. try: - cur.execute("SELECT ID, Name, ParentID FROM djmdMyTag") + cur.execute("SELECT ID, Name, ParentID, Seq, Attribute FROM djmdMyTag " + "WHERE COALESCE(rb_local_deleted, 0) = 0") tag_rows = cur.fetchall() - cur.execute("SELECT MyTagID, ContentID FROM djmdSongMyTag") + cur.execute("SELECT MyTagID, ContentID FROM djmdSongMyTag " + "WHERE COALESCE(rb_local_deleted, 0) = 0") tag_tracks = {} for tagid, cid in cur.fetchall(): - tag_tracks.setdefault(str(tagid), []).append(cid) - for tid, name, parent in tag_rows: + ids = tag_tracks.setdefault(str(tagid), []) + if cid not in ids: + ids.append(cid) + for tid, name, parent, seq, attr in tag_rows: out["tags"].append({ - "id": tid, - "name": name, - "parent_id": parent, - "track_ids": tag_tracks.get(str(tid), []), + "id": tid, + "name": name, + "parent_id": parent, + "seq": int(seq or 0), + "is_category": int(attr or 0) == 1, + "track_ids": tag_tracks.get(str(tid), []), }) except sqlcipher3.dbapi2.OperationalError: pass # Older DBs may not have MyTag tables