From 9d0f4daf3e4313218f4c228f0b61f12c28601d92 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sun, 26 Jul 2026 19:36:26 +0000 Subject: [PATCH 1/2] device: synthesize mixer packets to test the 0x30 merge The 0x30 clobber fix could not be verified live: the idle DJM-A9 never emits 0x30 status packets, so the flip only reproduces with the full rig. Cover it deterministically instead - the two mixer-status write paths move out of their socket loops into recordMixerChannels / recordMixerStatus (no behaviour change; recordMixerStatus returns the first-packet flag the listener logs on), and the test replays the newer-DJM interleaving with synthesized wire packets fed through the real parser: 0x03 channel state, then twenty interleaved stripped 0x30s that previously wiped ChannelStateKnown each time, plus a channel change mid-stream. Reverting the merge makes the test fail on the first 0x30, so it pins the actual bug. Also covered: a rich legacy 0x29 replaces the entry wholesale, a lone 0x30 before any 0x03 leaves channel state honestly unknown, and the first-packet log flag fires exactly once. --- device/device.go | 88 +++++++++++++----------- device/mixerstatus_test.go | 134 +++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 40 deletions(-) create mode 100644 device/mixerstatus_test.go diff --git a/device/device.go b/device/device.go index bb16815..507b4c6 100644 --- a/device/device.go +++ b/device/device.go @@ -622,21 +622,56 @@ func (d *VirtualDevice) listenBeats(ctx context.Context) { if !ok { continue } - d.mixerStatusesMu.Lock() - if d.mixerStatuses == nil { - d.mixerStatuses = make(map[uint8]*proto.MixerStatus) + d.recordMixerChannels(mx) + } +} + +// recordMixerChannels folds a 0x03 channel-state packet into the mixer +// status map: merge channel state with whatever 0x29/0x30 presence info we +// already have so the API can surface both at once. +func (d *VirtualDevice) recordMixerChannels(mx *proto.MixerStatus) { + d.mixerStatusesMu.Lock() + defer d.mixerStatusesMu.Unlock() + if d.mixerStatuses == nil { + d.mixerStatuses = make(map[uint8]*proto.MixerStatus) + } + if prev := d.mixerStatuses[mx.DeviceNumber]; prev != nil { + prev.ChannelOnAir = mx.ChannelOnAir + prev.ChannelStateKnown = true + } else { + d.mixerStatuses[mx.DeviceNumber] = mx + } +} + +// recordMixerStatus folds a 0x29/0x30 status packet into the mixer status +// map, returning whether this mixer had no status entry yet (callers log the +// first packet). A stripped 0x30 (newer DJMs) carries only presence — no +// channel or master state. Merge it over what the 0x03 channel listener has +// learned instead of clobbering, or the mixer views flip between "channel +// state" and "detected" at packet cadence as the two writers fight. A rich +// 0x29 carries everything and may replace the entry wholesale. +func (d *VirtualDevice) recordMixerStatus(mx *proto.MixerStatus) (first bool) { + d.mixerStatusesMu.Lock() + defer d.mixerStatusesMu.Unlock() + if d.mixerStatuses == nil { + d.mixerStatuses = make(map[uint8]*proto.MixerStatus) + } + prev, seen := d.mixerStatuses[mx.DeviceNumber] + if prev != nil && !mx.ChannelStateKnown && prev.ChannelStateKnown { + mx.ChannelOnAir = prev.ChannelOnAir + mx.ChannelStateKnown = true + if mx.MasterBPM == 0 { + mx.MasterBPM = prev.MasterBPM } - prev := d.mixerStatuses[mx.DeviceNumber] - // Merge channel state with whatever 0x29/0x30 presence info - // we already have so the API can surface both at once. - if prev != nil { - prev.ChannelOnAir = mx.ChannelOnAir - prev.ChannelStateKnown = true - } else { - d.mixerStatuses[mx.DeviceNumber] = mx + if mx.MasterDevice == 0 { + mx.MasterDevice = prev.MasterDevice + } + if mx.BeatInBar == 0 { + mx.BeatInBar = prev.BeatInBar } - d.mixerStatusesMu.Unlock() } + d.mixerStatuses[mx.DeviceNumber] = mx + return !seen } func (d *VirtualDevice) listenStatus(ctx context.Context) { @@ -695,34 +730,7 @@ func (d *VirtualDevice) listenStatus(ctx context.Context) { } if isMixer { if mx, ok := proto.ParseMixerStatus(buf[:n]); ok { - d.mixerStatusesMu.Lock() - if d.mixerStatuses == nil { - d.mixerStatuses = make(map[uint8]*proto.MixerStatus) - } - prev, alreadyLogged := d.mixerStatuses[mx.DeviceNumber] - // A stripped 0x30 (newer DJMs) carries only presence — - // no channel or master state. Merge it over what the - // 0x03 channel listener has learned instead of - // clobbering, or the mixer views flip between "channel - // state" and "detected" at packet cadence as the two - // writers fight. A rich 0x29 carries everything and - // may replace the entry wholesale. - if prev != nil && !mx.ChannelStateKnown && prev.ChannelStateKnown { - mx.ChannelOnAir = prev.ChannelOnAir - mx.ChannelStateKnown = true - if mx.MasterBPM == 0 { - mx.MasterBPM = prev.MasterBPM - } - if mx.MasterDevice == 0 { - mx.MasterDevice = prev.MasterDevice - } - if mx.BeatInBar == 0 { - mx.BeatInBar = prev.BeatInBar - } - } - d.mixerStatuses[mx.DeviceNumber] = mx - d.mixerStatusesMu.Unlock() - if !alreadyLogged { + if d.recordMixerStatus(mx) { log.Printf("mixer: first 0x29 from %s (%s) device %d — len=%d masterBPM=%.2f masterDev=%d onAir=%04b beat=%d\n%s", mx.Name, addr.IP, mx.DeviceNumber, n, mx.MasterBPM, mx.MasterDevice, mx.ChannelOnAir, mx.BeatInBar, hex.Dump(buf[:n])) } diff --git a/device/mixerstatus_test.go b/device/mixerstatus_test.go new file mode 100644 index 0000000..aaee7b7 --- /dev/null +++ b/device/mixerstatus_test.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package device + +import ( + "encoding/binary" + "testing" + + "github.com/vynulldev/vynull/proto" +) + +// Wire-packet builders for the three mixer broadcast types, laid out per the +// offsets documented on proto.ParseMixerStatus. Each goes through the real +// parser, so the tests cover parse + merge, not a hand-built MixerStatus. + +func synthPktHeader(typ uint8, size int) []byte { + p := make([]byte, size) + copy(p, proto.Magic[:]) + p[0x0a] = typ + return p +} + +// synthChannels builds a 0x03 channel-state broadcast (newer DJMs, port +// 50001): name at 0x0b, device number at 0x21, per-channel on-air bytes at +// 0x24-0x27. +func synthChannels(name string, devNum uint8, onAir [4]bool) []byte { + p := synthPktHeader(proto.TypeMixerChannels, 53) + copy(p[0x0b:], name) + p[0x21] = devNum + for ch, on := range onAir { + if on { + p[0x24+ch] = 0x01 + } + } + return p +} + +// synthStatusNew builds a stripped 0x30 status broadcast (newer DJMs, port +// 50002): name at 0x0b, device number at 0x21, no channel or master state. +func synthStatusNew(name string, devNum uint8) []byte { + p := synthPktHeader(proto.TypeMixerStatusNew, 36) + copy(p[0x0b:], name) + p[0x21] = devNum + return p +} + +// synthStatusLegacy builds a rich 0x29 status broadcast (older DJMs): name +// at 0x0c, device number at 0x21, master BPM ×100 at 0x2c, on-air bitfield +// at 0x36. +func synthStatusLegacy(name string, devNum uint8, masterBPM float64, onAirBits uint8) []byte { + p := synthPktHeader(proto.TypeMixerStatusLegacy, 56) + copy(p[0x0c:], name) + p[0x21] = devNum + binary.BigEndian.PutUint16(p[0x2c:], uint16(masterBPM*100)) + p[0x36] = onAirBits + return p +} + +func parse(t *testing.T, pkt []byte) *proto.MixerStatus { + t.Helper() + mx, ok := proto.ParseMixerStatus(pkt) + if !ok { + t.Fatalf("synthesized packet type 0x%02x did not parse", pkt[0x0a]) + } + return mx +} + +// TestMixerStatus0x30DoesNotClobberChannelState replays the newer-DJM +// packet interleaving that made mixer views flip between the rich +// channel-state line and the bare "detected" fallback: 0x03 channel packets +// on the on-air port teach the channel state, and stripped 0x30 status +// packets used to overwrite the shared entry wholesale, wiping +// ChannelStateKnown until the next 0x03. The snapshot must stay rich across +// any number of interleaved 0x30s. +func TestMixerStatus0x30DoesNotClobberChannelState(t *testing.T) { + d := &VirtualDevice{} + + // Channel state arrives first (0x03, channels 1+2 on-air). + d.recordMixerChannels(parse(t, synthChannels("DJM-A9", 33, [4]bool{true, true, false, false}))) + snap := d.MixerSnapshot() + mx, ok := snap[33] + if !ok || !mx.ChannelStateKnown || mx.ChannelOnAir != 0b0011 { + t.Fatalf("after 0x03: %+v (want known, on-air 0011)", mx) + } + + // The real-world flip trigger: stripped 0x30s interleaved with 0x03s. + for i := 0; i < 20; i++ { + d.recordMixerStatus(parse(t, synthStatusNew("DJM-A9", 33))) + mx = d.MixerSnapshot()[33] + if !mx.ChannelStateKnown { + t.Fatalf("0x30 #%d wiped ChannelStateKnown — views would flip to the 'detected' fallback", i+1) + } + if mx.ChannelOnAir != 0b0011 { + t.Fatalf("0x30 #%d lost channel state: on-air %04b, want 0011", i+1, mx.ChannelOnAir) + } + d.recordMixerChannels(parse(t, synthChannels("DJM-A9", 33, [4]bool{true, true, false, false}))) + } + + // A channel change mid-stream still lands. + d.recordMixerChannels(parse(t, synthChannels("DJM-A9", 33, [4]bool{false, false, true, true}))) + d.recordMixerStatus(parse(t, synthStatusNew("DJM-A9", 33))) + if mx = d.MixerSnapshot()[33]; mx.ChannelOnAir != 0b1100 { + t.Fatalf("channel change lost through 0x30: on-air %04b, want 1100", mx.ChannelOnAir) + } +} + +// TestMixerStatusLegacy0x29ReplacesWholesale: a rich 0x29 carries channel +// and master state inline, so it must replace the entry (not merge stale +// fields over fresh ones). +func TestMixerStatusLegacy0x29ReplacesWholesale(t *testing.T) { + d := &VirtualDevice{} + d.recordMixerChannels(parse(t, synthChannels("DJM-900", 33, [4]bool{true, false, false, false}))) + d.recordMixerStatus(parse(t, synthStatusLegacy("DJM-900", 33, 128.0, 0b0110))) + mx := d.MixerSnapshot()[33] + if !mx.ChannelStateKnown || mx.ChannelOnAir != 0b0110 || mx.MasterBPM != 128.0 { + t.Fatalf("0x29 not applied wholesale: %+v", mx) + } +} + +// TestMixerStatusFirstPacketFlag: recordMixerStatus reports the first status +// packet from a mixer (the listener logs it), and only the first. +func TestMixerStatusFirstPacketFlag(t *testing.T) { + d := &VirtualDevice{} + if !d.recordMixerStatus(parse(t, synthStatusNew("DJM-A9", 33))) { + t.Error("first status packet not flagged") + } + if d.recordMixerStatus(parse(t, synthStatusNew("DJM-A9", 33))) { + t.Error("second status packet flagged as first") + } + // A 0x30 arriving before any 0x03 must leave state honestly unknown. + if mx := d.MixerSnapshot()[33]; mx.ChannelStateKnown { + t.Errorf("0x30 alone must not claim channel state: %+v", mx) + } +} From 3848289d9ac8f788a816c8b98a268fb02406f8dc Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sun, 26 Jul 2026 19:59:00 +0000 Subject: [PATCH 2/2] e2e: drive the real binary against synthesized media An end-to-end suite for the flows that until now could only be verified by hand before a release. Run with make e2e (gated on VYNULL_E2E=1 so go test ./... stays fast); the suite skips itself when the DJ-Link UDP ports are held by a running instance. The harness builds the binary once, launches one server per test on a free port with an isolated temp data-dir, and synthesizes all of its own media: deterministic kick-train WAVs whose ground-truth BPM makes the integer-snap assertion exact, an ffmpeg-produced FLAC for the lossless path, and an MP3 with an embedded JPEG cover for the artwork path. No fixtures, no dependence on a local music library. Five scenarios covering the main user-facing flows: the analysis pipeline (rows fill, detected BPM lands exactly on the known integers including FLAC, waveform PNGs serve), lazy artwork (embedded art on first request, artless tracks 404 once and stay probed, directory cover.jpg fallback), playlist membership (wholesale set-tracks removal leaves library rows intact, smart rules resolve, delete leaves the library alone), the analysis-cache upgrade path (a manufactured stale-cacheVersion cache entry with a stale fractional row BPM re-analyzes on first touch and the row follows, while a manual BPM override survives), and a bulk-add pass whose polling loop is itself the responsiveness assertion. Writing the artwork scenario surfaced that ExtractArtwork's directory fallback applies to any artless file beside a cover image - now an explicit assertion rather than an accident. Full suite runs in ~15s. --- Makefile | 7 +- e2e/harness_test.go | 355 +++++++++++++++++++++++++++++++++++++++++++ e2e/library_test.go | 165 ++++++++++++++++++++ e2e/playlist_test.go | 93 ++++++++++++ e2e/upgrade_test.go | 140 +++++++++++++++++ 5 files changed, 759 insertions(+), 1 deletion(-) create mode 100644 e2e/harness_test.go create mode 100644 e2e/library_test.go create mode 100644 e2e/playlist_test.go create mode 100644 e2e/upgrade_test.go diff --git a/Makefile b/Makefile index fefd850..b88105b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # SPDX-License-Identifier: GPL-3.0-or-later -.PHONY: build run run-cdj generate clean +.PHONY: build run run-cdj generate e2e clean build: go build -o vynull . @@ -15,5 +15,10 @@ run-cdj: build generate: build ./vynull --generate $(OUT) --music-dir $(MUSIC) +# End-to-end suite: drives the real binary over HTTP against synthesized +# media (needs ffmpeg + the DJ-Link UDP ports free, i.e. no running instance) +e2e: + VYNULL_E2E=1 go test ./e2e/ -v + clean: rm -f vynull diff --git a/e2e/harness_test.go b/e2e/harness_test.go new file mode 100644 index 0000000..f4db4e6 --- /dev/null +++ b/e2e/harness_test.go @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package e2e drives the real vynull binary over its HTTP API against +// synthesized media, covering the main user-facing flows end to end: the +// analysis pipeline (BPM integer snap, waveforms, artwork), playlist +// membership, the stale-cache upgrade path, and a light bulk-add +// stability pass. +// +// Gated behind VYNULL_E2E=1 so `go test ./...` stays fast: +// +// VYNULL_E2E=1 go test ./e2e/ -v +// +// The suite builds the binary once, launches one server per test with an +// isolated temp data-dir on a free port, and synthesizes its own audio +// (deterministic kick-train WAVs with known ground-truth BPM; an MP3 with +// embedded cover art via ffmpeg). It skips itself when the DJ-Link UDP +// ports are held by a running instance. +package e2e + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "image" + "image/color" + "image/jpeg" + "math" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +var binPath string + +func TestMain(m *testing.M) { + if os.Getenv("VYNULL_E2E") == "" { + fmt.Println("e2e: set VYNULL_E2E=1 to run") + os.Exit(0) + } + if _, err := exec.LookPath("ffmpeg"); err != nil { + fmt.Println("e2e: ffmpeg not on PATH") + os.Exit(1) + } + // The device side binds fixed UDP ports; a running vynull instance + // makes every server exit at startup. Probe and bail out early with a + // clear message instead of failing each test cryptically. + if pc, err := net.ListenPacket("udp4", ":50000"); err != nil { + fmt.Println("e2e: UDP 50000 busy — is a vynull instance running? skipping") + os.Exit(0) + } else { + pc.Close() + } + dir, err := os.MkdirTemp("", "vynull-e2e-*") + if err != nil { + fmt.Println("e2e:", err) + os.Exit(1) + } + defer os.RemoveAll(dir) + binPath = filepath.Join(dir, "vynull") + build := exec.Command("go", "build", "-o", binPath, ".") + build.Dir = ".." // module root + if out, err := build.CombinedOutput(); err != nil { + fmt.Printf("e2e: build failed: %v\n%s", err, out) + os.Exit(1) + } + os.Exit(m.Run()) +} + +// server is one running vynull instance over a temp data-dir. +type server struct { + t *testing.T + cmd *exec.Cmd + baseURL string + dataDir string + logPath string +} + +// startServer launches the built binary on a free port with the given +// data-dir (created if empty) and waits for the API to come up. +func startServer(t *testing.T, dataDir string) *server { + t.Helper() + if dataDir == "" { + dataDir = t.TempDir() + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + + logPath := filepath.Join(t.TempDir(), "server.log") + cmd := exec.Command(binPath, + "--interface", "lo", + "--data-dir", dataDir, + "--listen", fmt.Sprintf("127.0.0.1:%d", port), + "--tui=false", + "--log-file", logPath, + ) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + s := &server{ + t: t, + cmd: cmd, + baseURL: fmt.Sprintf("http://127.0.0.1:%d", port), + dataDir: dataDir, + logPath: logPath, + } + t.Cleanup(s.stop) + + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get(s.baseURL + "/api/tracks") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return s + } + } + time.Sleep(200 * time.Millisecond) + } + logTail, _ := os.ReadFile(logPath) + t.Fatalf("server did not come up on %s; log:\n%s", s.baseURL, tail(logTail, 2000)) + return nil +} + +func (s *server) stop() { + if s.cmd.Process == nil { + return + } + s.cmd.Process.Signal(syscall.SIGINT) + done := make(chan struct{}) + go func() { s.cmd.Wait(); close(done) }() + select { + case <-done: + case <-time.After(10 * time.Second): + s.cmd.Process.Kill() + <-done + } +} + +func tail(b []byte, n int) []byte { + if len(b) > n { + return b[len(b)-n:] + } + return b +} + +// ---------- HTTP helpers ---------- + +func (s *server) post(path string, body any) *http.Response { + s.t.Helper() + b, _ := json.Marshal(body) + resp, err := http.Post(s.baseURL+path, "application/json", bytes.NewReader(b)) + if err != nil { + s.t.Fatalf("POST %s: %v", path, err) + } + return resp +} + +func (s *server) postOK(path string, body any, out any) { + s.t.Helper() + resp := s.post(path, body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + var msg bytes.Buffer + msg.ReadFrom(resp.Body) + s.t.Fatalf("POST %s: %d %s", path, resp.StatusCode, msg.String()) + } + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + s.t.Fatalf("POST %s: decode: %v", path, err) + } + } +} + +func (s *server) getJSON(path string, out any) int { + s.t.Helper() + resp, err := http.Get(s.baseURL + path) + if err != nil { + s.t.Fatalf("GET %s: %v", path, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK && out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + s.t.Fatalf("GET %s: decode: %v", path, err) + } + } + return resp.StatusCode +} + +// track mirrors the API track payload fields the tests assert on. +type track struct { + ID uint32 `json:"id"` + Title string `json:"title"` + BPM float64 `json:"bpm"` + DetectedBPM float64 `json:"detected_bpm"` + Key string `json:"key"` + Duration float64 `json:"duration"` + ArtID uint32 `json:"art_id"` + ArtChecked bool `json:"art_checked"` +} + +func (s *server) tracks() []track { + s.t.Helper() + var ts []track + if code := s.getJSON("/api/tracks", &ts); code != http.StatusOK { + s.t.Fatalf("GET /api/tracks: %d", code) + } + return ts +} + +// addTracks POSTs paths to /api/tracks/add and returns the library rows. +func (s *server) addTracks(paths ...string) []track { + s.t.Helper() + s.postOK("/api/tracks/add", map[string]any{"paths": paths}, nil) + return s.tracks() +} + +// waitFor polls cond until it returns true or the deadline passes. +func (s *server) waitFor(what string, timeout time.Duration, cond func() bool) { + s.t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(500 * time.Millisecond) + } + logTail, _ := os.ReadFile(s.logPath) + s.t.Fatalf("timed out waiting for %s; server log tail:\n%s", what, tail(logTail, 3000)) +} + +// ---------- media synthesis ---------- + +const synthRate = 44100 + +// writeWAV writes 16-bit mono PCM at synthRate. +func writeWAV(t *testing.T, path string, samples []float32) { + t.Helper() + var buf bytes.Buffer + n := len(samples) + dataLen := n * 2 + buf.WriteString("RIFF") + binary.Write(&buf, binary.LittleEndian, uint32(36+dataLen)) + buf.WriteString("WAVEfmt ") + binary.Write(&buf, binary.LittleEndian, uint32(16)) + binary.Write(&buf, binary.LittleEndian, uint16(1)) // PCM + binary.Write(&buf, binary.LittleEndian, uint16(1)) // mono + binary.Write(&buf, binary.LittleEndian, uint32(synthRate)) + binary.Write(&buf, binary.LittleEndian, uint32(synthRate*2)) + binary.Write(&buf, binary.LittleEndian, uint16(2)) + binary.Write(&buf, binary.LittleEndian, uint16(16)) + buf.WriteString("data") + binary.Write(&buf, binary.LittleEndian, uint32(dataLen)) + for _, v := range samples { + if v > 1 { + v = 1 + } else if v < -1 { + v = -1 + } + binary.Write(&buf, binary.LittleEndian, int16(v*32767)) + } + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } +} + +// kickTrain renders a four-on-the-floor kick pattern at exactly the given +// BPM (decaying 60Hz bursts), the same signal the analysis unit tests use — +// so the detected BPM has an exact expected value. +func kickTrain(bpm, durSec float64) []float32 { + n := int(synthRate * durSec) + out := make([]float32, n) + period := 60.0 / bpm * synthRate + burst := int(0.08 * synthRate) + twoPiF := 2.0 * math.Pi * 60.0 / synthRate + for beat := 0; ; beat++ { + start := int(float64(beat) * period) + if start >= n { + break + } + for i := 0; i < burst && start+i < n; i++ { + env := math.Exp(-float64(i) / (0.02 * synthRate)) + out[start+i] += float32(0.8 * env * math.Sin(twoPiF*float64(i))) + } + } + return out +} + +// kickWAV writes a kick train and returns its path. +func kickWAV(t *testing.T, dir string, name string, bpm float64) string { + t.Helper() + p := filepath.Join(dir, name) + writeWAV(t, p, kickTrain(bpm, 30)) + return p +} + +// writeCoverJPEG writes a small gradient JPEG (a stand-in cover image). +func writeCoverJPEG(t *testing.T, path string) { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 64, 64)) + for y := 0; y < 64; y++ { + for x := 0; x < 64; x++ { + img.Set(x, y, color.RGBA{uint8(x * 4), uint8(y * 4), 200, 255}) + } + } + var jb bytes.Buffer + if err := jpeg.Encode(&jb, img, nil); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, jb.Bytes(), 0o644); err != nil { + t.Fatal(err) + } +} + +// flacKick transcodes a kick train to FLAC (the lossless analysis path — +// no encoder-delay compensation). +func flacKick(t *testing.T, dir, name string, bpm float64) string { + t.Helper() + wav := kickWAV(t, dir, name+".tmp.wav", bpm) + flac := filepath.Join(dir, name) + out, err := exec.Command("ffmpeg", "-y", "-i", wav, "-c:a", "flac", flac).CombinedOutput() + if err != nil { + t.Fatalf("ffmpeg flac: %v\n%s", err, out) + } + os.Remove(wav) + return flac +} + +// mp3WithArt transcodes a kick train to MP3 with an embedded JPEG cover. +func mp3WithArt(t *testing.T, dir string, bpm float64) string { + t.Helper() + wav := kickWAV(t, dir, "art-src.wav", bpm) + cover := filepath.Join(dir, "cover.jpg") + writeCoverJPEG(t, cover) + mp3 := filepath.Join(dir, "with-art.mp3") + out, err := exec.Command("ffmpeg", "-y", "-i", wav, "-i", cover, + "-map", "0:a", "-map", "1:v", "-c:a", "libmp3lame", "-q:a", "5", + "-c:v", "copy", "-id3v2_version", "3", + "-metadata:s:v", "comment=Cover (front)", mp3).CombinedOutput() + if err != nil { + t.Fatalf("ffmpeg mp3+art: %v\n%s", err, out) + } + os.Remove(wav) // only the MP3 should enter the library + os.Remove(cover) // and no stray folder art beside it + return mp3 +} diff --git a/e2e/library_test.go b/e2e/library_test.go new file mode 100644 index 0000000..c65253f --- /dev/null +++ b/e2e/library_test.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package e2e + +import ( + "fmt" + "io" + "net/http" + "path/filepath" + "testing" + "time" +) + +// TestAnalysisPipeline covers the fresh-library flow: tracks added over +// the API analyze in the background, rows fill +// with BPM/duration, detected BPM lands EXACTLY on the known ground-truth +// integer (the snap), and the waveform PNG endpoint serves an image. +func TestAnalysisPipeline(t *testing.T) { + s := startServer(t, "") + media := t.TempDir() + p124 := kickWAV(t, media, "kick124.wav", 124) + p128 := kickWAV(t, media, "kick128.wav", 128) + // FLAC exercises the lossless decode path (no encoder-delay shift). + p126 := flacKick(t, media, "kick126.flac", 126) + s.addTracks(p124, p128, p126) + + want := map[string]float64{"kick124": 124, "kick128": 128, "kick126": 126} + s.waitFor("all three tracks analyzed", 2*time.Minute, func() bool { + n := 0 + for _, tr := range s.tracks() { + if tr.BPM > 0 && tr.Duration > 0 { + n++ + } + } + return n == 3 + }) + for _, tr := range s.tracks() { + wantBPM, ok := want[tr.Title] + if !ok { + t.Errorf("unexpected title %q", tr.Title) + continue + } + if tr.BPM != wantBPM { + t.Errorf("%s: BPM %v, want exactly %v (integer snap)", tr.Title, tr.BPM, wantBPM) + } + if tr.Duration < 29 || tr.Duration > 31 { + t.Errorf("%s: duration %v, want ~30s", tr.Title, tr.Duration) + } + + resp, err := http.Get(fmt.Sprintf("%s/api/analysis/waveform-png/%d?type=detail&w=192&h=44", s.baseURL, tr.ID)) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || len(body) < 8 || string(body[1:4]) != "PNG" { + t.Errorf("%s: waveform-png status %d, %d bytes (want a PNG)", tr.Title, resp.StatusCode, len(body)) + } + } +} + +// TestArtworkLazyExtraction covers lazy artwork extraction: a track +// with embedded cover art serves it from /api/artwork on first request (no +// deck involved), the probe result persists as art_checked, and an artless +// track 404s and is marked checked so the UI stops re-requesting. +func TestArtworkLazyExtraction(t *testing.T) { + s := startServer(t, "") + // Separate directories: ExtractArtwork falls back to a directory scan + // for cover images, so an artless file next to the MP3's cover.jpg + // would legitimately inherit it (asserted below as folderart). + withArt := mp3WithArt(t, t.TempDir(), 124) + noArt := kickWAV(t, t.TempDir(), "noart.wav", 126) + folderDir := t.TempDir() + folderArt := kickWAV(t, folderDir, "folderart.wav", 122) + writeCoverJPEG(t, filepath.Join(folderDir, "cover.jpg")) + s.addTracks(withArt, noArt, folderArt) + + byTitle := func(title string) track { + for _, tr := range s.tracks() { + if tr.Title == title { + return tr + } + } + t.Fatalf("track %q not found", title) + return track{} + } + + // Request art for both — first request triggers the lazy probe. + fetch := func(id uint32) (int, string, int) { + resp, err := http.Get(fmt.Sprintf("%s/api/artwork/%d", s.baseURL, id)) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return resp.StatusCode, resp.Header.Get("Content-Type"), len(body) + } + + art := byTitle("with-art") + s.waitFor("artwork served for with-art", 30*time.Second, func() bool { + code, _, n := fetch(art.ID) + return code == http.StatusOK && n > 0 + }) + code, ctype, n := fetch(art.ID) + if code != http.StatusOK || n == 0 { + t.Errorf("with-art: artwork %d, %d bytes", code, n) + } + if ctype != "image/jpeg" { + t.Errorf("with-art: content-type %q", ctype) + } + + plain := byTitle("noart") + fetch(plain.ID) // trigger the probe + s.waitFor("noart probe recorded", 30*time.Second, func() bool { + return byTitle("noart").ArtChecked + }) + if tr := byTitle("noart"); tr.ArtID != 0 { + t.Errorf("noart: art_id %d, want 0", tr.ArtID) + } + if code, _, _ := fetch(plain.ID); code != http.StatusNotFound { + t.Errorf("noart: artwork status %d, want 404", code) + } + + // Directory-scan fallback: an artless file with a cover.jpg beside it + // serves the folder art. + fa := byTitle("folderart") + s.waitFor("folder art served", 30*time.Second, func() bool { + code, _, n := fetch(fa.ID) + return code == http.StatusOK && n > 0 + }) +} + +// TestBulkAddResponsive is a light stability pass: a batch of +// tracks analyzes to completion while the API stays responsive throughout. +func TestBulkAddResponsive(t *testing.T) { + s := startServer(t, "") + media := t.TempDir() + var paths []string + for i := 0; i < 12; i++ { + paths = append(paths, kickWAV(t, media, fmt.Sprintf("bulk%02d.wav", i), float64(120+i))) + } + s.addTracks(paths...) + + s.waitFor("all 12 analyzed", 5*time.Minute, func() bool { + // The poll itself is the responsiveness check: a wedged server + // fails here, not silently. + start := time.Now() + ts := s.tracks() + if d := time.Since(start); d > 2*time.Second { + t.Errorf("GET /api/tracks took %v mid-analysis", d) + } + n := 0 + for _, tr := range ts { + if tr.BPM > 0 { + n++ + } + } + return n == len(paths) + }) + for _, tr := range s.tracks() { + if tr.BPM != float64(int(tr.BPM)) { + t.Errorf("%s: fractional BPM %v survived on a synthetic integer tempo", tr.Title, tr.BPM) + } + } +} diff --git a/e2e/playlist_test.go b/e2e/playlist_test.go new file mode 100644 index 0000000..d7f84ad --- /dev/null +++ b/e2e/playlist_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package e2e + +import ( + "fmt" + "net/http" + "testing" + "time" +) + +// TestPlaylistMembership covers the API side of playlist editing: +// playlist create, wholesale set-tracks (the operation behind both the +// per-row × and bulk "− PLAYLIST" UI affordances), removal leaving library +// rows intact, and smart-playlist rule resolution. The UI wiring itself +// (which buttons render where) stays a manual/browser check. +func TestPlaylistMembership(t *testing.T) { + s := startServer(t, "") + media := t.TempDir() + var paths []string + for i := 0; i < 3; i++ { + paths = append(paths, kickWAV(t, media, fmt.Sprintf("pl%d.wav", i), float64(122+i*2))) + } + s.addTracks(paths...) + s.waitFor("tracks analyzed", 2*time.Minute, func() bool { + n := 0 + for _, tr := range s.tracks() { + if tr.BPM > 0 { + n++ + } + } + return n == 3 + }) + all := s.tracks() + ids := make([]uint32, len(all)) + for i, tr := range all { + ids[i] = tr.ID + } + + // Create a regular playlist and set its membership wholesale. + var pl struct { + ID uint32 `json:"id"` + } + s.postOK("/api/playlists", map[string]any{"name": "E2E Set", "parent_id": 0, "is_folder": false}, &pl) + s.postOK(fmt.Sprintf("/api/playlists/%d/tracks", pl.ID), map[string]any{"track_ids": ids}, nil) + + var got []track + s.getJSON(fmt.Sprintf("/api/playlists/%d/tracks", pl.ID), &got) + if len(got) != 3 { + t.Fatalf("playlist has %d tracks, want 3", len(got)) + } + + // Remove the middle track the way the UI does: post the list minus it. + s.postOK(fmt.Sprintf("/api/playlists/%d/tracks", pl.ID), + map[string]any{"track_ids": []uint32{ids[0], ids[2]}}, nil) + got = nil + s.getJSON(fmt.Sprintf("/api/playlists/%d/tracks", pl.ID), &got) + if len(got) != 2 || got[0].ID != ids[0] || got[1].ID != ids[2] { + t.Fatalf("after removal: %+v, want [%d %d]", got, ids[0], ids[2]) + } + // The removed track must still be in the library, analysis intact. + if lib := s.tracks(); len(lib) != 3 { + t.Fatalf("library shrank to %d rows after playlist removal", len(lib)) + } + + // Smart playlist: BPM >= 124 resolves to the two faster tracks. + var sp struct { + ID uint32 `json:"id"` + } + s.postOK("/api/playlists", map[string]any{ + "name": "E2E Smart", "is_smart": true, + "rules": map[string]any{ + "combinator": "all", + "conditions": []map[string]any{{"field": "bpm", "operator": "gte", "value": 124}}, + }, + }, &sp) + got = nil + s.getJSON(fmt.Sprintf("/api/playlists/%d/tracks", sp.ID), &got) + if len(got) != 2 { + t.Fatalf("smart playlist resolved %d tracks, want 2 (BPM >= 124)", len(got)) + } + + // Deleting the playlist leaves the library untouched. + req, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/playlists/%d", s.baseURL, pl.ID), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("delete playlist: %v %v", err, resp.Status) + } + resp.Body.Close() + if lib := s.tracks(); len(lib) != 3 { + t.Fatalf("library has %d rows after playlist delete, want 3", len(lib)) + } +} diff --git a/e2e/upgrade_test.go b/e2e/upgrade_test.go new file mode 100644 index 0000000..d676b0c --- /dev/null +++ b/e2e/upgrade_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package e2e + +import ( + "bytes" + "crypto/sha256" + "encoding/gob" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/vynulldev/vynull/analysis" +) + +// TestUpgradeReanalysis covers the analysis-cache upgrade path: +// a data-dir whose analysis cache predates the current cacheVersion gets a +// re-analysis pass when its tracks are next touched, the stale fractional +// BPM on the library row FOLLOWS the re-analysis (the writeback change), +// and a manual BPM override survives it. +// +// The stale state is manufactured rather than restored from a fixture: the +// suite analyzes fresh, stops the server, rewrites the cache gob with an +// ancient CacheVersion and a pre-snap fractional BPM, edits the row to +// match, and restarts. That reproduces exactly what a v26→v27 upgrade +// looks like on disk, without shipping binary fixtures that rot. +func TestUpgradeReanalysis(t *testing.T) { + dataDir := t.TempDir() + media := t.TempDir() + stale := kickWAV(t, media, "stale.wav", 124) + overridden := kickWAV(t, media, "overridden.wav", 128) + + // Phase 1: fresh analysis, then set a manual override on one track. + s := startServer(t, dataDir) + s.addTracks(stale, overridden) + s.waitFor("initial analysis", 2*time.Minute, func() bool { + n := 0 + for _, tr := range s.tracks() { + if tr.BPM > 0 { + n++ + } + } + return n == 2 + }) + var overriddenID, staleID uint32 + for _, tr := range s.tracks() { + switch tr.Title { + case "overridden": + overriddenID = tr.ID + case "stale": + staleID = tr.ID + } + } + // Manual override: user pins 130 over the detected 128. + req, _ := http.NewRequest(http.MethodPut, + fmt.Sprintf("%s/api/tracks/%d/beats", s.baseURL, overriddenID), + bytes.NewReader([]byte(`{"bpm": 130}`))) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("set override: %v %v", err, resp.Status) + } + resp.Body.Close() + s.stop() + + // Phase 2: age the on-disk state to look like a pre-snap library. + // Stale cache entry: ancient CacheVersion, fractional BPM. + h := sha256.Sum256([]byte(stale)) + gobPath := filepath.Join(dataDir, "analysis", fmt.Sprintf("%x.gob", h[:8])) + if _, err := os.Stat(gobPath); err != nil { + t.Fatalf("expected cache gob at %s: %v", gobPath, err) + } + var gb bytes.Buffer + if err := gob.NewEncoder(&gb).Encode(&analysis.Result{CacheVersion: 1, BPM: 123.77, Duration: 30}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(gobPath, gb.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + // Matching stale row BPM in library.json. + libPath := filepath.Join(dataDir, "library.json") + raw, err := os.ReadFile(libPath) + if err != nil { + t.Fatal(err) + } + var rows []map[string]any + if err := json.Unmarshal(raw, &rows); err != nil { + t.Fatal(err) + } + for _, row := range rows { + if row["title"] == "stale" { + row["bpm"] = 123.77 + } + } + edited, _ := json.Marshal(rows) + if err := os.WriteFile(libPath, edited, 0o644); err != nil { + t.Fatal(err) + } + + // Phase 3: restart and touch both tracks — the stale cache must be + // discarded (version mismatch), re-analysis runs, and the row follows. + s = startServer(t, dataDir) + for _, id := range []uint32{staleID, overriddenID} { + // The waveform request is what a UI view or deck load does; it + // routes through getOrAnalyze and triggers the re-analysis. + http.Get(fmt.Sprintf("%s/api/analysis/waveform-png/%d?type=detail&w=64&h=16", s.baseURL, id)) + } + s.waitFor("stale row re-analyzed to 124", 2*time.Minute, func() bool { + for _, tr := range s.tracks() { + if tr.ID == staleID { + return tr.BPM == 124 + } + } + return false + }) + for _, tr := range s.tracks() { + switch tr.ID { + case staleID: + if tr.BPM != 124 { + t.Errorf("stale row BPM %v after re-analysis, want 124", tr.BPM) + } + case overriddenID: + if tr.BPM != 130 { + t.Errorf("override lost through re-analysis: BPM %v, want 130", tr.BPM) + } + // detected_bpm lives on the beats endpoint, not the list payload. + var beats struct { + DetectedBPM float64 `json:"detected_bpm"` + } + s.getJSON(fmt.Sprintf("/api/tracks/%d/beats", overriddenID), &beats) + if beats.DetectedBPM == 0 { + t.Errorf("override snapshot (detected_bpm) missing from /beats") + } + } + } +}