Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/smart_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion api/smart_import_date_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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")
}
}
2 changes: 2 additions & 0 deletions api/smart_playlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
15 changes: 9 additions & 6 deletions dbserver/menuitem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions dbserver/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions dbserver/songinfo_delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
74 changes: 74 additions & 0 deletions dbserver/songinfo_display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package dbserver

import (
"slices"
"testing"

"github.com/vynulldev/vynull/library"
Expand Down Expand Up @@ -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)
}
}
84 changes: 84 additions & 0 deletions dbserver/songinfo_title.go
Original file line number Diff line number Diff line change
@@ -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
}
87 changes: 63 additions & 24 deletions dbserver/track.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"math"
"path/filepath"
"strings"
"unicode/utf16"

"github.com/vynulldev/vynull/analysis"
"github.com/vynulldev/vynull/library"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]))
}
Loading
Loading