Compare commits

..

1 Commits

Author SHA1 Message Date
david e156c70eff 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:00:27 -07:00
28 changed files with 161 additions and 5273 deletions
-5
View File
@@ -25,10 +25,5 @@ wavs
.DS_Store
Thumbs.db
# JS dependencies
web/ui/node_modules/
# Temp
/tmp/
akai-fetch.test
cover.out
-14
View File
@@ -1,14 +0,0 @@
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- staticcheck
- unused
exclusions:
paths:
- web/ui
- electron
- third_party/akaiutil/
+16 -41
View File
@@ -5,38 +5,29 @@
```bash
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 # → ./akai-fetch
mise run build # → ./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 # golangci-lint run (Go)
mise run js-lint # eslint (JS)
mise run js-test # node --test (JS)
mise run lint # go vet ./...
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 akai-fetch . # → ./akai-fetch
go build -buildvcs=false -o fetch . # → ./fetch
# Build and run web server
./akai-fetch serve # → starts on free port, opens browser
./akai-fetch serve --host 0.0.0.0 -p 8080 # → Docker / network-accessible mode
./fetch serve # → starts on free port, opens browser
./fetch serve --host 0.0.0.0 -p 8080 # → Docker / network-accessible mode
# Docker
docker compose up --build # serves on :8080, binds 0.0.0.0
@@ -46,8 +37,8 @@ docker compose up --build # serves on :8080, binds 0.0.0.0
(cd electron && npx electron .)
# Cross-compile for macOS (from Linux)
GOOS=darwin GOARCH=arm64 go build -o akai-fetch-darwin-arm64 .
GOOS=darwin GOARCH=amd64 go build -o akai-fetch-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o fetch-darwin-arm64 .
GOOS=darwin GOARCH=amd64 go build -o fetch-darwin-amd64 .
```
## Architecture
@@ -57,14 +48,7 @@ Single binary (`fetch`) with subcommands. No external Go dependencies — pure s
```
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 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
web/ui/ Static frontend (index.html, style.css, app.js)
electron/ Electron wrapper shell
scripts/ Bash helpers called via exec
├── extract_wavs.sh batch WAV extraction via akaiutil
@@ -79,18 +63,14 @@ 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
- **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.
- **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`
- **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
`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:
`scripts/extract_wavs.sh` shells out to `akaiutil` for:
- `df` — disk info (partitions, block counts)
- `dir` — list volumes/files
- `sample2wavall` — batch WAV extraction
@@ -105,8 +85,8 @@ All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, p
### Electron integration
`electron/main.js` spawns the Go binary in `serve` mode:
1. Finds `akai-fetch` binary next to app or in `process.resourcesPath`
2. Spawns `akai-fetch serve -p 0 --no-browser`
1. Finds `fetch` binary next to app or in `process.resourcesPath`
2. Spawns `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
@@ -134,15 +114,10 @@ All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, p
## Testing
No test framework yet. Manual verification:
```bash
# 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)
go build -o /dev/null . # compile check
./fetch serve # manual smoke test
```
## Secrets & Tokens
+6 -6
View File
@@ -9,13 +9,13 @@ COPY third_party/akaiutil/ /src/akaiutil/
WORKDIR /src/akaiutil
RUN make
# Stage 2: Build akai-fetch (Go tool)
FROM golang:1.26-alpine AS akai-fetch-builder
# Stage 2: Build fetch (Go tool)
FROM golang:1.26-alpine AS fetch-builder
WORKDIR /src
COPY go.mod *.go /src/
COPY go.mod main.go serve.go /src/
COPY web/ /src/web/
RUN CGO_ENABLED=0 go build -o /akai-fetch .
RUN CGO_ENABLED=0 go build -o /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=akai-fetch-builder /akai-fetch /usr/local/bin/akai-fetch
COPY --from=fetch-builder /fetch /usr/local/bin/fetch
COPY scripts/extract_wavs.sh /usr/local/bin/
WORKDIR /data
EXPOSE 8080
ENTRYPOINT ["akai-fetch"]
ENTRYPOINT ["fetch"]
CMD ["serve", "-p", "8080", "--host", "0.0.0.0"]
+8 -6
View File
@@ -1,5 +1,6 @@
BIN := akai-fetch
BIN := fetch
IMAGE := akai-fetch
REMOTE := dhg.lol
ELECTRON_DIR := electron
build:
@@ -16,10 +17,7 @@ docker:
docker-run:
docker run --rm -v "$(PWD)/output:/data" $(IMAGE) $(ARGS)
init:
mkdir -p output/isos output/wavs
docker-up: init
docker-up:
docker compose up --build
docker-down:
@@ -47,6 +45,10 @@ 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
@@ -55,4 +57,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 \
clean
install clean
+7 -9
View File
@@ -12,7 +12,7 @@ Two interfaces:
```bash
mise trust # first time only
mise install # installs go 1.26, node 22
mise run build # → ./akai-fetch
mise run build # → ./fetch
```
### Toolchain check
@@ -32,14 +32,14 @@ mise run docker-up
```bash
# CLI
mise run build
./akai-fetch search --filter vocal
./fetch search --filter vocal
# Web server (local)
mise run serve
# Open http://localhost:PORT (auto-assigned)
# Web server (Docker / network-accessible)
./akai-fetch serve --host 0.0.0.0 -p 8080
./fetch serve --host 0.0.0.0 -p 8080
# Open http://<host-ip>:8080
# Electron (macOS)
@@ -60,14 +60,14 @@ akai-fetch serve [flags] start HTTP server with web UI
## Architecture
```
akai-fetch serve (Go HTTP server, embedded web UI)
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 akai-fetch serve as sidecar)
electron/ Electron wrapper (spawns fetch serve as sidecar)
├── main.js main process
├── preload.js context bridge
└── package.json electron-builder config
@@ -87,7 +87,7 @@ third_party/akaiutil/ Vendored akaiutil 4.6.7 (C, GPLv2)
| CLI server | Go 1.26, stdlib | 0 |
| Web UI | HTML/CSS/JS (vanilla) | 0 |
| Electron | Electron 33, electron-builder | Node deps |
| Extraction | akaiutil (C) via Go wrappers | vendored C source |
| Extraction | akaiutil (C), bash | vendored C source |
Zero Go dependencies. The web UI uses no framework — vanilla JS, Web Audio API, Server-Sent Events.
@@ -99,8 +99,6 @@ 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 |
@@ -110,7 +108,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 | done |
| #9 | Audio Preview Player | todo |
## License
-610
View File
@@ -1,610 +0,0 @@
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
@@ -1,326 +0,0 @@
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, '..', 'akai-fetch'),
path.join(__dirname, '..', 'fetch'),
path.join(process.resourcesPath, 'akai-fetch'),
path.join(__dirname, '..', 'akai-fetch'),
path.join(process.resourcesPath, 'fetch'),
path.join(process.resourcesPath, 'akai-fetch'),
];
for (const p of candidates) {
try {
+5 -5
View File
@@ -16,8 +16,8 @@
],
"extraResources": [
{
"from": "../akai-fetch",
"to": "akai-fetch"
"from": "../fetch",
"to": "fetch"
},
{
"from": "../scripts/extract_wavs.sh",
@@ -35,8 +35,8 @@
"artifactName": "${name}-${version}-${arch}.${ext}",
"extraResources": [
{
"from": "../akai-fetch",
"to": "akai-fetch"
"from": "../fetch",
"to": "fetch"
},
{
"from": "../scripts/extract_wavs.sh",
@@ -60,7 +60,7 @@
"build:linux": "electron-builder --linux"
},
"devDependencies": {
"electron": "33.4.11",
"electron": "^33.0.0",
"electron-builder": "^25.0.0"
}
}
-263
View File
@@ -1,263 +0,0 @@
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
@@ -1,483 +0,0 @@
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
@@ -1,191 +0,0 @@
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
}
+83 -79
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
@@ -16,10 +17,10 @@ import (
"time"
)
var (
userAgent = "akai-fetch/1.0"
const (
iaSearchURL = "https://archive.org/advancedsearch.php"
iaBaseURL = "https://archive.org"
userAgent = "akai-fetch/1.0"
)
type SearchResult struct {
@@ -99,9 +100,10 @@ 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:
@@ -121,15 +123,6 @@ 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")
@@ -176,7 +169,7 @@ func searchArchive(query string, limit int) []SearchDoc {
if err != nil {
fatal("search request: %v", err)
}
defer func() { _ = resp.Body.Close() }()
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -193,15 +186,6 @@ 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>")
}
@@ -218,7 +202,7 @@ func cmdList(args []string) {
}
}
var getItemFiles = func(identifier string) []MetadataFile {
func getItemFiles(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)
@@ -227,7 +211,7 @@ var getItemFiles = func(identifier string) []MetadataFile {
if err != nil {
fatal("metadata request: %v", err)
}
defer func() { _ = resp.Body.Close() }()
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -261,15 +245,6 @@ 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)")
@@ -277,9 +252,7 @@ func cmdDownload(args []string) {
jobs := fs.Int("jobs", 2, "concurrent downloads")
_ = fs.Parse(args)
if err := os.MkdirAll(*outDir, 0755); err != nil {
fatal("create %s: %v", *outDir, err)
}
os.MkdirAll(*outDir, 0755)
var identifiers []string
if *identifier != "" {
@@ -356,11 +329,7 @@ func cmdDownload(args []string) {
fmt.Printf("\nDownloaded %s total\n", humanSize(totalDownloaded))
}
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)
}
func downloadFile(job downloadJob) (int64, error) {
// 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 {
@@ -388,7 +357,7 @@ var downloadFile = func(job downloadJob) (int64, error) {
if err != nil {
return 0, fmt.Errorf("get: %w", err)
}
defer func() { _ = resp.Body.Close() }()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return 0, fmt.Errorf("HTTP %d", resp.StatusCode)
@@ -398,7 +367,7 @@ var downloadFile = func(job downloadJob) (int64, error) {
if err != nil {
return 0, fmt.Errorf("open: %w", err)
}
defer func() { _ = outFile.Close() }()
defer outFile.Close()
written, err := io.Copy(outFile, resp.Body)
if err != nil {
@@ -410,23 +379,16 @@ var downloadFile = func(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)
if err := os.MkdirAll(*outDir, 0755); err != nil {
fatal("create %s: %v", *outDir, err)
toolPath := *toolFlag
if toolPath == "" {
toolPath = findScript("extract_wavs.sh")
}
entries, err := os.ReadDir(*isoDir)
@@ -450,6 +412,8 @@ 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)
@@ -459,7 +423,10 @@ func cmdExtract(args []string) {
defer func() { <-sem }()
out := *outDir
result, err := extractISO(isoPath, out)
result, err := extractISO(toolPath, isoPath, out)
mu.Lock()
results[filepath.Base(isoPath)] = result
mu.Unlock()
if err != nil {
fmt.Printf(" FAIL %-40s %v\n", filepath.Base(isoPath), err)
} else {
@@ -470,6 +437,46 @@ 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 != "" {
@@ -500,15 +507,6 @@ 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")
@@ -520,6 +518,7 @@ 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()
@@ -565,26 +564,33 @@ 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, "-jobs", fmt.Sprintf("%d", *extJobs)})
cmdExtract([]string{"-dir", isoDir, "-out", wavDir, "-tool", toolPath, "-jobs", fmt.Sprintf("%d", *extJobs)})
fmt.Printf("\n=== Pipeline Complete ===\n")
fmt.Printf(" ISOs: %s\n", isoDir)
fmt.Printf(" WAVs: %s\n", wavDir)
}
// ─── Helpers ───────────────────────────────────────────────────────────
var osExit = os.Exit
type fatalErr struct {
code int
message string
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 ───────────────────────────────────────────────────────────
func fatal(format string, args ...any) {
msg := fmt.Sprintf("error: "+format+"\n", args...)
fmt.Fprintf(os.Stderr, msg, args...)
panic(fatalErr{code: 1, message: msg})
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
os.Exit(1)
}
func truncate(s string, n int) string {
@@ -611,8 +617,6 @@ func humanSize(bytes int64) string {
}
func init() {
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)
}
// Ensure we can parse flags even with -count or other test flags
flag.CommandLine.Parse([]string{})
}
+3 -9
View File
@@ -1,7 +1,6 @@
[tools]
go = "1.26"
golangci-lint = "latest"
node = "22.12"
node = "22"
[env]
CGO_ENABLED = "0"
@@ -11,18 +10,13 @@ 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 = "golangci-lint run"
js-lint = "cd web/ui && npm run lint"
js-test = "cd web/ui && npm test"
lint = "go vet ./..."
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" }
init = "mkdir -p output/isos output/wavs"
docker-up = "mkdir -p output/isos output/wavs && docker compose up --build"
docker-up = "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,19 +98,6 @@ 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
@@ -123,64 +110,5 @@ 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
@@ -1,45 +0,0 @@
#!/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 ==="
+12 -148
View File
@@ -54,23 +54,10 @@ 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 {
@@ -171,13 +158,7 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
outDir = "."
}
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
}
os.MkdirAll(outDir, 0755)
files := getItemFiles(identifier)
if len(files) == 0 {
@@ -202,11 +183,6 @@ 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)
@@ -222,23 +198,20 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
s.Error = err.Error()
return
}
defer func() { _ = resp.Body.Close() }()
defer resp.Body.Close()
outFile, err := os.Create(j.OutPath)
if err != nil {
s.Error = err.Error()
return
}
defer func() { _ = outFile.Close() }()
defer outFile.Close()
buf := make([]byte, 32*1024)
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
if _, err := outFile.Write(buf[:n]); err != nil {
s.Error = err.Error()
return
}
outFile.Write(buf[:n])
s.Downloaded += int64(n)
now := time.Now()
@@ -267,112 +240,6 @@ 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")
@@ -392,9 +259,7 @@ func handleProgress(w http.ResponseWriter, r *http.Request) {
data, _ := json.Marshal(downloadJobs)
downloadJobsMu.Unlock()
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
allDone := true
@@ -422,6 +287,8 @@ 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()})
@@ -442,7 +309,7 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) {
var messages []string
for _, isoPath := range isos {
result, err := extractISO(isoPath, outDir)
result, err := extractISO(toolPath, isoPath, outDir)
if err != nil {
messages = append(messages, fmt.Sprintf("FAIL %s: %v", filepath.Base(isoPath), err))
} else {
@@ -458,16 +325,13 @@ 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", "*")
if err := json.NewEncoder(w).Encode(v); err != nil {
// client disconnected, nothing we can do
return
}
json.NewEncoder(w).Encode(v)
}
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
@@ -1,636 +0,0 @@
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
@@ -1,205 +0,0 @@
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")
}
})
}
+15 -329
View File
@@ -29,7 +29,6 @@ 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;
@@ -46,8 +45,8 @@ function renderResults(items) {
<span>${r.downloads.toLocaleString()} downloads</span>
</div>
<div class="actions">
<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>
<button class="btn-dl" onclick="downloadItem('${esc(r.identifier)}')">Download ISOs</button>
<button class="btn-list" onclick="listItem('${esc(r.identifier)}')">List Files</button>
</div>
<div id="list-${sanitize(r.identifier)}" class="file-list"></div>
</div>
@@ -175,11 +174,6 @@ 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) {
@@ -188,333 +182,25 @@ async function doExtract() {
}
}
// ─── WAV Browser ──────────────────────────────────────────────────
// ─── Helpers ──────────────────────────────────────────────────────
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 esc(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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 sanitize(s) {
return s.replace(/[^a-zA-Z0-9_-]/g, '_');
}
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');
});
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];
}
// 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
@@ -1,45 +0,0 @@
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"
}
}
];
+2 -47
View File
@@ -22,6 +22,7 @@
<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">
@@ -38,53 +39,7 @@
</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="util.js"></script>
<script src="app.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
@@ -1,12 +0,0 @@
{
"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,23 +50,6 @@ 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);
@@ -223,358 +206,3 @@ h2 {
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
@@ -1,52 +0,0 @@
// 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
@@ -1,161 +0,0 @@
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')
})
})