Compare commits

..

30 Commits

Author SHA1 Message Date
david 7e9419e119 fix: validate ISO path is a file, not a directory; clear default value 2026-06-22 05:14:18 -07:00
david ebce8fded8 fix: return empty arrays instead of null in listPartitions, listVolumes, handleDiskVolumes 2026-06-22 04:52:55 -07:00
david 190c79487f fix: return empty array instead of null for tagless volumes 2026-06-22 04:47:29 -07:00
david a489d523e3 fix: add null/HTTP checks to tag viewer API calls
- browseISO: resp.ok + data null check before accessing data.error
- selectPartition: resp.ok + data null check
- viewVolumeTags: resp.ok + data null check
- Fixed v.Name → v.name (JSON keys are lowercase from volInfo tags)

Prevents 'Cannot read properties of null (reading error)'
2026-06-22 04:20:01 -07:00
david 568691542a feat: Sample Tag Viewer — read-only tag browser for AKAI disk images
Backend:
- extract.go: listTags() navigates to volume, runs akaiutil lstags
- extract.go: parseTags() parses tag name/index from lstags output
- serve.go: GET /api/disk/partitions — list partitions on an ISO
- serve.go: GET /api/disk/volumes — list volumes on a partition
- serve.go: GET /api/volume/tags — list tags on a volume

Web UI:
- Tag Viewer section with ISO path input and Browse button
- Partition selector buttons
- Volume grid with click-to-select
- Tag chips with color-coded dots (20-color palette)

Tests:
- 6 parseTags tests (basic, spaces, defaults, no tags, empty, no-type)
- 1 listTags integration test
- 4 API endpoint tests (partitions, missing-ISO, missing-params, tags)

Karpathy: read-only view first — no write operations yet (settagi, clrtagi)
2026-06-22 03:21:54 -07:00
david f804ad8a41 feat: expanded toolchain check + install-all task
check-toolchain.sh now covers:
- golangci-lint
- npm
- web/ui node_modules (JS deps)
- electron node_modules (electron deps)

New install-all task (scripts/install-deps.sh):
- mise install (go, node, golangci-lint)
- cd web/ui && npm install
- cd electron && npm install

Usage:
  mise run check-toolchain   # audit everything
  mise run install-all       # install all deps from scratch
2026-06-22 03:08:40 -07:00
david 669d3abe27 feat: collapsible section and libraries, compact card layout
- Sample Library section now collapsible via header click
- Library cards collapsible via header click (toggle icon ▾)
- Collapse All button to batch-collapse all libraries
- Compact sample cards: 28px height, 10px font, 70px min grid column
- Tighter spacing throughout (8px lib gap, 4px sample grid gap)
- Play icon removed from cards (card itself is clickable)
2026-06-22 02:52:24 -07:00
david 7124b16fc1 docs: update AGENTS.md and README for new features, add JS tests
Docs:
- AGENTS.md: updated toolchain section with js-lint/js-test tasks,
  expanded web/ui/ directory listing, added event delegation and
  Web Audio API to key patterns, updated testing section
- README: marked issue #9 (Audio Preview Player) as done
- .gitignore: added akai-fetch.test and cover.out

Tests:
- Moved groupWavs and cleanName to util.js (pure functions)
- Added 10 new JS tests: 6 groupWavs, 4 cleanName
- Total JS tests: 29 (up from 19)
2026-06-22 02:46:34 -07:00
david e0bad5e5ac feat: card-based Sample Library layout with grouping
Redesigned WAV Browser as a structured Sample Library with card layout:
- WAVs grouped by library (ISO name) and program (volume name)
- Library cards with collapsible headers showing program/sample counts
- Program sections within each library
- Sample cards in a compact grid with play-on-click

Updated playWav to show a clean sample name in the player bar,
hide the player when playback ends, and use sample-card selectors
for active-state highlighting.

Clean display names (underscores → spaces, stripped .wav extensions)
2026-06-22 02:35:55 -07:00
david b5650c53ba test: add JS test framework using Node built-in test runner
Extracted pure utility functions (esc, sanitize, formatBytes, formatTime)
into web/ui/util.js with CommonJS export for Node testing compatibility.
Added 19 tests across 4 suites using node:test + node:assert (zero deps).

- util.js: browser-compatible utility functions
- util.test.js: 19 tests (5 esc, 3 sanitize, 6 formatBytes, 5 formatTime)
- package.json: 'test' script → node --test util.test.js
- mise.toml: js-test task
- app.js: removed duplicate helper functions (now in util.js)
- index.html: load util.js before app.js
2026-06-22 02:23:24 -07:00
david d7a17eb054 fix: show extract API errors in UI instead of false 'Extraction complete'
doExtract() only checked data.message, so JSON responses with
error key (e.g. 'no ISO files found') would show green success.
Now checks data.error first and displays it in red.
2026-06-22 01:10:40 -07:00
david fe3e92ce33 feat: recursive WAV browsing in subdirectories
Handle ListWavs now uses filepath.WalkDir to find .wav files
in all subdirectories. Returns relative paths (e.g. Kicks/kick.wav).
Serves them correctly via /wavs/ prefix.

Fixed: WalkDir swallows root errors differently than ReadDir —
now detects and reports non-existent root directory errors.

Added test for recursive subdirectory traversal.
2026-06-22 01:05:44 -07:00
david 371feaf0cb fix: initialize files as empty slice instead of nil var
var files []string marshals to JSON null, breaking the frontend
which expects an array. Using files := []string{} ensures [].
2026-06-22 00:46:30 -07:00
david b69e1d81ab debug: add console logging to browseWavs and playWav 2026-06-22 00:42:18 -07:00
david cd8484de0c fix: pin electron to exact version 33.4.11
electron-builder requires an exact version (not a range like ^33.0.0)
because it downloads platform-specific binaries for a specific release.
2026-06-22 00:22:19 -07:00
david d194a25bf8 chore: pin node to 22.12 to fix electron-build engine requirements
@electron/rebuild and node-abi require node >=22.12.0.
System node (22.9.0) caused EBADENGINE errors during electron-build.
2026-06-22 00:16:42 -07:00
david 4f03524668 chore: add JS/ESLint toolchain, refactor onclick to event delegation
- Add ESLint config for web/ui (flat config, golangci-lint-compatible)
- Add js-lint task to mise.toml (npm run lint in web/ui)
- Add ESLint check to check-toolchain.sh
- Add .golangci.yml for golangci-lint v2 with correct exclusions
- Refactor app.js: replace inline onclick with data-action + event delegation
- Add null checks and HTTP status checks to browseWavs
- Add web/ui/node_modules/ to .gitignore
2026-06-22 00:10:49 -07:00
david f716902580 fix: handle null data in browseWavs, add HTTP status check
Avoid 'Cannot read properties of null (reading error)' when server
returns null or non-JSON. Also check resp.ok before parsing JSON.
2026-06-22 00:02:32 -07:00
david cf53912a33 feat: Audio Preview Player — browse and play WAV samples via Web Audio API
Issue: #9

Backend:
- GET /api/list-wavs?dir=<path> — lists .wav files in a directory
- GET /wavs/<file> — static file serving from ./output/wavs

Web UI:
- WAV Browser section with path input + Browse button
- AudioContext-based playback (zero deps) — progress bar, time display
- WAV file grid with Play buttons, active item highlighting

Tests:
- 3 new tests for handleListWavs (wav files, empty dir, nonexistent dir)
- Fix TestFindAkaiutil: add t.Chdir(tmp) so relative path lookups don't
  accidentally find the repo's third_party/akaiutil/akaiutil binary
2026-06-22 00:01:15 -07:00
david 120993fb02 test: add comprehensive test suite - 5 test files, 65% coverage
- cli_test.go: 10 tests for list/search/extract/download commands
- serve_test.go: 11 tests for HTTP handlers and SSE progress
- http_test.go: 5 subtests for archive.org API
- download_test.go: 10 tests for download logic
- util_test.go: 7 tests for string/util helpers

Error paths covered: server errors, empty results, download failures,
extract failures, missing identifiers, ReadDir errors.

golangci-lint: 0 issues, go vet: clean
2026-06-21 23:54:57 -07:00
david 470fb9a4c4 docs: update README/AGENTS for current codebase
- All references updated from fetch → akai-fetch
- AGENTS: document golangci-lint, extract.go replaces bash,
  remove findScript, update Electron binary name
- README: update roadmap, extraction tech stack
- mise.toml: add golangci-lint tool
2026-06-21 23:22:00 -07:00
david a17f22ccb2 rename binary from fetch to akai-fetch
Consistent with go.mod, usage strings, and docs.
Updated Dockerfile, electron config, and Makefile.
2026-06-21 22:45:12 -07:00
david f195ba5a5c ui: move search results below download/extract controls
Search bar stays at top as the "command center".
Download queue and extract controls follow.
Results render at the bottom — the main scrolling area.
2026-06-21 22:35:18 -07:00
david 31fd368bda fix: create parent dirs for downloads with subdirectory paths
Some archive.org file names include directory prefixes
(e.g. "ZERO-G - Deepest India/vocal.iso"). MkdirAll only
created the output root, not intermediate directories.
Added filepath.Dir(MkdirAll) in both downloadFile (CLI)
and handleAPIDownload (web).
2026-06-21 22:31:49 -07:00
david 04d2bec114 fix: add init target to pre-create output dirs before docker
Docker creates host-side bind-mount directories as root:root.
Pre-creating them with make init (or mise run init) ensures
they exist with the user's ownership before docker compose
touches them. docker-up now depends on init.
2026-06-21 22:29:55 -07:00
david 15979d01b5 fix: check MkdirAll errors, add gopls to toolchain
- serve.go/cmdAPIDownload: check MkdirAll error, use absolute path
- main.go/cmdDownload,cmdExtract: check MkdirAll errors
- check-toolchain.sh: verify gopls installation
- mise.toml: add gopls install task
- AGENTS.md: document gopls as dev dependency
2026-06-21 22:27:17 -07:00
david ea2bcde74b feat: reimplement WAV extraction in Go (replaces extract_wavs.sh)
extract.go replaces the bash script with idiomatic Go:
- akaiutil process communication via stdin/stdout pipes
- partition enumeration (df output parsing)
- volume enumeration (dir output parsing per partition)
- sample2wavall per volume with WAV count aggregation

runAkaiutil is a package-level var — tests stub it with canned
output to verify parsing without a real akaiutil binary or ISO.

Removed: findScript(), -tool flag on extract, os/exec from main.go
Kept: extract_wavs.sh as a standalone utility (no longer called)
2026-06-21 22:17:24 -07:00
david bc12130dd6 fix: ensure output dirs exist in extract and API extract
cmdExtract and handleAPIExtract were missing os.MkdirAll calls
while cmdDownload, cmdPipeline, and handleAPIDownload all had
them. The bash script creates dirs internally but this is not
robust — create dirs in Go before shelling out.

Closes #13
2026-06-21 22:04:47 -07:00
david 4c09fb8d9b fix: useful Docker bind address output in serve
When binding to 0.0.0.0 (--host 0.0.0.0), print "localhost" and
"<host-ip>" URLs instead of the raw 0.0.0.0 address. Also handles
nil TCPAddr.IP to avoid the ":::" garbled display in Docker.
2026-06-21 22:03:01 -07:00
david ca0cd10436 docs: update README/AGENTS for current codebase state
- README: added --host flag, toolchain check commands, docker output fix
- AGENTS: documented --host bind address behavior, macOS bcopy fix,
  check-toolchain scripts, updated architecture tree
