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