From a814ab7e015749107abdd8260506e6bef7a96e79 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Thu, 15 Oct 2026 20:16:59 +0000 Subject: [PATCH] web: show Hot Cue Banks in the library, read-only Imported rekordbox Hot Cue Banks were only visible on a deck. Show them in the web UI and API as decks browse them. - GET /api/hotcuebanks lists the live folders and banks (parent_id links the tree, seq orders siblings) with each bank's track count. GET /api/hotcuebanks/{id}/tracks lists a bank's tracks in membership order, as a deck does: duplicates kept, content not in the library left out, each with its position, cue in milliseconds, loop end (only for loops), hot-cue palette colour (color_id) and comment. Both are in the OpenAPI spec under a new Hot Cue Banks tag. - The library sidebar gets a HOT CUE BANKS section with the folder tree. Selecting a bank shows its tracks with a colour column like rekordbox's (the cue's palette colour, or the default green for a cue and orange for a loop, plus a loop marker), and the pad each position maps to (positions 1-8 are pads A-H, as in rekordbox and on the deck), the cue and loop end, and the comment; the text filter narrows it, and a row opens the track drawer. Tracks whose file is missing on this machine are listed dimmed and marked, since decks aren't sent them, and the bank's track count leaves them out. Nothing is editable. --- api/api.go | 2 + api/hotcuebank.go | 109 +++++++++++++++++++++++++++++++ api/hotcuebank_test.go | 85 ++++++++++++++++++++++++ api/web/index.html | 144 +++++++++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 48 ++++++++++++++ library/hotcuebank.go | 16 +++++ 6 files changed, 404 insertions(+) create mode 100644 api/hotcuebank.go create mode 100644 api/hotcuebank_test.go diff --git a/api/api.go b/api/api.go index 37a63ea..c2ad3ef 100644 --- a/api/api.go +++ b/api/api.go @@ -345,6 +345,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/tag-categories/", s.handleTagCategoryByID) mux.HandleFunc("/api/playlists", s.handlePlaylists) mux.HandleFunc("/api/playlists/", s.handlePlaylistByID) + mux.HandleFunc("/api/hotcuebanks", s.handleHotCueBanks) + mux.HandleFunc("/api/hotcuebanks/", s.handleHotCueBankByID) mux.HandleFunc("/api/menu-items", s.handleMenuItems) mux.HandleFunc("/api/menu-items/reset", s.handleMenuItemsReset) diff --git a/api/hotcuebank.go b/api/hotcuebank.go new file mode 100644 index 0000000..a8d0833 --- /dev/null +++ b/api/hotcuebank.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package api + +import ( + "net/http" + "strings" +) + +// HotCueBankNode is one folder or bank in GET /api/hotcuebanks. +type HotCueBankNode struct { + ID uint32 `json:"id"` + ParentID uint32 `json:"parent_id"` // 0 at the top level + Seq int `json:"seq"` + Name string `json:"name"` + IsFolder bool `json:"is_folder"` + TrackCount int `json:"track_count"` // banks only: the tracks a deck is sent +} + +// HotCueBankEntry is one track of a bank in GET /api/hotcuebanks/{id}/tracks, +// with the cue the bank holds for it. +type HotCueBankEntry struct { + // Position is the membership position; positions 1-8 are pads A-H. + Position int32 `json:"position"` + Track TrackInfo `json:"track"` + InMs int32 `json:"in_ms"` + OutMs *int32 `json:"out_ms,omitempty"` // loop end; absent when not a loop + Color int `json:"color"` // rekordbox's Color field as stored (-1 = none) + ColorID int `json:"color_id"` // rekordbox hot-cue palette colour; 0 = unset (default colour) + Comment string `json:"comment,omitempty"` +} + +// hotCueBankEntries lists a bank's tracks in membership order, duplicates +// kept, entries whose track isn't in the library left out. Tracks whose file +// is missing here are listed (track.file_missing) though decks aren't sent +// them. +func (s *Server) hotCueBankEntries(bankID uint32) []HotCueBankEntry { + out := []HotCueBankEntry{} + for _, m := range s.Library.HotCueBankMembers(bankID) { + t := s.Library.Track(m.TrackID) + if m.TrackID == 0 || t == nil { + continue + } + e := HotCueBankEntry{Position: m.TrackNo, Track: s.libTrackListInfo(t), InMs: m.InMsec, Color: m.Color, ColorID: m.ColorTableIndex, Comment: m.Comment} + if m.OutMsec != -1 { + end := m.OutMsec + e.OutMs = &end + } + out = append(out, e) + } + return out +} + +// handleHotCueBanks serves GET /api/hotcuebanks: the Hot Cue Bank folders and +// banks imported from rekordbox, as a flat list (parent_id links the tree; +// siblings order by seq). Read-only. +func (s *Server) handleHotCueBanks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET required", http.StatusMethodNotAllowed) + return + } + if s.Library == nil { + http.Error(w, "library not available", http.StatusServiceUnavailable) + return + } + out := []HotCueBankNode{} + for _, b := range s.Library.HotCueBankList() { + n := HotCueBankNode{ID: b.ID, ParentID: b.ParentID, Seq: b.Seq, Name: b.Name, IsFolder: b.Folder} + if !b.Folder { + for _, e := range s.hotCueBankEntries(b.ID) { + if !e.Track.FileMissing { + n.TrackCount++ + } + } + } + out = append(out, n) + } + writeJSON(w, out) +} + +// handleHotCueBankByID serves GET /api/hotcuebanks/{id}/tracks: a bank's +// tracks and the cue it holds for each. Read-only. +func (s *Server) handleHotCueBankByID(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET required", http.StatusMethodNotAllowed) + return + } + if s.Library == nil { + http.Error(w, "library not available", http.StatusServiceUnavailable) + return + } + if !strings.HasSuffix(strings.TrimSuffix(r.URL.Path, "/"), "/tracks") { + http.NotFound(w, r) + return + } + id := parseTrackIDFromPath(r.URL.Path, "/api/hotcuebanks/") + found := false + for _, b := range s.Library.HotCueBankList() { + if b.ID == id && !b.Folder { + found = true + break + } + } + if !found { + http.Error(w, "hot cue bank not found", http.StatusNotFound) + return + } + writeJSON(w, s.hotCueBankEntries(id)) +} diff --git a/api/hotcuebank_test.go b/api/hotcuebank_test.go new file mode 100644 index 0000000..c7a2351 --- /dev/null +++ b/api/hotcuebank_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package api + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/vynulldev/vynull/library" +) + +// GET /api/hotcuebanks lists the live tree with each bank's track count, and +// GET /api/hotcuebanks/{id}/tracks lists a bank's tracks as decks see them: +// membership order, duplicates kept, unresolved content left out, loop end +// only for loops. +func TestHotCueBankAPI(t *testing.T) { + lib := library.New() + lib.AddTrack(&library.Track{ID: 1, Title: "One", Artist: "A"}) + lib.AddTrack(&library.Track{ID: 2, Title: "Two", Artist: "B"}) + lib.AddTrack(&library.Track{ID: 3, Title: "Gone", FileMissing: true}) + lib.SetHotCueBanks([]library.HotCueBank{ + {ID: 10, Seq: 1, Name: "Folder", Folder: true}, + {ID: 11, ParentID: 10, Seq: 1, Name: "Bank"}, + {ID: 12, ParentID: 10, Seq: 2, Name: "Gone", Deleted: true}, + }, []library.HotCueBankMember{ + {BankID: 11, TrackID: 2, TrackNo: 2, InMsec: 2000, OutMsec: 4000, ColorTableIndex: 0x2a}, + {BankID: 11, TrackID: 1, TrackNo: 1, InMsec: 1000, OutMsec: -1, Comment: "drop"}, + {BankID: 11, ContentID: 999, TrackNo: 3}, + {BankID: 11, TrackID: 1, TrackNo: 4, OutMsec: -1}, + {BankID: 11, TrackID: 3, TrackNo: 5, OutMsec: -1}, // file missing: listed, not counted + }) + h := (&Server{Library: lib}).Handler() + get := func(path string, v any) int { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code == 200 { + if err := json.Unmarshal(w.Body.Bytes(), v); err != nil { + t.Fatalf("%s: %v", path, err) + } + } + return w.Code + } + + var nodes []HotCueBankNode + if code := get("/api/hotcuebanks", &nodes); code != 200 || len(nodes) != 2 { + t.Fatalf("tree: %d %+v, want the folder and the live bank", code, nodes) + } + for _, n := range nodes { + if n.ID == 11 && n.TrackCount != 3 { + t.Errorf("bank track_count = %d, want 3 (the missing file isn't sent to decks)", n.TrackCount) + } + } + + var entries []HotCueBankEntry + if code := get("/api/hotcuebanks/11/tracks", &entries); code != 200 { + t.Fatalf("tracks: %d", code) + } + var got []int32 + for _, e := range entries { + got = append(got, e.Position) + } + if len(entries) != 4 || got[0] != 1 || got[1] != 2 || got[2] != 4 || got[3] != 5 { + t.Fatalf("positions = %v, want [1 2 4 5]", got) + } + if !entries[3].Track.FileMissing { + t.Errorf("entry 5 should be flagged file_missing") + } + if entries[0].Track.Title != "One" || entries[0].OutMs != nil || entries[0].Comment != "drop" { + t.Errorf("entry 1 = %+v, want One, no loop, comment drop", entries[0]) + } + if entries[1].OutMs == nil || *entries[1].OutMs != 4000 { + t.Errorf("entry 2 loop end = %v, want 4000", entries[1].OutMs) + } + if entries[1].ColorID != 0x2a || entries[0].ColorID != 0 { + t.Errorf("color_id = %#x, %#x, want 0x2a and 0 (unset)", entries[1].ColorID, entries[0].ColorID) + } + + if code := get("/api/hotcuebanks/10/tracks", &entries); code != 404 { + t.Errorf("folder tracks: %d, want 404", code) + } + if code := get("/api/hotcuebanks/99/tracks", &entries); code != 404 { + t.Errorf("unknown bank: %d, want 404", code) + } +} diff --git a/api/web/index.html b/api/web/index.html index 106b59e..dc57438 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -1861,6 +1861,10 @@ .pl-toolbar button:disabled { color: var(--text3); cursor: not-allowed; border-color: var(--wire); } .pl-tree { padding: 6px 0; max-height: 70vh; overflow-y: auto; } + table.lib tr.hcb-missing td { opacity: 0.45; } + .hcb-missing-tag { color: var(--orange); font-size: 0.85em; letter-spacing: 0.08em; } + .hcb-swatch { display: inline-block; width: 22px; height: 12px; vertical-align: middle; border-radius: 2px; } + .hcb-loop-tag { margin-left: 8px; font-size: 0.85em; letter-spacing: 0.08em; color: var(--orange); } .pl-empty { padding: 14px; color: var(--text3); font-size: 10px; letter-spacing: 0.12em; } .pl-node { display: flex; align-items: center; @@ -3531,6 +3535,8 @@
+ +
@@ -9728,6 +9734,7 @@

