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/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/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/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.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 09deea7..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"
@@ -151,6 +152,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"`
}
@@ -203,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
@@ -317,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
@@ -373,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
@@ -617,6 +649,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/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/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..ebf03fa 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.
@@ -253,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