From 07587e8d67dbfd7ff851cc2f29c69a279f96646b Mon Sep 17 00:00:00 2001 From: Vynull App Date: Fri, 16 Oct 2026 23:50:54 +0000 Subject: [PATCH 1/4] dbserver: serve the CDJ's TRACK FILTER (BPM and key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deck's TRACK FILTER narrows its track lists by BPM, key, rating, colour and MyTag, and rekordbox does the filtering: the deck sends the conditions and rekordbox's TRACK list comes back narrowed. We acknowledged only 0x3007, ignored the rest, and filtered nothing, so the filter screen showed no settings and TRACK FILTER kept every track. Decoded from real rekordbox captures of a CDJ-2000NXS2: - 0x3007 [ctx, on] turns the filter on or off; 0x3207 [ctx, prop, 0, len, blob] sets one condition (little-endian: on, kind, u16 count, u32 values); 0x3107 [ctx] returns all four as 0x4004 [kind, 0, len, blob, 4], one big-endian FCND record each; 0x3307 and 0x3407 set the MyTag condition and its tags. Each player's settings are kept across sessions, and the 0x3107 replies match rekordbox's byte for byte. - The deck works out each condition's values itself: a BPM range from the typed tempo and chosen percentage (126 at ±1% sends 12474-12726), and a key's related-key set as rekordbox key indices ("Am related" sends 8A, 8B, 7A, 9A). While the filter is on, the TRACK list at menu location 1 keeps tracks whose BPM lies within the range and whose key is in the set, as rekordbox's does. Against the captured library the counts match rekordbox's: 388 and 1441 for the two BPM ranges, 628 and 530 for the two key sets. Library track rows now round BPM to hundredths, so a BPM like 124.74 isn't truncated just below a range's lower bound. Rating, colour and MyTag conditions are stored and returned but don't narrow the list yet; their matching is still being decoded. --- dbserver/categories.go | 1 + dbserver/handler.go | 34 ++--- dbserver/menuitem.go | 2 +- dbserver/server.go | 3 + dbserver/trackfilter.go | 255 +++++++++++++++++++++++++++++++++++ dbserver/trackfilter_test.go | 101 ++++++++++++++ 6 files changed, 373 insertions(+), 23 deletions(-) create mode 100644 dbserver/trackfilter.go create mode 100644 dbserver/trackfilter_test.go diff --git a/dbserver/categories.go b/dbserver/categories.go index be6afe1..a611b2f 100644 --- a/dbserver/categories.go +++ b/dbserver/categories.go @@ -32,6 +32,7 @@ func (h *Handler) handleGetTracks(msg *proto.DBMessage) []*proto.DBMessage { orderByTitle(h.pendingItems) // sorts keep title order on ties } h.sortTracks(h.pendingItems, order) + h.pendingItems = h.applyTrackFilter(msg, h.pendingItems) dlog.Debugf("dbserver: get tracks returning %d items (sort=%d)", len(h.pendingItems), order) return []*proto.DBMessage{h.successWithCount(msg)} } diff --git a/dbserver/handler.go b/dbserver/handler.go index e21a9ad..3009c02 100644 --- a/dbserver/handler.go +++ b/dbserver/handler.go @@ -19,6 +19,9 @@ import ( // Handler processes individual dbserver messages and returns responses. type Handler struct { + // filters holds the players' TRACK FILTER settings, shared by the + // server's sessions (trackfilter.go). + filters *trackFilters // ctxTrackType is the context track type of the browse request being // served (the low byte of its first argument). rekordbox echoes it in // argument 7 of hierarchy track rows (see markContextType). @@ -219,8 +222,16 @@ func (h *Handler) Handle(msg *proto.DBMessage) []*proto.DBMessage { switch msg.Type { case proto.DBMsgSetup: return h.handleSetup(msg) - case 0x3007: // set filter on/off (CMD_SET_FILTER_ONOFF) + case 0x3007: // TRACK FILTER on/off (CMD_SET_FILTER_ONOFF) return h.handleSetFilterOnOff(msg) + case 0x3107: // TRACK FILTER conditions (CMD_GET_FILTER_CONDITION_PROPERTY) + return h.handleGetFilterConditions(msg) + case 0x3207: // set a TRACK FILTER condition + return h.handleSetFilterCondition(msg) + case 0x3307: // TRACK FILTER MyTag condition on/off + return h.handleSetFilterMyTag(msg) + case 0x3407: // add/remove a TRACK FILTER MyTag + return h.handleAddFilterMyTag(msg) case proto.DBMsgRootMenu: // 0x1000 return h.handleRootMenu(msg) case 0x3e03: // NXS2 extension @@ -510,27 +521,6 @@ func (h *Handler) handleSetup(msg *proto.DBMessage) []*proto.DBMessage { }} } -// handleSetFilterOnOff handles 0x3007, which decks send before each root -// menu request. It is the first of rekordbox's filter commands (0x3007 to -// 0x3407 in 0x100 steps, dispatched by PSvDBMain::OnFilterCmd), and XDJ-RR -// firmware sends it from dbcl_SetFilterOnOff (link-spec's static analysis -// in docs/LINK_EXPORT_REQUEST_VOCABULARY.md and -// data/static-analysis/xdj-rr-client-navigation.json). We keep no filter -// state, so it is only acknowledged. rekordbox's reply has not been -// captured; this plain success is what decks have always accepted from us. -func (h *Handler) handleSetFilterOnOff(msg *proto.DBMessage) []*proto.DBMessage { - argVals := make([]string, len(msg.Args)) - for i, a := range msg.Args { - argVals[i] = fmt.Sprintf("0x%08x", a.Int()) - } - dlog.Debugf("dbserver: set filter on/off (0x3007) args=%v", argVals) - return []*proto.DBMessage{{ - TxID: msg.TxID, - Type: proto.DBMsgSuccess, - Args: []proto.DBArg{proto.ArgI32(0x3007), proto.ArgI32(0)}, - }} -} - func (h *Handler) handleNXS2Extension(msg *proto.DBMessage) []*proto.DBMessage { dlog.Debugf("dbserver: NXS2 extension (0x3e03)") // Respond: type 0x4b02 with [echo_type, 0, 2, ""] — matches the CDJ diff --git a/dbserver/menuitem.go b/dbserver/menuitem.go index ebca0c6..cd61b0b 100644 --- a/dbserver/menuitem.go +++ b/dbserver/menuitem.go @@ -160,7 +160,7 @@ func (h *Handler) trackToStdItem(t *library.Track) *menuItem { ColorID: uint32(t.ColorID), sortArtist: t.Artist, sortAlbum: t.Album, - sortBPM: uint32(t.BPM * 100), + sortBPM: uint32(math.Round(t.BPM * 100)), // exact for range checks (TRACK FILTER) sortKey: t.Key, hasSongTag: h.lib != nil && h.lib.HasSongTag(t.ID), autoLoadHotCue: t.AutoLoadHotCue, diff --git a/dbserver/server.go b/dbserver/server.go index 5ebd6f7..e3948a0 100644 --- a/dbserver/server.go +++ b/dbserver/server.go @@ -118,6 +118,8 @@ type SmartPlaylistSource interface { // Server implements the Pro DJ Link database server protocol. type Server struct { + // filters holds every player's TRACK FILTER settings across sessions. + filters trackFilters Library *library.Library PDB *pdb.Database DeviceNumber uint8 @@ -433,6 +435,7 @@ func (s *Server) handleSession(ctx context.Context, conn net.Conn) { menu: s.Menu, cues: s.Cues, settings: s.Settings, + filters: &s.filters, } for { diff --git a/dbserver/trackfilter.go b/dbserver/trackfilter.go new file mode 100644 index 0000000..289be32 --- /dev/null +++ b/dbserver/trackfilter.go @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package dbserver + +import ( + "encoding/binary" + "sync" + + "github.com/vynulldev/vynull/internal/dlog" + "github.com/vynulldev/vynull/proto" +) + +// trackfilter.go serves the deck's TRACK FILTER: rekordbox's filter commands +// 0x3007 (on/off), 0x3107 (get the conditions), 0x3207 (set a condition), +// 0x3307 (MyTag condition on/off) and 0x3407 (add/remove a MyTag). The deck +// works out each condition's values itself (a BPM range from the tempo and +// percentage chosen, the related-key set of the chosen key) and rekordbox +// stores them, then filters the deck's TRACK list (0x1004 at menu location +// 1) by the enabled conditions while the filter is on. Decoded from real +// rekordbox captures of a CDJ-2000NXS2 setting the filter. + +// Filter condition properties, as rekordbox numbers them. +const ( + filterPropBPM = 6 + filterPropRating = 7 + filterPropKey = 12 + filterPropColor = 15 +) + +// filterPropOrder is the order rekordbox lists the conditions in. +var filterPropOrder = []uint32{filterPropBPM, filterPropKey, filterPropRating, filterPropColor} + +// filterCondition is one condition: whether it's on, its kind (6 a range, +// 17 a list, 0 for rating), and its values. +type filterCondition struct { + on byte + kind byte + values []uint32 +} + +// trackFilter is one player's filter settings. +type trackFilter struct { + on bool + conds map[uint32]*filterCondition + mytagOn bool + mytagArg uint32 // the 0x3307 mode argument, kept as sent + tags []uint32 +} + +// newTrackFilter returns the settings a player starts with: everything off, +// with values like rekordbox's own initial ones. +func newTrackFilter() *trackFilter { + return &trackFilter{conds: map[uint32]*filterCondition{ + filterPropBPM: {kind: 6, values: []uint32{12220, 13780}}, + filterPropKey: {kind: 17, values: []uint32{16, 15, 14, 18}}, + filterPropRating: {kind: 0, values: []uint32{5, 0}}, + filterPropColor: {kind: 17, values: []uint32{0}}, + }} +} + +// trackFilters holds every player's filter settings, shared by all of the +// server's sessions so they survive a deck reconnecting. +type trackFilters struct { + mu sync.Mutex + players map[uint8]*trackFilter +} + +// filterFor returns the filter settings of the player that sent msg (its +// context's first byte), creating them on first use. +func (h *Handler) filterFor(msg *proto.DBMessage) *trackFilter { + if h.filters == nil { + h.filters = &trackFilters{} + } + h.filters.mu.Lock() + defer h.filters.mu.Unlock() + if h.filters.players == nil { + h.filters.players = map[uint8]*trackFilter{} + } + player := uint8(0) + if len(msg.Args) > 0 { + player = uint8(msg.Args[0].Int() >> 24) + } + f := h.filters.players[player] + if f == nil { + f = newTrackFilter() + h.filters.players[player] = f + } + return f +} + +func filterAck(msg *proto.DBMessage) []*proto.DBMessage { + return []*proto.DBMessage{{ + TxID: msg.TxID, Type: proto.DBMsgSuccess, + Args: []proto.DBArg{proto.ArgI32(uint32(msg.Type)), proto.ArgI32(0)}, + }} +} + +func msgArg(msg *proto.DBMessage, i int) uint32 { + if len(msg.Args) > i { + return msg.Args[i].Int() + } + return 0 +} + +// handleSetFilterOnOff handles 0x3007 [ctx, on]: TRACK FILTER on (1) or off +// (0). Decks send it with 0 before opening the root menu, too. +func (h *Handler) handleSetFilterOnOff(msg *proto.DBMessage) []*proto.DBMessage { + f := h.filterFor(msg) + h.filters.mu.Lock() + f.on = msgArg(msg, 1) != 0 + h.filters.mu.Unlock() + dlog.Debugf("dbserver: track filter (0x3007) on=%v", f.on) + return filterAck(msg) +} + +// handleGetFilterConditions handles 0x3107 [ctx]: the player's conditions as +// rekordbox's 0x4004 reply, [kind, 0, blob length, blob, count], the blob +// holding one FCND record per condition. +func (h *Handler) handleGetFilterConditions(msg *proto.DBMessage) []*proto.DBMessage { + f := h.filterFor(msg) + h.filters.mu.Lock() + var blob []byte + for _, prop := range filterPropOrder { + blob = append(blob, encodeFilterCondition(prop, f.conds[prop])...) + } + h.filters.mu.Unlock() + return []*proto.DBMessage{{ + TxID: msg.TxID, Type: 0x4004, + Args: []proto.DBArg{ + proto.ArgI32(uint32(msg.Type)), proto.ArgI32(0), + proto.ArgI32(uint32(len(blob))), proto.ArgBlob(blob), + proto.ArgI32(uint32(len(filterPropOrder))), + }, + }} +} + +// encodeFilterCondition builds one FCND record: a little-endian size and +// version, "FCND", then big-endian fields: 0x14, the size less 8, the +// property, and the condition (on, kind, u16 count, u32 values). +func encodeFilterCondition(prop uint32, c *filterCondition) []byte { + size := 28 + 4*len(c.values) + r := make([]byte, size) + binary.LittleEndian.PutUint32(r[0:], uint32(size)) + binary.LittleEndian.PutUint32(r[4:], 1) + copy(r[8:], "FCND") + binary.BigEndian.PutUint32(r[12:], 0x14) + binary.BigEndian.PutUint32(r[16:], uint32(size-8)) + binary.BigEndian.PutUint32(r[20:], prop) + r[24], r[25] = c.on, c.kind + binary.BigEndian.PutUint16(r[26:], uint16(len(c.values))) + for i, v := range c.values { + binary.BigEndian.PutUint32(r[28+4*i:], v) + } + return r +} + +// handleSetFilterCondition handles 0x3207 [ctx, prop, 0, length, blob]: one +// condition, its blob little-endian (on, kind, u16 count, u32 values). +func (h *Handler) handleSetFilterCondition(msg *proto.DBMessage) []*proto.DBMessage { + f := h.filterFor(msg) + prop := msgArg(msg, 1) + var b []byte + if len(msg.Args) > 4 { + b = msg.Args[4].Bytes + } + if len(b) >= 4 { + n := int(binary.LittleEndian.Uint16(b[2:])) + c := &filterCondition{on: b[0], kind: b[1]} + for i := 0; i < n && 8+4*i <= len(b); i++ { + c.values = append(c.values, binary.LittleEndian.Uint32(b[4+4*i:])) + } + h.filters.mu.Lock() + f.conds[prop] = c + h.filters.mu.Unlock() + dlog.Debugf("dbserver: track filter condition (0x3207) prop=%d on=%d values=%v", prop, c.on, c.values) + } + return filterAck(msg) +} + +// handleSetFilterMyTag handles 0x3307 [ctx, 0, 1, on, mode]: the MyTag +// condition on or off. +func (h *Handler) handleSetFilterMyTag(msg *proto.DBMessage) []*proto.DBMessage { + f := h.filterFor(msg) + h.filters.mu.Lock() + f.mytagOn = msgArg(msg, 3) != 0 + f.mytagArg = msgArg(msg, 4) + h.filters.mu.Unlock() + dlog.Debugf("dbserver: track filter MyTag (0x3307) on=%v mode=%#x", f.mytagOn, f.mytagArg) + return filterAck(msg) +} + +// handleAddFilterMyTag handles 0x3407 [ctx, 0, 1, tag ID, add]: a MyTag added +// to (1) or removed from (0) the MyTag condition. +func (h *Handler) handleAddFilterMyTag(msg *proto.DBMessage) []*proto.DBMessage { + f := h.filterFor(msg) + tag, add := msgArg(msg, 3), msgArg(msg, 4) != 0 + h.filters.mu.Lock() + kept := f.tags[:0] + for _, t := range f.tags { + if t != tag { + kept = append(kept, t) + } + } + f.tags = kept + if add { + f.tags = append(f.tags, tag) + } + h.filters.mu.Unlock() + dlog.Debugf("dbserver: track filter MyTag item (0x3407) tag=%#x add=%v", tag, add) + return filterAck(msg) +} + +// applyTrackFilter narrows a TRACK list (0x1004) at menu location 1 by the +// player's enabled conditions while its filter is on, as rekordbox does: a +// BPM range keeps tracks whose BPM lies within it, a key list keeps tracks +// whose key is in it. Conditions whose matching isn't decoded yet (rating, +// colour, MyTag) don't narrow the list. +func (h *Handler) applyTrackFilter(msg *proto.DBMessage, items []*menuItem) []*menuItem { + if dmstMenu(msg) != 1 { + return items + } + f := h.filterFor(msg) + h.filters.mu.Lock() + defer h.filters.mu.Unlock() + if !f.on { + return items + } + bpm, key := f.conds[filterPropBPM], f.conds[filterPropKey] + useBPM := bpm != nil && bpm.on != 0 && len(bpm.values) == 2 + useKey := key != nil && key.on != 0 + if !useBPM && !useKey { + return items + } + var out []*menuItem + for _, it := range items { + if useBPM && (it.sortBPM < bpm.values[0] || it.sortBPM > bpm.values[1]) { + continue + } + if useKey && !containsU32(key.values, it.newKeyID) { + continue + } + out = append(out, it) + } + dlog.Debugf("dbserver: track filter kept %d of %d tracks", len(out), len(items)) + return out +} + +func containsU32(s []uint32, v uint32) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/dbserver/trackfilter_test.go b/dbserver/trackfilter_test.go new file mode 100644 index 0000000..144b764 --- /dev/null +++ b/dbserver/trackfilter_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package dbserver + +import ( + "encoding/hex" + "testing" + + "github.com/vynulldev/vynull/library" + "github.com/vynulldev/vynull/proto" +) + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return b +} + +// The TRACK FILTER commands, replayed from a real rekordbox capture of a +// CDJ-2000NXS2: each 0x3207 sets a condition, and the next 0x3107 reply's +// condition blob must match rekordbox's byte for byte. +func TestTrackFilterConditionsMatchRekordbox(t *testing.T) { + h := &Handler{} + const ctx = 0x01010401 + set := func(prop uint32, blob string) { + r := h.Handle(&proto.DBMessage{TxID: 1, Type: 0x3207, Args: []proto.DBArg{ + proto.ArgI32(ctx), proto.ArgI32(prop), proto.ArgI32(0), proto.ArgI32(uint32(len(blob) / 2)), proto.ArgBlob(mustHex(t, blob))}}) + if len(r) != 1 || r[0].Type != proto.DBMsgSuccess || r[0].Args[0].Int() != 0x3207 { + t.Fatalf("0x3207 reply = %+v", r) + } + } + get := func() *proto.DBMessage { + r := h.Handle(&proto.DBMessage{TxID: 2, Type: 0x3107, Args: []proto.DBArg{proto.ArgI32(ctx)}}) + if len(r) != 1 { + t.Fatalf("0x3107: %d replies", len(r)) + } + return r[0] + } + rest := "2c0000000100000046434e4400000014000000240000000c00110004000000100000000f0000000e00000012" + + "240000000100000046434e44000000140000001c00000007000000020000000500000000" + + "200000000100000046434e4400000014000000180000000f0011000100000000" + for _, step := range []struct{ set, want string }{ + {"00060200ba300000b6310000", "240000000100000046434e44000000140000001c0000000600060002000030ba000031b6"}, + {"01060200ba300000b6310000", "240000000100000046434e44000000140000001c0000000601060002000030ba000031b6"}, + {"01060200402f000030330000", "240000000100000046434e44000000140000001c000000060106000200002f4000003330"}, + {"00060200402f000030330000", "240000000100000046434e44000000140000001c000000060006000200002f4000003330"}, + } { + set(filterPropBPM, step.set) + r := get() + if r.Type != 0x4004 || r.Args[0].Int() != 0x3107 || r.Args[2].Int() != 148 || r.Args[4].Int() != 4 { + t.Fatalf("0x3107 envelope = %+v", r) + } + if got := hex.EncodeToString(r.Args[3].Bytes); got != step.want+rest { + t.Errorf("after 0x3207 %s:\n got %s\nwant %s", step.set, got, step.want+rest) + } + } +} + +// With the filter on, the TRACK list at menu location 1 keeps only tracks in +// the BPM range and the key list; location 2 and an off filter are unfiltered. +func TestTrackFilterNarrowsTrackList(t *testing.T) { + lib := library.New() + for i, tr := range []struct { + bpm float64 + key string + }{{126, "Am"}, {124.74, "C"}, {127.26, "Fm"}, {130, "Am"}, {120, "Em"}} { + lib.AddTrack(&library.Track{ID: uint32(i + 1), Title: string(rune('A' + i)), BPM: tr.bpm, Key: tr.key}) + } + h := &Handler{lib: lib} + count := func(ctx uint32) int { + h.Handle(&proto.DBMessage{TxID: 3, Type: proto.DBMsgGetTracks, Args: []proto.DBArg{proto.ArgI32(ctx), proto.ArgI32(0)}}) + return len(h.pendingItems) + } + send := func(kind uint16, args ...proto.DBArg) { + h.Handle(&proto.DBMessage{TxID: 4, Type: kind, Args: append([]proto.DBArg{proto.ArgI32(0x01010401)}, args...)}) + } + if n := count(0x01010401); n != 5 { + t.Fatalf("unfiltered = %d, want 5", n) + } + // BPM 126 ±1% (124.74-127.26), as the deck sent it; filter on. + send(0x3207, proto.ArgI32(filterPropBPM), proto.ArgI32(0), proto.ArgI32(12), proto.ArgBlob(mustHex(t, "01060200ba300000b6310000"))) + send(0x3007, proto.ArgI32(1)) + if n := count(0x01010401); n != 3 { + t.Errorf("BPM 124.74-127.26 = %d tracks, want 3 (bounds inclusive)", n) + } + if n := count(0x01020401); n != 5 { + t.Errorf("location 2 = %d tracks, want 5 (unfiltered)", n) + } + // Add "Am related" keys (15,16,13,17 = 8A, 8B, 7A, 9A): Am and C stay. + send(0x3207, proto.ArgI32(filterPropKey), proto.ArgI32(0), proto.ArgI32(20), proto.ArgBlob(mustHex(t, "011104000f000000100000000d00000011000000"))) + if n := count(0x01010401); n != 2 { + t.Errorf("BPM and Am-related keys = %d tracks, want 2", n) + } + send(0x3007, proto.ArgI32(0)) + if n := count(0x01010401); n != 5 { + t.Errorf("filter off = %d tracks, want 5", n) + } +} From deaa0590a662526dc7a3a69d343399adfef0eb5f Mon Sep 17 00:00:00 2001 From: Vynull App Date: Fri, 16 Oct 2026 23:53:30 +0000 Subject: [PATCH 2/4] dbserver: filter by rating and colour in the TRACK FILTER Decoded from real rekordbox captures of a CDJ-2000NXS2: - Rating [n, 0] keeps tracks rated exactly n stars: 5, 4, 2 and 1 star kept 2, 5, 3 and 0 tracks, as many as the captured library has at each rating. - Colour is a bitmask of colours, bit colour-1 (yellow, colour 4, is sent as 8; red, colour 2, as 2), and a zero mask, sent when no colour is chosen, keeps every track. Yellow and red kept 3 each, matching the library. Track rows now carry the track's rating for the filter. --- dbserver/menuitem.go | 3 +++ dbserver/trackfilter.go | 39 ++++++++++++++++++++++-------- dbserver/trackfilter_test.go | 46 ++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/dbserver/menuitem.go b/dbserver/menuitem.go index cd61b0b..1e5892a 100644 --- a/dbserver/menuitem.go +++ b/dbserver/menuitem.go @@ -30,6 +30,7 @@ type menuItem struct { FileType string // "mp3", "m4a", "flac", "wav", "aiff" — for title items TrackInfo bool // true for track-info title items (no special flags) ColorID uint32 // track color (1=pink, 2=red, ..., 8=purple) for arg[11] + rating uint32 // track rating (0-5 stars), for the TRACK FILTER // Sort metadata (for tracks). sortArtist string @@ -135,6 +136,7 @@ func (h *Handler) pdbTrackToStdItem(t *pdb.Track) *menuItem { ArtID: t.ArtworkID, FileType: h.resolveFileType(t.ID), ColorID: uint32(t.ColorID), + rating: uint32(t.Rating), // USB exports carry hot-cue autoload; the compatibility bit and // new-key index fall out of the codec string and Camelot key. The PDB // row has no FileTypeID/sample-rate, so those use the string fallback. @@ -158,6 +160,7 @@ func (h *Handler) trackToStdItem(t *library.Track) *menuItem { ArtID: t.ArtID, FileType: t.FileType, ColorID: uint32(t.ColorID), + rating: uint32(t.Rating), sortArtist: t.Artist, sortAlbum: t.Album, sortBPM: uint32(math.Round(t.BPM * 100)), // exact for range checks (TRACK FILTER) diff --git a/dbserver/trackfilter.go b/dbserver/trackfilter.go index 289be32..902977f 100644 --- a/dbserver/trackfilter.go +++ b/dbserver/trackfilter.go @@ -211,10 +211,15 @@ func (h *Handler) handleAddFilterMyTag(msg *proto.DBMessage) []*proto.DBMessage } // applyTrackFilter narrows a TRACK list (0x1004) at menu location 1 by the -// player's enabled conditions while its filter is on, as rekordbox does: a -// BPM range keeps tracks whose BPM lies within it, a key list keeps tracks -// whose key is in it. Conditions whose matching isn't decoded yet (rating, -// colour, MyTag) don't narrow the list. +// player's enabled conditions while its filter is on, as rekordbox does: +// +// - BPM [lo, hi] (BPM*100) keeps tracks whose BPM lies within it; +// - key [indices] keeps tracks whose 1-24 key index is listed; +// - rating [n, 0] keeps tracks rated exactly n stars; +// - colour [mask] keeps tracks whose colour's bit (1 << (colour-1); yellow, +// colour 4, is 8) is set, and a zero mask (no colour chosen) keeps all. +// +// The MyTag condition isn't decoded yet and doesn't narrow the list. func (h *Handler) applyTrackFilter(msg *proto.DBMessage, items []*menuItem) []*menuItem { if dmstMenu(msg) != 1 { return items @@ -225,18 +230,32 @@ func (h *Handler) applyTrackFilter(msg *proto.DBMessage, items []*menuItem) []*m if !f.on { return items } - bpm, key := f.conds[filterPropBPM], f.conds[filterPropKey] - useBPM := bpm != nil && bpm.on != 0 && len(bpm.values) == 2 - useKey := key != nil && key.on != 0 - if !useBPM && !useKey { + active := func(prop uint32, minValues int) *filterCondition { + if c := f.conds[prop]; c != nil && c.on != 0 && len(c.values) >= minValues { + return c + } + return nil + } + bpm, key := active(filterPropBPM, 2), active(filterPropKey, 0) + rating, color := active(filterPropRating, 1), active(filterPropColor, 1) + if color != nil && color.values[0] == 0 { + color = nil // no colour chosen + } + if bpm == nil && key == nil && rating == nil && color == nil { return items } var out []*menuItem for _, it := range items { - if useBPM && (it.sortBPM < bpm.values[0] || it.sortBPM > bpm.values[1]) { + if bpm != nil && (it.sortBPM < bpm.values[0] || it.sortBPM > bpm.values[1]) { + continue + } + if key != nil && !containsU32(key.values, it.newKeyID) { + continue + } + if rating != nil && it.rating != rating.values[0] { continue } - if useKey && !containsU32(key.values, it.newKeyID) { + if color != nil && (it.ColorID == 0 || it.ColorID > 32 || color.values[0]&(1<<(it.ColorID-1)) == 0) { continue } out = append(out, it) diff --git a/dbserver/trackfilter_test.go b/dbserver/trackfilter_test.go index 144b764..39a3871 100644 --- a/dbserver/trackfilter_test.go +++ b/dbserver/trackfilter_test.go @@ -99,3 +99,49 @@ func TestTrackFilterNarrowsTrackList(t *testing.T) { t.Errorf("filter off = %d tracks, want 5", n) } } + +// Rating [n, 0] keeps tracks rated exactly n stars; colour is a bitmask of +// colours (bit colour-1: yellow, colour 4, is 8), and a zero mask keeps all. +// From real rekordbox captures: "5 stars exact" sends [5, 0], yellow sends +// [8], red [2]. +func TestTrackFilterRatingAndColour(t *testing.T) { + lib := library.New() + for i, tr := range []struct { + rating uint8 + color uint8 + }{{5, 4}, {5, 0}, {4, 4}, {2, 2}, {0, 4}, {0, 8}} { + lib.AddTrack(&library.Track{ID: uint32(i + 1), Title: string(rune('A' + i)), Rating: tr.rating, ColorID: tr.color}) + } + h := &Handler{lib: lib} + const ctx = 0x01010401 + send := func(kind uint16, args ...proto.DBArg) { + h.Handle(&proto.DBMessage{TxID: 4, Type: kind, Args: append([]proto.DBArg{proto.ArgI32(ctx)}, args...)}) + } + count := func() int { + h.Handle(&proto.DBMessage{TxID: 3, Type: proto.DBMsgGetTracks, Args: []proto.DBArg{proto.ArgI32(ctx), proto.ArgI32(0)}}) + return len(h.pendingItems) + } + setCond := func(prop uint32, blob string) { + send(0x3207, proto.ArgI32(prop), proto.ArgI32(0), proto.ArgI32(uint32(len(blob)/2)), proto.ArgBlob(mustHex(t, blob))) + } + send(0x3007, proto.ArgI32(1)) + for _, tc := range []struct { + prop uint32 + blob, name string + want int + }{ + {filterPropRating, "010002000500000000000000", "rating 5 exact", 2}, + {filterPropRating, "010002000400000000000000", "rating 4 exact", 1}, + {filterPropRating, "010002000100000000000000", "rating 1 exact", 0}, + {filterPropRating, "000002000100000000000000", "rating off", 6}, + {filterPropColor, "0111010008000000", "yellow", 3}, + {filterPropColor, "0111010002000000", "red", 1}, + {filterPropColor, "011101000a000000", "yellow or red", 4}, + {filterPropColor, "0111010000000000", "no colour chosen", 6}, + } { + setCond(tc.prop, tc.blob) + if n := count(); n != tc.want { + t.Errorf("%s: %d tracks, want %d", tc.name, n, tc.want) + } + } +} From cf389b0ade02169d7873e462e6ebe6b269356a04 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Fri, 16 Oct 2026 23:58:06 +0000 Subject: [PATCH 3/4] dbserver: read the TRACK FILTER condition blob instead of dropping the session Ticking a TRACK FILTER condition on a CDJ-2000NXS2 against Vynull unticked it straight away. The deck's 0x3207 sends its condition blob as a trailing binary argument, and the reader only waits for one on 0x2705 and 0x2805 (other requests advertise one they never send). So 0x3207 was answered without its condition, the blob bytes were then read as the start of a new message ("unexpected byte 0x14"), the session dropped, and the deck, reconnecting, unticked the filter. The reader now waits for 0x3207's blob too. --- dbserver/server.go | 5 ++++- dbserver/trackfilter_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/dbserver/server.go b/dbserver/server.go index e3948a0..88d4e83 100644 --- a/dbserver/server.go +++ b/dbserver/server.go @@ -672,8 +672,11 @@ func readMessage(conn net.Conn) (*proto.DBMessage, error) { // 0x2805 = tagged analysis-section read — sends a ~150-byte binary spec // blob (e.g. "PVB2"/"EXT"). This was the cause of the deck // dropping the dbserver link mid-session. + // 0x3207 = TRACK FILTER condition — the condition blob (on, kind, + // count, values). Skipping it dropped the session the moment a + // filter was ticked, and the deck unticked it. actualArgCount := argCount - sendsTrailingBinary := msgType == 0x2705 || msgType == 0x2805 + sendsTrailingBinary := msgType == 0x2705 || msgType == 0x2805 || msgType == 0x3207 if !sendsTrailingBinary { // A tag list shorter than the argument count (malformed) has no tag // for the missing positions; treat those as sent, so the read below diff --git a/dbserver/trackfilter_test.go b/dbserver/trackfilter_test.go index 39a3871..f3ef41a 100644 --- a/dbserver/trackfilter_test.go +++ b/dbserver/trackfilter_test.go @@ -3,8 +3,11 @@ package dbserver import ( + "bytes" "encoding/hex" + "net" "testing" + "time" "github.com/vynulldev/vynull/library" "github.com/vynulldev/vynull/proto" @@ -145,3 +148,25 @@ func TestTrackFilterRatingAndColour(t *testing.T) { } } } + +// A 0x3207 carries its condition blob as a trailing binary argument, which +// the reader must wait for: skipping it left the blob in the stream, the +// next read failed ("unexpected byte 0x14"), the session dropped, and the +// deck unticked the filter it had just ticked. +func TestReadSetFilterConditionBlob(t *testing.T) { + blob := mustHex(t, "01060200bc2f0000d4350000") + frame := proto.MarshalDBMessage(&proto.DBMessage{TxID: 9, Type: 0x3207, Args: []proto.DBArg{ + proto.ArgI32(0x01010401), proto.ArgI32(filterPropBPM), proto.ArgI32(0), proto.ArgI32(uint32(len(blob))), proto.ArgBlob(blob)}}) + client, server := net.Pipe() + defer client.Close() + defer server.Close() + go client.Write(frame) + server.SetReadDeadline(time.Now().Add(2 * time.Second)) + msg, err := readMessage(server) + if err != nil { + t.Fatal(err) + } + if len(msg.Args) != 5 || !bytes.Equal(msg.Args[4].Bytes, blob) { + t.Fatalf("0x3207 read as %+v, want 5 arguments ending in the condition blob", msg.Args) + } +} From 5603297b48c59610370aba002f24a7a39a1c602c Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 17 Oct 2026 00:13:55 +0000 Subject: [PATCH 4/4] dbserver: serve the TRACK FILTER's MyTag screen and filter by MyTags The deck's TRACK FILTER can also narrow track lists by MyTags, per MyTag column with an AND or OR mode. Decoded from a real rekordbox capture of a CDJ-2000NXS2: - 0x1015 [ctx, sort, parent, 0] lists the MyTag screen: parent 0 gives the columns, any other parent that column's tags, leaving out tags no track carries (rekordbox listed 22 of a column's 24 tags, the two unused ones missing). Rows are [0, id, len, name, 2, "", type, 0, mode, index]: type 0x4b marks a column whose condition is on or a ticked tag, 0x4a otherwise, and a column row carries its mode (0x10 AND, 0x11 OR) in argument 8. We didn't handle 0x1015, so the screen was empty and there was nothing to tick. - 0x3307 [ctx, 0, column, on, mode] turns a column's condition on or off with its mode; 0x3407 [ctx, 0, column, tag, add] ticks or unticks a tag. - While the filter is on, each column whose condition is on keeps tracks carrying all its ticked tags (AND) or any of them (OR). The captured sequence (Trap, Trap and Deep House, Deep House, then OR, then AND) kept 1, 0, 26, 26, 27 and 0 tracks, which the tests reproduce on a smaller library. The dbserver reads the tag store through a new MyTagSource, wired in main.go; categories are the columns, in rekordbox's order after an import. --- api/tagstore.go | 20 +++++ dbserver/handler.go | 3 + dbserver/server.go | 19 +++++ dbserver/trackfilter.go | 147 ++++++++++++++++++++++++++++++----- dbserver/trackfilter_test.go | 129 ++++++++++++++++++++++++++++++ main.go | 39 ++++++++++ 6 files changed, 336 insertions(+), 21 deletions(-) diff --git a/api/tagstore.go b/api/tagstore.go index 0975a9f..602045e 100644 --- a/api/tagstore.go +++ b/api/tagstore.go @@ -263,6 +263,26 @@ func (ts *TagStore) GetTagsForTrack(trackID uint32) []TagInfo { return out } +// TrackTagIDs returns the IDs of the tags assigned to a track. +func (ts *TagStore) TrackTagIDs(trackID uint32) []uint32 { + ts.mu.RLock() + defer ts.mu.RUnlock() + return append([]uint32(nil), ts.trackTags[trackID]...) +} + +// TagTrackCounts returns how many tracks carry each tag. +func (ts *TagStore) TagTrackCounts() map[uint32]int { + ts.mu.RLock() + defer ts.mu.RUnlock() + counts := make(map[uint32]int) + for _, ids := range ts.trackTags { + for _, id := range ids { + counts[id]++ + } + } + return counts +} + func (ts *TagStore) SetTagsForTrack(trackID uint32, tagIDs []uint32) { ts.mu.Lock() defer ts.mu.Unlock() diff --git a/dbserver/handler.go b/dbserver/handler.go index 3009c02..7c60f43 100644 --- a/dbserver/handler.go +++ b/dbserver/handler.go @@ -19,6 +19,7 @@ import ( // Handler processes individual dbserver messages and returns responses. type Handler struct { + mytags MyTagSource // TRACK FILTER MyTag lists (nil: none) // filters holds the players' TRACK FILTER settings, shared by the // server's sessions (trackfilter.go). filters *trackFilters @@ -232,6 +233,8 @@ func (h *Handler) Handle(msg *proto.DBMessage) []*proto.DBMessage { return h.handleSetFilterMyTag(msg) case 0x3407: // add/remove a TRACK FILTER MyTag return h.handleAddFilterMyTag(msg) + case 0x1015: // TRACK FILTER MyTag columns, or a column's tags + return h.handleGetFilterMyTags(msg) case proto.DBMsgRootMenu: // 0x1000 return h.handleRootMenu(msg) case 0x3e03: // NXS2 extension diff --git a/dbserver/server.go b/dbserver/server.go index 88d4e83..f172efa 100644 --- a/dbserver/server.go +++ b/dbserver/server.go @@ -84,6 +84,22 @@ type MenuSource interface { TrackDetail() string } +// MyTagSource gives the dbserver the MyTag catalog and assignments for the +// TRACK FILTER's MyTag condition: the columns (categories) in order, a +// column's tags in order, and a track's tag IDs. Tags that no track carries +// are left out of a column's list, as rekordbox does. +type MyTagSource interface { + MyTagColumns() []MyTagEntry + MyTagsIn(columnID uint32) []MyTagEntry + TrackMyTags(trackID uint32) []uint32 +} + +// MyTagEntry is one MyTag column or tag. +type MyTagEntry struct { + ID uint32 + Name string +} + // PlaylistEntry is the minimal shape the dbserver needs to render the // CDJ's PLAYLIST menu. Implemented by api.PlaylistStore (and any future // playlist source) so the dbserver doesn't depend on the api package. @@ -118,6 +134,8 @@ type SmartPlaylistSource interface { // Server implements the Pro DJ Link database server protocol. type Server struct { + // MyTags serves the TRACK FILTER's MyTag lists and matching (nil: none). + MyTags MyTagSource // filters holds every player's TRACK FILTER settings across sessions. filters trackFilters Library *library.Library @@ -436,6 +454,7 @@ func (s *Server) handleSession(ctx context.Context, conn net.Conn) { cues: s.Cues, settings: s.Settings, filters: &s.filters, + mytags: s.MyTags, } for { diff --git a/dbserver/trackfilter.go b/dbserver/trackfilter.go index 902977f..d31f916 100644 --- a/dbserver/trackfilter.go +++ b/dbserver/trackfilter.go @@ -38,13 +38,38 @@ type filterCondition struct { values []uint32 } +// MyTag condition modes, per column: a track must carry every ticked tag +// (AND) or any of them (OR). +const ( + mytagAnd = 0x10 + mytagOr = 0x11 +) + +// mytagCondition is the MyTag condition of one column: whether it's on, its +// AND/OR mode, and the ticked tags. +type mytagCondition struct { + on bool + mode uint32 + tags []uint32 +} + // trackFilter is one player's filter settings. type trackFilter struct { - on bool - conds map[uint32]*filterCondition - mytagOn bool - mytagArg uint32 // the 0x3307 mode argument, kept as sent - tags []uint32 + on bool + conds map[uint32]*filterCondition + mytags map[uint32]*mytagCondition // by MyTag column +} + +func (f *trackFilter) mytag(column uint32) *mytagCondition { + if f.mytags == nil { + f.mytags = map[uint32]*mytagCondition{} + } + c := f.mytags[column] + if c == nil { + c = &mytagCondition{mode: mytagAnd} + f.mytags[column] = c + } + return c } // newTrackFilter returns the settings a player starts with: everything off, @@ -177,39 +202,108 @@ func (h *Handler) handleSetFilterCondition(msg *proto.DBMessage) []*proto.DBMess return filterAck(msg) } -// handleSetFilterMyTag handles 0x3307 [ctx, 0, 1, on, mode]: the MyTag -// condition on or off. +// handleSetFilterMyTag handles 0x3307 [ctx, 0, column, on, mode]: a MyTag +// column's condition on or off, with its mode (0x10 AND, 0x11 OR). func (h *Handler) handleSetFilterMyTag(msg *proto.DBMessage) []*proto.DBMessage { f := h.filterFor(msg) + column := msgArg(msg, 2) h.filters.mu.Lock() - f.mytagOn = msgArg(msg, 3) != 0 - f.mytagArg = msgArg(msg, 4) + c := f.mytag(column) + c.on = msgArg(msg, 3) != 0 + if m := msgArg(msg, 4); m == mytagAnd || m == mytagOr { + c.mode = m + } h.filters.mu.Unlock() - dlog.Debugf("dbserver: track filter MyTag (0x3307) on=%v mode=%#x", f.mytagOn, f.mytagArg) + dlog.Debugf("dbserver: track filter MyTag (0x3307) column=%d on=%v mode=%#x", column, c.on, c.mode) return filterAck(msg) } -// handleAddFilterMyTag handles 0x3407 [ctx, 0, 1, tag ID, add]: a MyTag added -// to (1) or removed from (0) the MyTag condition. +// handleAddFilterMyTag handles 0x3407 [ctx, 0, column, tag ID, add]: a tag +// ticked (1) or unticked (0) in a MyTag column's condition. func (h *Handler) handleAddFilterMyTag(msg *proto.DBMessage) []*proto.DBMessage { f := h.filterFor(msg) - tag, add := msgArg(msg, 3), msgArg(msg, 4) != 0 + column, tag, add := msgArg(msg, 2), msgArg(msg, 3), msgArg(msg, 4) != 0 h.filters.mu.Lock() - kept := f.tags[:0] - for _, t := range f.tags { + c := f.mytag(column) + kept := c.tags[:0] + for _, t := range c.tags { if t != tag { kept = append(kept, t) } } - f.tags = kept + c.tags = kept if add { - f.tags = append(f.tags, tag) + c.tags = append(c.tags, tag) } h.filters.mu.Unlock() - dlog.Debugf("dbserver: track filter MyTag item (0x3407) tag=%#x add=%v", tag, add) + dlog.Debugf("dbserver: track filter MyTag item (0x3407) column=%d tag=%d add=%v", column, tag, add) return filterAck(msg) } +// handleGetFilterMyTags handles 0x1015 [ctx, sort, parent, 0], the TRACK +// FILTER's MyTag screen: parent 0 lists the MyTag columns, any other the +// tags in that column (only tags some track carries, as rekordbox lists +// them). Rows are [0, id, len, name, 2, "", 0x4a or 0x4b, 0, mode, index]: +// item type 0x4b marks a column whose condition is on, or a ticked tag, and +// a column row carries its AND/OR mode in argument 8. +func (h *Handler) handleGetFilterMyTags(msg *proto.DBMessage) []*proto.DBMessage { + f := h.filterFor(msg) + parent := msgArg(msg, 2) + var items []*menuItem + if h.mytags != nil { + h.filters.mu.Lock() + if parent == 0 { + for i, col := range h.mytags.MyTagColumns() { + c := f.mytag(col.ID) + items = append(items, &menuItem{ID: col.ID, Label1: col.Name, ItemType: tickType(c.on), ArtID: c.mode, trackPos: uint32(i)}) + } + } else { + c := f.mytag(parent) + for i, tag := range h.mytags.MyTagsIn(parent) { + items = append(items, &menuItem{ID: tag.ID, Label1: tag.Name, ItemType: tickType(containsU32(c.tags, tag.ID)), trackPos: uint32(i)}) + } + } + h.filters.mu.Unlock() + } + h.setPendingAll(msg, items) + dlog.Debugf("dbserver: track filter MyTag list (0x1015) parent=%d returning %d rows", parent, len(items)) + return []*proto.DBMessage{h.successWithCount(msg)} +} + +// tickType is the MyTag screen row type: 0x4b ticked, 0x4a not. +func tickType(ticked bool) uint32 { + if ticked { + return 0x4b + } + return 0x4a +} + +// mytagMatch reports whether a track passes every enabled MyTag column +// condition: a column on with ticked tags keeps tracks carrying all of them +// (AND) or any of them (OR); a column with nothing ticked doesn't narrow. +func (h *Handler) mytagMatch(f *trackFilter, trackID uint32) bool { + var have []uint32 + loaded := false + for _, c := range f.mytags { + if !c.on || len(c.tags) == 0 { + continue + } + if !loaded { + have, loaded = h.mytags.TrackMyTags(trackID), true + } + hits := 0 + for _, t := range c.tags { + if containsU32(have, t) { + hits++ + } + } + if c.mode == mytagOr && hits == 0 || c.mode != mytagOr && hits < len(c.tags) { + return false + } + } + return true +} + // applyTrackFilter narrows a TRACK list (0x1004) at menu location 1 by the // player's enabled conditions while its filter is on, as rekordbox does: // @@ -218,8 +312,8 @@ func (h *Handler) handleAddFilterMyTag(msg *proto.DBMessage) []*proto.DBMessage // - rating [n, 0] keeps tracks rated exactly n stars; // - colour [mask] keeps tracks whose colour's bit (1 << (colour-1); yellow, // colour 4, is 8) is set, and a zero mask (no colour chosen) keeps all. -// -// The MyTag condition isn't decoded yet and doesn't narrow the list. +// - each MyTag column whose condition is on keeps tracks carrying all its +// ticked tags (AND) or any of them (OR). func (h *Handler) applyTrackFilter(msg *proto.DBMessage, items []*menuItem) []*menuItem { if dmstMenu(msg) != 1 { return items @@ -241,7 +335,15 @@ func (h *Handler) applyTrackFilter(msg *proto.DBMessage, items []*menuItem) []*m if color != nil && color.values[0] == 0 { color = nil // no colour chosen } - if bpm == nil && key == nil && rating == nil && color == nil { + useMyTag := false + if h.mytags != nil { + for _, c := range f.mytags { + if c.on && len(c.tags) > 0 { + useMyTag = true + } + } + } + if bpm == nil && key == nil && rating == nil && color == nil && !useMyTag { return items } var out []*menuItem @@ -258,6 +360,9 @@ func (h *Handler) applyTrackFilter(msg *proto.DBMessage, items []*menuItem) []*m if color != nil && (it.ColorID == 0 || it.ColorID > 32 || color.values[0]&(1<<(it.ColorID-1)) == 0) { continue } + if useMyTag && !h.mytagMatch(f, it.ID) { + continue + } out = append(out, it) } dlog.Debugf("dbserver: track filter kept %d of %d tracks", len(out), len(items)) diff --git a/dbserver/trackfilter_test.go b/dbserver/trackfilter_test.go index f3ef41a..fcaeba5 100644 --- a/dbserver/trackfilter_test.go +++ b/dbserver/trackfilter_test.go @@ -5,6 +5,7 @@ package dbserver import ( "bytes" "encoding/hex" + "fmt" "net" "testing" "time" @@ -170,3 +171,131 @@ func TestReadSetFilterConditionBlob(t *testing.T) { t.Fatalf("0x3207 read as %+v, want 5 arguments ending in the condition blob", msg.Args) } } + +// fakeMyTags is a MyTagSource for the MyTag filter tests. +type fakeMyTags struct { + columns []MyTagEntry + tags map[uint32][]MyTagEntry + track map[uint32][]uint32 +} + +func (f fakeMyTags) MyTagColumns() []MyTagEntry { return f.columns } +func (f fakeMyTags) MyTagsIn(c uint32) []MyTagEntry { return f.tags[c] } +func (f fakeMyTags) TrackMyTags(trackID uint32) []uint32 { return f.track[trackID] } + +// The MyTag filter, replayed from a real rekordbox capture: 0x1015 lists the +// columns and a column's tags (ticked rows typed 0x4b, a column's AND/OR mode +// in argument 8), 0x3307 turns a column's condition on with its mode, and +// 0x3407 ticks and unticks tags. Trap and Deep House kept 1, 0, 26, 26, 27 +// and 0 tracks along the way; here the library is scaled down. +func TestTrackFilterMyTag(t *testing.T) { + const trap, deep = 1740876187, 1937762829 + lib := library.New() + src := fakeMyTags{ + columns: []MyTagEntry{{1, "Genre"}, {2, "Components"}}, + tags: map[uint32][]MyTagEntry{1: {{trap, "Trap"}, {deep, "Deep House"}}}, + track: map[uint32][]uint32{1: {trap}, 2: {deep}, 3: {deep}}, + } + for i := uint32(1); i <= 4; i++ { + lib.AddTrack(&library.Track{ID: i, Title: string(rune('A' + i))}) + } + h := &Handler{lib: lib, mytags: src} + send := func(kind uint16, ctx uint32, args ...uint32) []*proto.DBMessage { + a := []proto.DBArg{proto.ArgI32(ctx)} + for _, v := range args { + a = append(a, proto.ArgI32(v)) + } + return h.Handle(&proto.DBMessage{TxID: 5, Type: kind, Args: a}) + } + count := func() int { + send(proto.DBMsgGetTracks, 0x01010401, 0) + return len(h.pendingItems) + } + rows := func(ctx, parent uint32) [][]uint32 { + send(0x1015, ctx, 0, parent, 0) + var out [][]uint32 + for _, it := range h.pendingItems { + out = append(out, []uint32{it.ParentID, it.ID, it.ItemType, it.ArtID, it.trackPos}) + } + return out + } + + if got := rows(0x01070401, 0); len(got) != 2 || got[0][1] != 1 || got[0][2] != 0x4a || got[0][3] != mytagAnd || got[1][4] != 1 { + t.Fatalf("columns = %v", got) + } + send(0x3007, 0x01010401, 1) + send(0x3407, 0x01010401, 0, 1, trap, 1) + send(0x3307, 0x01010401, 0, 1, 1, mytagAnd) + if got := rows(0x01050401, 1); got[0][2] != 0x4b || got[1][2] != 0x4a { + t.Errorf("Genre tags after ticking Trap = %v, want Trap 0x4b, Deep House 0x4a", got) + } + if got := rows(0x01070401, 0); got[0][2] != 0x4b { + t.Errorf("Genre column with its condition on = %v, want 0x4b", got[0]) + } + for _, step := range []struct { + name string + kind uint16 + args []uint32 + want int + }{ + {"Trap, AND", 0, nil, 1}, + {"Trap and Deep House, AND", 0x3407, []uint32{0, 1, deep, 1}, 0}, + {"Deep House, AND", 0x3407, []uint32{0, 1, trap, 0}, 2}, + {"Deep House, OR", 0x3307, []uint32{0, 1, 1, mytagOr}, 2}, + {"Trap or Deep House, OR", 0x3407, []uint32{0, 1, trap, 1}, 3}, + {"Trap and Deep House, AND again", 0x3307, []uint32{0, 1, 1, mytagAnd}, 0}, + {"Genre condition off", 0x3307, []uint32{0, 1, 0, mytagAnd}, 4}, + } { + if step.kind != 0 { + send(step.kind, 0x01010401, step.args...) + } + if n := count(); n != step.want { + t.Errorf("%s: %d tracks, want %d", step.name, n, step.want) + } + } +} + +// The MyTag screen rows render like rekordbox's (first 12 arguments, from +// the capture): [0, id, len, name, 2, "", type, 0, mode, index, 0, 0]. +func TestTrackFilterMyTagRows(t *testing.T) { + const trap, deep = 1740876187, 1937762829 + h := &Handler{mytags: fakeMyTags{ + columns: []MyTagEntry{{1, "Genre"}, {2, "Components"}}, + tags: map[uint32][]MyTagEntry{1: {{trap, "Trap"}, {deep, "Deep House"}}}, + }} + render := func(ctx, parent uint32) [][]any { + h.Handle(&proto.DBMessage{TxID: 1, Type: 0x1015, Args: []proto.DBArg{proto.ArgI32(ctx), proto.ArgI32(0), proto.ArgI32(parent), proto.ArgI32(0)}}) + resp := h.Handle(&proto.DBMessage{TxID: 2, Type: proto.DBMsgRenderMenu, Args: []proto.DBArg{ + proto.ArgI32(ctx), proto.ArgI32(0), proto.ArgI32(8), proto.ArgI32(0), proto.ArgI32(8), proto.ArgI32(0)}}) + var out [][]any + for _, m := range resp { + if m.Type != proto.DBMsgMenuItem { + continue + } + var row []any + for i, a := range m.Args { + if i >= 12 { + break + } + if a.Tag == 0x26 { + row = append(row, a.Str) + } else { + row = append(row, a.Int()) + } + } + out = append(out, row) + } + return out + } + want := func(id uint32, name string, typ, mode, idx uint32) []any { + return []any{uint32(0), id, uint32(2 * (len([]rune(name)) + 1)), name, uint32(2), "", typ, uint32(0), mode, idx, uint32(0), uint32(0)} + } + cols := render(0x01070401, 0) + if len(cols) != 2 || fmt.Sprint(cols[0]) != fmt.Sprint(want(1, "Genre", 0x4a, mytagAnd, 0)) { + t.Errorf("column row = %v\nwant %v", cols, want(1, "Genre", 0x4a, mytagAnd, 0)) + } + tags := render(0x01050401, 1) + if len(tags) != 2 || fmt.Sprint(tags[1]) != fmt.Sprint(want(deep, "Deep House", 0x4a, 0, 1)) { + t.Errorf("tag row = %v\nwant %v", tags, want(deep, "Deep House", 0x4a, 0, 1)) + } +} diff --git a/main.go b/main.go index 8da1977..33a1e49 100644 --- a/main.go +++ b/main.go @@ -515,6 +515,7 @@ func main() { Analysis: analysisStore, Folders: folderLookup, Playlists: playlistSource{ps: playlistStore, lib: lib, tags: tagStore}, + MyTags: mytagSource{ts: tagStore}, Menu: menuSource{ms: menuStore}, Cues: cueStore, Settings: cdjSettings, @@ -969,6 +970,44 @@ func (a playlistSource) HistoryFolderID() uint32 { // menuSource adapts api.MenuStore to dbserver.MenuSource. Only the // visible entries reach the CDJ; hidden ones stay in storage so the // user can flip them back on without losing their order. +// mytagSource adapts the tag store to dbserver.MyTagSource for the TRACK +// FILTER's MyTag condition: categories are the columns, in creation order (a +// rekordbox import creates them in rekordbox's order), and a column lists +// its tags in that order, leaving out tags no track carries. +type mytagSource struct{ ts *api.TagStore } + +func (a mytagSource) MyTagColumns() []dbserver.MyTagEntry { + if a.ts == nil { + return nil + } + var out []dbserver.MyTagEntry + for _, c := range a.ts.GetAllCategories() { + out = append(out, dbserver.MyTagEntry{ID: c.ID, Name: c.Name}) + } + return out +} + +func (a mytagSource) MyTagsIn(columnID uint32) []dbserver.MyTagEntry { + if a.ts == nil { + return nil + } + used := a.ts.TagTrackCounts() + var out []dbserver.MyTagEntry + for _, t := range a.ts.GetAllTags() { + if t.CategoryID == columnID && used[t.ID] > 0 { + out = append(out, dbserver.MyTagEntry{ID: t.ID, Name: t.Name}) + } + } + return out +} + +func (a mytagSource) TrackMyTags(trackID uint32) []uint32 { + if a.ts == nil { + return nil + } + return a.ts.TrackTagIDs(trackID) +} + type menuSource struct{ ms *api.MenuStore } func (a menuSource) RootMenu() []dbserver.RootMenuEntry {