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/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..7c60f43 100644 --- a/dbserver/handler.go +++ b/dbserver/handler.go @@ -19,6 +19,10 @@ 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 // 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 +223,18 @@ 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 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 @@ -510,27 +524,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..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,9 +160,10 @@ 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(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..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,10 @@ 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 PDB *pdb.Database DeviceNumber uint8 @@ -433,6 +453,8 @@ func (s *Server) handleSession(ctx context.Context, conn net.Conn) { menu: s.Menu, cues: s.Cues, settings: s.Settings, + filters: &s.filters, + mytags: s.MyTags, } for { @@ -669,8 +691,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.go b/dbserver/trackfilter.go new file mode 100644 index 0000000..d31f916 --- /dev/null +++ b/dbserver/trackfilter.go @@ -0,0 +1,379 @@ +// 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 +} + +// 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 + 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, +// 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, 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() + 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) column=%d on=%v mode=%#x", column, c.on, c.mode) + return filterAck(msg) +} + +// 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) + column, tag, add := msgArg(msg, 2), msgArg(msg, 3), msgArg(msg, 4) != 0 + h.filters.mu.Lock() + c := f.mytag(column) + kept := c.tags[:0] + for _, t := range c.tags { + if t != tag { + kept = append(kept, t) + } + } + c.tags = kept + if add { + c.tags = append(c.tags, tag) + } + h.filters.mu.Unlock() + 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: +// +// - 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. +// - 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 + } + f := h.filterFor(msg) + h.filters.mu.Lock() + defer h.filters.mu.Unlock() + if !f.on { + return items + } + 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 + } + 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 + for _, it := range items { + 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 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)) + 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..fcaeba5 --- /dev/null +++ b/dbserver/trackfilter_test.go @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package dbserver + +import ( + "bytes" + "encoding/hex" + "fmt" + "net" + "testing" + "time" + + "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) + } +} + +// 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) + } + } +} + +// 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) + } +} + +// 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 {