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
2 changes: 2 additions & 0 deletions dbserver/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ func (h *Handler) Handle(msg *proto.DBMessage) []*proto.DBMessage {
return h.handleGetCuePoints(msg)
case 0x2102: // track file info (path, duration, BPM, key, comment)
return h.handleGetTrackInfo(msg)
case 0x2602: // Delivery Info (13 metadata rows)
return h.handleGetDeliveryInfo(msg)
case 0x2103: // alternate artwork request (by track ID)
return h.handleGetArtwork(msg)
case 0x2107: // rating update from CDJ
Expand Down
22 changes: 16 additions & 6 deletions dbserver/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,25 @@ func (h *Handler) handleRenderMenu(msg *proto.DBMessage) []*proto.DBMessage {
limit = int(msg.Args[2].Int())
}

// rekordbox normalizes the requested window (link-spec
// PROTOCOL_REFERENCE.md, "Render ranges and pagination"): a zero count
// reads one row, a window running past the end slides back so it ends on
// the last row (an offset at or past the end reads the final row), and a
// count larger than the list reads all of it. Only the maximum offset is
// ignored outright: no reply.
if len(msg.Args) >= 4 && msg.Args[1].Int() == 0xffffffff {
dlog.Debugf("dbserver: render at offset 0xffffffff left unanswered, as rekordbox does")
return nil
}
items := pending
if offset >= len(items) {
items = nil
if n := len(items); n == 0 {
offset, items = 0, nil
} else {
end := offset + limit
if end > len(items) {
end = len(items)
limit = min(max(limit, 1), n)
if offset < 0 || offset+limit > n {
offset = n - limit
}
items = items[offset:end]
items = items[offset : offset+limit]
}

var responses []*proto.DBMessage
Expand Down
68 changes: 68 additions & 0 deletions dbserver/songinfo_delivery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package dbserver

import (
"github.com/vynulldev/vynull/internal/dlog"
"github.com/vynulldev/vynull/proto"
)

// handleGetDeliveryInfo — opcode 0x2602, Delivery Info: [context, track_id].
// rekordbox answers a header with 13 rows, then serves them through the usual
// render (link-spec docs/SONG_INFO_SIBLINGS_ORACLE.md, "Delivery Info rows"):
//
// composer, BPM, DeliveryComment, key, file type, genre, label, artist,
// lyricist, DeliveryControl/ISRC, the track row, album, length
//
// We store no composer, DeliveryComment, lyricist, ISRC or DeliveryControl,
// so those rows carry rekordbox's values for unset fields: empty text, with
// the track ID where rekordbox puts it. The rows come in rekordbox's base
// order; rekordbox itself permutes them according to earlier requests (its
// reused list storage), which decks don't depend on since every row is typed.
// A track we don't have gets a zero-row menu.
func (h *Handler) handleGetDeliveryInfo(msg *proto.DBMessage) []*proto.DBMessage {
var trackID uint32
if len(msg.Args) >= 2 {
trackID = msg.Args[1].Int()
}
var items []*menuItem
if h.lib != nil {
if t := h.lib.Track(trackID); t != nil {
id := func(kind, name string) uint32 {
if name == "" {
return 0
}
return h.catID(kind, name)
}
keyID := id("key", t.Key)

// The track row: the title with the key as its second column, the
// 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
}
markContextType([]*menuItem{track}, uint32(dmstTrackType(msg)))

items = []*menuItem{
{ItemType: 0x36}, // composer (ComposerID lookup)
{ID: uint32(t.BPM * 100), ItemType: 0x0d}, // BPM
{ID: t.ID, ItemType: 0x23}, // DeliveryComment
{ID: newKeyID(t.Key), ParentID: keyID, Label2: t.Key, ItemType: 0x0f}, // key
{ID: uint32(t.FileTypeID), ItemType: 0x12}, // file type
{ID: id("genre", t.Genre), Label1: t.Genre, ItemType: 0x06},
{ID: id("label", t.Label), Label1: t.Label, ItemType: 0x0e},
{ID: id("artist", t.Artist), Label1: t.Artist, ItemType: 0x07},
{ID: t.ID, ItemType: 0x37}, // lyricist
{ParentID: t.ID, ItemType: 0x4f}, // DeliveryControl / ISRC
track,
{ID: id("album", t.Album), Label1: t.Album, ItemType: 0x02},
{ID: uint32(t.Duration.Seconds()), ItemType: 0x0b}, // length
}
}
}
h.setPendingAll(msg, items)
dlog.Debugf("dbserver: delivery info 0x2602 track=%d returning %d rows", trackID, len(items))
return []*proto.DBMessage{h.successWithCount(msg)}
}
133 changes: 133 additions & 0 deletions dbserver/songinfo_siblings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package dbserver

import (
"slices"
"testing"
"time"

"github.com/vynulldev/vynull/library"
"github.com/vynulldev/vynull/proto"
)

func siblingsFixture() *Handler {
lib := library.New()
lib.AddTrack(&library.Track{
ID: 10001, Title: "Alpha One", Artist: "Alpha Artist", Album: "Album One", Genre: "Fixture House",
Label: "Fixture Label One", Key: "Am", BPM: 120, Comment: "comment-1", FileTypeID: 1,
FilePath: "/Z:/tracks/link-export-fixture/fixture-01.wav", Duration: library.DurationSec(59 * time.Second),
})
lib.AddTrack(&library.Track{ID: 10002, Title: "Local", Key: "Am", FilePath: "/music/local.flac"})
lib.RegisterCategoryID("key", "Am", 5001)
lib.RegisterCategoryID("genre", "Fixture House", 3001)
lib.RegisterCategoryID("label", "Fixture Label One", 4001)
lib.RegisterCategoryID("artist", "Alpha Artist", 1001)
lib.RegisterCategoryID("album", "Album One", 2001)
return &Handler{lib: lib}
}

// Play Song Info (0x2102) carries the track's comment, rekordbox's key ID,
// and a Windows drive path as rekordbox sends it (link-spec
// SONG_INFO_SIBLINGS_ORACLE, "Play Song Info rows").
func TestPlaySongInfoRows(t *testing.T) {
h := siblingsFixture()
h.Handle(&proto.DBMessage{TxID: 1, Type: 0x2102, Args: []proto.DBArg{proto.ArgI32(0x01020401), proto.ArgI32(10001)}})
rows := h.pendingItems
if len(rows) != 7 {
t.Fatalf("%d rows, want 7", len(rows))
}
if rows[3].Label1 != "comment-1" {
t.Errorf("comment row = %q, want comment-1", rows[3].Label1)
}
if rows[4].Label1 != "Z:/tracks/link-export-fixture/fixture-01.wav" {
t.Errorf("path row = %q, want the drive path without a leading slash", rows[4].Label1)
}
if rows[6].ID != 5001 || rows[6].Label1 != "Am" {
t.Errorf("key row = %d %q, want 5001 Am", rows[6].ID, rows[6].Label1)
}

// A local path keeps its leading slash: the deck opens it over NFS.
h.Handle(&proto.DBMessage{TxID: 2, Type: 0x2102, Args: []proto.DBArg{proto.ArgI32(0x01020401), proto.ArgI32(10002)}})
if got := h.pendingItems[4].Label1; got != "/music/local.flac" {
t.Errorf("local path row = %q, want /music/local.flac", got)
}
}

// Delivery Info (0x2602) returns rekordbox's 13 rows in its base order.
func TestDeliveryInfoRows(t *testing.T) {
h := siblingsFixture()
resp := h.Handle(&proto.DBMessage{TxID: 1, Type: 0x2602, Args: []proto.DBArg{proto.ArgI32(0x01020401), proto.ArgI32(10001)}})
if len(resp) != 1 || resp[0].Args[1].Int() != 13 {
t.Fatalf("header = %+v, want 13 rows", resp)
}
rows := h.pendingItems
var types []uint32
for _, r := range rows {
types = append(types, r.ItemType)
}
want := []uint32{0x36, 0x0d, 0x23, 0x0f, 0x12, 0x06, 0x0e, 0x07, 0x37, 0x4f, rows[10].ItemType, 0x02, 0x0b}
if !slices.Equal(types, want) || rows[10].ID != 10001 {
t.Fatalf("row types = %x, want rekordbox's base order with the track row 11th", types)
}
if k := rows[3]; k.ParentID != 5001 || k.ID != 15 || k.Label2 != "Am" {
t.Errorf("key row = parent %d, id %d, %q; want 5001, 15, Am", k.ParentID, k.ID, k.Label2)
}
if rows[5].ID != 3001 || rows[7].ID != 1001 || rows[11].ID != 2001 || rows[12].ID != 59 {
t.Errorf("genre/artist/album/length = %d/%d/%d/%d", rows[5].ID, rows[7].ID, rows[11].ID, rows[12].ID)
}
if rows[9].ParentID != 10001 || rows[8].ID != 10001 {
t.Errorf("DeliveryControl/lyricist rows should carry the track ID where rekordbox does")
}

resp = h.Handle(&proto.DBMessage{TxID: 2, Type: 0x2602, Args: []proto.DBArg{proto.ArgI32(0x01020401), proto.ArgI32(99999)}})
if resp[0].Args[1].Int() != 0 {
t.Errorf("missing track: %d rows, want 0", resp[0].Args[1].Int())
}
}

// Render windows follow rekordbox's normalization (link-spec
// PROTOCOL_REFERENCE, "Render ranges and pagination"), here over its
// eight-row sweep.
func TestRenderWindowNormalization(t *testing.T) {
h := &Handler{}
var items []*menuItem
for i := uint32(1); i <= 8; i++ {
items = append(items, &menuItem{ID: i, Label1: "row", ItemType: 0x0004})
}
req := &proto.DBMessage{TxID: 1, Type: 0x1004, Args: []proto.DBArg{proto.ArgI32(0x01020401), proto.ArgI32(0)}}
render := func(offset, count uint32) []uint32 {
h.setPendingAll(req, items)
resp := h.Handle(&proto.DBMessage{TxID: 2, Type: proto.DBMsgRenderMenu, Args: []proto.DBArg{
proto.ArgI32(0x01020401), proto.ArgI32(offset), proto.ArgI32(count), proto.ArgI32(0), proto.ArgI32(8), proto.ArgI32(0)}})
if resp == nil {
return nil
}
var ids []uint32
for _, m := range resp {
if m.Type == proto.DBMsgMenuItem {
ids = append(ids, m.Args[1].Int())
}
}
return ids
}
for _, tc := range []struct {
offset, count uint32
want []uint32
}{
{0, 8, []uint32{1, 2, 3, 4, 5, 6, 7, 8}},
{7, 1, []uint32{8}},
{0, 0, []uint32{1}}, // zero count reads one row
{8, 1, []uint32{8}}, // at the end: the final row
{9, 1, []uint32{8}}, // past the end: the final row
{7, 10, []uint32{1, 2, 3, 4, 5, 6, 7, 8}}, // overrun larger than the list: all of it
{6, 3, []uint32{6, 7, 8}}, // overrun: slid back to end on the last row
} {
if got := render(tc.offset, tc.count); !slices.Equal(got, tc.want) {
t.Errorf("render offset %d count %d = %v, want %v", tc.offset, tc.count, got, tc.want)
}
}
if got := render(0xffffffff, 1); got != nil {
t.Errorf("render at 0xffffffff = %v, want no reply", got)
}
}
21 changes: 20 additions & 1 deletion dbserver/track.go
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,12 @@ func (h *Handler) handleGetTrackInfo(msg *proto.DBMessage) []*proto.DBMessage {
Args: []proto.DBArg{proto.ArgI32(uint32(msg.Type)), proto.ArgI32(0)},
}}
}
if !strings.HasPrefix(relPath, "/") {
// A Windows drive path (a rekordbox import whose file isn't on this
// machine) is sent as rekordbox sends it, "Z:/...", not "/Z:/..." as
// stored; any other path is absolute from the export root.
if p := strings.TrimPrefix(relPath, "/"); isDrivePath(p) {
relPath = p
} else if !strings.HasPrefix(relPath, "/") {
relPath = "/" + relPath
}

Expand Down Expand Up @@ -989,6 +994,9 @@ func (h *Handler) handleGetTrackInfo(msg *proto.DBMessage) []*proto.DBMessage {
if key == "" {
key = t.Key
}
if comment == "" {
comment = t.Comment
}
if fileSize == 0 {
fileSize = uint32(t.FileSize)
}
Expand All @@ -1008,6 +1016,11 @@ func (h *Handler) handleGetTrackInfo(msg *proto.DBMessage) []*proto.DBMessage {
}
}

// The key row carries rekordbox's key ID, as it does in the KEY menu.
if keyID == 0 && key != "" && h.lib != nil {
keyID = h.catID("key", key)
}

dlog.Debugf("dbserver: track info for track %d: path=%s dur=%d bpm=%d", trackID, relPath, duration, tempo)

// The response returns 7 items: title, duration, BPM, comment, path, unknown, key.
Expand Down Expand Up @@ -1098,3 +1111,9 @@ func (h *Handler) handleGetNXS2CuePoints(msg *proto.DBMessage) []*proto.DBMessag
},
}}
}

// isDrivePath reports whether p starts with a Windows drive letter ("Z:/").
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')
}
Loading