Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
109 changes: 109 additions & 0 deletions api/hotcuebank.go
Original file line number Diff line number Diff line change
@@ -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))
}
85 changes: 85 additions & 0 deletions api/hotcuebank_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
144 changes: 144 additions & 0 deletions api/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3531,6 +3535,8 @@
</div>
<div class="lib-sidebar-section" id="lib-history-head" style="display:none">HISTORY</div>
<div class="lib-sidebar-tree" id="lib-sidebar-history"></div>
<div class="lib-sidebar-section" id="lib-hcb-head" style="display:none">HOT CUE BANKS</div>
<div class="lib-sidebar-tree" id="lib-sidebar-hcb"></div>
<div class="lib-sidebar-foot">
<button type="button" id="pl-new-pl" title="New playlist">+ PL</button>
<button type="button" id="pl-new-smart" title="New smart playlist">+ SMART</button>
Expand Down Expand Up @@ -9728,6 +9734,7 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
plSelectedTracks = [];
}
}
await loadHotCueBanks();
renderLibrarySidebar();
refreshLibraryView();
}
Expand All @@ -9737,6 +9744,134 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
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:<id>'.
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 => `
<div class="pl-node hcb-node ${b.is_folder ? 'folder' : ''} ${!b.is_folder && b.id === hcbSelectedID && isHotCueBankSource() ? 'selected' : ''}"
data-hcb-id="${b.id}" data-is-folder="${b.is_folder ? 1 : 0}"
style="padding-left: ${14 + depth * 14}px">
<span class="icon">${b.is_folder ? '▸' : '◆'}</span>
<span class="name">${escape(b.name)}</span>
${b.is_folder ? '' : `<span class="count">${b.track_count}</span>`}
</div>` + (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 = '<div class="empty-state">— EMPTY BANK —</div>';
} else if (shown.length === 0) {
el.innerHTML = '<div class="empty-state">NO TRACKS MATCH</div>';
} 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 => `
<tr data-hcb-track="${e.track.id}" class="${e.track.file_missing ? 'hcb-missing' : ''}"
title="${e.track.file_missing ? 'File missing on this machine: not sent to decks, so this pad is empty there' : pad(e.position) ? 'Pad ' + pad(e.position) : 'No pad (positions 1-8 are pads A-H)'}">
<td>${pad(e.position) ? `<b>${pad(e.position)}</b>` : '—'}</td>
<td><span class="hcb-swatch" style="background:${swatch(e)}" title="${e.color_id ? 'Colour 0x' + e.color_id.toString(16).padStart(2, '0') : 'Default colour'}"></span>${isLoop(e) ? '<span class="hcb-loop-tag" title="Loop">⟲ LOOP</span>' : ''}</td>
<td>${e.position}</td>
<td>${escape(e.track.title || '')}${e.track.file_missing ? ' <span class="hcb-missing-tag">MISSING FILE</span>' : ''}</td>
<td>${escape(e.track.artist || '')}</td>
<td>${e.track.bpm ? Number(e.track.bpm).toFixed(1) : ''}</td>
<td>${escape(e.track.key || '')}</td>
<td>${formatTime(e.in_ms)}</td>
<td>${isLoop(e) ? formatTime(e.out_ms) : '—'}</td>
<td>${escape(e.comment || '')}</td>
</tr>`).join('');
el.innerHTML = `<div class="lib-table-wrap"><table class="lib">
<thead><tr><th>PAD</th><th>COLOUR</th><th>POS</th><th>TITLE</th><th>ARTIST</th><th>BPM</th><th>KEY</th><th>CUE</th><th>LOOP END</th><th>COMMENT</th></tr></thead>
<tbody>${rows}</tbody></table></div>`;
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
Expand Down Expand Up @@ -10339,7 +10474,14 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
// 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
Expand Down Expand Up @@ -10416,6 +10558,7 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
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;
Expand Down Expand Up @@ -10470,6 +10613,7 @@ <h4>RELATED <span class="count" id="related-count"></span></h4>
if (!host) return;
host.innerHTML = renderPlaylistTree() || '<div class="pl-empty">— NO PLAYLISTS YET —</div>';
renderHistorySection();
renderHotCueBankSection();
wireLibrarySidebar();
}

Expand Down
Loading
Loading