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/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) + } +} 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") + } + } + } +}