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.