diff --git a/dbserver/handler.go b/dbserver/handler.go index 4e6ecb4..fc8f3fa 100644 --- a/dbserver/handler.go +++ b/dbserver/handler.go @@ -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 diff --git a/dbserver/render.go b/dbserver/render.go index 4287e55..1279bd1 100644 --- a/dbserver/render.go +++ b/dbserver/render.go @@ -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 diff --git a/dbserver/songinfo_delivery.go b/dbserver/songinfo_delivery.go new file mode 100644 index 0000000..8b57c30 --- /dev/null +++ b/dbserver/songinfo_delivery.go @@ -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)} +} diff --git a/dbserver/songinfo_siblings_test.go b/dbserver/songinfo_siblings_test.go new file mode 100644 index 0000000..56a1d49 --- /dev/null +++ b/dbserver/songinfo_siblings_test.go @@ -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) + } +} diff --git a/dbserver/track.go b/dbserver/track.go index 184467f..3cc8941 100644 --- a/dbserver/track.go +++ b/dbserver/track.go @@ -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 } @@ -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) } @@ -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. @@ -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') +}