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 @@
+ +