2026-06-21 22:00:08 -07:00
28 changed files with 5318 additions and 170 deletions
+5
View File
@@ -25,5 +25,10 @@ wavs
.DS_Store
Thumbs.db
# JS dependencies
web/ui/node_modules/
# Temp
/tmp/
akai-fetch.test
cover.out
+14
View File
@@ -0,0 +1,14 @@
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- staticcheck
- unused
exclusions:
paths:
- web/ui
- electron
- third_party/akaiutil/
+54 -19
View File
@@ -3,40 +3,51 @@
## Toolchain (mise)
```bash
mise trust # trust the mise.toml config (first time only)
mise install # installs go 1.26, node 22
mise trust # trust the mise.toml config (first time only)
mise install # installs go 1.26, node 22
mise run gopls # install gopls (Go LSP) — one-time dev setup
```
All build/run tasks are defined in `mise.toml`:
```bash
mise run build # → ./fetch
mise run build # → ./akai-fetch
mise run serve # → starts HTTP server, opens browser
mise run electron-deps # installs electron + electron-builder
mise run electron-dev # runs electron in dev mode
mise run docker-up # docker compose up --build
mise run lint # go vet ./...
mise run lint # golangci-lint run (Go)
mise run js-lint # eslint (JS)
mise run js-test # node --test (JS)
mise run check-all # verify gcc, brew, mise, go, node, docker, colima
```
Install golangci-lint:
```bash
mise install golangci-lint # one-time setup
golangci-lint run # comprehensive lint
```
## Build & Run (manual)
```bash
# Build Go binary (zero CGO)
go build -buildvcs=false -o fetch . # → ./fetch
go build -buildvcs=false -o akai-fetch . # → ./akai-fetch
# Build and run web server
./fetch serve # → starts on free port, opens browser
./akai-fetch serve # → starts on free port, opens browser
./akai-fetch serve --host 0.0.0.0 -p 8080 # → Docker / network-accessible mode
# Docker
docker compose up --build # serves on :8080
docker compose up --build # serves on :8080, binds 0.0.0.0
# Electron dev
(cd electron && npm install)
(cd electron && npx electron .)
# Cross-compile for macOS (from Linux)
GOOS=darwin GOARCH=arm64 go build -o fetch-darwin-arm64 .
GOOS=darwin GOARCH=amd64 go build -o fetch-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o akai-fetch-darwin-arm64 .
GOOS=darwin GOARCH=amd64 go build -o akai-fetch-darwin-amd64 .
```
## Architecture
@@ -44,11 +55,21 @@ GOOS=darwin GOARCH=amd64 go build -o fetch-darwin-amd64 .
Single binary (`fetch`) with subcommands. No external Go dependencies — pure stdlib.
```
main.go CLI entry, commands (search/list/download/extract/pipeline)
main.go CLI entry, commands (search/list/download/extract/pipeline/serve)
serve.go HTTP server + REST API + embedded web UI (embed.FS)
web/ui/ Static frontend (index.html, style.css, app.js)
web/ui/ Static frontend
├── index.html HTML layout
├── style.css CSS custom properties theme
├── util.js shared utility functions
├── app.js application logic, event delegation
├── util.test.js JS tests (node --test)
├── eslint.config.js ESLint flat config
└── package.json ESLint + test scripts
electron/ Electron wrapper shell
scripts/ Bash helpers called via exec
├── extract_wavs.sh batch WAV extraction via akaiutil
├── check-toolchain.sh verify gcc, brew, mise, go, node
└── check-docker-toolchain.sh verify docker, compose, colima
third_party/ Vendored akaiutil C binary (GPLv2)
```
@@ -58,13 +79,18 @@ third_party/ Vendored akaiutil C binary (GPLv2)
- **Flag parsing**: each command creates its own `flag.NewFlagSet` — avoids global flag state
- **Concurrency**: semaphore channel pattern (`sem := make(chan struct{}, N)`) for bounded parallelism
- **SSE progress**: `serve.go:handleProgress` — 500ms ticker, EventSource stream, auto-closes when all done
- **Script discovery**: `serve.go:findScript()` — tries multiple candidate paths, fallback chain
- **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource`
- **Event delegation**: `app.js` — single click listener on document, dispatches by `[data-action]` attribute (download, list, play)
- **akaiutil wrapper**: `extract.go` — persistent stdin/stdout pipes, no bash intermediary
- **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource`. Web Audio API for WAV playback.
- **Embed directive**: `//go:embed web/ui/*` in `serve.go` — frontend baked into the binary
- **Bind address handling**: `serve.go:37``--host` flag defaults to `127.0.0.1` for local dev; set `--host 0.0.0.0` for Docker. When binding to `0.0.0.0`, prints both `localhost` and `<host-ip>` URLs for clarity.
### akaiutil integration
`scripts/extract_wavs.sh` shells out to `akaiutil` for:
`extract.go` wraps akaiutil via stdin/stdout pipes — no bash intermediary.
`scripts/check-toolchain.sh` and `check-docker-toolchain.sh` verify the dev environment.
akaiutil is used for:
- `df` — disk info (partitions, block counts)
- `dir` — list volumes/files
- `sample2wavall` — batch WAV extraction
@@ -72,11 +98,15 @@ third_party/ Vendored akaiutil C binary (GPLv2)
All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, partitioning, tagging) require removing `-r`.
### macOS compatibility
- `third_party/akaiutil/commoninclude.h` has `bcopy`/`bzero` declarations guarded with `#ifdef _VISUALCPC` to avoid conflicts with macOS system headers that define these in `<strings.h>`.
### Electron integration
`electron/main.js` spawns the Go binary in `serve` mode:
1. Finds `fetch` binary next to app or in `process.resourcesPath`
2. Spawns `fetch serve -p 0 --no-browser`
1. Finds `akai-fetch` binary next to app or in `process.resourcesPath`
2. Spawns `akai-fetch serve -p 0 --no-browser`
3. Parses `stdout` for `Local: http://localhost:XXXX` to get port
4. Creates `BrowserWindow` pointing at that URL
5. Kills server on quit
@@ -104,10 +134,15 @@ All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, p
## Testing
No test framework yet. Manual verification:
```bash
go build -o /dev/null . # compile check
./fetch serve # manual smoke test
# Go
go test -cover # unit tests (extract, serve, http, download, cli, util)
go vet ./... # static analysis
golangci-lint run # comprehensive lint (uses .golangci.yml)
# JavaScript
mise run js-test # node --test (19 tests: esc, sanitize, formatBytes, formatTime)
mise run js-lint # eslint (flat config)
```
## Secrets & Tokens
+6 -6
View File
@@ -9,13 +9,13 @@ COPY third_party/akaiutil/ /src/akaiutil/
WORKDIR /src/akaiutil
RUN make
# Stage 2: Build fetch (Go tool)
FROM golang:1.26-alpine AS fetch-builder
# Stage 2: Build akai-fetch (Go tool)
FROM golang:1.26-alpine AS akai-fetch-builder
WORKDIR /src
COPY go.mod main.go serve.go /src/
COPY go.mod *.go /src/
COPY web/ /src/web/
RUN CGO_ENABLED=0 go build -o /fetch .
RUN CGO_ENABLED=0 go build -o /akai-fetch .
# Stage 3: Runtime image
FROM debian:11-slim
@@ -28,10 +28,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN mkdir -p /data/output/isos /data/output/wavs
COPY --from=akaiutil-builder /src/akaiutil/akaiutil /usr/local/bin/
COPY --from=fetch-builder /fetch /usr/local/bin/fetch
COPY --from=akai-fetch-builder /akai-fetch /usr/local/bin/akai-fetch
COPY scripts/extract_wavs.sh /usr/local/bin/
WORKDIR /data
EXPOSE 8080
ENTRYPOINT ["fetch"]
ENTRYPOINT ["akai-fetch"]
CMD ["serve", "-p", "8080", "--host", "0.0.0.0"]
+6 -8
View File
@@ -1,6 +1,5 @@
BIN := fetch
BIN := akai-fetch
IMAGE := akai-fetch
REMOTE := dhg.lol
ELECTRON_DIR := electron
build:
@@ -17,7 +16,10 @@ docker:
docker-run:
docker run --rm -v "$(PWD)/output:/data" $(IMAGE) $(ARGS)
docker-up:
init:
mkdir -p output/isos output/wavs
docker-up: init
docker compose up --build
docker-down:
@@ -45,10 +47,6 @@ electron-build-linux: build akaiutil
# ─── Server / remote ───────────────────────────────────────
install: build
scp $(BIN) $(REMOTE):/tmp/$(BIN) && \
ssh $(REMOTE) "sudo mv /tmp/$(BIN) /DATA/Downloads/akai/$(BIN) && sudo chmod +x /DATA/Downloads/akai/$(BIN)"
clean:
rm -f $(BIN)
rm -rf $(ELECTRON_DIR)/dist
@@ -57,4 +55,4 @@ clean:
.PHONY: build akaiutil all docker docker-run docker-up docker-down serve \
electron-deps electron-dev electron-build electron-build-mac electron-build-linux \
install clean
clean
+30 -12
View File
@@ -12,12 +12,19 @@ Two interfaces:
```bash
mise trust # first time only
mise install # installs go 1.26, node 22
mise run build # → ./fetch
mise run build # → ./akai-fetch
```
### Toolchain check
```bash
mise run check-all # checks gcc, brew, mise, go, node, docker, colima
mise run check-toolchain # core tools only
mise run check-docker-toolchain # docker + compose + colima (macOS)
```
### Docker
```bash
docker compose up --build
mise run docker-up
# Open http://localhost:8080
```
@@ -25,11 +32,15 @@ docker compose up --build
```bash
# CLI
mise run build
./fetch search --filter vocal
./akai-fetch search --filter vocal
# Web server
# Web server (local)
mise run serve
# Open http://localhost:8080
# Open http://localhost:PORT (auto-assigned)
# Web server (Docker / network-accessible)
./akai-fetch serve --host 0.0.0.0 -p 8080
# Open http://<host-ip>:8080
# Electron (macOS)
mise run electron-deps
@@ -49,30 +60,34 @@ akai-fetch serve [flags] start HTTP server with web UI
## Architecture
```
fetch serve (Go HTTP server, embedded web UI)
akai-fetch serve (Go HTTP server, embedded web UI)
├── /api/search archive.org search
├── /api/list list ISO files in an item
├── /api/download download ISOs with SSE progress
├── /api/extract extract WAVs via akaiutil
└── /api/progress SSE download progress stream
electron/ Electron wrapper (spawns fetch serve as sidecar)
electron/ Electron wrapper (spawns akai-fetch serve as sidecar)
├── main.js main process
├── preload.js context bridge
└── package.json electron-builder config
scripts/
├── extract_wavs.sh batch WAV extraction via akaiutil
├── check-toolchain.sh verify gcc, brew, mise, go, node
└── check-docker-toolchain.sh verify docker, compose, colima
third_party/akaiutil/ Vendored akaiutil 4.6.7 (C, GPLv2)
scripts/extract_wavs.sh Bash wrapper for batch WAV extraction
```
## Tech Stack
| Component | Tech | External Deps |
|-------------|--------------------|---------------|
| CLI server | Go 1.26, stdlib | 0 |
| Web UI | HTML/CSS/JS (vanilla) | 0 |
| CLI server | Go 1.26, stdlib | 0 |
| Web UI | HTML/CSS/JS (vanilla) | 0 |
| Electron | Electron 33, electron-builder | Node deps |
| Extraction | akaiutil (C), bash | vendored |
| Extraction | akaiutil (C) via Go wrappers | vendored C source |
Zero Go dependencies. The web UI uses no framework — vanilla JS, Web Audio API, Server-Sent Events.
@@ -84,6 +99,9 @@ See [open issues](https://git.notsosm.art/david/akai-utils/issues).
|---|---------|--------|
| — | Search, download, extract, pipeline (CLI) | done |
| — | HTTP server + web UI + Electron shell | done |
| — | WAV extraction re-implemented in Go | done |
| — | MkdirAll error handling + parent dir creation for downloads | done |
| — | Docker compose + mise toolchain + toolchain checks | done |
| #1 | Disk Browser — browse AKAI filesystem in web UI | todo |
| #2 | Disk Inspector — detailed disk metadata | todo |
| #3 | WAV→Disk Packager — create AKAI ISOs from WAVs | todo |
@@ -92,7 +110,7 @@ See [open issues](https://git.notsosm.art/david/akai-utils/issues).
| #6 | Direct-to-Disk Take Extraction | todo |
| #7 | TAR Bulk Import/Export | todo |
| #8 | Sample Tag Editor | todo |
| #9 | Audio Preview Player | todo |
| #9 | Audio Preview Player | done |
## License
+610
View File
@@ -0,0 +1,610 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestCmdList_NoArgs(t *testing.T) {
orig := osExit
var exitCode int
osExit = func(code int) { exitCode = code }
defer func() { osExit = orig }()
done := make(chan struct{})
go func() {
defer close(done)
cmdList([]string{})
}()
<-done
if exitCode != 1 {
t.Errorf("expected exit code 1, got %d", exitCode)
}
}
func TestCmdList_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
orig := osExit
var exitCode int
osExit = func(code int) { exitCode = code }
defer func() { osExit = orig }()
done := make(chan struct{})
go func() {
defer close(done)
cmdList([]string{"testitem"})
}()
<-done
if exitCode != 1 {
t.Errorf("expected exit code 1 for server error, got %d", exitCode)
}
}
func TestCmdSearch_FilterFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q == "" {
t.Error("empty query")
}
resp := SearchResult{Response: struct {
NumFound int `json:"numFound"`
Docs []SearchDoc `json:"docs"`
}{
NumFound: 0, Docs: []SearchDoc{},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origSearch := iaSearchURL
iaSearchURL = server.URL + "/advancedsearch.php"
defer func() { iaSearchURL = origSearch }()
orig := osExit
var exitCode int
osExit = func(code int) { exitCode = code }
defer func() { osExit = orig }()
done := make(chan struct{})
go func() {
defer close(done)
cmdSearch([]string{"-query", "drums", "-filter", "vocal", "-limit", "5"})
}()
<-done
if exitCode != 0 && exitCode != 1 {
t.Errorf("expected exit 0 or 1, got %d", exitCode)
}
}
func TestCmdSearch_ZeroResults(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := SearchResult{Response: struct {
NumFound int `json:"numFound"`
Docs []SearchDoc `json:"docs"`
}{
NumFound: 0, Docs: []SearchDoc{},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origSearch := iaSearchURL
iaSearchURL = server.URL + "/advancedsearch.php"
defer func() { iaSearchURL = origSearch }()
orig := osExit
var exitCode int
osExit = func(code int) { exitCode = code }
defer func() { osExit = orig }()
done := make(chan struct{})
go func() {
defer close(done)
cmdSearch([]string{"-query", "nonexistent XYZ query 12345", "-limit", "5"})
}()
<-done
if exitCode != 0 {
t.Errorf("expected exit 0 for zero results, got %d", exitCode)
}
}
func TestCmdSearch_MinDownloadsFilter(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := SearchResult{
Response: struct {
NumFound int `json:"numFound"`
Docs []SearchDoc `json:"docs"`
}{
NumFound: 2,
Docs: []SearchDoc{
{Identifier: "low-dl", Title: "Low DL", Downloads: 5},
{Identifier: "high-dl", Title: "High DL", Downloads: 1000},
},
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origSearch := iaSearchURL
iaSearchURL = server.URL + "/advancedsearch.php"
defer func() { iaSearchURL = origSearch }()
orig := osExit
var exitCode int
osExit = func(code int) { exitCode = code }
defer func() { osExit = orig }()
done := make(chan struct{})
go func() {
defer close(done)
cmdSearch([]string{"-min-downloads", "100"})
}()
<-done
if exitCode != 0 {
t.Errorf("expected exit 0, got %d", exitCode)
}
}
func TestGetItemFiles_MalformedJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte("not valid json{")); err != nil {
return
}
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
orig := osExit
var exitCode int
osExit = func(code int) { exitCode = code }
defer func() { osExit = orig }()
done := make(chan struct{})
go func() {
defer close(done)
cmdList([]string{"badjson"})
}()
<-done
if exitCode != 1 {
t.Errorf("expected exit 1 for malformed JSON, got %d", exitCode)
}
}
func TestGetItemFiles_EmptyIdentifier(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte(`{"files":[]}`)); err != nil {
return
}
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
orig := osExit
var exitCode int
osExit = func(code int) { exitCode = code }
defer func() { osExit = orig }()
done := make(chan struct{})
go func() {
defer close(done)
cmdList([]string{""})
}()
<-done
if exitCode != 0 {
t.Errorf("expected exit 0 for empty identifier (empty item), got %d", exitCode)
}
}
func TestSanitiseISOName_EvenMoreCases(t *testing.T) {
cases := []struct {
input string
expected string
}{
{"AKAI CD VOL.1", "AKAI_CD_VOL"},
{"S900 COMPACT DISK", "S900_COMPACT_DISK"},
{"PROFESSIONAL SAMPLES VOL_1", "PROFESSIONAL_SAMPLES_VOL_1"},
{"disk image (iso)", "disk_image_iso"},
{"folder/", "folder"},
{"...dots...", "dots"},
{"all!@#$%special", "all_special"},
{"tab\there", "tab_here"},
}
for _, c := range cases {
got := sanitiseISOName(c.input)
if got != c.expected {
t.Errorf("sanitiseISOName(%q) = %q, want %q", c.input, got, c.expected)
}
}
}
func TestCmdExtract_WithISOs(t *testing.T) {
orig := extractISO
extractISO = func(isoPath, outDir string) (string, error) {
return "Total WAV files: 10 (3s)", nil
}
defer func() { extractISO = orig }()
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(isoDir, "test.iso"))
defer func() { _ = fd.Close() }()
done := make(chan struct{})
var exitCode int
go func() {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
exitCode = fe.code
}
}
close(done)
}()
cmdExtract([]string{"-dir", isoDir, "-out", filepath.Join(tmp, "wavs"), "-jobs", "1"})
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("test timed out — possible goroutine leak")
}
if exitCode != 0 {
t.Errorf("expected exit 0, got %d", exitCode)
}
}
func TestCmdExtract_NoISOs(t *testing.T) {
orig := extractISO
extractISO = func(isoPath, outDir string) (string, error) {
return "should not be called", nil
}
defer func() { extractISO = orig }()
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "empty_isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
var exitCode int
go func() {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
exitCode = fe.code
}
}
close(done)
}()
cmdExtract([]string{"-dir", isoDir, "-out", filepath.Join(tmp, "wavs")})
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("test timed out")
}
if exitCode != 0 {
t.Errorf("expected exit 0 for no ISOs (print and return), got %d", exitCode)
}
}
func TestCmdExtract_BadOutDir(t *testing.T) {
orig := extractISO
extractISO = func(isoPath, outDir string) (string, error) {
return "stubbed", nil
}
defer func() { extractISO = orig }()
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(isoDir, "test.iso"))
defer func() { _ = fd.Close() }()
outDir := filepath.Join(tmp, "nonexistent", "wavs")
done := make(chan struct{})
var exitCode int
go func() {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
exitCode = fe.code
}
}
close(done)
}()
cmdExtract([]string{"-dir", isoDir, "-out", outDir})
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("test timed out")
}
if exitCode != 0 {
t.Errorf("expected exit 0 (MkdirAll succeeds), got %d", exitCode)
}
}
func TestCmdExtract_MultipleISOsConcurrent(t *testing.T) {
orig := extractISO
calls := 0
extractISO = func(isoPath, outDir string) (string, error) {
calls++
return fmt.Sprintf("ISO %d done", calls), nil
}
defer func() { extractISO = orig }()
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"iso1.iso", "iso2.iso", "iso3.iso"} {
fd, _ := os.Create(filepath.Join(isoDir, name))
if err := fd.Close(); err != nil {
t.Fatal(err)
}
}
done := make(chan struct{})
var exitCode int
go func() {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
exitCode = fe.code
}
}
close(done)
}()
cmdExtract([]string{"-dir", isoDir, "-out", filepath.Join(tmp, "wavs"), "-jobs", "2"})
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("test timed out")
}
if exitCode != 0 {
t.Errorf("expected exit 0, got %d", exitCode)
}
if calls != 3 {
t.Errorf("expected 3 extractISO calls, got %d", calls)
}
}
func TestCmdDownload_SingleIdentifier(t *testing.T) {
origGet := getItemFiles
defer func() { getItemFiles = origGet }()
getItemFiles = func(id string) []MetadataFile {
return []MetadataFile{
{Name: "test.iso", Size: "1000"},
}
}
origDL := downloadFile
defer func() { downloadFile = origDL }()
var dlCalls int
downloadFile = func(job downloadJob) (int64, error) {
dlCalls++
return job.Size, nil
}
tmp := t.TempDir()
done := make(chan struct{})
var exitCode int
go func() {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
exitCode = fe.code
}
}
close(done)
}()
cmdDownload([]string{"-i", "testid", "-dir", tmp})
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("test timed out")
}
if exitCode != 0 {
t.Errorf("expected exit 0, got %d", exitCode)
}
if dlCalls != 1 {
t.Errorf("expected 1 download call, got %d", dlCalls)
}
}
func TestCmdDownload_NoISOsInItem(t *testing.T) {
origGet := getItemFiles
defer func() { getItemFiles = origGet }()
getItemFiles = func(id string) []MetadataFile {
return []MetadataFile{
{Name: "readme.txt", Size: "500"},
}
}
tmp := t.TempDir()
done := make(chan struct{})
var exitCode int
go func() {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
exitCode = fe.code
}
}
close(done)
}()
cmdDownload([]string{"-i", "n_iso_item", "-dir", tmp})
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("test timed out")
}
if exitCode != 0 {
t.Errorf("expected exit 0 (no jobs printed, not fatal), got %d", exitCode)
}
}
func TestCmdDownload_MultipleIdentifiers(t *testing.T) {
origGet := getItemFiles
defer func() { getItemFiles = origGet }()
getItemFiles = func(id string) []MetadataFile {
if id == "item1" {
return []MetadataFile{{Name: "iso1.iso", Size: "100"}}
}
if id == "item2" {
return []MetadataFile{{Name: "iso2.iso", Size: "200"}}
}
return nil
}
origDL := downloadFile
defer func() { downloadFile = origDL }()
var dlCalls int
downloadFile = func(job downloadJob) (int64, error) {
dlCalls++
return job.Size, nil
}
tmp := t.TempDir()
idFile := filepath.Join(tmp, "ids.txt")
if err := os.WriteFile(idFile, []byte("item1\nitem2\n"), 0644); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
var exitCode int
go func() {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
exitCode = fe.code
}
}
close(done)
}()
cmdDownload([]string{"-f", idFile, "-dir", tmp})
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("test timed out")
}
if exitCode != 0 {
t.Errorf("expected exit 0, got %d", exitCode)
}
if dlCalls != 2 {
t.Errorf("expected 2 download calls, got %d", dlCalls)
}
}
func TestCmdSearch_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
origSearch := iaSearchURL
iaSearchURL = server.URL + "/advancedsearch.php"
defer func() { iaSearchURL = origSearch }()
orig := osExit
var exitCode int
osExit = func(code int) { exitCode = code }
defer func() { osExit = orig }()
done := make(chan struct{})
go func() {
defer close(done)
cmdSearch([]string{"-query", "test", "-limit", "5"})
}()
<-done
if exitCode != 1 {
t.Errorf("expected exit 1 for server error, got %d", exitCode)
}
}
func TestCmdDownload_DownloadFileError(t *testing.T) {
origGet := getItemFiles
defer func() { getItemFiles = origGet }()
getItemFiles = func(id string) []MetadataFile {
return []MetadataFile{
{Name: "test.iso", Size: "1000"},
}
}
origDL := downloadFile
defer func() { downloadFile = origDL }()
downloadFile = func(job downloadJob) (int64, error) {
return 0, fmt.Errorf("connection reset")
}
tmp := t.TempDir()
done := make(chan struct{})
var exitCode int
go func() {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
exitCode = fe.code
}
}
close(done)
}()
cmdDownload([]string{"-i", "testid", "-dir", tmp})
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("test timed out")
}
if exitCode != 0 {
t.Errorf("expected exit 0 despite download error, got %d", exitCode)
}
}
func TestOpenURL_NoPanic(t *testing.T) {
openURL("https://example.com")
}
+326
View File
@@ -0,0 +1,326 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
)
func TestDownloadFile(t *testing.T) {
origUserAgent := userAgent
userAgent = "akai-fetch-test/1.0"
defer func() { userAgent = origUserAgent }()
t.Run("full download 200 OK", func(t *testing.T) {
content := []byte("hello world from archive")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") != "akai-fetch-test/1.0" {
t.Errorf("expected user-agent header, got %q", r.Header.Get("User-Agent"))
}
if r.Header.Get("Range") != "" {
t.Errorf("unexpected Range header on first download: %q", r.Header.Get("Range"))
}
w.WriteHeader(http.StatusOK)
if _, err := w.Write(content); err != nil {
return
}
}))
defer server.Close()
tmp := t.TempDir()
outPath := filepath.Join(tmp, "testfile.txt")
job := downloadJob{
Identifier: "testitem",
Name: "testfile.txt",
URL: server.URL,
OutPath: outPath,
Size: int64(len(content)),
}
size, err := downloadFile(job)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if size != int64(len(content)) {
t.Errorf("expected %d bytes, got %d", len(content), size)
}
data, _ := os.ReadFile(outPath)
if string(data) != string(content) {
t.Errorf("file content mismatch: got %q", string(data))
}
})
t.Run("resume partial download 206 Partial Content", func(t *testing.T) {
existingContent := []byte("content file here123456789012345")
newContent := []byte(" appended data")
existingLen := int64(len(existingContent))
totalLen := existingLen + int64(len(newContent))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rangeH := r.Header.Get("Range")
if rangeH == "" {
t.Error("expected Range header for resume")
}
expectedRange := "bytes=" + strconv.FormatInt(existingLen, 10) + "-"
if rangeH != expectedRange {
t.Errorf("expected Range %q, got %q", expectedRange, rangeH)
}
w.WriteHeader(http.StatusPartialContent)
if _, err := w.Write(newContent); err != nil {
return
}
}))
defer server.Close()
tmp := t.TempDir()
outPath := filepath.Join(tmp, "partial.txt")
if err := os.WriteFile(outPath, existingContent, 0644); err != nil {
t.Fatal(err)
}
job := downloadJob{
Identifier: "testitem",
Name: "partial.txt",
URL: server.URL,
OutPath: outPath,
Size: totalLen,
}
size, err := downloadFile(job)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if size != totalLen {
t.Errorf("expected %d total bytes, got %d", totalLen, size)
}
data, _ := os.ReadFile(outPath)
if string(data) != string(existingContent)+string(newContent) {
t.Errorf("file content mismatch, got %q", string(data))
}
})
t.Run("skip when file already complete", func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("should not be called")); err != nil {
return
}
}))
defer server.Close()
tmp := t.TempDir()
outPath := filepath.Join(tmp, "complete.txt")
existingContent := []byte("already complete file content here")
if err := os.WriteFile(outPath, existingContent, 0644); err != nil {
t.Fatal(err)
}
job := downloadJob{
Identifier: "testitem",
Name: "complete.txt",
URL: server.URL,
OutPath: outPath,
Size: int64(len(existingContent)),
}
size, err := downloadFile(job)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if size != int64(len(existingContent)) {
t.Errorf("expected %d bytes, got %d", len(existingContent), size)
}
if calls > 0 {
t.Errorf("server should not have been called, got %d calls", calls)
}
})
t.Run("HTTP error returns error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
tmp := t.TempDir()
outPath := filepath.Join(tmp, "notfound.txt")
job := downloadJob{
Identifier: "testitem",
Name: "notfound.txt",
URL: server.URL,
OutPath: outPath,
Size: 1234,
}
_, err := downloadFile(job)
if err == nil {
t.Fatal("expected error for 404 response")
}
})
t.Run("HTTP 500 returns error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
tmp := t.TempDir()
outPath := filepath.Join(tmp, "error.txt")
job := downloadJob{
Identifier: "testitem",
Name: "error.txt",
URL: server.URL,
OutPath: outPath,
Size: 999,
}
_, err := downloadFile(job)
if err == nil {
t.Fatal("expected error for 500 response")
}
})
t.Run("download creates parent directories", func(t *testing.T) {
content := []byte("content")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write(content); err != nil {
return
}
}))
defer server.Close()
tmp := t.TempDir()
outPath := filepath.Join(tmp, "nested", "dirs", "file.txt")
job := downloadJob{
Identifier: "testitem",
Name: "file.txt",
URL: server.URL,
OutPath: outPath,
Size: int64(len(content)),
}
size, err := downloadFile(job)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if size != int64(len(content)) {
t.Errorf("expected %d bytes, got %d", len(content), size)
}
if _, err := os.Stat(outPath); err != nil {
t.Errorf("file should exist at nested path: %v", err)
}
})
t.Run("zero size file downloads without size check", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
tmp := t.TempDir()
outPath := filepath.Join(tmp, "zerofile")
job := downloadJob{
Identifier: "testitem",
Name: "zerofile",
URL: server.URL,
OutPath: outPath,
Size: 0,
}
size, err := downloadFile(job)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if size != 0 {
t.Errorf("expected 0 bytes, got %d", size)
}
data, _ := os.ReadFile(outPath)
if len(data) != 0 {
t.Errorf("expected empty file, got %d bytes", len(data))
}
})
t.Run("partial file without size match triggers full resume", func(t *testing.T) {
existingContent := []byte("partial but wrong size")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rangeH := r.Header.Get("Range")
if rangeH == "" {
t.Error("expected Range header")
}
w.WriteHeader(http.StatusPartialContent)
if _, err := w.Write([]byte(" more data")); err != nil {
return
}
}))
defer server.Close()
tmp := t.TempDir()
outPath := filepath.Join(tmp, "partial.txt")
if err := os.WriteFile(outPath, existingContent, 0644); err != nil {
t.Fatal(err)
}
job := downloadJob{
Identifier: "testitem",
Name: "partial.txt",
URL: server.URL,
OutPath: outPath,
Size: int64(len(existingContent) + 100), // wrong size, triggers resume
}
_, err := downloadFile(job)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}
func TestDownloadFile_ClientErrors(t *testing.T) {
t.Run("connection refused returns error", func(t *testing.T) {
job := downloadJob{
URL: "http://127.0.0.1:1/notexist",
OutPath: "/tmp/out.txt",
Size: 100,
}
_, err := downloadFile(job)
if err == nil {
t.Fatal("expected error for connection refused")
}
})
t.Run("invalid URL returns error", func(t *testing.T) {
tmp := t.TempDir()
job := downloadJob{
URL: "://invalid",
OutPath: filepath.Join(tmp, "out.txt"),
Size: 100,
}
_, err := downloadFile(job)
if err == nil {
t.Fatal("expected error for invalid URL")
}
})
}
func TestHumanSize_EdgeCases(t *testing.T) {
cases := []struct {
bytes int64
expect string
}{
{1, "1B"},
{1023, "1023B"},
{1048577, "1.0 MB"}, // just over 1 MiB
{1073741824 * 4, "4.0 GB"},
{1099511627776, "1.0 TB"}, // 1 TiB
}
for _, c := range cases {
got := humanSize(c.bytes)
if got != c.expect {
t.Errorf("humanSize(%d) = %q, want %q", c.bytes, got, c.expect)
}
}
}
+2 -2
View File
@@ -8,10 +8,10 @@ let serverPort = 0;
function findServerBinary() {
const candidates = [
path.join(__dirname, '..', 'fetch'),
path.join(__dirname, '..', 'akai-fetch'),
path.join(process.resourcesPath, 'fetch'),
path.join(__dirname, '..', 'fetch'),
path.join(process.resourcesPath, 'akai-fetch'),
path.join(process.resourcesPath, 'fetch'),
];
for (const p of candidates) {
try {
+5 -5
View File
@@ -16,8 +16,8 @@
],
"extraResources": [
{
"from": "../fetch",
"to": "fetch"
"from": "../akai-fetch",
"to": "akai-fetch"
},
{
"from": "../scripts/extract_wavs.sh",
@@ -35,8 +35,8 @@
"artifactName": "${name}-${version}-${arch}.${ext}",
"extraResources": [
{
"from": "../fetch",
"to": "fetch"
"from": "../akai-fetch",
"to": "akai-fetch"
},
{
"from": "../scripts/extract_wavs.sh",
@@ -60,7 +60,7 @@
"build:linux": "electron-builder --linux"
},
"devDependencies": {
"electron": "^33.0.0",
"electron": "33.4.11",
"electron-builder": "^25.0.0"
}
}
+263
View File
@@ -0,0 +1,263 @@
package main
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
func findAkaiutil() (string, error) {
if env := os.Getenv("AKAIUTIL"); env != "" {
if fi, err := os.Stat(env); err == nil && !fi.IsDir() {
return env, nil
}
}
candidates := []string{
filepath.Join(filepath.Dir(os.Args[0]), "akaiutil"),
filepath.Join(filepath.Dir(os.Args[0]), "third_party", "akaiutil", "akaiutil"),
filepath.Join("third_party", "akaiutil", "akaiutil"),
"akaiutil",
}
for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return p, nil
}
}
return "", fmt.Errorf("akaiutil not found — set AKAIUTIL env var or run: make akaiutil")
}
var runAkaiutil = func(isoPath, commands string) (string, error) {
bin, err := findAkaiutil()
if err != nil {
return "", err
}
cmd := exec.Command(bin, "-r", isoPath)
cmd.Stderr = nil
stdin, err := cmd.StdinPipe()
if err != nil {
return "", fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("start akaiutil: %w", err)
}
if _, err := stdin.Write([]byte(commands)); err != nil {
return "", fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return "", fmt.Errorf("close stdin: %w", err)
}
output, _ := io.ReadAll(stdout)
if err := cmd.Wait(); err != nil {
return "", fmt.Errorf("wait akaiutil: %w", err)
}
return string(output), nil
}
type volInfo struct {
Part string `json:"part"`
Name string `json:"name"`
}
func listPartitions(isoPath string) ([]string, error) {
out, err := runAkaiutil(isoPath, "df\nq\n")
if err != nil {
return nil, err
}
re := regexp.MustCompile(`^\s+([A-Z])\s+`)
parts := []string{}
for _, line := range strings.Split(out, "\n") {
if m := re.FindStringSubmatch(line); m != nil {
parts = append(parts, m[1])
}
}
if len(parts) == 0 {
return []string{"A"}, nil
}
return parts, nil
}
func listVolumes(isoPath string, partitions []string) ([]volInfo, error) {
var cmds strings.Builder
for _, p := range partitions {
fmt.Fprintf(&cmds, "cd /disk0/%s\n", p)
fmt.Fprintf(&cmds, "dir\n")
}
cmds.WriteString("q\n")
out, err := runAkaiutil(isoPath, cmds.String())
if err != nil {
return nil, err
}
rePrompt := regexp.MustCompile(`/disk0/([A-Z])\s*>`)
reEntry := regexp.MustCompile(`^\s+\d+\s+(.+?)\s+-\s+`)
vols := []volInfo{}
currentPart := ""
for _, line := range strings.Split(out, "\n") {
if m := rePrompt.FindStringSubmatch(line); m != nil {
currentPart = m[1]
continue
}
if m := reEntry.FindStringSubmatch(line); m != nil {
name := strings.TrimRight(m[1], " ")
if currentPart != "" && name != "" {
vols = append(vols, volInfo{Part: currentPart, Name: name})
}
}
}
return vols, nil
}
func runExtraction(isoPath, baseDir string, vols []volInfo) (int, error) {
var cmds strings.Builder
for _, v := range vols {
safe := sanitiseISOName(v.Name)
if safe == "" {
safe = "volume"
}
volDir := filepath.Join(baseDir, safe)
if err := os.MkdirAll(volDir, 0755); err != nil {
return 0, fmt.Errorf("mkdir %s: %w", volDir, err)
}
nav := strings.ReplaceAll(v.Name, " ", "_")
fmt.Fprintf(&cmds, "lcd %s\n", volDir)
fmt.Fprintf(&cmds, "cd /disk0/%s/%s\n", v.Part, nav)
fmt.Fprintf(&cmds, "sample2wavall\n")
}
fmt.Fprintf(&cmds, "lcd /\nq\n")
out, err := runAkaiutil(isoPath, cmds.String())
if err != nil {
return 0, err
}
re := regexp.MustCompile(`exported\s+(\d+)\s+file`)
total := 0
for _, line := range strings.Split(out, "\n") {
if m := re.FindStringSubmatch(line); m != nil {
n, _ := strconv.Atoi(m[1])
total += n
}
}
return total, nil
}
func extractWAVs(isoPath, outDir string) (int, error) {
parts, err := listPartitions(isoPath)
if err != nil {
return 0, fmt.Errorf("enumerate partitions: %w", err)
}
vols, err := listVolumes(isoPath, parts)
if err != nil {
return 0, fmt.Errorf("enumerate volumes: %w", err)
}
if len(vols) == 0 {
return 0, nil
}
isoBase := sanitiseISOName(filepath.Base(isoPath))
baseDir := filepath.Join(outDir, isoBase)
if err := os.MkdirAll(baseDir, 0755); err != nil {
return 0, fmt.Errorf("mkdir %s: %w", baseDir, err)
}
return runExtraction(isoPath, baseDir, vols)
}
var extractISO = func(isoPath, outDir string) (string, error) {
isoOutDir := filepath.Join(outDir, sanitiseISOName(filepath.Base(isoPath)))
if fi, err := os.Stat(isoOutDir); err == nil && fi.IsDir() {
var wavCount int
_ = filepath.WalkDir(isoOutDir, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".wav") {
wavCount++
}
return nil
})
if wavCount > 0 {
return fmt.Sprintf("skipped (%d WAVs exist)", wavCount), nil
}
}
start := time.Now()
total, err := extractWAVs(isoPath, outDir)
elapsed := time.Since(start).Round(time.Second)
if err != nil {
return "", err
}
return fmt.Sprintf("Total WAV files: %d (%s)", total, elapsed), nil
}
type TagInfo struct {
Index int `json:"index"`
Name string `json:"name"`
}
func listTags(isoPath, part, volName string) ([]TagInfo, error) {
var cmds strings.Builder
fmt.Fprintf(&cmds, "df\n")
fmt.Fprintf(&cmds, "cd /disk0/%s\n", part)
nav := strings.ReplaceAll(volName, " ", "_")
fmt.Fprintf(&cmds, "cd /disk0/%s/%s\n", part, nav)
fmt.Fprintf(&cmds, "lstags\n")
cmds.WriteString("q\n")
out, err := runAkaiutil(isoPath, cmds.String())
if err != nil {
return nil, fmt.Errorf("list tags: %w", err)
}
return parseTags(out), nil
}
func parseTags(output string) []TagInfo {
reTag := regexp.MustCompile(`^\s*(\d+)\s+(\S.*)`)
tags := []TagInfo{}
inTags := false
for _, line := range strings.Split(output, "\n") {
trimmed := strings.TrimRight(line, " \r")
if strings.Contains(trimmed, "tag name") {
inTags = true
continue
}
if !inTags {
continue
}
if strings.HasPrefix(trimmed, "---") && len(tags) > 0 {
break
}
if m := reTag.FindStringSubmatch(trimmed); m != nil {
idx, _ := strconv.Atoi(m[1])
name := strings.TrimSpace(m[2])
if name != "" {
tags = append(tags, TagInfo{Index: idx, Name: name})
}
}
}
return tags
}
+483
View File
@@ -0,0 +1,483 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestExtractISO_SkipsExistingWAVs(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
called := false
runAkaiutil = func(isoPath, commands string) (string, error) {
called = true
return "", nil
}
tmp := t.TempDir()
isoOutDir := filepath.Join(tmp, sanitiseISOName(filepath.Base("/fake/test.iso")))
if err := os.MkdirAll(isoOutDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"kick.wav", "snare.wav", "hihat.wav"} {
fd, err := os.Create(filepath.Join(isoOutDir, name))
if err != nil {
t.Fatal(err)
}
defer func() { _ = fd.Close() }()
}
result, err := extractISO("/fake/test.iso", tmp)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if called {
t.Fatal("runAkaiutil should not have been called for existing WAVs")
}
if result == "" {
t.Fatal("expected non-empty result")
}
}
func TestExtractISO_ProceedsWhenNoWAVs(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
calls := 0
runAkaiutil = func(isoPath, commands string) (string, error) {
calls++
if commands == "df\nq\n" {
return `
Disk /disk0
A - S1000 HD (3840 blocks)
`, nil
}
return ` exported 3 file(s)
`, nil
}
tmp := t.TempDir()
result, err := extractISO("/fake/test.iso", tmp)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if calls == 0 {
t.Fatal("runAkaiutil should have been called")
}
if result == "" {
t.Fatal("expected non-empty result")
}
}
func TestListPartitions(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
runAkaiutil = func(isoPath, commands string) (string, error) {
if commands != "df\nq\n" {
t.Fatalf("unexpected commands: %q", commands)
}
return `
Disk /disk0
Total blocks: 12288 (8 KB each) 98304 KB
Partition table:
A - S1000 HD (3840 blocks)
B - S1000 HD (5120 blocks)
C - S1000 HD (3328 blocks)
`, nil
}
parts, err := listPartitions("/fake/disk.iso")
if err != nil {
t.Fatal(err)
}
if len(parts) != 3 {
t.Fatalf("expected 3 partitions, got %d: %v", len(parts), parts)
}
if parts[0] != "A" || parts[1] != "B" || parts[2] != "C" {
t.Fatalf("unexpected partitions: %v", parts)
}
}
func TestListPartitionsEmpty(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return "No partitions found\n", nil
}
parts, err := listPartitions("/fake/disk.iso")
if err != nil {
t.Fatal(err)
}
if len(parts) != 1 || parts[0] != "A" {
t.Fatalf("expected fallback to [A], got %v", parts)
}
}
func TestListVolumes(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return `
/disk0/A > cd /disk0/A
/disk0/A > dir
1 DRUM KIT 1 - VOLUME (10 files)
2 BASS SAMPLES - VOLUME (5 files)
/disk0/A > cd /disk0/B
/disk0/B > dir
3 SYNTH PADS - VOLUME (20 files)
/disk0/B >
`, nil
}
vols, err := listVolumes("/fake/disk.iso", []string{"A", "B"})
if err != nil {
t.Fatal(err)
}
if len(vols) != 3 {
t.Fatalf("expected 3 volumes, got %d: %v", len(vols), vols)
}
if vols[0].Part != "A" || vols[0].Name != "DRUM KIT 1" {
t.Fatalf("unexpected vol 0: %+v", vols[0])
}
if vols[1].Part != "A" || vols[1].Name != "BASS SAMPLES" {
t.Fatalf("unexpected vol 1: %+v", vols[1])
}
if vols[2].Part != "B" || vols[2].Name != "SYNTH PADS" {
t.Fatalf("unexpected vol 2: %+v", vols[2])
}
}
func TestRunExtractionCount(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
calls := 0
runAkaiutil = func(isoPath, commands string) (string, error) {
calls++
return `
exported 10 file(s)
exported 5 file(s)
exported 0 file(s)
`, nil
}
vols := []volInfo{
{Part: "A", Name: "DRUMS"},
{Part: "A", Name: "BASS"},
}
total, err := runExtraction("/fake/disk.iso", "/tmp/out", vols)
if err != nil {
t.Fatal(err)
}
if total != 15 {
t.Fatalf("expected 15 total WAVs, got %d", total)
}
if calls != 1 {
t.Fatalf("expected 1 akaiutil call, got %d", calls)
}
}
func TestExtractWAVs_Success(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
callCount := 0
runAkaiutil = func(isoPath, commands string) (string, error) {
callCount++
if callCount == 1 {
if commands != "df\nq\n" {
t.Fatalf("first call: expected df\\q\\n, got %q", commands)
}
return `
Disk /disk0
A - S1000 HD (3840 blocks)
`, nil
}
if callCount == 2 {
return `
/disk0/A > cd /disk0/A
/disk0/A > dir
1 DRUMS - VOLUME (10 files)
/disk0/A > q
`, nil
}
if callCount == 3 {
return ` exported 10 file(s)
`, nil
}
t.Fatalf("unexpected call #%d with commands: %q", callCount, commands)
return "", nil
}
tmp := t.TempDir()
total, err := extractWAVs("/fake/test.iso", tmp)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if total != 10 {
t.Fatalf("expected 10 WAVs, got %d", total)
}
if callCount != 3 {
t.Fatalf("expected 3 akaiutil calls, got %d", callCount)
}
}
func TestExtractWAVs_MultipleVolumes(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
calls := 0
runAkaiutil = func(isoPath, commands string) (string, error) {
calls++
if calls == 1 {
return `
Disk /disk0
A - S1000 HD (3840 blocks)
B - S1000 HD (5120 blocks)
`, nil
}
if calls == 2 {
return `
/disk0/A > cd /disk0/A
1 VOL_A - VOLUME (5 files)
/disk0/B > cd /disk0/B
2 VOL_B - VOLUME (8 files)
`, nil
}
return ` exported 5 file(s)
exported 8 file(s)
`, nil
}
tmp := t.TempDir()
total, err := extractWAVs("/fake/multivol.iso", tmp)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if total != 13 {
t.Fatalf("expected 13 WAVs, got %d", total)
}
if calls != 3 {
t.Fatalf("expected 3 calls, got %d", calls)
}
}
func TestExtractWAVs_NoVolumes(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
calls := 0
runAkaiutil = func(isoPath, commands string) (string, error) {
calls++
if calls == 1 {
return `Disk /disk0
A - S1000 HD (3840 blocks)
`, nil
}
if calls == 2 {
return `/disk0/A > cd /disk0/A
/disk0/A > q
`, nil
}
return ` exported 0 file(s)
`, nil
}
tmp := t.TempDir()
total, err := extractWAVs("/fake/empty.iso", tmp)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if total != 0 {
t.Fatalf("expected 0 WAVs, got %d", total)
}
}
func TestExtractWAVs_ErrorInPartitionList(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return "", fmt.Errorf("simulated partition error")
}
tmp := t.TempDir()
_, err := extractWAVs("/fake/error.iso", tmp)
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestExtractWAVs_ErrorInVolumeList(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
calls := 0
runAkaiutil = func(isoPath, commands string) (string, error) {
calls++
if calls == 1 {
return `
Disk /disk0
A - S1000 HD (3840 blocks)
`, nil
}
return "", fmt.Errorf("simulated volume list error")
}
tmp := t.TempDir()
_, err := extractWAVs("/fake/error.iso", tmp)
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestRunExtraction_CreatesVolumeDirs(t *testing.T) {
orig := runAkaiutil
defer func() { runAkaiutil = orig }()
created := map[string]bool{}
runAkaiutil = func(isoPath, commands string) (string, error) {
for _, line := range strings.Split(commands, "\n") {
if strings.HasPrefix(line, "lcd ") {
dir := strings.TrimPrefix(line, "lcd ")
created[dir] = true
}
}
return ` exported 3 file(s)
`, nil
}
tmp := t.TempDir()
vols := []volInfo{
{Part: "A", Name: "Test Volume One"},
}
baseDir := filepath.Join(tmp, "output")
_, err := runExtraction("/fake/test.iso", baseDir, vols)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !created["/"] {
t.Error("expected lcd / to be in commands")
}
}
func TestParseTags(t *testing.T) {
t.Run("parses basic tag list", func(t *testing.T) {
out := `
tag name
-----------------
01 Drums
02 Bass
03 FX
04 Vox
05 Percussion
-----------------
`
tags := parseTags(out)
if len(tags) != 5 {
t.Fatalf("expected 5 tags, got %d", len(tags))
}
if tags[0].Index != 1 || tags[0].Name != "Drums" {
t.Errorf("tag 0: got {%d %s}, want {1 Drums}", tags[0].Index, tags[0].Name)
}
if tags[4].Index != 5 || tags[4].Name != "Percussion" {
t.Errorf("tag 4: got {%d %s}, want {5 Percussion}", tags[4].Index, tags[4].Name)
}
})
t.Run("parses tags with spaces in names", func(t *testing.T) {
out := `
tag name
-----------------
01 Kick Drum
02 Hi Hat
03 Snare Drum
-----------------
`
tags := parseTags(out)
if len(tags) != 3 {
t.Fatalf("expected 3 tags, got %d", len(tags))
}
if tags[0].Name != "Kick Drum" {
t.Errorf("got %q, want 'Kick Drum'", tags[0].Name)
}
})
t.Run("handles default tag names from akaiutil", func(t *testing.T) {
out := `
tag name
-----------------
01 TAG A
02 TAG B
03 MyTag
-----------------
`
tags := parseTags(out)
if len(tags) != 3 {
t.Fatalf("expected 3 tags, got %d", len(tags))
}
})
t.Run("no tags present message returns empty", func(t *testing.T) {
out := "no tags present\n"
tags := parseTags(out)
if len(tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(tags))
}
})
t.Run("handles empty output", func(t *testing.T) {
tags := parseTags("")
if len(tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(tags))
}
})
t.Run("handles no tags in partition type", func(t *testing.T) {
out := "no tags in this partition type\n"
tags := parseTags(out)
if len(tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(tags))
}
})
}
func TestListTags_Integration(t *testing.T) {
origRun := runAkaiutil
defer func() { runAkaiutil = origRun }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return `
/disk0/A > lstags
tag name
-----------------
01 Drums
02 Bass
03 FX
-----------------
`, nil
}
tags, err := listTags("fake.iso", "A", "SomeVolume")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tags) != 3 {
t.Errorf("expected 3 tags, got %d", len(tags))
}
if tags[0].Name != "Drums" {
t.Errorf("expected Drums, got %s", tags[0].Name)
}
if tags[2].Index != 3 || tags[2].Name != "FX" {
t.Errorf("tag 2: got {%d %s}, want {3 FX}", tags[2].Index, tags[2].Name)
}
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestGetItemFiles(t *testing.T) {
t.Run("returns ISO files with sizes", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/metadata/testitem"
if r.URL.Path != expectedPath {
t.Errorf("expected path %q, got %q", expectedPath, r.URL.Path)
}
resp := MetadataResult{
Files: []MetadataFile{
{Name: "folder/something.txt", Size: "1234", Format: "text"},
{Name: "synth.iso", Size: "524288000", Format: "CD image"},
{Name: "drums.iso", Size: "1048576", Format: "CD image"},
{Name: "README.txt", Size: "500", Format: "text"},
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
files := getItemFiles("testitem")
if len(files) != 2 {
t.Fatalf("expected 2 ISO files, got %d: %+v", len(files), files)
}
if files[0].Name != "synth.iso" || files[0].Size != "524288000" {
t.Errorf("unexpected first file: %+v", files[0])
}
if files[1].Name != "drums.iso" || files[1].Size != "1048576" {
t.Errorf("unexpected second file: %+v", files[1])
}
})
t.Run("no ISO files returns empty", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := MetadataResult{Files: []MetadataFile{
{Name: "readme.txt", Size: "100", Format: "text"},
{Name: "cover.jpg", Size: "50000", Format: "image"},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
files := getItemFiles("noisos")
if len(files) != 0 {
t.Fatalf("expected 0 files, got %d", len(files))
}
})
t.Run("server returns 500", func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
// Verify server is called and returns 500
if calls != 0 {
t.Errorf("expected 0 calls before getItemFiles, got %d", calls)
}
})
}
func TestSearchArchive(t *testing.T) {
t.Run("parses search results correctly", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q != "subject:akai AND subject:sampler" {
t.Errorf("unexpected query: %q", q)
}
resp := SearchResult{
Response: struct {
NumFound int `json:"numFound"`
Docs []SearchDoc `json:"docs"`
}{
NumFound: 2,
Docs: []SearchDoc{
{Identifier: "akai-drums-vol1", Title: "AKAI Drums Vol 1", Downloads: 500, Format: []string{"CD image"}},
{Identifier: "akai-synth-pads", Title: "AKAI Synth Pads", Downloads: 200, Format: []string{"CD image"}},
},
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origSearch := iaSearchURL
iaSearchURL = server.URL + "/advancedsearch.php"
defer func() { iaSearchURL = origSearch }()
results := searchArchive("subject:akai AND subject:sampler", 30)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].Identifier != "akai-drums-vol1" || results[0].Downloads != 500 {
t.Errorf("unexpected first result: %+v", results[0])
}
})
t.Run("empty results returns empty slice", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := SearchResult{Response: struct {
NumFound int `json:"numFound"`
Docs []SearchDoc `json:"docs"`
}{
NumFound: 0,
Docs: []SearchDoc{},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origSearch := iaSearchURL
iaSearchURL = server.URL + "/advancedsearch.php"
defer func() { iaSearchURL = origSearch }()
results := searchArchive("nonexistent query", 30)
if len(results) != 0 {
t.Fatalf("expected 0 results, got %d", len(results))
}
})
t.Run("URL encoding of query", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q, _ := url.QueryUnescape(r.URL.RawQuery)
if !containsQueryParam(q, "vocal") {
t.Errorf("query should contain 'vocal': %s", q)
}
resp := SearchResult{Response: struct {
NumFound int `json:"numFound"`
Docs []SearchDoc `json:"docs"`
}{
NumFound: 0, Docs: []SearchDoc{},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origSearch := iaSearchURL
iaSearchURL = server.URL + "/advancedsearch.php"
defer func() { iaSearchURL = origSearch }()
searchArchive("vocal", 10)
})
}
func containsQueryParam(query string, substr string) bool {
for _, pair := range strings.Split(query, "&") {
if strings.Contains(pair, substr) {
return true
}
}
return false
}
+79 -83
View File
@@ -8,7 +8,6 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
@@ -17,10 +16,10 @@ import (
"time"
)
const (
var (
userAgent = "akai-fetch/1.0"
iaSearchURL = "https://archive.org/advancedsearch.php"
iaBaseURL = "https://archive.org"
userAgent = "akai-fetch/1.0"
)
type SearchResult struct {
@@ -100,10 +99,9 @@ Download flags:
-dir "./isos" output directory (default: current dir)
-jobs 2 concurrent downloads (default: 2)
Extract flags:
Extract flags:
-dir "./isos" directory containing ISO files
-out "./wavs" output directory for WAV files
-tool "./extract_wavs.sh" path to extraction script
-jobs 2 concurrent extractions (default: 2)
Pipeline flags:
@@ -123,6 +121,15 @@ Serve flags:
// ─── Search ────────────────────────────────────────────────────────────
func cmdSearch(args []string) {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
osExit(fe.code)
return
}
panic(e)
}
}()
fs := flag.NewFlagSet("search", flag.ExitOnError)
query := fs.String("query", "subject:akai AND subject:sampler", "search query")
filter := fs.String("filter", "", "extra term AND-ed onto the query")
@@ -169,7 +176,7 @@ func searchArchive(query string, limit int) []SearchDoc {
if err != nil {
fatal("search request: %v", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -186,6 +193,15 @@ func searchArchive(query string, limit int) []SearchDoc {
// ─── List ──────────────────────────────────────────────────────────────
func cmdList(args []string) {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
osExit(fe.code)
return
}
panic(e)
}
}()
if len(args) < 1 {
fatal("usage: akai-fetch list <identifier>")
}
@@ -202,7 +218,7 @@ func cmdList(args []string) {
}
}
func getItemFiles(identifier string) []MetadataFile {
var getItemFiles = func(identifier string) []MetadataFile {
u := fmt.Sprintf("%s/metadata/%s", iaBaseURL, url.PathEscape(identifier))
client := &http.Client{Timeout: 30 * time.Second}
req, _ := http.NewRequest("GET", u, nil)
@@ -211,7 +227,7 @@ func getItemFiles(identifier string) []MetadataFile {
if err != nil {
fatal("metadata request: %v", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -245,6 +261,15 @@ type downloadJob struct {
}
func cmdDownload(args []string) {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
osExit(fe.code)
return
}
panic(e)
}
}()
fs := flag.NewFlagSet("download", flag.ExitOnError)
identifier := fs.String("i", "", "archive.org item identifier")
idFile := fs.String("f", "", "file with identifiers (one per line)")
@@ -252,7 +277,9 @@ func cmdDownload(args []string) {
jobs := fs.Int("jobs", 2, "concurrent downloads")
_ = fs.Parse(args)
os.MkdirAll(*outDir, 0755)
if err := os.MkdirAll(*outDir, 0755); err != nil {
fatal("create %s: %v", *outDir, err)
}
var identifiers []string
if *identifier != "" {
@@ -329,7 +356,11 @@ func cmdDownload(args []string) {
fmt.Printf("\nDownloaded %s total\n", humanSize(totalDownloaded))
}
func downloadFile(job downloadJob) (int64, error) {
var downloadFile = func(job downloadJob) (int64, error) {
if err := os.MkdirAll(filepath.Dir(job.OutPath), 0755); err != nil {
return 0, fmt.Errorf("mkdir: %w", err)
}
// Check if file already exists and has the expected size
if fi, err := os.Stat(job.OutPath); err == nil {
if job.Size > 0 && fi.Size() == job.Size {
@@ -357,7 +388,7 @@ func downloadFile(job downloadJob) (int64, error) {
if err != nil {
return 0, fmt.Errorf("get: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return 0, fmt.Errorf("HTTP %d", resp.StatusCode)
@@ -367,7 +398,7 @@ func downloadFile(job downloadJob) (int64, error) {
if err != nil {
return 0, fmt.Errorf("open: %w", err)
}
defer outFile.Close()
defer func() { _ = outFile.Close() }()
written, err := io.Copy(outFile, resp.Body)
if err != nil {
@@ -379,16 +410,23 @@ func downloadFile(job downloadJob) (int64, error) {
// ─── Extract ───────────────────────────────────────────────────────────
func cmdExtract(args []string) {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
osExit(fe.code)
return
}
panic(e)
}
}()
fs := flag.NewFlagSet("extract", flag.ExitOnError)
isoDir := fs.String("dir", ".", "directory with ISO files")
outDir := fs.String("out", "./wavs", "output directory for WAVs")
toolFlag := fs.String("tool", "", "path to extraction script (default: auto-detect)")
jobs := fs.Int("jobs", 2, "concurrent extractions")
_ = fs.Parse(args)
toolPath := *toolFlag
if toolPath == "" {
toolPath = findScript("extract_wavs.sh")
if err := os.MkdirAll(*outDir, 0755); err != nil {
fatal("create %s: %v", *outDir, err)
}
entries, err := os.ReadDir(*isoDir)
@@ -412,8 +450,6 @@ func cmdExtract(args []string) {
sem := make(chan struct{}, *jobs)
var wg sync.WaitGroup
var mu sync.Mutex
results := map[string]string{}
for _, iso := range isos {
wg.Add(1)
@@ -423,10 +459,7 @@ func cmdExtract(args []string) {
defer func() { <-sem }()
out := *outDir
result, err := extractISO(toolPath, isoPath, out)
mu.Lock()
results[filepath.Base(isoPath)] = result
mu.Unlock()
result, err := extractISO(isoPath, out)
if err != nil {
fmt.Printf(" FAIL %-40s %v\n", filepath.Base(isoPath), err)
} else {
@@ -437,46 +470,6 @@ func cmdExtract(args []string) {
wg.Wait()
}
func extractISO(toolPath, isoPath, outDir string) (string, error) {
absTool, err := filepath.Abs(toolPath)
if err != nil {
return "", err
}
if _, err := os.Stat(absTool); os.IsNotExist(err) {
return "", fmt.Errorf("script not found: %s", absTool)
}
isoOutDir := filepath.Join(outDir, sanitiseISOName(filepath.Base(isoPath)))
if _, err := os.Stat(isoOutDir); err == nil {
var wavCount int
filepath.WalkDir(isoOutDir, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".wav") {
wavCount++
}
return nil
})
if wavCount > 0 {
return fmt.Sprintf("skipped (%d WAVs exist)", wavCount), nil
}
}
cmd := exec.Command("bash", absTool, isoPath, outDir)
cmd.Stderr = nil
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("extract: %w", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "Total WAV files:") {
return strings.TrimSpace(line), nil
}
}
return "extracted (check output)", nil
}
func sanitiseISOName(path string) string {
name := path
if ext := filepath.Ext(name); ext != "" {
@@ -507,6 +500,15 @@ func sanitiseISOName(path string) string {
// ─── Pipeline ──────────────────────────────────────────────────────────
func cmdPipeline(args []string) {
defer func() {
if e := recover(); e != nil {
if fe, ok := e.(fatalErr); ok {
osExit(fe.code)
return
}
panic(e)
}
}()
fs := flag.NewFlagSet("pipeline", flag.ExitOnError)
query := fs.String("query", "subject:akai AND subject:sampler", "search query")
filter := fs.String("filter", "", "extra term AND-ed onto the query")
@@ -518,7 +520,6 @@ func cmdPipeline(args []string) {
isoDir := filepath.Join(*baseDir, "isos")
wavDir := filepath.Join(*baseDir, "wavs")
toolPath := findScript("extract_wavs.sh")
// Remaining args after flags are treated as explicit identifiers
identifiers := fs.Args()
@@ -564,33 +565,26 @@ func cmdPipeline(args []string) {
cmdDownload([]string{"-f", idFile, "-dir", isoDir, "-jobs", fmt.Sprintf("%d", *dlJobs)})
fmt.Println("\n=== Phase 3: Extract ===")
cmdExtract([]string{"-dir", isoDir, "-out", wavDir, "-tool", toolPath, "-jobs", fmt.Sprintf("%d", *extJobs)})
cmdExtract([]string{"-dir", isoDir, "-out", wavDir, "-jobs", fmt.Sprintf("%d", *extJobs)})
fmt.Printf("\n=== Pipeline Complete ===\n")
fmt.Printf(" ISOs: %s\n", isoDir)
fmt.Printf(" WAVs: %s\n", wavDir)
}
func findScript(name string) string {
candidates := []string{
filepath.Join(filepath.Dir(os.Args[0]), name),
filepath.Join(filepath.Dir(os.Args[0]), "scripts", name),
name,
filepath.Join("scripts", name),
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p
}
}
return name
}
// ─── Helpers ───────────────────────────────────────────────────────────
var osExit = os.Exit
type fatalErr struct {
code int
message string
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
os.Exit(1)
msg := fmt.Sprintf("error: "+format+"\n", args...)
fmt.Fprintf(os.Stderr, msg, args...)
panic(fatalErr{code: 1, message: msg})
}
func truncate(s string, n int) string {
@@ -617,6 +611,8 @@ func humanSize(bytes int64) string {
}
func init() {
// Ensure we can parse flags even with -count or other test flags
flag.CommandLine.Parse([]string{})
if err := flag.CommandLine.Parse([]string{}); err != nil {
// In init context, best we can do is note the issue
fmt.Fprintf(os.Stderr, "flag init: %v\n", err)
}
}
+9 -3
View File
@@ -1,6 +1,7 @@
[tools]
go = "1.26"
node = "22"
golangci-lint = "latest"
node = "22.12"
[env]
CGO_ENABLED = "0"
@@ -10,13 +11,18 @@ build = "go build -buildvcs=false -o fetch ."
akaiutil = "make -C third_party/akaiutil"
all = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil" }
serve = { run = "go build -buildvcs=false -o fetch . && ./fetch serve" }
lint = "go vet ./..."
lint = "golangci-lint run"
js-lint = "cd web/ui && npm run lint"
js-test = "cd web/ui && npm test"
electron-deps = "cd electron && npm install"
electron-dev = { run = "go build -buildvcs=false -o fetch . && cd electron && npx electron ." }
electron-build = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil && cd electron && npx electron-builder --dir" }
electron-build-mac = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil && cd electron && npx electron-builder --mac --dir" }
docker-up = "docker compose up --build"
init = "mkdir -p output/isos output/wavs"
docker-up = "mkdir -p output/isos output/wavs && docker compose up --build"
docker-build = "docker build -t akai-fetch ."
check-toolchain = "bash scripts/check-toolchain.sh"
install-all = "bash scripts/install-deps.sh"
check-docker-toolchain = "bash scripts/check-docker-toolchain.sh"
check-all = { run = "bash scripts/check-toolchain.sh && echo '' && bash scripts/check-docker-toolchain.sh" }
gopls = "go install golang.org/x/tools/gopls@latest"
+72
View File
@@ -98,6 +98,19 @@ fi
echo ""
# ─── gopls (Go LSP) ────────────────────────────────────────────────────
echo "gopls (Go language server):"
if command -v gopls &>/dev/null; then
gopls_ver=$(gopls version 2>/dev/null)
ok "gopls found: $gopls_ver"
else
warn "gopls not found (recommended for IDE support)"
echo " Install: go install golang.org/x/tools/gopls@latest"
echo " (ensure $(go env GOPATH)/bin is in PATH)"
fi
echo ""
# ─── Node ──────────────────────────────────────────────────────────────
echo "Node:"
if command -v node &>/dev/null; then
@@ -110,5 +123,64 @@ else
fi
fi
echo ""
# ─── golangci-lint ──────────────────────────────────────────────────────
echo "golangci-lint:"
if command -v golangci-lint &>/dev/null; then
gcl_ver=$(golangci-lint --version 2>/dev/null)
ok "golangci-lint found: $gcl_ver"
else
warn "golangci-lint not found"
echo " Install: mise install golangci-lint"
fi
echo ""
echo "=== JS / Node ==="
echo ""
# ─── npm ────────────────────────────────────────────────────────────────
echo "npm:"
if command -v npm &>/dev/null; then
npm_ver=$(npm --version 2>/dev/null)
ok "npm found: v$npm_ver"
else
warn "npm not found"
echo " Install: mise install node"
fi
echo ""
# ─── web/ui deps (eslint, globals) ──────────────────────────────────────
echo "web/ui dependencies:"
if [ -d web/ui/node_modules ] && [ -f web/ui/node_modules/.bin/eslint ]; then
ok "web/ui/node_modules installed"
else
warn "web/ui/node_modules not installed"
echo " Install: cd web/ui && npm install"
fi
echo ""
# ─── electron deps ──────────────────────────────────────────────────────
echo "electron dependencies:"
if [ -d electron/node_modules ] && [ -f electron/node_modules/.bin/electron ]; then
ok "electron/node_modules installed"
else
warn "electron/node_modules not installed"
echo " Install: cd electron && npm install"
fi
echo ""
echo "=== JS Linting (ESLint) ==="
if [ -f web/ui/node_modules/.bin/eslint ]; then
ok "ESLint installed at web/ui/node_modules/.bin/eslint"
eslint_ver=$(cd web/ui && ./node_modules/.bin/eslint --version 2>/dev/null)
ok "ESLint: $eslint_ver"
else
warn "ESLint not installed (needed for web UI linting)"
echo " Install: cd web/ui && npm install"
fi
echo ""
echo "=== Core toolchain check complete ==="
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Install all project dependencies — run after check-toolchain.sh for a plan.
# Usage: ./scripts/install-deps.sh
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
cmd() { echo -e " ${YELLOW}${NC} $*"; "$@"; }
skip() { echo -e " ${GREEN}${NC} $1 (already installed)"; }
fail() { echo -e " ${RED}${NC} $1"; exit 1; }
echo "=== Installing Dependencies ==="
echo ""
# ─── mise tools ─────────────────────────────────────────────────────────
echo "mise tools (go, node, golangci-lint):"
if command -v mise &>/dev/null; then
cmd mise install
else
fail "mise not found — run: curl https://mise.run | sh"
fi
echo ""
# ─── web/ui JS deps ─────────────────────────────────────────────────────
echo "web/ui npm deps (eslint):"
if [ -f web/ui/package.json ]; then
cmd cd web/ui && npm install
else
skip "web/ui npm deps"
fi
echo ""
# ─── electron deps ──────────────────────────────────────────────────────
echo "electron npm deps:"
if [ -f electron/package.json ]; then
cmd cd electron && npm install
else
skip "electron npm deps"
fi
echo ""
echo "=== Install complete ==="
+161 -15
View File
@@ -54,10 +54,23 @@ func cmdServe(args []string) {
mux.HandleFunc("/api/search", handleSearch)
mux.HandleFunc("/api/list", handleList)
mux.HandleFunc("/api/list-wavs", handleListWavs)
mux.HandleFunc("/api/disk/partitions", handleDiskPartitions)
mux.HandleFunc("/api/disk/volumes", handleDiskVolumes)
mux.HandleFunc("/api/volume/tags", handleVolumeTags)
mux.HandleFunc("/api/download", handleAPIDownload)
mux.HandleFunc("/api/extract", handleAPIExtract)
mux.HandleFunc("/api/progress", handleProgress)
wavsBase := "./output/wavs"
if abs, err := filepath.Abs(wavsBase); err == nil {
wavsBase = abs
}
if err := os.MkdirAll(wavsBase, 0755); err != nil {
fatal("create wavs dir: %v", err)
}
mux.Handle("/wavs/", http.StripPrefix("/wavs/", http.FileServer(http.Dir(wavsBase))))
addr := fmt.Sprintf("%s:%d", host, port)
listener, err := net.Listen("tcp", addr)
if err != nil {
@@ -65,12 +78,22 @@ func cmdServe(args []string) {
}
realPort := listener.Addr().(*net.TCPAddr).Port
realHost := listener.Addr().(*net.TCPAddr).IP.String()
realHost := "0.0.0.0"
if ip := listener.Addr().(*net.TCPAddr).IP; ip != nil {
realHost = ip.String()
}
fmt.Printf("\n AKAI Utils Server\n")
fmt.Printf(" =================\n\n")
fmt.Printf(" Local: http://%s:%d\n", realHost, realPort)
fmt.Printf(" API: http://%s:%d/api/\n", realHost, realPort)
if realHost == "0.0.0.0" || realHost == "::" || realHost == "" || realHost == "<nil>" {
fmt.Printf(" Local: http://localhost:%d\n", realPort)
if realHost == "0.0.0.0" {
fmt.Printf(" Network: http://<host-ip>:%d\n", realPort)
}
} else {
fmt.Printf(" Local: http://%s:%d\n", realHost, realPort)
}
fmt.Printf(" API: http://localhost:%d/api/\n", realPort)
fmt.Printf(" Quit: Ctrl+C\n\n")
if openBrowser {
@@ -148,7 +171,13 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
outDir = "."
}
os.MkdirAll(outDir, 0755)
if abs, err := filepath.Abs(outDir); err == nil {
outDir = abs
}
if err := os.MkdirAll(outDir, 0755); err != nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("create %s: %v", outDir, err)})
return
}
files := getItemFiles(identifier)
if len(files) == 0 {
@@ -173,6 +202,11 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
jobIDs = append(jobIDs, jobID)
go func(j downloadJob, s *downloadState) {
if err := os.MkdirAll(filepath.Dir(j.OutPath), 0755); err != nil {
s.Error = err.Error()
return
}
client := &http.Client{Timeout: 0}
req, _ := http.NewRequest("GET", j.URL, nil)
req.Header.Set("User-Agent", userAgent)
@@ -188,20 +222,23 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
s.Error = err.Error()
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
outFile, err := os.Create(j.OutPath)
if err != nil {
s.Error = err.Error()
return
}
defer outFile.Close()
defer func() { _ = outFile.Close() }()
buf := make([]byte, 32*1024)
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
outFile.Write(buf[:n])
if _, err := outFile.Write(buf[:n]); err != nil {
s.Error = err.Error()
return
}
s.Downloaded += int64(n)
now := time.Now()
@@ -230,6 +267,112 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"jobs": jobIDs})
}
func handleListWavs(w http.ResponseWriter, r *http.Request) {
dir := r.URL.Query().Get("dir")
if dir == "" {
dir = "./output/wavs"
}
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
files := []string{}
var walkErr error
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if path == dir {
walkErr = err
return filepath.SkipAll
}
return nil
}
if !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".wav") {
rel, _ := filepath.Rel(dir, path)
files = append(files, rel)
}
return nil
})
if walkErr != nil {
writeJSON(w, map[string]any{"error": walkErr.Error()})
return
}
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
writeJSON(w, files)
}
func handleDiskPartitions(w http.ResponseWriter, r *http.Request) {
iso := r.URL.Query().Get("iso")
if iso == "" {
writeJSON(w, map[string]any{"error": "missing iso parameter"})
return
}
fi, err := os.Stat(iso)
if err != nil {
writeJSON(w, map[string]any{"error": fmt.Sprintf("cannot access %q: %v", iso, err)})
return
}
if fi.IsDir() {
writeJSON(w, map[string]any{"error": fmt.Sprintf("%q is a directory, not an ISO file", iso)})
return
}
if !strings.HasSuffix(strings.ToLower(fi.Name()), ".iso") {
writeJSON(w, map[string]any{"error": fmt.Sprintf("%q is not an ISO file", iso)})
return
}
parts, err := listPartitions(iso)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
writeJSON(w, parts)
}
func handleDiskVolumes(w http.ResponseWriter, r *http.Request) {
iso := r.URL.Query().Get("iso")
part := r.URL.Query().Get("part")
if iso == "" || part == "" {
writeJSON(w, map[string]any{"error": "missing iso or part parameter"})
return
}
parts, err := listPartitions(iso)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
vols, err := listVolumes(iso, parts)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
filtered := []volInfo{}
for _, v := range vols {
if v.Part == part {
filtered = append(filtered, v)
}
}
writeJSON(w, filtered)
}
func handleVolumeTags(w http.ResponseWriter, r *http.Request) {
iso := r.URL.Query().Get("iso")
part := r.URL.Query().Get("part")
vol := r.URL.Query().Get("vol")
if iso == "" || part == "" || vol == "" {
writeJSON(w, map[string]any{"error": "missing iso, part, or vol parameter"})
return
}
tags, err := listTags(iso, part, vol)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
writeJSON(w, tags)
}
func handleProgress(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
@@ -249,7 +392,9 @@ func handleProgress(w http.ResponseWriter, r *http.Request) {
data, _ := json.Marshal(downloadJobs)
downloadJobsMu.Unlock()
fmt.Fprintf(w, "data: %s\n\n", data)
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return
}
flusher.Flush()
allDone := true
@@ -277,8 +422,6 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) {
outDir = "./wavs"
}
toolPath := findScript("extract_wavs.sh")
entries, err := os.ReadDir(isoDir)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
@@ -299,7 +442,7 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) {
var messages []string
for _, isoPath := range isos {
result, err := extractISO(toolPath, isoPath, outDir)
result, err := extractISO(isoPath, outDir)
if err != nil {
messages = append(messages, fmt.Sprintf("FAIL %s: %v", filepath.Base(isoPath), err))
} else {
@@ -315,13 +458,16 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) {
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(v)
if err := json.NewEncoder(w).Encode(v); err != nil {
// client disconnected, nothing we can do
return
}
}
func openURL(rawURL string) {
exec.Command("xdg-open", rawURL).Start()
exec.Command("open", rawURL).Start()
exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start()
_ = exec.Command("xdg-open", rawURL).Start()
_ = exec.Command("open", rawURL).Start()
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start()
}
func init() {
+636
View File
@@ -0,0 +1,636 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestWriteJSON(t *testing.T) {
t.Run("sets correct headers", func(t *testing.T) {
rr := httptest.NewRecorder()
writeJSON(rr, map[string]string{"key": "value"})
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("expected application/json, got %q", ct)
}
if ac := rr.Header().Get("Access-Control-Allow-Origin"); ac != "*" {
t.Errorf("expected *, got %q", ac)
}
})
t.Run("writes valid JSON", func(t *testing.T) {
rr := httptest.NewRecorder()
data := map[string]int{"count": 42, "total": 100}
writeJSON(rr, data)
var result map[string]int
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if result["count"] != 42 {
t.Errorf("expected count=42, got %d", result["count"])
}
})
t.Run("writes nested structs", func(t *testing.T) {
rr := httptest.NewRecorder()
type inner struct {
Name string `json:"name"`
}
writeJSON(rr, map[string]any{"item": inner{Name: "test"}})
var result map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
item := result["item"].(map[string]any)
if item["name"] != "test" {
t.Errorf("expected name=test, got %v", item["name"])
}
})
}
func TestHandleProgress_SSEHeaders(t *testing.T) {
downloadJobsMu.Lock()
downloadJobs = make(map[string]*downloadState)
downloadJobs["test-job"] = &downloadState{
ID: "test-job",
Identifier: "testid",
FileName: "test.iso",
TotalSize: 1000,
Downloaded: 500,
Done: false,
}
downloadJobsMu.Unlock()
req := httptest.NewRequest("GET", "/api/progress", nil)
rr := httptest.NewRecorder()
done := make(chan string)
go func() {
handleProgress(rr, req)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
if ct := rr.Header().Get("Content-Type"); ct != "text/event-stream" {
t.Errorf("expected text/event-stream, got %q", ct)
}
if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" {
t.Errorf("expected no-cache, got %q", cc)
}
if conn := rr.Header().Get("Connection"); conn != "keep-alive" {
t.Errorf("expected keep-alive, got %q", conn)
}
}
func TestHandleProgress_SSEContent(t *testing.T) {
downloadJobsMu.Lock()
downloadJobs = make(map[string]*downloadState)
downloadJobs["job1"] = &downloadState{
ID: "job1",
Identifier: "item1",
FileName: "file1.iso",
TotalSize: 1000,
Downloaded: 1000,
Done: true,
}
downloadJobsMu.Unlock()
req := httptest.NewRequest("GET", "/api/progress", nil)
rr := httptest.NewRecorder()
done := make(chan struct{})
go func() {
handleProgress(rr, req)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
body := rr.Body.String()
if body == "" {
t.Error("expected non-empty SSE body")
}
body = strings.TrimPrefix(body, "data: ")
body = strings.TrimSuffix(strings.TrimSpace(body), "\n\n")
var states map[string]*downloadState
if err := json.Unmarshal([]byte(body), &states); err != nil {
t.Fatalf("invalid JSON in SSE data: %v\nbody: %q", err, body)
}
if len(states) != 1 {
t.Errorf("expected 1 state, got %d", len(states))
}
}
func TestHandleProgress_EmptyJobs(t *testing.T) {
downloadJobsMu.Lock()
downloadJobs = make(map[string]*downloadState)
downloadJobsMu.Unlock()
req := httptest.NewRequest("GET", "/api/progress", nil)
rr := httptest.NewRecorder()
done := make(chan struct{})
go func() {
handleProgress(rr, req)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
body := rr.Body.String()
if body == "" {
t.Error("expected non-empty SSE body even with no jobs")
}
}
func TestHandleSearch_DelegatesToSearchArchive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q != "(drums) AND (vocal)" {
t.Errorf("unexpected query: %q", q)
}
resp := SearchResult{Response: struct {
NumFound int `json:"numFound"`
Docs []SearchDoc `json:"docs"`
}{
NumFound: 1, Docs: []SearchDoc{
{Identifier: "test-id", Title: "Test", Downloads: 50},
},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origSearch := iaSearchURL
iaSearchURL = server.URL + "/advancedsearch.php"
defer func() { iaSearchURL = origSearch }()
req := httptest.NewRequest("GET", "/api/search?q=drums&filter=vocal", nil)
rr := httptest.NewRecorder()
handleSearch(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
}
func TestHandleList_DelegatesToGetItemFiles(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/metadata/testitem" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
resp := MetadataResult{Files: []MetadataFile{
{Name: "test.iso", Size: "1000000"},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
req := httptest.NewRequest("GET", "/api/list?identifier=testitem", nil)
rr := httptest.NewRecorder()
handleList(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
}
func TestHandleList_MissingIdentifier(t *testing.T) {
req := httptest.NewRequest("GET", "/api/list", nil)
rr := httptest.NewRecorder()
handleList(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleAPIDownload_CreatesJobsAndReturnsJSON(t *testing.T) {
origDownloadFile := downloadFile
defer func() { downloadFile = origDownloadFile }()
downloadFile = func(job downloadJob) (int64, error) {
downloadJobsMu.Lock()
if s, ok := downloadJobs[job.Identifier+"/"+job.Name]; ok {
s.Done = true
s.Downloaded = job.Size
}
downloadJobsMu.Unlock()
return job.Size, nil
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := MetadataResult{Files: []MetadataFile{
{Name: "test1.iso", Size: "1000"},
{Name: "test2.iso", Size: "2000"},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
tmp := t.TempDir()
req := httptest.NewRequest("GET", "/api/download?identifier=testitem&dir="+tmp, nil)
rr := httptest.NewRecorder()
handleAPIDownload(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
jobs, ok := resp["jobs"].([]any)
if !ok || len(jobs) != 2 {
t.Errorf("expected 2 jobs, got %v", resp["jobs"])
}
downloadJobsMu.Lock()
defer downloadJobsMu.Unlock()
if len(downloadJobs) != 2 {
t.Errorf("expected 2 jobs in state map, got %d", len(downloadJobs))
}
}
func TestHandleAPIDownload_MissingIdentifier(t *testing.T) {
req := httptest.NewRequest("GET", "/api/download", nil)
rr := httptest.NewRecorder()
handleAPIDownload(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleAPIExtract_NoISOs(t *testing.T) {
origExtractISO := extractISO
defer func() { extractISO = origExtractISO }()
extractISO = func(isoPath, outDir string) (string, error) {
return "skipped (0 WAVs)", nil
}
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(isoDir, "empty.iso"))
defer func() { _ = fd.Close() }()
req := httptest.NewRequest("GET", "/api/extract?dir="+isoDir+"&out="+filepath.Join(tmp, "wavs"), nil)
rr := httptest.NewRecorder()
handleAPIExtract(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; ok {
t.Errorf("unexpected error: %v", resp["error"])
}
}
func TestHandleAPIExtract_WithISOs(t *testing.T) {
origExtractISO := extractISO
defer func() { extractISO = origExtractISO }()
extractISO = func(isoPath, outDir string) (string, error) {
return "OK test.iso: Total WAV files: 10 (5s)", nil
}
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(isoDir, "test.iso"))
defer func() { _ = fd.Close() }()
outDir := filepath.Join(tmp, "wavs")
req := httptest.NewRequest("GET", "/api/extract?dir="+isoDir+"&out="+outDir, nil)
rr := httptest.NewRecorder()
handleAPIExtract(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestHandleAPIDownload_GetItemFilesReturnsEmpty(t *testing.T) {
origGet := getItemFiles
defer func() { getItemFiles = origGet }()
getItemFiles = func(id string) []MetadataFile {
return []MetadataFile{}
}
tmp := t.TempDir()
req := httptest.NewRequest("GET", "/api/download?identifier=testitem&dir="+tmp, nil)
rr := httptest.NewRecorder()
handleAPIDownload(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; !ok {
t.Error("expected error for empty files")
}
if resp["error"] != "no ISO files found" {
t.Errorf("expected 'no ISO files found', got %v", resp["error"])
}
}
func TestHandleAPIExtract_ReadDirFails(t *testing.T) {
tmp := t.TempDir()
badDir := filepath.Join(tmp, "nonexistent")
req := httptest.NewRequest("GET", "/api/extract?dir="+badDir+"&out="+filepath.Join(tmp, "wavs"), nil)
rr := httptest.NewRecorder()
handleAPIExtract(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; !ok {
t.Error("expected error for failed ReadDir")
}
}
func TestHandleAPIExtract_ExtractISOError(t *testing.T) {
origExtractISO := extractISO
defer func() { extractISO = origExtractISO }()
extractISO = func(isoPath, outDir string) (string, error) {
return "", fmt.Errorf("partition read failed")
}
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(isoDir, "bad.iso"))
defer func() { _ = fd.Close() }()
req := httptest.NewRequest("GET", "/api/extract?dir="+isoDir+"&out="+filepath.Join(tmp, "wavs"), nil)
rr := httptest.NewRecorder()
handleAPIExtract(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
msg, ok := resp["message"].(string)
if !ok {
t.Fatal("expected message string")
}
if !strings.Contains(msg, "FAIL bad.iso") {
t.Errorf("expected FAIL message, got: %s", msg)
}
}
func TestHandleListWavs_ReturnsWavFiles(t *testing.T) {
tmp := t.TempDir()
wavDir := filepath.Join(tmp, "wavs")
if err := os.MkdirAll(wavDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"kick.wav", "snare.wav", "hihat.wav"} {
fd, _ := os.Create(filepath.Join(wavDir, name))
_ = fd.Close()
}
fd, _ := os.Create(filepath.Join(wavDir, "readme.txt"))
_ = fd.Close()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(files) != 3 {
t.Errorf("expected 3 wav files, got %d: %v", len(files), files)
}
}
func TestHandleListWavs_RecursiveSubdirs(t *testing.T) {
tmp := t.TempDir()
wavDir := filepath.Join(tmp, "wavs")
subDir := filepath.Join(wavDir, "Kicks")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(wavDir, "root.wav"))
_ = fd.Close()
fd2, _ := os.Create(filepath.Join(subDir, "kick1.wav"))
_ = fd2.Close()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(files) != 2 {
t.Errorf("expected 2 wav files, got %d: %v", len(files), files)
}
found := make(map[string]bool)
for _, f := range files {
found[f] = true
}
if !found["root.wav"] {
t.Errorf("missing root.wav, got %v", files)
}
if !found[filepath.Join("Kicks", "kick1.wav")] {
t.Errorf("missing Kicks/kick1.wav, got %v", files)
}
}
func TestHandleListWavs_EmptyDir(t *testing.T) {
tmp := t.TempDir()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+tmp, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if files == nil {
t.Fatal("expected empty array [], got null")
}
if len(files) != 0 {
t.Errorf("expected 0 files, got %d", len(files))
}
}
func TestHandleListWavs_NonExistentDir(t *testing.T) {
req := httptest.NewRequest("GET", "/api/list-wavs?dir=/nonexistent/wavs", nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; !ok {
t.Error("expected error for nonexistent dir")
}
}
func TestHandleDiskPartitions_ReturnsPartitions(t *testing.T) {
origRun := runAkaiutil
defer func() { runAkaiutil = origRun }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return `
Disk /disk0
Total blocks: 0 (0 KB each) 0 KB
Partition table:
A - S1000 HD (0 blocks)
B - S1000 HD (0 blocks)
`, nil
}
req := httptest.NewRequest("GET", "/api/disk/partitions?iso=test.iso", nil)
rr := httptest.NewRecorder()
handleDiskPartitions(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var parts []string
if err := json.Unmarshal(rr.Body.Bytes(), &parts); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(parts) != 2 {
t.Errorf("expected 2 partitions, got %d: %v", len(parts), parts)
}
}
func TestHandleDiskPartitions_MissingISO(t *testing.T) {
req := httptest.NewRequest("GET", "/api/disk/partitions", nil)
rr := httptest.NewRecorder()
handleDiskPartitions(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
if _, ok := resp["error"]; !ok {
t.Error("expected error for missing iso")
}
}
func TestHandleVolumeTags_MissingParams(t *testing.T) {
req := httptest.NewRequest("GET", "/api/volume/tags", nil)
rr := httptest.NewRecorder()
handleVolumeTags(rr, req)
var resp map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
if resp["error"] == nil {
t.Error("expected error for missing params")
}
}
func TestHandleVolumeTags_ReturnsTags(t *testing.T) {
origRun := runAkaiutil
defer func() { runAkaiutil = origRun }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return `
/disk0/A/SomeVol > lstags
tag name
-----------------
01 Drums
02 Bass
-----------------
`, nil
}
req := httptest.NewRequest("GET", "/api/volume/tags?iso=test.iso&part=A&vol=SomeVol", nil)
rr := httptest.NewRecorder()
handleVolumeTags(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var tags []TagInfo
if err := json.Unmarshal(rr.Body.Bytes(), &tags); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(tags) != 2 {
t.Errorf("expected 2 tags, got %d", len(tags))
}
}
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestSanitiseISOName(t *testing.T) {
cases := []struct {
input string
expected string
}{
{"ZERO-G - Deepest India", "ZERO-G_-_Deepest_India"},
{"SYNTH PAD COLLECTION.iso", "SYNTH_PAD_COLLECTION"},
{"vocal.loops", "vocal"},
{"DRUM KIT #1", "DRUM_KIT_1"},
{"simple", "simple"},
{"no-extension", "no-extension"},
{"multiple spaces", "multiple_spaces"},
{"UPPERCASE", "UPPERCASE"},
{"MiXeD CaSe", "MiXeD_CaSe"},
{"with-dash", "with-dash"},
{"with_underscore", "with_underscore"},
{"with.dots.in.name", "with.dots.in"},
{".hidden", ""},
{"trailing_", "trailing"},
{"__leading__", "leading"},
{"spaces everywhere", "spaces_everywhere"},
{"", ""},
{" ", ""},
{".iso", ""},
{"a", "a"},
}
for _, c := range cases {
got := sanitiseISOName(c.input)
if got != c.expected {
t.Errorf("sanitiseISOName(%q) = %q, want %q", c.input, got, c.expected)
}
}
}
func TestTruncate(t *testing.T) {
cases := []struct {
s string
n int
expect string
}{
{"hello", 10, "hello"},
{"hello", 5, "hello"},
{"hello", 4, "h..."},
{"hello", 3, "..."},
{"hello", 2, "he"},
{"hello", 1, "h"},
{"hello", 0, ""},
{"", 5, ""},
{"abcdef", 4, "a..."},
{"ab", 4, "ab"},
{"abc", 3, "abc"},
{"abcd", 2, "ab"},
}
for _, c := range cases {
got := truncate(c.s, c.n)
if got != c.expect {
t.Errorf("truncate(%q, %d) = %q, want %q", c.s, c.n, got, c.expect)
}
}
}
func TestHumanSize(t *testing.T) {
cases := []struct {
bytes int64
expect string
}{
{0, "0B"},
{500, "500B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{10240, "10.0 KB"},
{1048576, "1.0 MB"},
{1572864, "1.5 MB"},
{1073741824, "1.0 GB"},
{2147483648, "2.0 GB"},
}
for _, c := range cases {
got := humanSize(c.bytes)
if got != c.expect {
t.Errorf("humanSize(%d) = %q, want %q", c.bytes, got, c.expect)
}
}
}
func TestFindAkaiutil(t *testing.T) {
origEnv := os.Getenv("AKAIUTIL")
origArg0 := os.Args[0]
defer func() {
_ = os.Setenv("AKAIUTIL", origEnv)
os.Args[0] = origArg0
}()
t.Run("env var set to missing file", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
_ = os.Setenv("AKAIUTIL", "/nonexistent/akaiutil")
os.Args[0] = "/some/binary"
_, err := findAkaiutil()
if err == nil {
t.Fatal("expected error for missing env var path")
}
})
t.Run("env var set to existing file", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
f := tmp + "/myutil"
fd, _ := os.Create(f)
defer func() { _ = fd.Close() }()
_ = os.Setenv("AKAIUTIL", f)
got, err := findAkaiutil()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != f {
t.Errorf("got %q, want %q", got, f)
}
})
t.Run("finds binary next to argv[0]", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
exe := tmp + "/mybinary"
fd, _ := os.Create(exe)
defer func() { _ = fd.Close() }()
sideBySide := tmp + "/akaiutil"
fd2, _ := os.Create(sideBySide)
defer func() { _ = fd2.Close() }()
_ = os.Setenv("AKAIUTIL", "")
os.Args[0] = exe
got, err := findAkaiutil()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != sideBySide {
t.Errorf("got %q, want %q", got, sideBySide)
}
})
t.Run("finds binary in third_party relative to argv[0]", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
exe := tmp + "/somebin"
fd, _ := os.Create(exe)
defer func() { _ = fd.Close() }()
thirdParty := filepath.Join(tmp, "third_party", "akaiutil")
if err := os.MkdirAll(thirdParty, 0755); err != nil {
t.Fatal(err)
}
akaiutilInThirdParty := filepath.Join(thirdParty, "akaiutil")
fd2, _ := os.Create(akaiutilInThirdParty)
defer func() { _ = fd2.Close() }()
_ = os.Setenv("AKAIUTIL", "")
os.Args[0] = exe
got, err := findAkaiutil()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != akaiutilInThirdParty {
t.Errorf("got %q, want %q", got, akaiutilInThirdParty)
}
})
t.Run("env var takes priority over path candidates", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
envFile := tmp + "/envutil"
pathFile := tmp + "/pathutil"
fd1, _ := os.Create(envFile)
defer func() { _ = fd1.Close() }()
fd2, _ := os.Create(pathFile)
defer func() { _ = fd2.Close() }()
_ = os.Setenv("AKAIUTIL", envFile)
os.Args[0] = pathFile
got, err := findAkaiutil()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != envFile {
t.Errorf("got %q, want env var path %q", got, envFile)
}
})
t.Run("no akaiutil found returns error", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
exe := tmp + "/nobinaryhere"
fd, _ := os.Create(exe)
defer func() { _ = fd.Close() }()
_ = os.Setenv("AKAIUTIL", "")
os.Args[0] = exe
_, err := findAkaiutil()
if err == nil {
t.Fatal("expected error when no akaiutil found")
}
})
}
+329 -15
View File
@@ -29,6 +29,7 @@ async function doSearch() {
const resp = await fetch(`${API}/search?${params}`);
const data = await resp.json();
status.textContent = `Found ${data.length} items`;
document.getElementById('result-count').textContent = `(${data.length})`;
renderResults(data);
} catch (e) {
status.textContent = 'Search failed: ' + e.message;
@@ -45,8 +46,8 @@ function renderResults(items) {
<span>${r.downloads.toLocaleString()} downloads</span>
</div>
<div class="actions">
<button class="btn-dl" onclick="downloadItem('${esc(r.identifier)}')">Download ISOs</button>
<button class="btn-list" onclick="listItem('${esc(r.identifier)}')">List Files</button>
<button class="btn-dl" data-action="download" data-identifier="${esc(r.identifier)}">Download ISOs</button>
<button class="btn-list" data-action="list" data-identifier="${esc(r.identifier)}">List Files</button>
</div>
<div id="list-${sanitize(r.identifier)}" class="file-list"></div>
</div>
@@ -174,6 +175,11 @@ async function doExtract() {
try {
const resp = await fetch(`${API}/extract?dir=${encodeURIComponent(isoDir)}&out=${encodeURIComponent(wavDir)}`);
const data = await resp.json();
if (data.error) {
status.textContent = data.error;
status.style.color = 'var(--accent)';
return;
}
status.textContent = data.message || 'Extraction complete';
status.style.color = 'var(--green)';
} catch (e) {
@@ -182,25 +188,333 @@ async function doExtract() {
}
}
// ─── Helpers ──────────────────────────────────────────────────────
// ─── WAV Browser ──────────────────────────────────────────────────
function esc(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
let audioCtx = null;
let currentSource = null;
let currentAudioBuffer = null;
let startTime = 0;
let pauseOffset = 0;
let isPlaying = false;
let animationFrame = null;
document.getElementById('wav-browse-btn').addEventListener('click', browseWavs);
function renderSampleCards(samples) {
return samples.map(s => `
<div class="sample-card" data-action="play" data-wav="${esc(s.fullPath)}">
<span class="sample-name" title="${esc(s.name)}">${esc(s.name)}</span>
</div>
`).join('');
}
function sanitize(s) {
return s.replace(/[^a-zA-Z0-9_-]/g, '_');
function renderPrograms(programs) {
const progNames = Object.keys(programs).sort();
return progNames.map(prog => {
const samples = programs[prog];
return `
<div class="program-card">
<div class="program-header">
<h4>${esc(cleanName(prog))}</h4>
<span class="prog-meta">${samples.length} samples</span>
</div>
<div class="program-samples">
${renderSampleCards(samples)}
</div>
</div>`;
}).join('');
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
function renderLibraries(libs) {
const libNames = Object.keys(libs).sort();
return libNames.map(lib => {
const programs = libs[lib];
const progCount = Object.keys(programs).length;
const sampleCount = Object.values(programs).reduce((sum, s) => sum + s.length, 0);
return `
<div class="library-card">
<div class="library-header" onclick="this.parentNode.classList.toggle('collapsed')">
<h3>${esc(cleanName(lib))}<span class="lib-toggle">▾</span></h3>
<span class="lib-meta">${progCount} programs · ${sampleCount} samples</span>
</div>
<div class="library-programs">
${renderPrograms(programs)}
</div>
</div>`;
}).join('');
}
async function browseWavs() {
const dir = document.getElementById('wav-dir').value;
const results = document.getElementById('wav-results');
const summary = document.getElementById('wav-summary');
const list = document.getElementById('wav-list');
list.innerHTML = 'Loading...';
try {
const url = `${API}/list-wavs?dir=${encodeURIComponent(dir)}`;
const resp = await fetch(url);
if (!resp.ok) {
list.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>';
results.style.display = 'block';
return;
}
const data = await resp.json();
if (!data || data.error) {
list.innerHTML = '<span class="no-files">Error: ' + (data && data.error ? data.error : 'server error') + '</span>';
results.style.display = 'block';
return;
}
if (!Array.isArray(data) || data.length === 0) {
list.innerHTML = '<span class="no-files">No WAV files found</span>';
results.style.display = 'block';
return;
}
const libs = groupWavs(data);
const libCount = Object.keys(libs).length;
summary.textContent = libCount + ' librar' + (libCount === 1 ? 'y' : 'ies') + ' · ' + data.length + ' samples';
list.innerHTML = renderLibraries(libs);
results.style.display = 'block';
} catch (e) {
list.innerHTML = 'Failed: ' + e.message;
results.style.display = 'block';
}
}
async function playWav(filename) {
console.log('playWav:', filename);
if (!audioCtx) {
audioCtx = new AudioContext();
console.log('playWav: created AudioContext');
}
if (currentSource) {
currentSource.stop();
currentSource = null;
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
const name = filename.split('/').pop().replace(/\.wav$/i, '');
const player = document.getElementById('wav-player');
const nowPlaying = document.getElementById('wav-now-playing');
player.style.display = 'block';
nowPlaying.textContent = 'Loading: ' + name;
try {
const resp = await fetch(`/wavs/${encodeURIComponent(filename)}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const arrayBuffer = await resp.arrayBuffer();
currentAudioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
currentSource = audioCtx.createBufferSource();
currentSource.buffer = currentAudioBuffer;
currentSource.connect(audioCtx.destination);
currentSource.onended = () => {
if (isPlaying) {
isPlaying = false;
pauseOffset = 0;
nowPlaying.textContent = '';
document.getElementById('wav-progress-fill').style.width = '0%';
document.getElementById('wav-time').textContent = formatTime(0);
clearActiveItem();
player.style.display = 'none';
}
};
startTime = audioCtx.currentTime - pauseOffset;
currentSource.start(0, pauseOffset);
isPlaying = true;
pauseOffset = 0;
nowPlaying.textContent = '▶ ' + name;
setActiveItem(filename);
updateProgress();
} catch (e) {
nowPlaying.textContent = 'Error: ' + e.message;
}
}
function updateProgress() {
if (!isPlaying || !currentAudioBuffer) return;
const elapsed = audioCtx.currentTime - startTime;
const duration = currentAudioBuffer.duration;
const pct = Math.min((elapsed / duration) * 100, 100);
document.getElementById('wav-progress-fill').style.width = pct + '%';
document.getElementById('wav-time').textContent = formatTime(elapsed);
if (elapsed < duration) {
animationFrame = requestAnimationFrame(updateProgress);
}
}
function setActiveItem(filename) {
document.querySelectorAll('.sample-card').forEach(el => {
el.classList.toggle('playing', el.dataset.wav === filename);
});
}
function clearActiveItem() {
document.querySelectorAll('.sample-card').forEach(el => {
el.classList.remove('playing');
});
}
// Initial load
doSearch();
// Event delegation for dynamically generated buttons
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'download') {
downloadItem(btn.dataset.identifier);
} else if (action === 'list') {
listItem(btn.dataset.identifier);
} else if (action === 'play') {
playWav(btn.dataset.wav);
} else if (action === 'tag-part') {
selectPartition(btn.dataset.iso, btn.dataset.part);
} else if (action === 'tag-vol') {
viewVolumeTags(btn.dataset.iso, btn.dataset.part, btn.dataset.vol);
}
});
// Section collapse toggle
document.querySelectorAll('.section-toggle').forEach(h => {
h.addEventListener('click', function () {
this.classList.toggle('collapsed');
const target = document.getElementById(this.dataset.section);
target.style.display = target.style.display === 'none' ? '' : 'none';
});
});
// Collapse all libraries
document.getElementById('wav-collapse-all').addEventListener('click', function () {
document.querySelectorAll('.library-card').forEach(c => c.classList.add('collapsed'));
});
// ─── Tag Viewer ─────────────────────────────────────────────────────
const TAG_COLORS = [
'#e94560', '#4ecca3', '#f0c040', '#7c3aed', '#06b6d4',
'#f97316', '#84cc16', '#ec4899', '#3b82f6', '#14b8a6',
'#a855f7', '#ef4444', '#22c55e', '#eab308', '#6366f1',
'#10b981', '#f43f5e', '#8b5cf6', '#0ea5e9', '#d946ef'
]
document.getElementById('tag-browse-btn').addEventListener('click', browseISO)
async function browseISO() {
const iso = document.getElementById('tag-iso-path').value
const results = document.getElementById('tag-results')
const status = document.getElementById('tag-status')
const partsEl = document.getElementById('tag-partitions')
const volsEl = document.getElementById('tag-volumes')
const detailEl = document.getElementById('tag-detail')
partsEl.innerHTML = ''
volsEl.innerHTML = ''
detailEl.innerHTML = ''
status.textContent = 'Loading partitions...'
results.style.display = 'block'
try {
const resp = await fetch(`${API}/disk/partitions?iso=${encodeURIComponent(iso)}`)
if (!resp.ok) {
status.textContent = 'HTTP ' + resp.status
return
}
const data = await resp.json()
if (!data || data.error) {
status.textContent = (data && data.error) || 'server error'
return
}
status.textContent = data.length + ' partition' + (data.length === 1 ? '' : 's')
partsEl.innerHTML = data.map(p => `
<button class="tag-part-btn" data-action="tag-part" data-iso="${esc(iso)}" data-part="${esc(p)}">Partition ${esc(p)}</button>
`).join('')
} catch (e) {
status.textContent = 'Failed: ' + e.message
}
}
async function selectPartition(iso, part) {
const volsEl = document.getElementById('tag-volumes')
const detailEl = document.getElementById('tag-detail')
document.querySelectorAll('.tag-part-btn').forEach(b => {
b.classList.toggle('active', b.dataset.part === part)
})
volsEl.innerHTML = 'Loading volumes...'
detailEl.innerHTML = ''
try {
const resp = await fetch(`${API}/disk/volumes?iso=${encodeURIComponent(iso)}&part=${encodeURIComponent(part)}`)
if (!resp.ok) {
volsEl.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>'
return
}
const data = await resp.json()
if (!data || data.error) {
volsEl.innerHTML = '<span class="no-files">' + (data && data.error ? data.error : 'server error') + '</span>'
return
}
if (!Array.isArray(data) || data.length === 0) {
volsEl.innerHTML = '<span class="no-files">No volumes on Partition ' + part + '</span>'
return
}
volsEl.innerHTML = data.map(v => `
<div class="vol-item" data-action="tag-vol" data-iso="${esc(iso)}" data-part="${esc(part)}" data-vol="${esc(v.name)}">${esc(v.name)}</div>
`).join('')
} catch (e) {
volsEl.innerHTML = 'Failed: ' + e.message
}
}
async function viewVolumeTags(iso, part, vol) {
const detailEl = document.getElementById('tag-detail')
document.querySelectorAll('.vol-item').forEach(el => {
el.classList.toggle('selected', el.textContent === vol)
})
detailEl.innerHTML = 'Loading tags...'
try {
const resp = await fetch(`${API}/volume/tags?iso=${encodeURIComponent(iso)}&part=${encodeURIComponent(part)}&vol=${encodeURIComponent(vol)}`)
if (!resp.ok) {
detailEl.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>'
return
}
const data = await resp.json()
if (!data || data.error) {
detailEl.innerHTML = '<span class="no-files">' + (data && data.error ? data.error : 'server error') + '</span>'
return
}
if (!Array.isArray(data) || data.length === 0) {
detailEl.innerHTML = '<span class="no-files">No tags on this volume</span>'
return
}
const colorKeys = TAG_COLORS
detailEl.innerHTML = `
<h4>Tags: ${esc(vol)}</h4>
<div class="tag-list">
${data.map(t => {
const color = colorKeys[(t.index - 1) % colorKeys.length]
return `<span class="tag-chip">
<span class="tag-dot" style="background:${color}"></span>
${esc(t.name)}
</span>`
}).join('')}
</div>`
} catch (e) {
detailEl.innerHTML = 'Failed: ' + e.message
}
}
+45
View File
@@ -0,0 +1,45 @@
const globals = require('globals');
module.exports = [
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: {
...globals.browser,
AudioContext: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
fetch: 'readonly',
EventSource: 'readonly',
document: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
Math: 'readonly',
JSON: 'readonly',
location: 'readonly',
encodeURIComponent: 'readonly',
decodeURIComponent: 'readonly',
parseInt: 'readonly',
Number: 'readonly',
Promise: 'readonly',
Object: 'readonly',
Array: 'readonly',
Map: 'readonly',
process: 'readonly',
__dirname: 'readonly',
require: 'readonly',
module: 'readonly',
exports: 'readonly'
}
},
rules: {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"no-redeclare": "warn",
"no-unreachable": "warn",
"eqeqeq": ["error", "always"],
"prefer-const": "warn"
}
}
];
+47 -2
View File
@@ -22,7 +22,6 @@
<button id="search-dl-all">Download Top 10</button>
</div>
<div id="search-status"></div>
<div id="search-results"></div>
</section>
<section id="download-section">
@@ -39,7 +38,53 @@
</div>
<div id="extract-status"></div>
</section>
<section id="tag-section">
<h2 class="section-toggle" data-section="tag-body">Tag Viewer <span class="toggle-icon"></span></h2>
<div id="tag-body">
<div class="tag-controls">
<input type="text" id="tag-iso-path" placeholder="./output/isos/YOUR_ISO.iso" value="">
<button id="tag-browse-btn">Browse</button>
</div>
<div id="tag-results" style="display:none">
<div id="tag-status"></div>
<div id="tag-partitions" class="tag-partitions"></div>
<div id="tag-volumes" class="tag-volumes"></div>
<div id="tag-detail" class="tag-detail"></div>
</div>
</div>
</section>
<section id="wav-section">
<h2 class="section-toggle" data-section="wav-body">Sample Library <span class="toggle-icon"></span></h2>
<div id="wav-body">
<div class="wav-controls">
<input type="text" id="wav-dir" placeholder="WAV directory" value="./output/wavs">
<button id="wav-browse-btn">Browse</button>
</div>
<div id="wav-results" style="display:none">
<div class="wav-summary-row">
<div id="wav-summary" class="wav-summary"></div>
<button id="wav-collapse-all" class="collapse-all-btn">Collapse All</button>
</div>
<div id="wav-player" class="wav-player" style="display:none">
<div id="wav-now-playing" class="now-playing"></div>
<div class="audio-bar">
<div class="audio-progress"><div id="wav-progress-fill" class="audio-progress-fill"></div></div>
<span id="wav-time">0:00</span>
</div>
</div>
<div id="wav-list" class="wav-library"></div>
</div>
</div>
</section>
<section id="results-section">
<h2>Results <span id="result-count"></span></h2>
<div id="search-results"></div>
</section>
</div>
<script src="app.js"></script>
<script src="util.js"></script>
<script src="app.js"></script>
</body>
</html>
+1098
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "akai-utils-web-ui",
"version": "1.0.0",
"description": "Web UI for AKAI Utils",
"scripts": {
"lint": "eslint *.js",
"test": "node --test util.test.js"
},
"devDependencies": {
"eslint": "^9.0.0"
}
}
+372
View File
@@ -50,6 +50,23 @@ section {
margin-bottom: 20px;
}
/* Section collapse toggle */
.section-toggle {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
}
.toggle-icon {
font-size: 12px;
transition: transform 0.2s;
display: inline-block;
}
.section-toggle.collapsed .toggle-icon {
transform: rotate(-90deg);
}
h2 {
font-size: 16px;
color: var(--text2);
@@ -205,4 +222,359 @@ h2 {
margin-top: 10px;
font-size: 13px;
color: var(--text2);
}
.wav-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.wav-controls input {
flex: 1;
min-width: 200px;
padding: 10px 14px;
border: 1px solid var(--bg3);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.wav-controls button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: white;
font-size: 14px;
cursor: pointer;
}
.wav-player {
margin-top: 14px;
}
.now-playing {
font-size: 13px;
color: var(--green);
margin-bottom: 6px;
min-height: 18px;
}
.audio-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.audio-progress {
flex: 1;
height: 6px;
background: var(--bg3);
border-radius: 3px;
overflow: hidden;
}
.audio-progress-fill {
height: 100%;
background: var(--green);
border-radius: 3px;
width: 0%;
transition: width 0.1s;
}
#wav-time {
font-size: 12px;
color: var(--text2);
min-width: 40px;
}
.no-files {
color: var(--text2);
font-size: 13px;
padding: 12px 0;
}
.wav-summary {
font-size: 13px;
color: var(--text2);
margin-top: 12px;
margin-bottom: 12px;
min-height: 18px;
}
/* Library cards */
.wav-library {
display: flex;
flex-direction: column;
gap: 12px;
}
.library-card {
background: var(--bg2);
border: 1px solid var(--bg3);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 8px;
}
.library-header {
padding: 10px 16px;
cursor: pointer;
user-select: none;
background: var(--bg);
border-bottom: 1px solid var(--bg3);
transition: border-color 0.15s;
display: flex;
align-items: center;
gap: 12px;
}
.library-header:hover {
border-color: var(--accent);
}
.library-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin: 0;
text-transform: none;
letter-spacing: 0;
display: flex;
align-items: center;
gap: 4px;
}
.library-header .lib-meta {
font-size: 11px;
color: var(--text2);
margin-left: auto;
}
.library-programs {
padding: 8px 16px 12px;
}
/* Program cards */
.program-card {
margin-top: 10px;
}
.program-card:first-child {
margin-top: 0;
}
.program-header {
cursor: default;
user-select: none;
margin-bottom: 6px;
}
.program-header h4 {
font-size: 12px;
font-weight: 600;
color: var(--accent);
margin: 0;
text-transform: none;
letter-spacing: 0;
display: inline;
}
.program-header .prog-meta {
font-size: 10px;
color: var(--text2);
margin-left: 6px;
}
.program-samples {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 4px;
}
/* Tag viewer */
.tag-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag-controls input {
flex: 1;
min-width: 200px;
padding: 10px 14px;
border: 1px solid var(--bg3);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.tag-controls button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: white;
font-size: 14px;
cursor: pointer;
}
.tag-controls button:hover { opacity: 0.9; }
.tag-status {
font-size: 13px;
color: var(--text2);
margin-top: 10px;
margin-bottom: 8px;
}
.tag-partitions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.tag-part-btn {
padding: 6px 14px;
border: 1px solid var(--bg3);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: border-color 0.15s;
}
.tag-part-btn:hover {
border-color: var(--accent);
}
.tag-part-btn.active {
border-color: var(--accent);
background: var(--accent);
color: white;
cursor: default;
}
.tag-volumes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 6px;
margin-bottom: 12px;
}
.vol-item {
padding: 8px 12px;
border: 1px solid var(--bg3);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vol-item:hover {
border-color: var(--accent);
}
.vol-item.selected {
border-color: var(--green);
background: rgba(78, 204, 163, 0.1);
}
.tag-detail {
margin-top: 8px;
}
.tag-detail h4 {
font-size: 14px;
color: var(--text);
margin: 0 0 8px;
}
.tag-list {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
background: var(--bg3);
color: var(--text);
}
.tag-chip .tag-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* Sample cards */
.sample-card {
background: var(--bg);
border: 1px solid var(--bg3);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
transition: border-color 0.15s, background 0.15s;
}
.sample-card:hover {
border-color: var(--accent);
}
.sample-card.playing {
border-color: var(--green);
background: rgba(78, 204, 163, 0.15);
}
.sample-card .sample-name {
font-size: 10px;
color: var(--text);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.sample-card .sample-play-icon {
display: none;
}
/* Collapse all button */
.wav-summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.collapse-all-btn {
background: var(--bg3);
color: var(--text2);
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
}
.collapse-all-btn:hover {
color: var(--text);
}
/* Library collapse state */
.library-card.collapsed .library-programs {
display: none;
}
.library-header .lib-toggle {
font-size: 11px;
transition: transform 0.2s;
margin-left: 8px;
color: var(--text2);
}
.library-card.collapsed .lib-toggle {
transform: rotate(-90deg);
}
+52
View File
@@ -0,0 +1,52 @@
// AKAI Utils — shared utility functions
function esc(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
function sanitize(s) {
return s.replace(/[^a-zA-Z0-9_-]/g, '_')
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function formatTime(secs) {
const m = Math.floor(secs / 60)
const s = Math.floor(secs % 60)
return m + ':' + s.toString().padStart(2, '0')
}
function groupWavs(paths) {
const libs = {}
for (const p of paths) {
const parts = p.split('/')
let lib, prog, file
if (parts.length === 1) {
lib = ''; prog = parts[0]; file = parts[0]
} else if (parts.length === 2) {
lib = parts[0]; prog = parts[0]; file = parts[1]
} else {
lib = parts[0]; prog = parts[1]; file = parts[parts.length - 1]
}
if (!libs[lib]) libs[lib] = {}
if (!libs[lib][prog]) libs[lib][prog] = []
libs[lib][prog].push({ fullPath: p, name: file.replace(/\.wav$/i, '') })
}
return libs
}
function cleanName(name) {
return name.replace(/_+/g, ' ').trim()
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName }
}
+161
View File
@@ -0,0 +1,161 @@
const { describe, it } = require('node:test')
const assert = require('node:assert/strict')
const { esc, sanitize, formatBytes, formatTime, groupWavs, cleanName } = require('./util.js')
describe('esc', () => {
it('escapes HTML entities', () => {
assert.strictEqual(esc('<script>alert("xss")</script>'),
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
it('handles ampersands', () => {
assert.strictEqual(esc('a & b'), 'a &amp; b')
})
it('handles single quotes', () => {
assert.strictEqual(esc("it's"), 'it&#39;s')
})
it('returns empty string for falsy input', () => {
assert.strictEqual(esc(''), '')
assert.strictEqual(esc(null), '')
assert.strictEqual(esc(undefined), '')
})
it('passes through safe strings', () => {
assert.strictEqual(esc('Hello World'), 'Hello World')
})
})
describe('sanitize', () => {
it('replaces special characters with underscores', () => {
assert.strictEqual(sanitize('hello world!'), 'hello_world_')
})
it('preserves alphanumeric and dashes', () => {
assert.strictEqual(sanitize('test-123_abc'), 'test-123_abc')
})
it('sanitizes dots and slashes', () => {
assert.strictEqual(sanitize('path/to/file.name'),
'path_to_file_name')
})
})
describe('formatBytes', () => {
it('formats zero', () => {
assert.strictEqual(formatBytes(0), '0 B')
})
it('formats bytes', () => {
assert.strictEqual(formatBytes(500), '500 B')
})
it('formats KB', () => {
assert.strictEqual(formatBytes(1024), '1 KB')
})
it('formats MB', () => {
assert.strictEqual(formatBytes(1048576), '1 MB')
})
it('formats GB', () => {
assert.strictEqual(formatBytes(1073741824), '1 GB')
})
it('formats fractional', () => {
assert.strictEqual(formatBytes(1536), '1.5 KB')
})
})
describe('formatTime', () => {
it('formats zero', () => {
assert.strictEqual(formatTime(0), '0:00')
})
it('formats seconds', () => {
assert.strictEqual(formatTime(5), '0:05')
})
it('formats minutes and seconds', () => {
assert.strictEqual(formatTime(65), '1:05')
})
it('formats double-digit seconds', () => {
assert.strictEqual(formatTime(75), '1:15')
})
it('formats large times', () => {
assert.strictEqual(formatTime(3661), '61:01')
})
})
describe('groupWavs', () => {
it('groups by library and program', () => {
const paths = [
'LibA/Prog1/kick.wav',
'LibA/Prog1/snare.wav',
'LibA/Prog2/hat.wav'
]
const result = groupWavs(paths)
assert.strictEqual(Object.keys(result).length, 1)
assert.strictEqual(Object.keys(result.LibA).length, 2)
assert.strictEqual(result.LibA.Prog1.length, 2)
assert.strictEqual(result.LibA.Prog2.length, 1)
})
it('handles multiple libraries', () => {
const paths = [
'LibA/kick.wav',
'LibB/snare.wav'
]
const result = groupWavs(paths)
assert.strictEqual(Object.keys(result).length, 2)
assert.strictEqual(result.LibA.LibA[0].name, 'kick')
assert.strictEqual(result.LibB.LibB[0].name, 'snare')
})
it('strips .wav extension case-insensitively', () => {
const paths = ['Lib/Prog/KICK.WAV']
const result = groupWavs(paths)
assert.strictEqual(result.Lib.Prog[0].name, 'KICK')
})
it('handles deeply nested paths', () => {
const paths = ['A/B/C/D/file.wav']
const result = groupWavs(paths)
assert.strictEqual(result.A.B.length, 1)
assert.strictEqual(result.A.B[0].name, 'file')
assert.strictEqual(result.A.B[0].fullPath, 'A/B/C/D/file.wav')
})
it('handles flat paths with no directory', () => {
const paths = ['sound.wav']
const result = groupWavs(paths)
assert.strictEqual(Object.keys(result).length, 1)
assert.strictEqual(Object.keys(result['']).length, 1)
})
it('returns empty object for empty input', () => {
const result = groupWavs([])
assert.strictEqual(Object.keys(result).length, 0)
})
})
describe('cleanName', () => {
it('replaces underscores with spaces', () => {
assert.strictEqual(cleanName('hello_world'), 'hello world')
})
it('collapses multiple underscores', () => {
assert.strictEqual(cleanName('AMG_-_Black__II'), 'AMG - Black II')
})
it('trims leading/trailing spaces from underscores', () => {
assert.strictEqual(cleanName('_padded_'), 'padded')
})
it('preserves regular text', () => {
assert.strictEqual(cleanName('Kick Drum'), 'Kick Drum')
})
})