From 6eeac7ac40b7954e8b7cc4e2776ce23831457661 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Fri, 16 Oct 2026 02:10:30 +0000 Subject: [PATCH] dbserver: match rekordbox's Display Song Info rows link-spec's Display Song Info suites (rekordbox 7.2.19) showed our TRACK INFO rows diverging in layout and content. - The sixteen rows follow rekordbox's order (bitrate and year before label) and its argument-0 flags (1 on the category rows, 0 on Date Added and Comment). The colour row's type is 0x13 + the colour ID, as in the COLOUR menu, where it was fixed at 0x13. - The title row is an ordinary track row (key ID, BPM and the rest in the extended fields) whose second column the render request picks: an eight-argument render with gate 1 shows its override column, or its column argument when the override is 0; shorter renders and gate 0 show the plain title, which is what CDJ-2000NXS2 TRACK INFO requests. Delivery Info's title row composes the same way. - Strings are cut to rekordbox's limits: 127 UTF-16 units for the title and category names, 255 for Comment and Date Added. - Date Added is rekordbox's StockDate; the dump read DateCreated, which is often empty, so imports showed the import date. DateCreated is kept separately, and smart playlist rules on dateCreated test it, as rekordbox's do, while stockDate rules test Date Added. Tests cover the row layout, the render-time title column, the string limits and the two date fields. --- api/smart_import.go | 4 +- api/smart_import_date_test.go | 22 +++++++- api/smart_playlist.go | 2 + dbserver/menuitem.go | 15 +++--- dbserver/render.go | 3 ++ dbserver/songinfo_delivery.go | 4 +- dbserver/songinfo_display_test.go | 74 ++++++++++++++++++++++++++ dbserver/songinfo_title.go | 84 +++++++++++++++++++++++++++++ dbserver/track.go | 87 ++++++++++++++++++++++--------- library/import_masterdb.go | 6 +++ library/track.go | 17 +++--- tools/rekordbox_dump.py | 7 ++- 12 files changed, 280 insertions(+), 45 deletions(-) create mode 100644 dbserver/songinfo_title.go diff --git a/api/smart_import.go b/api/smart_import.go index 793b208..96c7a35 100644 --- a/api/smart_import.go +++ b/api/smart_import.go @@ -39,7 +39,7 @@ var rbFieldMap = map[string]string{ "mixName": "mix_name", "myTag": "tag", "stockDate": "date_added", - "dateCreated": "date_added", + "dateCreated": "date_created", } func isNumericField(field string) bool { @@ -102,7 +102,7 @@ func rbCondToSmart(c library.RBSmartCond) (SmartCond, bool) { return SmartCond{}, false } isTag := field == "tag" - isDate := field == "date_added" + isDate := field == "date_added" || field == "date_created" isNum := isNumericField(field) // num parses a rekordbox numeric value string, scaling BPM down by 100. diff --git a/api/smart_import_date_test.go b/api/smart_import_date_test.go index f599d84..c82a8cd 100644 --- a/api/smart_import_date_test.go +++ b/api/smart_import_date_test.go @@ -15,7 +15,9 @@ import ( // imported playlist matched nothing). func TestSmartImportDateConditions(t *testing.T) { now := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) - track := func(d time.Time) *library.Track { return &library.Track{ID: 1, DateAdded: d} } + // Both dates set: dateCreated rules test DateCreated, stockDate rules + // DateAdded (rekordbox's StockDate). + track := func(d time.Time) *library.Track { return &library.Track{ID: 1, DateAdded: d, DateCreated: d} } before := track(time.Date(2022, 6, 1, 0, 0, 0, 0, time.UTC)) after := track(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)) @@ -62,3 +64,21 @@ func TestSmartImportDateConditions(t *testing.T) { }) } } + +// rekordbox's stockDate (Date Added) and dateCreated are separate dates: a +// rule on one must not test the other. +func TestSmartImportDateFieldsAreDistinct(t *testing.T) { + now := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + tr := &library.Track{ID: 1, DateAdded: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)} // no DateCreated + rule := func(prop string) *SmartRules { + rules, _, _ := smartRulesFromRekordbox(&library.RBSmartList{Logical: 1, + Conditions: []library.RBSmartCond{{Property: prop, Operator: 3, Left: "2023-01-01"}}}) + return rules + } + if !rule("stockDate").Match(tr, nil, now) { + t.Errorf("stockDate after 2023 should match the 2024 date added") + } + if rule("dateCreated").Match(tr, nil, now) { + t.Errorf("dateCreated after 2023 matched a track with no created date") + } +} diff --git a/api/smart_playlist.go b/api/smart_playlist.go index 066d4e5..9741fc7 100644 --- a/api/smart_playlist.go +++ b/api/smart_playlist.go @@ -122,6 +122,8 @@ func evalLeaf(field, op string, value any, t *library.Track, tags TagLookup, now case "date_added": return dateMatch(op, value, t.DateAdded, now) + case "date_created": + return dateMatch(op, value, t.DateCreated, now) } return false } diff --git a/dbserver/menuitem.go b/dbserver/menuitem.go index 355e095..f22c615 100644 --- a/dbserver/menuitem.go +++ b/dbserver/menuitem.go @@ -40,12 +40,15 @@ type menuItem struct { // Track-row wire-flag inputs (see render.go's flag assembly). contextTrackType uint32 // packed context track type — arg 7 high byte (TT<<24) on hierarchy track leaves noArtHandle bool // album row in a song-info list: arg 8 stays 0 - trackPos uint32 // arg 9: scope position / track number (album scope uses TrackNo; 0 elsewhere) - hasSongTag bool // track carries a MyTag — arg 7 bit 0x1 - autoLoadHotCue bool // deck auto-loads hot cues — arg 10 bit 0x100 - fileTypeID int // rekordbox FileType enum — arg 10 compatibility bit - sampleRate int // Hz — arg 10 compatibility bit for wav/aiff - newKeyID uint32 // converted (Camelot 1-24) key index — arg 11 + // songInfoTitle, on a Display/Delivery title row, picks its second + // column at render time (songinfo_title.go). + songInfoTitle *songInfoTitle + trackPos uint32 // arg 9: scope position / track number (album scope uses TrackNo; 0 elsewhere) + hasSongTag bool // track carries a MyTag — arg 7 bit 0x1 + autoLoadHotCue bool // deck auto-loads hot cues — arg 10 bit 0x100 + fileTypeID int // rekordbox FileType enum — arg 10 compatibility bit + sampleRate int // Hz — arg 10 compatibility bit for wav/aiff + newKeyID uint32 // converted (Camelot 1-24) key index — arg 11 } // Sort order constants from CDJ protocol. diff --git a/dbserver/render.go b/dbserver/render.go index 9d191b4..c4d379c 100644 --- a/dbserver/render.go +++ b/dbserver/render.go @@ -129,6 +129,9 @@ func (h *Handler) handleRenderMenu(msg *proto.DBMessage) []*proto.DBMessage { // Category items (type >= 0x80) use fffa/fffb wrapped strings. // Track/data items use plain strings. for _, item := range items { + if item.songInfoTitle != nil { + item = item.songInfoTitle.render(item, msg) + } label1 := item.Label1 label2 := item.Label2 isCategory := item.ItemType >= 0x80 && item.ItemType&0xff != 0x04 diff --git a/dbserver/songinfo_delivery.go b/dbserver/songinfo_delivery.go index 0d90766..cc1c8db 100644 --- a/dbserver/songinfo_delivery.go +++ b/dbserver/songinfo_delivery.go @@ -40,9 +40,7 @@ func (h *Handler) handleGetDeliveryInfo(msg *proto.DBMessage) []*proto.DBMessage // single value rekordbox's eight-argument render reads (as in the // Hot Cue Bank list), and the request's context track type. track := h.trackToStdItem(t) - if h.trackDetailKey() == "key" { - track.Label2 = t.Key - } + track.songInfoTitle = h.newSongInfoTitle(t, t.ID) markContextType([]*menuItem{track}, uint32(dmstTrackType(msg))) items = []*menuItem{ diff --git a/dbserver/songinfo_display_test.go b/dbserver/songinfo_display_test.go index b3ab620..9a3e0e3 100644 --- a/dbserver/songinfo_display_test.go +++ b/dbserver/songinfo_display_test.go @@ -3,6 +3,7 @@ package dbserver import ( + "slices" "testing" "github.com/vynulldev/vynull/library" @@ -72,3 +73,76 @@ func TestListOffsetForInfoJump(t *testing.T) { t.Errorf("offset of an ID not in the list = %d, want 0", got) } } + +// Display Song Info follows rekordbox's sixteen-row layout: its order, the +// argument-0 flags, the colour type (0x13 + colour ID), and a title row whose +// second column the render request picks. +func TestDisplaySongInfoLayout(t *testing.T) { + lib := library.New() + lib.AddTrack(&library.Track{ID: 10001, Title: "Alpha One", Artist: "Alpha Artist", Album: "Album One", + Key: "Am", BPM: 120, ColorID: 1, Comment: "comment-1"}) + lib.RegisterCategoryID("artist", "Alpha Artist", 1001) + lib.RegisterCategoryID("key", "Am", 5001) + h := &Handler{lib: lib} + h.Handle(&proto.DBMessage{TxID: 1, Type: proto.DBMsgGetMetadata, Args: []proto.DBArg{proto.ArgI32(0x01020401), proto.ArgI32(10001)}}) + rows := h.pendingItems + + var types, parents []uint32 + for _, r := range rows[1:] { + types = append(types, r.ItemType) + parents = append(parents, r.ParentID) + } + wantTypes := []uint32{0x07, 0x02, 0x0b, 0x0d, 0x0f, 0x0a, 0x14, 0x06, 0x2e, 0x23, 0x10, 0x11, 0x0e, 0x28, 0x29} + wantParents := []uint32{1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1} + if !slices.Equal(types, wantTypes) { + t.Errorf("row types = %x, want %x", types, wantTypes) + } + if !slices.Equal(parents, wantParents) { + t.Errorf("argument 0 = %v, want %v", parents, wantParents) + } + + title := rows[0] + render := func(args ...uint32) *menuItem { + a := []proto.DBArg{proto.ArgI32(0x01020401), proto.ArgI32(0), proto.ArgI32(16)} + for _, v := range args { + a = append(a, proto.ArgI32(v)) + } + return title.songInfoTitle.render(title, &proto.DBMessage{Type: proto.DBMsgRenderMenu, Args: a}) + } + for _, tc := range []struct { + name string + args []uint32 + arg0 uint32 + label2 string + typ uint32 + }{ + {"six-argument render: plain title", []uint32{0, 16, 12}, 1, "", 0x0004}, + {"gate 0: plain title", []uint32{0, 16, 12, 0, 7}, 1, "", 0x0004}, + {"gate 1, column 12: key", []uint32{0, 16, 12, 1, 0}, 5001, "Am", 0x0f04}, + {"override 2: artist", []uint32{0, 16, 12, 1, 2}, 1001, "Alpha Artist", 0x0704}, + {"override 4: BPM", []uint32{0, 16, 12, 1, 4}, 12000, "", 0x0d04}, + {"override 15: colour", []uint32{0, 16, 12, 1, 15}, 1, "Pink", 0x1404}, + } { + got := render(tc.args...) + if got.ParentID != tc.arg0 || got.Label2 != tc.label2 || got.ItemType != tc.typ { + t.Errorf("%s: %d %q %#x, want %d %q %#x", tc.name, got.ParentID, got.Label2, got.ItemType, tc.arg0, tc.label2, tc.typ) + } + } + if title.ItemType == 0x0004 && title.Label2 == "" && title.ParentID == 1 { + t.Errorf("render must not modify the pending title row") + } +} + +// Song-info strings are cut to rekordbox's limits in UTF-16 units; a cut +// through a surrogate pair leaves U+FFFD. +func TestTruncateUTF16(t *testing.T) { + if got := truncateUTF16("abcdef", 4); got != "abcd" { + t.Errorf("ascii: %q", got) + } + if got := truncateUTF16("a🙂b", 2); got != "a�" { + t.Errorf("split pair: %q", got) + } + if got := truncateUTF16("short", 127); got != "short" { + t.Errorf("under the limit: %q", got) + } +} diff --git a/dbserver/songinfo_title.go b/dbserver/songinfo_title.go new file mode 100644 index 0000000..ab6bc83 --- /dev/null +++ b/dbserver/songinfo_title.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package dbserver + +import ( + "github.com/vynulldev/vynull/library" + "github.com/vynulldev/vynull/proto" +) + +// The title row of Display Song Info (0x2002) and Delivery Info (0x2602) +// takes its second column from the render request, not from the list it was +// built in (link-spec DISPLAY_SONG_INFO_ORACLE and SONG_INFO_SIBLINGS_ORACLE, +// render controls). An eight-argument render [ctx, offset, count, 0, total, +// column, gate, override] with gate 1 shows the override column, or the +// column argument when the override is 0; shorter renders and gate 0 show the +// plain title. The columns follow the ordinary secondary-column map. + +// songInfoColumn is one second-column choice for a title row: the value in +// argument 0, the text in argument 5, and the field type in the item type's +// high byte. +type songInfoColumn struct { + arg0 uint32 + label string + hi uint32 +} + +// songInfoTitle holds a title row's plain form and every column a render can +// select, keyed by selector number (2-17). +type songInfoTitle struct { + plainArg0 uint32 + columns map[uint32]songInfoColumn +} + +// newSongInfoTitle precomputes the selectable columns for a library track. +// plainArg0 is argument 0 of the plain title: 1 for Display, the track ID +// for Delivery. +func (h *Handler) newSongInfoTitle(t *library.Track, plainArg0 uint32) *songInfoTitle { + id := func(kind, name string) uint32 { + if name == "" { + return 0 + } + return h.catID(kind, name) + } + date := "" + if !t.DateAdded.IsZero() { + date = t.DateAdded.Format("2006-01-02") + } + color := uint32(t.ColorID) + return &songInfoTitle{plainArg0: plainArg0, columns: map[uint32]songInfoColumn{ + 2: {id("artist", t.Artist), t.Artist, 0x07}, + 3: {id("album", t.Album), t.Album, 0x02}, + 4: {uint32(t.BPM * 100), "", 0x0d}, + 5: {uint32(t.Rating), "", 0x0a}, + 6: {id("genre", t.Genre), t.Genre, 0x06}, + 7: {t.ID, t.Comment, 0x23}, + 8: {uint32(t.Duration.Seconds()), "", 0x0b}, + 9: {id("remixer", t.Remixer), t.Remixer, 0x29}, + 10: {id("label", t.Label), t.Label, 0x0e}, + 11: {id("origartist", t.OriginalArtist), t.OriginalArtist, 0x28}, + 12: {id("key", t.Key), t.Key, 0x0f}, + 13: {uint32(t.Bitrate), "", 0x10}, + 14: {0, "", 0x00}, + 15: {color, trackColorName(uint8(color)), 0x13 + color&0xff}, + 16: {uint32(t.PlayCount), "", 0x2a}, + 17: {t.ID, date, 0x2e}, + }} +} + +// render returns a copy of the title row composed for this render request. +func (st *songInfoTitle) render(item *menuItem, msg *proto.DBMessage) *menuItem { + out := *item + out.ParentID, out.Label2, out.ItemType = st.plainArg0, "", 0x0004 + if len(msg.Args) < 8 || msg.Args[6].Int() == 0 { + return &out + } + sel := msg.Args[7].Int() + if sel == 0 { + sel = msg.Args[5].Int() + } + if c, ok := st.columns[sel]; ok { + out.ParentID, out.Label2, out.ItemType = c.arg0, c.label, c.hi<<8|0x04 + } + return &out +} diff --git a/dbserver/track.go b/dbserver/track.go index 8a2e7b6..f129166 100644 --- a/dbserver/track.go +++ b/dbserver/track.go @@ -9,6 +9,7 @@ import ( "math" "path/filepath" "strings" + "unicode/utf16" "github.com/vynulldev/vynull/analysis" "github.com/vynulldev/vynull/library" @@ -158,31 +159,58 @@ func (h *Handler) handleGetMetadata(msg *proto.DBMessage) []*proto.DBMessage { // Metadata items in the standard format (16 items). // ParentID values: some items have parent=1, others parent=0. + // The title row is an ordinary track row (title with the key as its + // second column, key and BPM in the extended fields), as rekordbox's is; + // the key alone, not the "Key - BPM" composite, as its render reads it. + titleRow := &menuItem{ID: tID, Label1: title, ArtID: artworkID, ItemType: 0x0004, ParentID: 1, FileType: fileType} + if h.pdb != nil { + if pt := h.pdb.TrackByID(trackID); pt != nil { + titleRow = h.pdbTrackToStdItem(pt) + } + } else if h.lib != nil { + if t := h.lib.Track(trackID); t != nil { + titleRow = h.trackToStdItem(t) + titleRow.songInfoTitle = h.newSongInfoTitle(t, 1) + } + } + + // rekordbox's sixteen Display Song Info rows, in its order (link-spec + // DISPLAY_SONG_INFO_ORACLE). Argument 0 (ParentID) is 1 on the rows whose + // category is enabled, 0 on Date Added and Comment. metaItems := []*menuItem{ - {ID: tID, Label1: title, ArtID: artworkID, ItemType: 0x0004, ParentID: 1, FileType: fileType}, // title - {ID: artistID, Label1: artist, ItemType: 0x0007, ParentID: 1}, // artist - {ID: albumID, Label1: album, ItemType: 0x0002, ParentID: 1, noArtHandle: true}, // album - {ID: duration / 1000, Label1: "", ItemType: 0x000b}, // duration (parent=0) - {ID: tempo, Label1: "", ItemType: 0x000d, ParentID: 1}, // BPM (* 100) - {ID: keyID, Label1: key, ItemType: 0x000f, ParentID: 1}, // key - {ID: rating, Label1: "", ItemType: 0x000a}, // rating - // TRACK INFO colour row. Match the pattern every other - // metadata row uses: ItemType is just the field type marker - // (0x0013 here, no high-byte colour encoding — that was a - // guess that made the deck wrap the label in "[…]" and skip - // the chip rendering). ID carries the actual colour value - // (0 = none, 1 = Pink, … 8 = Purple) the deck looks up in - // its own palette. - {ID: colorID, Label1: trackColorName(uint8(colorID)), - ItemType: 0x0013}, - {ID: genreID, Label1: genre, ItemType: 0x0006, ParentID: 1}, // genre - {ID: tID, Label1: dateAdded, ItemType: 0x002e, ParentID: 1}, // date added - {ID: tID, Label1: comment, ItemType: 0x0023}, // comment (parent=0) - {ID: labelID, Label1: label, ItemType: 0x000e, ParentID: 1}, // label - {ID: bitrate, Label1: "", ItemType: 0x0010}, // bitrate (parent=0) - {ID: year, Label1: "", ItemType: 0x0011, ParentID: 1}, // year - {ID: catID("origartist", originalArtist), Label1: originalArtist, ItemType: 0x0028}, // original artist (parent=0) - {ID: catID("remixer", remixer), Label1: remixer, ItemType: 0x0029}, // remixer (parent=0) + titleRow, + {ID: artistID, Label1: artist, ItemType: 0x0007, ParentID: 1}, // artist + {ID: albumID, Label1: album, ItemType: 0x0002, ParentID: 1, noArtHandle: true}, // album + {ID: duration / 1000, ItemType: 0x000b, ParentID: 1}, // duration (seconds) + {ID: tempo, ItemType: 0x000d, ParentID: 1}, // BPM (* 100) + {ID: keyID, Label1: key, ItemType: 0x000f, ParentID: 1}, // key + {ID: rating, ItemType: 0x000a, ParentID: 1}, // rating + // Colour: the ID is the colour (0 = none, 1 = Pink ... 8 = Purple) and + // the type is 0x13 + that ID, as in the COLOUR menu (0x14 = Pink). + {ID: colorID, Label1: trackColorName(uint8(colorID)), ItemType: 0x13 + colorID&0xff, ParentID: 1}, + {ID: genreID, Label1: genre, ItemType: 0x0006, ParentID: 1}, // genre + {ID: tID, Label1: dateAdded, ItemType: 0x002e}, // date added + {ID: tID, Label1: comment, ItemType: 0x0023}, // comment + {ID: bitrate, ItemType: 0x0010, ParentID: 1}, // bitrate + {ID: year, ItemType: 0x0011, ParentID: 1}, // year + {ID: labelID, Label1: label, ItemType: 0x000e, ParentID: 1}, // label + {ID: catID("origartist", originalArtist), Label1: originalArtist, ItemType: 0x0028, ParentID: 1}, // original artist + {ID: catID("remixer", remixer), Label1: remixer, ItemType: 0x0029, ParentID: 1}, // remixer + } + // rekordbox's string limits for these rows (DISPLAY_SONG_INFO_ORACLE, + // "string paths"): 127 UTF-16 units for the title and lookup names, 255 + // for Comment and Date Added, and 127 characters for the title row's key. + for i, it := range metaItems { + limit := 127 + if it.ItemType == 0x0023 || it.ItemType == 0x002e { + limit = 255 + } + it.Label1 = truncateUTF16(it.Label1, limit) + if i == 0 { + if r := []rune(it.Label2); len(r) > 127 { + it.Label2 = string(r[:127]) + } + } } h.setPendingAll(msg, metaItems) @@ -1117,3 +1145,14 @@ func isDrivePath(p string) bool { return len(p) >= 3 && p[1] == ':' && (p[2] == '/' || p[2] == '\\') && (p[0] >= 'A' && p[0] <= 'Z' || p[0] >= 'a' && p[0] <= 'z') } + +// truncateUTF16 cuts s to at most n UTF-16 code units, as rekordbox's string +// fields do. A cut through a surrogate pair leaves a lone high surrogate, +// which decodes (on the wire, too) as U+FFFD. +func truncateUTF16(s string, n int) string { + units := utf16.Encode([]rune(s)) + if len(units) <= n { + return s + } + return string(utf16.Decode(units[:n])) +} diff --git a/library/import_masterdb.go b/library/import_masterdb.go index c2d334d..09deea7 100644 --- a/library/import_masterdb.go +++ b/library/import_masterdb.go @@ -131,6 +131,7 @@ type masterDBTrack struct { Comment string `json:"comment"` PlayCount int `json:"play_count"` DateAdded string `json:"date_added"` + DateCreated string `json:"date_created"` ColorID int `json:"color_id"` // rekordbox track-colour palette ID (1-8) AnalyzePath string `json:"analyze_path"` // /PIONEER/USBANLZ/.../ANLZ0000.DAT, relative to share/ ImagePath string `json:"image_path"` // /PIONEER/Artwork/.../artwork.jpg, relative to share/ @@ -579,6 +580,10 @@ func masterDBToLibrary(t *masterDBTrack, path string) *Track { if dateAdded.IsZero() { dateAdded = time.Now() } + dateCreated, _ := time.Parse("2006-01-02", t.DateCreated) + if dateCreated.IsZero() { + dateCreated, _ = time.Parse("2006-01-02 15:04:05", t.DateCreated) + } // Preserve the rekordbox djmdContent.ID as the library track ID so served // rows carry the same IDs rekordbox does (addTrackLocked respects a preset // ID; idMap then resolves to it identically). Fall back to auto-assign (0) @@ -603,6 +608,7 @@ func masterDBToLibrary(t *masterDBTrack, path string) *Track { Comment: t.Comment, Label: t.Label, DateAdded: dateAdded, + DateCreated: dateCreated, Bitrate: t.Bitrate, SampleRate: t.SampleRate, PlayCount: t.PlayCount, diff --git a/library/track.go b/library/track.go index 462d0f8..3c2bd13 100644 --- a/library/track.go +++ b/library/track.go @@ -60,13 +60,16 @@ type Track struct { Remixer string `json:"remixer"` MixName string `json:"mix_name"` // mix/version name DateAdded time.Time `json:"date_added"` - Bitrate int `json:"bitrate"` // kbps - SampleRate int `json:"sample_rate"` // Hz - SampleDepth int `json:"sample_depth"` // bits - PlayCount int `json:"play_count"` - ColorID uint8 `json:"color_id"` // 0=none, 1=pink, 2=red, etc. - ArtChecked bool `json:"art_checked,omitempty"` // true after artwork extraction attempted - FileMissing bool `json:"file_missing,omitempty"` // FilePath doesn't exist on disk — unplayable (e.g. an imported path not remapped to a local file) + // 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"` + Bitrate int `json:"bitrate"` // kbps + SampleRate int `json:"sample_rate"` // Hz + SampleDepth int `json:"sample_depth"` // bits + PlayCount int `json:"play_count"` + ColorID uint8 `json:"color_id"` // 0=none, 1=pink, 2=red, etc. + ArtChecked bool `json:"art_checked,omitempty"` // true after artwork extraction attempted + FileMissing bool `json:"file_missing,omitempty"` // FilePath doesn't exist on disk — unplayable (e.g. an imported path not remapped to a local file) // Beat-grid override fields. DetectedBPM is the auto-detected value // snapshotted before any manual override; BPM (above) is the diff --git a/tools/rekordbox_dump.py b/tools/rekordbox_dump.py index 53b1d1d..024aeee 100644 --- a/tools/rekordbox_dump.py +++ b/tools/rekordbox_dump.py @@ -131,7 +131,7 @@ def main(): cur.execute(""" SELECT c.ID, c.Title, c.FolderPath, c.FileNameL, c.FileSize, c.BPM, c.Length, c.BitRate, c.SampleRate, c.Rating, c.ReleaseYear, - c.TrackNo, c.DiscNo, c.Commnt, c.DJPlayCount, c.DateCreated, + c.TrackNo, c.DiscNo, c.Commnt, c.DJPlayCount, c.DateCreated, c.StockDate, c.ColorID, c.AnalysisDataPath, c.ImagePath, c.FileType, c.HotCueAutoLoad, a.Name AS Artist, al.Name AS Album, g.Name AS Genre, @@ -184,7 +184,10 @@ def main(): "disc_num": d.get("DiscNo") or 0, "comment": d.get("Commnt") or "", "play_count": d.get("DJPlayCount") or 0, - "date_added": d.get("DateCreated") or "", + # StockDate is rekordbox's "Date Added" (TRACK INFO, the DATE + # ADDED menu); DateCreated is often empty. + "date_added": d.get("StockDate") or d.get("DateCreated") or "", + "date_created": d.get("DateCreated") or "", # smart rules' dateCreated # ColorID is stored as TEXT ("0".."8"); coerce to the palette int. "color_id": int(d.get("ColorID") or 0), # rekordbox lookup IDs (djmd* row IDs), so served category rows can