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