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
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# JS dependencies
|
||||||
|
web/ui/node_modules/
|
||||||
|
|
||||||
# Temp
|
# Temp
|
||||||
/tmp/
|
/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/
|
||||||
@@ -5,29 +5,38 @@
|
|||||||
```bash
|
```bash
|
||||||
mise trust # trust the mise.toml config (first time only)
|
mise trust # trust the mise.toml config (first time only)
|
||||||
mise install # installs go 1.26, node 22
|
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`:
|
All build/run tasks are defined in `mise.toml`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run build # → ./fetch
|
mise run build # → ./akai-fetch
|
||||||
mise run serve # → starts HTTP server, opens browser
|
mise run serve # → starts HTTP server, opens browser
|
||||||
mise run electron-deps # installs electron + electron-builder
|
mise run electron-deps # installs electron + electron-builder
|
||||||
mise run electron-dev # runs electron in dev mode
|
mise run electron-dev # runs electron in dev mode
|
||||||
mise run docker-up # docker compose up --build
|
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
|
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)
|
## Build & Run (manual)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build Go binary (zero CGO)
|
# 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
|
# Build and run web server
|
||||||
./fetch serve # → starts on free port, opens browser
|
./akai-fetch serve # → starts on free port, opens browser
|
||||||
./fetch serve --host 0.0.0.0 -p 8080 # → Docker / network-accessible mode
|
./akai-fetch serve --host 0.0.0.0 -p 8080 # → Docker / network-accessible mode
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker compose up --build # serves on :8080, binds 0.0.0.0
|
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 .)
|
(cd electron && npx electron .)
|
||||||
|
|
||||||
# Cross-compile for macOS (from Linux)
|
# Cross-compile for macOS (from Linux)
|
||||||
GOOS=darwin GOARCH=arm64 go build -o fetch-darwin-arm64 .
|
GOOS=darwin GOARCH=arm64 go build -o akai-fetch-darwin-arm64 .
|
||||||
GOOS=darwin GOARCH=amd64 go build -o fetch-darwin-amd64 .
|
GOOS=darwin GOARCH=amd64 go build -o akai-fetch-darwin-amd64 .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## 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)
|
main.go CLI entry, commands (search/list/download/extract/pipeline/serve)
|
||||||
serve.go HTTP server + REST API + embedded web UI (embed.FS)
|
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
|
electron/ Electron wrapper shell
|
||||||
scripts/ Bash helpers called via exec
|
scripts/ Bash helpers called via exec
|
||||||
├── extract_wavs.sh batch WAV extraction via akaiutil
|
├── 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
|
- **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
|
- **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
|
- **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
|
- **Event delegation**: `app.js` — single click listener on document, dispatches by `[data-action]` attribute (download, list, play)
|
||||||
- **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource`
|
- **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
|
- **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.
|
- **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
|
### 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)
|
- `df` — disk info (partitions, block counts)
|
||||||
- `dir` — list volumes/files
|
- `dir` — list volumes/files
|
||||||
- `sample2wavall` — batch WAV extraction
|
- `sample2wavall` — batch WAV extraction
|
||||||
@@ -85,8 +105,8 @@ All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, p
|
|||||||
### Electron integration
|
### Electron integration
|
||||||
|
|
||||||
`electron/main.js` spawns the Go binary in `serve` mode:
|
`electron/main.js` spawns the Go binary in `serve` mode:
|
||||||
1. Finds `fetch` binary next to app or in `process.resourcesPath`
|
1. Finds `akai-fetch` binary next to app or in `process.resourcesPath`
|
||||||
2. Spawns `fetch serve -p 0 --no-browser`
|
2. Spawns `akai-fetch serve -p 0 --no-browser`
|
||||||
3. Parses `stdout` for `Local: http://localhost:XXXX` to get port
|
3. Parses `stdout` for `Local: http://localhost:XXXX` to get port
|
||||||
4. Creates `BrowserWindow` pointing at that URL
|
4. Creates `BrowserWindow` pointing at that URL
|
||||||
5. Kills server on quit
|
5. Kills server on quit
|
||||||
@@ -114,10 +134,15 @@ All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, p
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
No test framework yet. Manual verification:
|
|
||||||
```bash
|
```bash
|
||||||
go build -o /dev/null . # compile check
|
# Go
|
||||||
./fetch serve # manual smoke test
|
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
|
## Secrets & Tokens
|
||||||
|
|||||||
+6
-6
@@ -9,13 +9,13 @@ COPY third_party/akaiutil/ /src/akaiutil/
|
|||||||
WORKDIR /src/akaiutil
|
WORKDIR /src/akaiutil
|
||||||
RUN make
|
RUN make
|
||||||
|
|
||||||
# Stage 2: Build fetch (Go tool)
|
# Stage 2: Build akai-fetch (Go tool)
|
||||||
FROM golang:1.26-alpine AS fetch-builder
|
FROM golang:1.26-alpine AS akai-fetch-builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod main.go serve.go /src/
|
COPY go.mod *.go /src/
|
||||||
COPY web/ /src/web/
|
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
|
# Stage 3: Runtime image
|
||||||
FROM debian:11-slim
|
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
|
RUN mkdir -p /data/output/isos /data/output/wavs
|
||||||
|
|
||||||
COPY --from=akaiutil-builder /src/akaiutil/akaiutil /usr/local/bin/
|
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/
|
COPY scripts/extract_wavs.sh /usr/local/bin/
|
||||||
|
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["fetch"]
|
ENTRYPOINT ["akai-fetch"]
|
||||||
CMD ["serve", "-p", "8080", "--host", "0.0.0.0"]
|
CMD ["serve", "-p", "8080", "--host", "0.0.0.0"]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
BIN := fetch
|
BIN := akai-fetch
|
||||||
IMAGE := akai-fetch
|
IMAGE := akai-fetch
|
||||||
REMOTE := dhg.lol
|
|
||||||
ELECTRON_DIR := electron
|
ELECTRON_DIR := electron
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@@ -17,7 +16,10 @@ docker:
|
|||||||
docker-run:
|
docker-run:
|
||||||
docker run --rm -v "$(PWD)/output:/data" $(IMAGE) $(ARGS)
|
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 compose up --build
|
||||||
|
|
||||||
docker-down:
|
docker-down:
|
||||||
@@ -45,10 +47,6 @@ electron-build-linux: build akaiutil
|
|||||||
|
|
||||||
# ─── Server / remote ───────────────────────────────────────
|
# ─── 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:
|
clean:
|
||||||
rm -f $(BIN)
|
rm -f $(BIN)
|
||||||
rm -rf $(ELECTRON_DIR)/dist
|
rm -rf $(ELECTRON_DIR)/dist
|
||||||
@@ -57,4 +55,4 @@ clean:
|
|||||||
|
|
||||||
.PHONY: build akaiutil all docker docker-run docker-up docker-down serve \
|
.PHONY: build akaiutil all docker docker-run docker-up docker-down serve \
|
||||||
electron-deps electron-dev electron-build electron-build-mac electron-build-linux \
|
electron-deps electron-dev electron-build electron-build-mac electron-build-linux \
|
||||||
install clean
|
clean
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Two interfaces:
|
|||||||
```bash
|
```bash
|
||||||
mise trust # first time only
|
mise trust # first time only
|
||||||
mise install # installs go 1.26, node 22
|
mise install # installs go 1.26, node 22
|
||||||
mise run build # → ./fetch
|
mise run build # → ./akai-fetch
|
||||||
```
|
```
|
||||||
|
|
||||||
### Toolchain check
|
### Toolchain check
|
||||||
@@ -32,14 +32,14 @@ mise run docker-up
|
|||||||
```bash
|
```bash
|
||||||
# CLI
|
# CLI
|
||||||
mise run build
|
mise run build
|
||||||
./fetch search --filter vocal
|
./akai-fetch search --filter vocal
|
||||||
|
|
||||||
# Web server (local)
|
# Web server (local)
|
||||||
mise run serve
|
mise run serve
|
||||||
# Open http://localhost:PORT (auto-assigned)
|
# Open http://localhost:PORT (auto-assigned)
|
||||||
|
|
||||||
# Web server (Docker / network-accessible)
|
# 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
|
# Open http://<host-ip>:8080
|
||||||
|
|
||||||
# Electron (macOS)
|
# Electron (macOS)
|
||||||
@@ -60,14 +60,14 @@ akai-fetch serve [flags] start HTTP server with web UI
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
fetch serve (Go HTTP server, embedded web UI)
|
akai-fetch serve (Go HTTP server, embedded web UI)
|
||||||
├── /api/search archive.org search
|
├── /api/search archive.org search
|
||||||
├── /api/list list ISO files in an item
|
├── /api/list list ISO files in an item
|
||||||
├── /api/download download ISOs with SSE progress
|
├── /api/download download ISOs with SSE progress
|
||||||
├── /api/extract extract WAVs via akaiutil
|
├── /api/extract extract WAVs via akaiutil
|
||||||
└── /api/progress SSE download progress stream
|
└── /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
|
├── main.js main process
|
||||||
├── preload.js context bridge
|
├── preload.js context bridge
|
||||||
└── package.json electron-builder config
|
└── 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 |
|
| CLI server | Go 1.26, stdlib | 0 |
|
||||||
| Web UI | HTML/CSS/JS (vanilla) | 0 |
|
| Web UI | HTML/CSS/JS (vanilla) | 0 |
|
||||||
| Electron | Electron 33, electron-builder | Node deps |
|
| 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.
|
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 |
|
| — | Search, download, extract, pipeline (CLI) | done |
|
||||||
| — | HTTP server + web UI + Electron shell | 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 |
|
| — | Docker compose + mise toolchain + toolchain checks | done |
|
||||||
| #1 | Disk Browser — browse AKAI filesystem in web UI | todo |
|
| #1 | Disk Browser — browse AKAI filesystem in web UI | todo |
|
||||||
| #2 | Disk Inspector — detailed disk metadata | 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 |
|
| #6 | Direct-to-Disk Take Extraction | todo |
|
||||||
| #7 | TAR Bulk Import/Export | todo |
|
| #7 | TAR Bulk Import/Export | todo |
|
||||||
| #8 | Sample Tag Editor | todo |
|
| #8 | Sample Tag Editor | todo |
|
||||||
| #9 | Audio Preview Player | todo |
|
| #9 | Audio Preview Player | done |
|
||||||
|
|
||||||
## License
|
## 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() {
|
function findServerBinary() {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(__dirname, '..', 'fetch'),
|
|
||||||
path.join(__dirname, '..', 'akai-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, 'akai-fetch'),
|
||||||
|
path.join(process.resourcesPath, 'fetch'),
|
||||||
];
|
];
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "../fetch",
|
"from": "../akai-fetch",
|
||||||
"to": "fetch"
|
"to": "akai-fetch"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "../scripts/extract_wavs.sh",
|
"from": "../scripts/extract_wavs.sh",
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
"artifactName": "${name}-${version}-${arch}.${ext}",
|
"artifactName": "${name}-${version}-${arch}.${ext}",
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "../fetch",
|
"from": "../akai-fetch",
|
||||||
"to": "fetch"
|
"to": "akai-fetch"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "../scripts/extract_wavs.sh",
|
"from": "../scripts/extract_wavs.sh",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"build:linux": "electron-builder --linux"
|
"build:linux": "electron-builder --linux"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^33.0.0",
|
"electron": "33.4.11",
|
||||||
"electron-builder": "^25.0.0"
|
"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/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -17,10 +16,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
var (
|
||||||
|
userAgent = "akai-fetch/1.0"
|
||||||
iaSearchURL = "https://archive.org/advancedsearch.php"
|
iaSearchURL = "https://archive.org/advancedsearch.php"
|
||||||
iaBaseURL = "https://archive.org"
|
iaBaseURL = "https://archive.org"
|
||||||
userAgent = "akai-fetch/1.0"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
@@ -103,7 +102,6 @@ Download flags:
|
|||||||
Extract flags:
|
Extract flags:
|
||||||
-dir "./isos" directory containing ISO files
|
-dir "./isos" directory containing ISO files
|
||||||
-out "./wavs" output directory for WAV files
|
-out "./wavs" output directory for WAV files
|
||||||
-tool "./extract_wavs.sh" path to extraction script
|
|
||||||
-jobs 2 concurrent extractions (default: 2)
|
-jobs 2 concurrent extractions (default: 2)
|
||||||
|
|
||||||
Pipeline flags:
|
Pipeline flags:
|
||||||
@@ -123,6 +121,15 @@ Serve flags:
|
|||||||
// ─── Search ────────────────────────────────────────────────────────────
|
// ─── Search ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func cmdSearch(args []string) {
|
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)
|
fs := flag.NewFlagSet("search", flag.ExitOnError)
|
||||||
query := fs.String("query", "subject:akai AND subject:sampler", "search query")
|
query := fs.String("query", "subject:akai AND subject:sampler", "search query")
|
||||||
filter := fs.String("filter", "", "extra term AND-ed onto the 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 {
|
if err != nil {
|
||||||
fatal("search request: %v", err)
|
fatal("search request: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,6 +193,15 @@ func searchArchive(query string, limit int) []SearchDoc {
|
|||||||
// ─── List ──────────────────────────────────────────────────────────────
|
// ─── List ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func cmdList(args []string) {
|
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 {
|
if len(args) < 1 {
|
||||||
fatal("usage: akai-fetch list <identifier>")
|
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))
|
u := fmt.Sprintf("%s/metadata/%s", iaBaseURL, url.PathEscape(identifier))
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
req, _ := http.NewRequest("GET", u, nil)
|
req, _ := http.NewRequest("GET", u, nil)
|
||||||
@@ -211,7 +227,7 @@ func getItemFiles(identifier string) []MetadataFile {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fatal("metadata request: %v", err)
|
fatal("metadata request: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -245,6 +261,15 @@ type downloadJob struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cmdDownload(args []string) {
|
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)
|
fs := flag.NewFlagSet("download", flag.ExitOnError)
|
||||||
identifier := fs.String("i", "", "archive.org item identifier")
|
identifier := fs.String("i", "", "archive.org item identifier")
|
||||||
idFile := fs.String("f", "", "file with identifiers (one per line)")
|
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")
|
jobs := fs.Int("jobs", 2, "concurrent downloads")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
os.MkdirAll(*outDir, 0755)
|
if err := os.MkdirAll(*outDir, 0755); err != nil {
|
||||||
|
fatal("create %s: %v", *outDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
var identifiers []string
|
var identifiers []string
|
||||||
if *identifier != "" {
|
if *identifier != "" {
|
||||||
@@ -329,7 +356,11 @@ func cmdDownload(args []string) {
|
|||||||
fmt.Printf("\nDownloaded %s total\n", humanSize(totalDownloaded))
|
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
|
// Check if file already exists and has the expected size
|
||||||
if fi, err := os.Stat(job.OutPath); err == nil {
|
if fi, err := os.Stat(job.OutPath); err == nil {
|
||||||
if job.Size > 0 && fi.Size() == job.Size {
|
if job.Size > 0 && fi.Size() == job.Size {
|
||||||
@@ -357,7 +388,7 @@ func downloadFile(job downloadJob) (int64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("get: %w", err)
|
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 {
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||||
return 0, fmt.Errorf("HTTP %d", resp.StatusCode)
|
return 0, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
@@ -367,7 +398,7 @@ func downloadFile(job downloadJob) (int64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("open: %w", err)
|
return 0, fmt.Errorf("open: %w", err)
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer func() { _ = outFile.Close() }()
|
||||||
|
|
||||||
written, err := io.Copy(outFile, resp.Body)
|
written, err := io.Copy(outFile, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -379,16 +410,23 @@ func downloadFile(job downloadJob) (int64, error) {
|
|||||||
// ─── Extract ───────────────────────────────────────────────────────────
|
// ─── Extract ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func cmdExtract(args []string) {
|
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)
|
fs := flag.NewFlagSet("extract", flag.ExitOnError)
|
||||||
isoDir := fs.String("dir", ".", "directory with ISO files")
|
isoDir := fs.String("dir", ".", "directory with ISO files")
|
||||||
outDir := fs.String("out", "./wavs", "output directory for WAVs")
|
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")
|
jobs := fs.Int("jobs", 2, "concurrent extractions")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
toolPath := *toolFlag
|
if err := os.MkdirAll(*outDir, 0755); err != nil {
|
||||||
if toolPath == "" {
|
fatal("create %s: %v", *outDir, err)
|
||||||
toolPath = findScript("extract_wavs.sh")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(*isoDir)
|
entries, err := os.ReadDir(*isoDir)
|
||||||
@@ -412,8 +450,6 @@ func cmdExtract(args []string) {
|
|||||||
|
|
||||||
sem := make(chan struct{}, *jobs)
|
sem := make(chan struct{}, *jobs)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var mu sync.Mutex
|
|
||||||
results := map[string]string{}
|
|
||||||
|
|
||||||
for _, iso := range isos {
|
for _, iso := range isos {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -423,10 +459,7 @@ func cmdExtract(args []string) {
|
|||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
|
|
||||||
out := *outDir
|
out := *outDir
|
||||||
result, err := extractISO(toolPath, isoPath, out)
|
result, err := extractISO(isoPath, out)
|
||||||
mu.Lock()
|
|
||||||
results[filepath.Base(isoPath)] = result
|
|
||||||
mu.Unlock()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf(" FAIL %-40s %v\n", filepath.Base(isoPath), err)
|
fmt.Printf(" FAIL %-40s %v\n", filepath.Base(isoPath), err)
|
||||||
} else {
|
} else {
|
||||||
@@ -437,46 +470,6 @@ func cmdExtract(args []string) {
|
|||||||
wg.Wait()
|
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 {
|
func sanitiseISOName(path string) string {
|
||||||
name := path
|
name := path
|
||||||
if ext := filepath.Ext(name); ext != "" {
|
if ext := filepath.Ext(name); ext != "" {
|
||||||
@@ -507,6 +500,15 @@ func sanitiseISOName(path string) string {
|
|||||||
// ─── Pipeline ──────────────────────────────────────────────────────────
|
// ─── Pipeline ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func cmdPipeline(args []string) {
|
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)
|
fs := flag.NewFlagSet("pipeline", flag.ExitOnError)
|
||||||
query := fs.String("query", "subject:akai AND subject:sampler", "search query")
|
query := fs.String("query", "subject:akai AND subject:sampler", "search query")
|
||||||
filter := fs.String("filter", "", "extra term AND-ed onto the 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")
|
isoDir := filepath.Join(*baseDir, "isos")
|
||||||
wavDir := filepath.Join(*baseDir, "wavs")
|
wavDir := filepath.Join(*baseDir, "wavs")
|
||||||
toolPath := findScript("extract_wavs.sh")
|
|
||||||
|
|
||||||
// Remaining args after flags are treated as explicit identifiers
|
// Remaining args after flags are treated as explicit identifiers
|
||||||
identifiers := fs.Args()
|
identifiers := fs.Args()
|
||||||
@@ -564,33 +565,26 @@ func cmdPipeline(args []string) {
|
|||||||
cmdDownload([]string{"-f", idFile, "-dir", isoDir, "-jobs", fmt.Sprintf("%d", *dlJobs)})
|
cmdDownload([]string{"-f", idFile, "-dir", isoDir, "-jobs", fmt.Sprintf("%d", *dlJobs)})
|
||||||
|
|
||||||
fmt.Println("\n=== Phase 3: Extract ===")
|
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("\n=== Pipeline Complete ===\n")
|
||||||
fmt.Printf(" ISOs: %s\n", isoDir)
|
fmt.Printf(" ISOs: %s\n", isoDir)
|
||||||
fmt.Printf(" WAVs: %s\n", wavDir)
|
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 ───────────────────────────────────────────────────────────
|
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var osExit = os.Exit
|
||||||
|
|
||||||
|
type fatalErr struct {
|
||||||
|
code int
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
func fatal(format string, args ...any) {
|
func fatal(format string, args ...any) {
|
||||||
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
|
msg := fmt.Sprintf("error: "+format+"\n", args...)
|
||||||
os.Exit(1)
|
fmt.Fprintf(os.Stderr, msg, args...)
|
||||||
|
panic(fatalErr{code: 1, message: msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncate(s string, n int) string {
|
func truncate(s string, n int) string {
|
||||||
@@ -617,6 +611,8 @@ func humanSize(bytes int64) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Ensure we can parse flags even with -count or other test flags
|
if err := flag.CommandLine.Parse([]string{}); err != nil {
|
||||||
flag.CommandLine.Parse([]string{})
|
// 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]
|
[tools]
|
||||||
go = "1.26"
|
go = "1.26"
|
||||||
node = "22"
|
golangci-lint = "latest"
|
||||||
|
node = "22.12"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
CGO_ENABLED = "0"
|
CGO_ENABLED = "0"
|
||||||
@@ -10,13 +11,18 @@ build = "go build -buildvcs=false -o fetch ."
|
|||||||
akaiutil = "make -C third_party/akaiutil"
|
akaiutil = "make -C third_party/akaiutil"
|
||||||
all = { run = "go build -buildvcs=false -o fetch . && 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" }
|
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-deps = "cd electron && npm install"
|
||||||
electron-dev = { run = "go build -buildvcs=false -o fetch . && cd electron && npx electron ." }
|
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 = { 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" }
|
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 ."
|
docker-build = "docker build -t akai-fetch ."
|
||||||
check-toolchain = "bash scripts/check-toolchain.sh"
|
check-toolchain = "bash scripts/check-toolchain.sh"
|
||||||
|
install-all = "bash scripts/install-deps.sh"
|
||||||
check-docker-toolchain = "bash scripts/check-docker-toolchain.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" }
|
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 ""
|
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 ──────────────────────────────────────────────────────────────
|
# ─── Node ──────────────────────────────────────────────────────────────
|
||||||
echo "Node:"
|
echo "Node:"
|
||||||
if command -v node &>/dev/null; then
|
if command -v node &>/dev/null; then
|
||||||
@@ -110,5 +123,64 @@ else
|
|||||||
fi
|
fi
|
||||||
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 ""
|
||||||
echo "=== Core toolchain check complete ==="
|
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/search", handleSearch)
|
||||||
mux.HandleFunc("/api/list", handleList)
|
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/download", handleAPIDownload)
|
||||||
mux.HandleFunc("/api/extract", handleAPIExtract)
|
mux.HandleFunc("/api/extract", handleAPIExtract)
|
||||||
mux.HandleFunc("/api/progress", handleProgress)
|
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)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
listener, err := net.Listen("tcp", addr)
|
listener, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,12 +78,22 @@ func cmdServe(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
realPort := listener.Addr().(*net.TCPAddr).Port
|
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 AKAI Utils Server\n")
|
||||||
fmt.Printf(" =================\n\n")
|
fmt.Printf(" =================\n\n")
|
||||||
|
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(" Local: http://%s:%d\n", realHost, realPort)
|
||||||
fmt.Printf(" API: http://%s:%d/api/\n", realHost, realPort)
|
}
|
||||||
|
fmt.Printf(" API: http://localhost:%d/api/\n", realPort)
|
||||||
fmt.Printf(" Quit: Ctrl+C\n\n")
|
fmt.Printf(" Quit: Ctrl+C\n\n")
|
||||||
|
|
||||||
if openBrowser {
|
if openBrowser {
|
||||||
@@ -148,7 +171,13 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
outDir = "."
|
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)
|
files := getItemFiles(identifier)
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
@@ -173,6 +202,11 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
jobIDs = append(jobIDs, jobID)
|
jobIDs = append(jobIDs, jobID)
|
||||||
|
|
||||||
go func(j downloadJob, s *downloadState) {
|
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}
|
client := &http.Client{Timeout: 0}
|
||||||
req, _ := http.NewRequest("GET", j.URL, nil)
|
req, _ := http.NewRequest("GET", j.URL, nil)
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
@@ -188,20 +222,23 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.Error = err.Error()
|
s.Error = err.Error()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
outFile, err := os.Create(j.OutPath)
|
outFile, err := os.Create(j.OutPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Error = err.Error()
|
s.Error = err.Error()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
defer func() { _ = outFile.Close() }()
|
||||||
|
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
for {
|
for {
|
||||||
n, readErr := resp.Body.Read(buf)
|
n, readErr := resp.Body.Read(buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
outFile.Write(buf[:n])
|
if _, err := outFile.Write(buf[:n]); err != nil {
|
||||||
|
s.Error = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
s.Downloaded += int64(n)
|
s.Downloaded += int64(n)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -230,6 +267,112 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]any{"jobs": jobIDs})
|
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) {
|
func handleProgress(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
@@ -249,7 +392,9 @@ func handleProgress(w http.ResponseWriter, r *http.Request) {
|
|||||||
data, _ := json.Marshal(downloadJobs)
|
data, _ := json.Marshal(downloadJobs)
|
||||||
downloadJobsMu.Unlock()
|
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()
|
flusher.Flush()
|
||||||
|
|
||||||
allDone := true
|
allDone := true
|
||||||
@@ -277,8 +422,6 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) {
|
|||||||
outDir = "./wavs"
|
outDir = "./wavs"
|
||||||
}
|
}
|
||||||
|
|
||||||
toolPath := findScript("extract_wavs.sh")
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(isoDir)
|
entries, err := os.ReadDir(isoDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, map[string]any{"error": err.Error()})
|
writeJSON(w, map[string]any{"error": err.Error()})
|
||||||
@@ -299,7 +442,7 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var messages []string
|
var messages []string
|
||||||
for _, isoPath := range isos {
|
for _, isoPath := range isos {
|
||||||
result, err := extractISO(toolPath, isoPath, outDir)
|
result, err := extractISO(isoPath, outDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
messages = append(messages, fmt.Sprintf("FAIL %s: %v", filepath.Base(isoPath), err))
|
messages = append(messages, fmt.Sprintf("FAIL %s: %v", filepath.Base(isoPath), err))
|
||||||
} else {
|
} else {
|
||||||
@@ -315,13 +458,16 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) {
|
|||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
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) {
|
func openURL(rawURL string) {
|
||||||
exec.Command("xdg-open", rawURL).Start()
|
_ = exec.Command("xdg-open", rawURL).Start()
|
||||||
exec.Command("open", rawURL).Start()
|
_ = exec.Command("open", rawURL).Start()
|
||||||
exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start()
|
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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 resp = await fetch(`${API}/search?${params}`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
status.textContent = `Found ${data.length} items`;
|
status.textContent = `Found ${data.length} items`;
|
||||||
|
document.getElementById('result-count').textContent = `(${data.length})`;
|
||||||
renderResults(data);
|
renderResults(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = 'Search failed: ' + e.message;
|
status.textContent = 'Search failed: ' + e.message;
|
||||||
@@ -45,8 +46,8 @@ function renderResults(items) {
|
|||||||
<span>${r.downloads.toLocaleString()} downloads</span>
|
<span>${r.downloads.toLocaleString()} downloads</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-dl" onclick="downloadItem('${esc(r.identifier)}')">Download ISOs</button>
|
<button class="btn-dl" data-action="download" data-identifier="${esc(r.identifier)}">Download ISOs</button>
|
||||||
<button class="btn-list" onclick="listItem('${esc(r.identifier)}')">List Files</button>
|
<button class="btn-list" data-action="list" data-identifier="${esc(r.identifier)}">List Files</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="list-${sanitize(r.identifier)}" class="file-list"></div>
|
<div id="list-${sanitize(r.identifier)}" class="file-list"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,6 +175,11 @@ async function doExtract() {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API}/extract?dir=${encodeURIComponent(isoDir)}&out=${encodeURIComponent(wavDir)}`);
|
const resp = await fetch(`${API}/extract?dir=${encodeURIComponent(isoDir)}&out=${encodeURIComponent(wavDir)}`);
|
||||||
const data = await resp.json();
|
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.textContent = data.message || 'Extraction complete';
|
||||||
status.style.color = 'var(--green)';
|
status.style.color = 'var(--green)';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -182,25 +188,333 @@ async function doExtract() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────
|
// ─── WAV Browser ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function esc(s) {
|
let audioCtx = null;
|
||||||
if (!s) return '';
|
let currentSource = null;
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<')
|
let currentAudioBuffer = null;
|
||||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
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) {
|
function renderPrograms(programs) {
|
||||||
return s.replace(/[^a-zA-Z0-9_-]/g, '_');
|
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) {
|
function renderLibraries(libs) {
|
||||||
if (bytes === 0) return '0 B';
|
const libNames = Object.keys(libs).sort();
|
||||||
const k = 1024;
|
return libNames.map(lib => {
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
const programs = libs[lib];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const progCount = Object.keys(programs).length;
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
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
|
// Initial load
|
||||||
doSearch();
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
+46
-1
@@ -22,7 +22,6 @@
|
|||||||
<button id="search-dl-all">Download Top 10</button>
|
<button id="search-dl-all">Download Top 10</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="search-status"></div>
|
<div id="search-status"></div>
|
||||||
<div id="search-results"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="download-section">
|
<section id="download-section">
|
||||||
@@ -39,7 +38,53 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="extract-status"></div>
|
<div id="extract-status"></div>
|
||||||
</section>
|
</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>
|
||||||
|
<div id="tag-results" style="display:none">
|
||||||
|
<div id="tag-status"></div>
|
||||||
|
<div id="tag-partitions" class="tag-partitions"></div>
|
||||||
|
<div id="tag-volumes" class="tag-volumes"></div>
|
||||||
|
<div id="tag-detail" class="tag-detail"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="wav-section">
|
||||||
|
<h2 class="section-toggle" data-section="wav-body">Sample Library <span class="toggle-icon">▾</span></h2>
|
||||||
|
<div id="wav-body">
|
||||||
|
<div class="wav-controls">
|
||||||
|
<input type="text" id="wav-dir" placeholder="WAV directory" value="./output/wavs">
|
||||||
|
<button id="wav-browse-btn">Browse</button>
|
||||||
|
</div>
|
||||||
|
<div id="wav-results" style="display:none">
|
||||||
|
<div class="wav-summary-row">
|
||||||
|
<div id="wav-summary" class="wav-summary"></div>
|
||||||
|
<button id="wav-collapse-all" class="collapse-all-btn">Collapse All</button>
|
||||||
|
</div>
|
||||||
|
<div id="wav-player" class="wav-player" style="display:none">
|
||||||
|
<div id="wav-now-playing" class="now-playing"></div>
|
||||||
|
<div class="audio-bar">
|
||||||
|
<div class="audio-progress"><div id="wav-progress-fill" class="audio-progress-fill"></div></div>
|
||||||
|
<span id="wav-time">0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="wav-list" class="wav-library"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="results-section">
|
||||||
|
<h2>Results <span id="result-count"></span></h2>
|
||||||
|
<div id="search-results"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<script src="util.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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;
|
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 {
|
h2 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
@@ -206,3 +223,358 @@ h2 {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text2);
|
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