RELATED

plSelectedTracks = []; } } + await loadHotCueBanks(); renderLibrarySidebar(); refreshLibraryView(); } @@ -9737,6 +9744,134 @@

RELATED

plSelectedTracks = Array.isArray(r) ? r : []; } +// ───────── HOT CUE BANKS (read-only) ───────── +// rekordbox Hot Cue Banks from a master.db import, as the decks browse them: +// a folder/bank tree in the sidebar, and a selected bank's tracks with the +// cue each holds. Positions 1-8 are pads A-H, as in rekordbox and on the deck. +// Selecting a bank sets selectedSource to 'hcb:'. +let hcbCache = []; // flat list from /api/hotcuebanks +let hcbSelectedEntries = []; // the selected bank's /tracks +let hcbSelectedID = null; + +function isHotCueBankSource() { + return typeof selectedSource === 'string' && selectedSource.startsWith('hcb:'); +} + +async function loadHotCueBanks() { + const r = await fetchJSON('/api/hotcuebanks'); + hcbCache = Array.isArray(r) ? r : []; + if (isHotCueBankSource() && !hcbCache.some(b => b.id === hcbSelectedID)) { + selectedSource = 'collection'; + hcbSelectedID = null; + hcbSelectedEntries = []; + } +} + +function renderHotCueBankSection() { + const head = document.getElementById('lib-hcb-head'); + const host = document.getElementById('lib-sidebar-hcb'); + if (!head || !host) return; + if (hcbCache.length === 0) { + head.style.display = 'none'; + host.innerHTML = ''; + return; + } + head.style.display = ''; + const byParent = new Map(); + for (const b of hcbCache) { + if (!byParent.has(b.parent_id)) byParent.set(b.parent_id, []); + byParent.get(b.parent_id).push(b); + } + for (const kids of byParent.values()) kids.sort((a, b) => a.seq - b.seq); + const render = (parentID, depth) => (byParent.get(parentID) || []).map(b => ` +
+ ${b.is_folder ? '▸' : '◆'} + ${escape(b.name)} + ${b.is_folder ? '' : `${b.track_count}`} +
` + (b.is_folder ? render(b.id, depth + 1) : '')).join(''); + host.innerHTML = render(0, 0); + host.querySelectorAll('.hcb-node').forEach(node => { + node.addEventListener('click', async () => { + if (node.getAttribute('data-is-folder') === '1') return; + const id = parseInt(node.getAttribute('data-hcb-id'), 10); + const r = await fetchJSON(`/api/hotcuebanks/${id}/tracks`); + hcbSelectedEntries = Array.isArray(r) ? r : []; + hcbSelectedID = id; + selectedSource = 'hcb:' + id; + plSelectedID = null; + plSelectedTracks = []; + refreshLibraryView(); + closeMobilePlaylists(); + }); + }); +} + +function renderHotCueBankView() { + const bank = hcbCache.find(b => b.id === hcbSelectedID); + const name = bank ? bank.name.toUpperCase() : 'HOT CUE BANK'; + const sourceName = document.getElementById('lib-source-name'); + if (sourceName) sourceName.textContent = name; + const toggleName = document.getElementById('lib-src-toggle-name'); + if (toggleName) toggleName.textContent = name; + const actions = document.getElementById('lib-playlist-actions'); + if (actions) actions.innerHTML = ''; + // The filter bar and column picker belong to the track table; this view + // has fixed columns. The text filter still narrows by title or artist. + const bar = document.getElementById('lib-filterbar'); + if (bar) bar.style.display = 'none'; + const colsBtn = document.getElementById('lib-columns-btn'); + if (colsBtn) colsBtn.style.display = 'none'; + const q = (document.getElementById('lib-search').value || '').toLowerCase(); + const shown = !q ? hcbSelectedEntries : hcbSelectedEntries.filter(e => + (e.track.title || '').toLowerCase().includes(q) || (e.track.artist || '').toLowerCase().includes(q)); + const stats = document.getElementById('lib-stats'); + const missing = hcbSelectedEntries.filter(e => e.track.file_missing).length; + if (stats) stats.textContent = `${hcbSelectedEntries.length} TRACKS${missing ? ` · ${missing} MISSING` : ''} · READ-ONLY`; + + const el = document.getElementById('library-content'); + if (el) { + if (hcbSelectedEntries.length === 0) { + el.innerHTML = '
— EMPTY BANK —
'; + } else if (shown.length === 0) { + el.innerHTML = '
NO TRACKS MATCH
'; + } else { + const pad = p => (p >= 1 && p <= 8) ? 'ABCDEFGH'[p - 1] : ''; + // A cue with a loop end is a loop (rekordbox's rule). The swatch is the + // cue's palette colour; unset is green for a cue and orange for a loop, + // as rekordbox and the deck show them. + const isLoop = e => typeof e.out_ms === 'number'; + const swatch = e => e.color_id ? (CUE_COLORS[e.color_id] || CUE_COLORS[0]) : (isLoop(e) ? CUE_COLORS[0x24] : CUE_COLORS[0]); + const rows = shown.map(e => ` + + ${pad(e.position) ? `${pad(e.position)}` : '—'} + ${isLoop(e) ? '⟲ LOOP' : ''} + ${e.position} + ${escape(e.track.title || '')}${e.track.file_missing ? ' MISSING FILE' : ''} + ${escape(e.track.artist || '')} + ${e.track.bpm ? Number(e.track.bpm).toFixed(1) : ''} + ${escape(e.track.key || '')} + ${formatTime(e.in_ms)} + ${isLoop(e) ? formatTime(e.out_ms) : '—'} + ${escape(e.comment || '')} + `).join(''); + el.innerHTML = `
+ + ${rows}
PADCOLOURPOSTITLEARTISTBPMKEYCUELOOP ENDCOMMENT
`; + el.querySelectorAll('tr[data-hcb-track]').forEach(tr => + tr.addEventListener('click', () => openDetail(parseInt(tr.getAttribute('data-hcb-track'), 10)))); + } + } + + // Selection highlight: this bank only. + document.querySelectorAll('#lib-sidebar-tree .pl-node, #lib-sidebar-history .pl-node').forEach(n => n.classList.remove('selected')); + document.querySelectorAll('.lib-src[data-source="collection"]').forEach(b => b.classList.remove('active')); + document.querySelectorAll('#lib-sidebar-hcb .hcb-node').forEach(n => + n.classList.toggle('selected', parseInt(n.getAttribute('data-hcb-id'), 10) === hcbSelectedID)); +} + // ───────── LIBRARY FILTER BAR ───────── // Per-tab filter state for the LIBRARY view, layered on top of the // search-text filter. Colors and ratings are Sets of selected values @@ -10339,7 +10474,14 @@

RELATED

// current sidebar selection. let lastFilterOptionsSig = ''; function refreshLibraryView() { + if (isHotCueBankSource()) { + renderHotCueBankView(); + return; + } + const colsBtn = document.getElementById('lib-columns-btn'); + if (colsBtn) colsBtn.style.display = ''; const bar = document.getElementById('lib-filterbar'); + if (bar) bar.style.display = ''; if (bar) { // The Tags/Key rows only render when their options exist, but the bar is // first drawn before tracks/tags have loaded — so re-render once the @@ -10416,6 +10558,7 @@

RELATED

document.querySelectorAll('.lib-src[data-source="collection"]').forEach(b => { b.classList.toggle('active', selectedSource === 'collection'); }); + document.querySelectorAll('#lib-sidebar-hcb .hcb-node').forEach(n => n.classList.remove('selected')); // Collection count chip. const collCount = document.getElementById('lib-coll-count'); if (collCount) collCount.textContent = (allTracks || []).length; @@ -10470,6 +10613,7 @@

RELATED

if (!host) return; host.innerHTML = renderPlaylistTree() || '
— NO PLAYLISTS YET —
'; renderHistorySection(); + renderHotCueBankSection(); wireLibrarySidebar(); } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f58938a..627a108 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -59,6 +59,7 @@ tags: - name: Library - name: Track edits - name: Playlists + - name: Hot Cue Banks - name: Playback - name: Analysis - name: Import / Export @@ -305,6 +306,32 @@ paths: get: { tags: [Playlists], summary: Smart-playlist rules (null if not smart)., responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SmartRules" } } } } } } put: { tags: [Playlists], summary: Set rules (body is a SmartRules, no wrapper)., requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SmartRules" } } } }, responses: { "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AckOK" } } } } } } + # --------------------------------------------------------- Hot Cue Banks + /api/hotcuebanks: + get: + tags: [Hot Cue Banks] + summary: rekordbox Hot Cue Bank folders and banks (read-only). + description: | + The Hot Cue Banks imported from a rekordbox master.db, as a flat list: + parent_id links the tree (0 = top level) and siblings order by seq, as + a deck's HOT CUE BANK menu shows them. Deleted folders and banks are + left out. + responses: { "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/HotCueBankNode" } } } } } } + + /api/hotcuebanks/{id}/tracks: + parameters: [ { name: id, in: path, required: true, schema: { type: integer, format: int64 } } ] + get: + tags: [Hot Cue Banks] + summary: A bank's tracks and the cue it holds for each (read-only). + description: | + In membership order: a track can appear more than once, and entries for + content not in the library are left out. A track whose file is missing + here is listed (track.file_missing) but isn't sent to decks. + Positions 1-8 are pads A-H, as rekordbox and the deck show them. + responses: + "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/HotCueBankEntry" } } } } } + "404": { description: No such bank (or the ID is a folder). } + # -------------------------------------------------------------- Playback /api/load: post: @@ -706,6 +733,27 @@ components: score: { type: number, description: Higher is a better match. } reasons: { type: array, items: { type: string }, description: 'Why it was suggested, strongest first, e.g. "same key (8A)", "BPM 126.5 (+0.4%)".' } + HotCueBankNode: + type: object + properties: + id: { type: integer, format: int64 } + parent_id: { type: integer, format: int64, description: 0 at the top level. } + seq: { type: integer, description: Order among siblings. } + name: { type: string } + is_folder: { type: boolean } + track_count: { type: integer, description: Banks only; the tracks a deck is sent (missing files excluded). } + + HotCueBankEntry: + type: object + properties: + position: { type: integer, description: Membership position; 1-8 are pads A-H. } + track: { $ref: "#/components/schemas/TrackInfo" } + in_ms: { type: integer, description: Cue position in milliseconds. } + out_ms: { type: integer, description: Loop end in milliseconds; absent when the cue isn't a loop. } + color: { type: integer, description: "rekordbox's Color field as stored (-1 = none)." } + color_id: { type: integer, description: rekordbox hot-cue palette colour (0x01-0x3e); 0 = unset, which rekordbox and the deck show as green for a cue and orange for a loop. } + comment: { type: string } + TrackInfo: type: object required: [id, title, artist, album, genre, bpm, key, duration, file_path] diff --git a/library/hotcuebank.go b/library/hotcuebank.go index 5eeca67..6f64eec 100644 --- a/library/hotcuebank.go +++ b/library/hotcuebank.go @@ -86,6 +86,22 @@ func (l *Library) HotCueBankChildren(parentID uint32) []HotCueBank { return out } +// HotCueBankList returns every live folder and bank, in no particular order. +func (l *Library) HotCueBankList() []HotCueBank { + l.mu.RLock() + defer l.mu.RUnlock() + if l.hotCueBanks == nil { + return nil + } + var out []HotCueBank + for _, b := range l.hotCueBanks.Banks { + if !b.Deleted { + out = append(out, b) + } + } + return out +} + // HotCueBankMembers returns every membership of bankID, deleted ones // included (rekordbox's catalog ignores the membership flag), ordered by // signed TrackNo. Duplicates are kept: a track can sit in a bank twice.