From 815d43ee978d3401b35ac2fd87c752b297cee0c3 Mon Sep 17 00:00:00 2001 From: casaos Date: Sun, 21 Jun 2026 22:05:18 -0400 Subject: [PATCH 1/4] WIP --- Makefile | 2 +- main.go | 31 +++++++++++++++++++++++-------- scripts/extract_wavs.sh | 11 ++++++++++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 0637910..32e3bfd 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ IMAGE := akai-fetch REMOTE := dhg.lol build: - CGO_ENABLED=0 go build -o $(BIN) . + CGO_ENABLED=0 go build -buildvcs=false -o $(BIN) . docker: docker build -t $(IMAGE) . diff --git a/main.go b/main.go index 2c99fd6..f9525af 100644 --- a/main.go +++ b/main.go @@ -374,10 +374,15 @@ func cmdExtract(args []string) { fs := flag.NewFlagSet("extract", flag.ExitOnError) isoDir := fs.String("dir", ".", "directory with ISO files") outDir := fs.String("out", "./wavs", "output directory for WAVs") - toolPath := fs.String("tool", "./extract_wavs.sh", "path to extraction script") + toolFlag := fs.String("tool", "", "path to extraction script (default: auto-detect)") jobs := fs.Int("jobs", 2, "concurrent extractions") _ = fs.Parse(args) + toolPath := *toolFlag + if toolPath == "" { + toolPath = findScript("extract_wavs.sh") + } + entries, err := os.ReadDir(*isoDir) if err != nil { fatal("read dir: %v", err) @@ -410,7 +415,7 @@ func cmdExtract(args []string) { defer func() { <-sem }() out := *outDir - result, err := extractISO(*toolPath, isoPath, out) + result, err := extractISO(toolPath, isoPath, out) mu.Lock() results[filepath.Base(isoPath)] = result mu.Unlock() @@ -505,12 +510,7 @@ func cmdPipeline(args []string) { isoDir := filepath.Join(*baseDir, "isos") wavDir := filepath.Join(*baseDir, "wavs") - toolPath := filepath.Join(filepath.Dir(os.Args[0]), "extract_wavs.sh") - - if _, err := os.Stat(toolPath); os.IsNotExist(err) { - cwd, _ := os.Getwd() - toolPath = filepath.Join(cwd, "extract_wavs.sh") - } + toolPath := findScript("extract_wavs.sh") // Remaining args after flags are treated as explicit identifiers identifiers := fs.Args() @@ -563,6 +563,21 @@ func cmdPipeline(args []string) { 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 ─────────────────────────────────────────────────────────── func fatal(format string, args ...any) { diff --git a/scripts/extract_wavs.sh b/scripts/extract_wavs.sh index 454de4d..e10fa53 100755 --- a/scripts/extract_wavs.sh +++ b/scripts/extract_wavs.sh @@ -5,7 +5,16 @@ set -euo pipefail -AKAIUTIL="${AKAIUTIL:-akaiutil}" +if [ -z "${AKAIUTIL:-}" ]; then + if command -v akaiutil &>/dev/null; then + AKAIUTIL="akaiutil" + elif [ -x "/DATA/Downloads/akai/akaiutil-4.6.7/akaiutil" ]; then + AKAIUTIL="/DATA/Downloads/akai/akaiutil-4.6.7/akaiutil" + else + echo "error: akaiutil not found — set AKAIUTIL env var" >&2 + exit 1 + fi +fi ISO="${1:?Usage: $0 [output-dir]}" OUTPUT="${2:-wavs}" -- 2.52.0 From e2dbb665253db5689ee4802e510a615e5523727c Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 21 Jun 2026 19:46:26 -0700 Subject: [PATCH 2/4] feat: web UI + HTTP server + Electron shell - serve.go: embedded web UI (embed.FS), REST API endpoints for search, list, download (SSE progress), and WAV extraction - main.go: added serve subcommand and flag registration - web/ui: vanilla JS frontend with search cards, download queue, extract controls; dark theme, zero dependencies - electron/: Electron wrapper that spawns fetch serve as sidecar, reads port from stdout, creates BrowserWindow; electron-builder config for macOS .app bundle with akaiutil + script as resources Refs #1, #9 --- electron/main.js | 122 ++++++++++++++++ electron/package.json | 66 +++++++++ electron/preload.js | 5 + main.go | 9 +- serve.go | 322 ++++++++++++++++++++++++++++++++++++++++++ web/ui/app.js | 206 +++++++++++++++++++++++++++ web/ui/index.html | 45 ++++++ web/ui/style.css | 208 +++++++++++++++++++++++++++ 8 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 electron/main.js create mode 100644 electron/package.json create mode 100644 electron/preload.js create mode 100644 serve.go create mode 100644 web/ui/app.js create mode 100644 web/ui/index.html create mode 100644 web/ui/style.css diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 0000000..a7fb24b --- /dev/null +++ b/electron/main.js @@ -0,0 +1,122 @@ +const { app, BrowserWindow, dialog } = require('electron'); +const path = require('path'); +const { spawn } = require('child_process'); + +let mainWindow; +let serverProcess; +let serverPort = 0; + +function findServerBinary() { + const candidates = [ + path.join(__dirname, '..', 'fetch'), + path.join(__dirname, '..', 'akai-fetch'), + path.join(process.resourcesPath, 'fetch'), + path.join(process.resourcesPath, 'akai-fetch'), + ]; + for (const p of candidates) { + try { + require('fs').accessSync(p, require('fs').constants.X_OK); + return p; + } catch (_) {} + } + return null; +} + +async function startServer() { + const bin = findServerBinary(); + if (!bin) { + dialog.showErrorBox('Missing Binary', + 'Could not find fetch/akai-fetch binary. Place it next to the app or in resources.'); + app.quit(); + return; + } + + const usePort = process.env.AKAI_PORT ? parseInt(process.env.AKAI_PORT) : 0; + + return new Promise((resolve, reject) => { + serverProcess = spawn(bin, ['serve', '-p', String(usePort), '--no-browser'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let output = ''; + serverProcess.stdout.on('data', (data) => { + output += data.toString(); + const m = output.match(/Local:\s+http:\/\/localhost:(\d+)/); + if (m) { + serverPort = parseInt(m[1]); + resolve(); + } + }); + + serverProcess.stderr.on('data', (data) => { + console.error('[server]', data.toString().trim()); + }); + + serverProcess.on('error', (err) => { + console.error('Failed to start server:', err); + reject(err); + }); + + serverProcess.on('close', (code) => { + if (serverPort === 0) { + reject(new Error(`Server exited with code ${code}`)); + } + }); + }); +} + +async function createWindow() { + try { + await startServer(); + } catch (err) { + dialog.showErrorBox('Server Error', String(err)); + app.quit(); + return; + } + + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + title: 'AKAI Utils', + backgroundColor: '#1a1a2e', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + mainWindow.loadURL(`http://localhost:${serverPort}`); + mainWindow.setMenuBarVisibility(false); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +app.whenReady().then(createWindow); + +app.on('window-all-closed', () => { + if (serverProcess) { + serverProcess.kill(); + serverProcess = null; + } + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('activate', () => { + if (mainWindow === null) { + createWindow(); + } +}); + +app.on('before-quit', () => { + if (serverProcess) { + serverProcess.kill(); + serverProcess = null; + } +}); diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..925b3c1 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,66 @@ +{ + "name": "akai-utils", + "version": "0.2.0", + "description": "AKAI Sampler ISO Downloader, WAV Extractor & Library Manager", + "main": "main.js", + "build": { + "appId": "dev.akai.utils", + "productName": "AKAI Utils", + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "preload.js", + "package.json" + ], + "extraResources": [ + { + "from": "../fetch", + "to": "fetch" + }, + { + "from": "../scripts/extract_wavs.sh", + "to": "extract_wavs.sh" + } + ], + "mac": { + "category": "public.app-category.music", + "target": [ + { + "target": "dir", + "arch": ["x64", "arm64"] + } + ], + "artifactName": "${name}-${version}-${arch}.${ext}", + "extraResources": [ + { + "from": "../fetch", + "to": "fetch" + }, + { + "from": "../scripts/extract_wavs.sh", + "to": "extract_wavs.sh" + }, + { + "from": "../third_party/akaiutil/akaiutil", + "to": "akaiutil" + } + ] + }, + "linux": { + "target": ["dir"], + "category": "Audio" + } + }, + "scripts": { + "start": "electron .", + "build": "electron-builder", + "build:mac": "electron-builder --mac", + "build:linux": "electron-builder --linux" + }, + "devDependencies": { + "electron": "^33.0.0", + "electron-builder": "^25.0.0" + } +} diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..0fa8dd0 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,5 @@ +const { contextBridge } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + platform: process.platform, +}); diff --git a/main.go b/main.go index f9525af..1b79be7 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,8 @@ func main() { cmdExtract(args) case "pipeline": cmdPipeline(args) + case "serve": + cmdServe(args) default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) printUsage() @@ -84,6 +86,7 @@ Usage: akai-fetch download [flags] download ISOs (from file or by identifier) akai-fetch extract [flags] extract WAVs from downloaded ISOs akai-fetch pipeline [flags] full search → download → extract pipeline + akai-fetch serve [flags] start HTTP server with web UI Search flags: -query "akai sampler" search query (default: "subject:akai AND subject:sampler") @@ -109,7 +112,11 @@ Pipeline flags: -limit 30 max results -dir "./output" base output directory -download-jobs 2 concurrent downloads - -extract-jobs 2 concurrent extractions`) + -extract-jobs 2 concurrent extractions + +Serve flags: + -p, --port 0 HTTP port (0 = auto-assign) + --no-browser don't open browser automatically`) } // ─── Search ──────────────────────────────────────────────────────────── diff --git a/serve.go b/serve.go new file mode 100644 index 0000000..56c06b4 --- /dev/null +++ b/serve.go @@ -0,0 +1,322 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "log" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +//go:embed web/ui/* +var webUI embed.FS + +func cmdServe(args []string) { + port := 0 + openBrowser := true + + for i := 0; i < len(args); i++ { + switch args[i] { + case "-p", "--port": + if i+1 < len(args) { + port, _ = strconv.Atoi(args[i+1]) + i++ + } + case "--no-browser": + openBrowser = false + } + } + + mux := http.NewServeMux() + + uiFS, err := fs.Sub(webUI, "web/ui") + if err != nil { + fatal("embed web/ui: %v", err) + } + mux.Handle("/", http.FileServer(http.FS(uiFS))) + + mux.HandleFunc("/api/search", handleSearch) + mux.HandleFunc("/api/list", handleList) + mux.HandleFunc("/api/download", handleAPIDownload) + mux.HandleFunc("/api/extract", handleAPIExtract) + mux.HandleFunc("/api/progress", handleProgress) + + addr := fmt.Sprintf("127.0.0.1:%d", port) + listener, err := net.Listen("tcp", addr) + if err != nil { + fatal("listen: %v", err) + } + + realPort := listener.Addr().(*net.TCPAddr).Port + + fmt.Printf("\n AKAI Utils Server\n") + fmt.Printf(" =================\n\n") + fmt.Printf(" Local: http://localhost:%d\n", realPort) + fmt.Printf(" API: http://localhost:%d/api/\n", realPort) + fmt.Printf(" Quit: Ctrl+C\n\n") + + if openBrowser { + go func() { + time.Sleep(500 * time.Millisecond) + openURL(fmt.Sprintf("http://localhost:%d", realPort)) + }() + } + + server := &http.Server{Handler: mux} + log.Fatal(server.Serve(listener)) +} + +func handleSearch(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + filter := r.URL.Query().Get("filter") + limitStr := r.URL.Query().Get("limit") + + if q == "" { + q = "subject:akai AND subject:sampler" + } + if filter != "" { + q = "(" + q + ") AND (" + filter + ")" + } + + limit := 30 + if limitStr != "" { + limit, _ = strconv.Atoi(limitStr) + } + + results := searchArchive(q, limit) + sort.Slice(results, func(i, j int) bool { + return results[i].Downloads > results[j].Downloads + }) + + writeJSON(w, results) +} + +func handleList(w http.ResponseWriter, r *http.Request) { + identifier := r.URL.Query().Get("identifier") + if identifier == "" { + http.Error(w, "missing identifier", http.StatusBadRequest) + return + } + files := getItemFiles(identifier) + writeJSON(w, files) +} + +var ( + downloadJobs = make(map[string]*downloadState) + downloadJobsMu sync.Mutex +) + +type downloadState struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + FileName string `json:"file_name"` + TotalSize int64 `json:"total_size"` + Downloaded int64 `json:"downloaded"` + Done bool `json:"done"` + Error string `json:"error,omitempty"` + Speed float64 `json:"speed"` + lastCheck time.Time + lastBytes int64 +} + +func handleAPIDownload(w http.ResponseWriter, r *http.Request) { + identifier := r.URL.Query().Get("identifier") + outDir := r.URL.Query().Get("dir") + if identifier == "" { + http.Error(w, "missing identifier", http.StatusBadRequest) + return + } + if outDir == "" { + outDir = "." + } + + os.MkdirAll(outDir, 0755) + + files := getItemFiles(identifier) + if len(files) == 0 { + writeJSON(w, map[string]any{"error": "no ISO files found"}) + return + } + + var jobIDs []string + for _, f := range files { + size, _ := strconv.ParseInt(f.Size, 10, 64) + jobID := identifier + "/" + f.Name + state := &downloadState{ + ID: jobID, + Identifier: identifier, + FileName: f.Name, + TotalSize: size, + lastCheck: time.Now(), + } + downloadJobsMu.Lock() + downloadJobs[jobID] = state + downloadJobsMu.Unlock() + jobIDs = append(jobIDs, jobID) + + go func(j downloadJob, s *downloadState) { + client := &http.Client{Timeout: 0} + req, _ := http.NewRequest("GET", j.URL, nil) + req.Header.Set("User-Agent", userAgent) + + if fi, err := os.Stat(j.OutPath); err == nil && j.Size > 0 && fi.Size() == j.Size { + s.Downloaded = j.Size + s.Done = true + return + } + + resp, err := client.Do(req) + if err != nil { + s.Error = err.Error() + return + } + defer resp.Body.Close() + + outFile, err := os.Create(j.OutPath) + if err != nil { + s.Error = err.Error() + return + } + defer outFile.Close() + + buf := make([]byte, 32*1024) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + outFile.Write(buf[:n]) + s.Downloaded += int64(n) + + now := time.Now() + elapsed := now.Sub(s.lastCheck).Seconds() + if elapsed >= 1.0 { + s.Speed = float64(s.Downloaded-s.lastBytes) / elapsed + s.lastCheck = now + s.lastBytes = s.Downloaded + } + } + if readErr != nil { + break + } + } + s.Done = true + s.Speed = 0 + }(downloadJob{ + Identifier: identifier, + Name: f.Name, + URL: fmt.Sprintf("%s/download/%s/%s", iaBaseURL, url.PathEscape(identifier), url.PathEscape(f.Name)), + OutPath: filepath.Join(outDir, f.Name), + Size: size, + }, state) + } + + writeJSON(w, map[string]any{"jobs": jobIDs}) +} + +func handleProgress(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for range ticker.C { + downloadJobsMu.Lock() + data, _ := json.Marshal(downloadJobs) + downloadJobsMu.Unlock() + + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + allDone := true + downloadJobsMu.Lock() + for _, s := range downloadJobs { + if !s.Done && s.Error == "" { + allDone = false + break + } + } + downloadJobsMu.Unlock() + if allDone && len(downloadJobs) > 0 { + break + } + } +} + +func handleAPIExtract(w http.ResponseWriter, r *http.Request) { + isoDir := r.URL.Query().Get("dir") + outDir := r.URL.Query().Get("out") + if isoDir == "" { + isoDir = "." + } + if outDir == "" { + outDir = "./wavs" + } + + toolPath := findScript("extract_wavs.sh") + + entries, err := os.ReadDir(isoDir) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + + var isos []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".iso") { + isos = append(isos, filepath.Join(isoDir, e.Name())) + } + } + + if len(isos) == 0 { + writeJSON(w, map[string]any{"error": "no ISO files found in " + isoDir}) + return + } + + var messages []string + for _, isoPath := range isos { + result, err := extractISO(toolPath, isoPath, outDir) + if err != nil { + messages = append(messages, fmt.Sprintf("FAIL %s: %v", filepath.Base(isoPath), err)) + } else { + messages = append(messages, fmt.Sprintf("OK %s: %s", filepath.Base(isoPath), result)) + } + } + + writeJSON(w, map[string]any{ + "message": fmt.Sprintf("Extracted %d ISOs: %s", len(isos), strings.Join(messages, "; ")), + }) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + json.NewEncoder(w).Encode(v) +} + +func openURL(rawURL string) { + exec.Command("xdg-open", rawURL).Start() + exec.Command("open", rawURL).Start() + exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start() +} + +func init() { + log.SetFlags(0) +} diff --git a/web/ui/app.js b/web/ui/app.js new file mode 100644 index 0000000..b08d0a3 --- /dev/null +++ b/web/ui/app.js @@ -0,0 +1,206 @@ +// AKAI Utils — Web UI +const API = '/api'; + +let eventSource = null; + +// ─── Search ──────────────────────────────────────────────────────── + +document.getElementById('search-btn').addEventListener('click', doSearch); +document.getElementById('search-query').addEventListener('keydown', e => { + if (e.key === 'Enter') doSearch(); +}); +document.getElementById('search-dl-all').addEventListener('click', downloadTop10); + +async function doSearch() { + const q = document.getElementById('search-query').value; + const filter = document.getElementById('search-filter').value; + const status = document.getElementById('search-status'); + const results = document.getElementById('search-results'); + + status.textContent = 'Searching...'; + results.innerHTML = ''; + + try { + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (filter) params.set('filter', filter); + params.set('limit', '50'); + + const resp = await fetch(`${API}/search?${params}`); + const data = await resp.json(); + status.textContent = `Found ${data.length} items`; + renderResults(data); + } catch (e) { + status.textContent = 'Search failed: ' + e.message; + } +} + +function renderResults(items) { + const container = document.getElementById('search-results'); + container.innerHTML = items.map(r => ` +
+
${esc(r.title)}
+
+ ${r.identifier} + ${r.downloads.toLocaleString()} downloads +
+
+ + +
+
+
+ `).join(''); +} + +async function downloadTop10() { + const q = document.getElementById('search-query').value; + const filter = document.getElementById('search-filter').value; + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (filter) params.set('filter', filter); + params.set('limit', '10'); + + try { + const resp = await fetch(`${API}/search?${params}`); + const data = await resp.json(); + for (const item of data) { + await downloadItem(item.identifier); + } + } catch (e) { + alert('Bulk download failed: ' + e.message); + } +} + +// ─── List ────────────────────────────────────────────────────────── + +async function listItem(identifier) { + const el = document.getElementById('list-' + sanitize(identifier)); + el.innerHTML = 'Loading...'; + + try { + const resp = await fetch(`${API}/list?identifier=${encodeURIComponent(identifier)}`); + const files = await resp.json(); + if (!Array.isArray(files) || files.length === 0) { + el.innerHTML = 'No ISO files'; + return; + } + el.innerHTML = files.map(f => + `
${esc(f.name)} ${f.size || '?'}
` + ).join(''); + } catch (e) { + el.innerHTML = 'Failed: ' + e.message; + } +} + +// ─── Download ────────────────────────────────────────────────────── + +async function downloadItem(identifier) { + try { + const resp = await fetch(`${API}/download?identifier=${encodeURIComponent(identifier)}&dir=./output/isos`); + const data = await resp.json(); + if (data.error) { + alert(data.error); + return; + } + startProgressStream(); + } catch (e) { + alert('Download failed: ' + e.message); + } +} + +function startProgressStream() { + if (eventSource) eventSource.close(); + eventSource = new EventSource(`${API}/progress`); + + eventSource.onmessage = function(e) { + const jobs = JSON.parse(e.data); + renderDownloadQueue(jobs); + + const allDone = Object.values(jobs).every(j => j.done || j.error); + if (allDone && Object.keys(jobs).length > 0) { + eventSource.close(); + eventSource = null; + } + }; + + eventSource.onerror = function() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + }; +} + +function renderDownloadQueue(jobs) { + const container = document.getElementById('download-queue'); + const count = document.getElementById('dl-count'); + const entries = Object.values(jobs); + count.textContent = `(${entries.length})`; + + container.innerHTML = entries.map(j => { + const pct = j.total_size > 0 ? Math.round(j.downloaded / j.total_size * 100) : 0; + let cls = ''; + if (j.error) cls = 'error'; + else if (j.done) cls = 'done'; + + let info = ''; + if (j.error) info = j.error; + else if (j.done) info = formatBytes(j.total_size) + ' — complete'; + else info = formatBytes(j.downloaded) + ' / ' + formatBytes(j.total_size) + + (j.speed > 0 ? ' — ' + formatBytes(j.speed) + '/s' : ''); + + return ` +
+
${esc(j.file_name)}
+
+
${info}${j.done && !j.error ? '✓' : pct + '%'}
+
`; + }).join(''); +} + +// ─── Extract ─────────────────────────────────────────────────────── + +document.getElementById('extract-btn').addEventListener('click', doExtract); + +async function doExtract() { + const isoDir = document.getElementById('extract-iso-dir').value; + const wavDir = document.getElementById('extract-wav-dir').value; + const status = document.getElementById('extract-status'); + + status.textContent = 'Extracting... (check server console for output)'; + status.style.color = 'var(--yellow)'; + + try { + const resp = await fetch(`${API}/extract?dir=${encodeURIComponent(isoDir)}&out=${encodeURIComponent(wavDir)}`); + const data = await resp.json(); + status.textContent = data.message || 'Extraction complete'; + status.style.color = 'var(--green)'; + } catch (e) { + status.textContent = 'Extraction failed: ' + e.message; + status.style.color = 'var(--accent)'; + } +} + +// ─── Helpers ────────────────────────────────────────────────────── + +function esc(s) { + if (!s) return ''; + return s.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]; +} + +// Initial load +doSearch(); diff --git a/web/ui/index.html b/web/ui/index.html new file mode 100644 index 0000000..fe4b4ad --- /dev/null +++ b/web/ui/index.html @@ -0,0 +1,45 @@ + + + + + +AKAI Utils + + + +
+
+

AKAI Utils

+ Sampler ISO Downloader & WAV Extractor +
+ +
+ +
+
+
+ +
+

Downloads

+
+
+ +
+

WAV Extraction

+
+ + + +
+
+
+
+ + + diff --git a/web/ui/style.css b/web/ui/style.css new file mode 100644 index 0000000..82bdc98 --- /dev/null +++ b/web/ui/style.css @@ -0,0 +1,208 @@ +:root { + --bg: #1a1a2e; + --bg2: #16213e; + --bg3: #0f3460; + --text: #e0e0e0; + --text2: #a0a0b0; + --accent: #e94560; + --accent2: #533483; + --green: #4ecca3; + --yellow: #f0c040; + --radius: 6px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; + min-height: 100vh; +} + +#app { + max-width: 1100px; + margin: 0 auto; + padding: 20px; +} + +header { + text-align: center; + padding: 30px 0 20px; + border-bottom: 1px solid var(--bg3); + margin-bottom: 24px; +} +header h1 { + font-size: 28px; + color: var(--accent); + letter-spacing: 1px; +} +header .subtitle { + color: var(--text2); + font-size: 14px; +} + +section { + background: var(--bg2); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 20px; +} + +h2 { + font-size: 16px; + color: var(--text2); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.search-bar { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.search-bar 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; +} +.search-bar input:focus { + outline: none; + border-color: var(--accent); +} +.search-bar button { + padding: 10px 20px; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: white; + font-size: 14px; + cursor: pointer; + white-space: nowrap; +} +.search-bar button:hover { opacity: 0.9; } +#search-dl-all { + background: var(--green); + color: #111; +} + +#search-status { + margin-top: 10px; + color: var(--text2); + font-size: 13px; +} + +#search-results { + margin-top: 16px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 10px; +} + +.result-card { + background: var(--bg); + border: 1px solid var(--bg3); + border-radius: var(--radius); + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; +} +.result-card .title { + font-weight: 600; + font-size: 14px; + word-break: break-word; +} +.result-card .meta { + font-size: 12px; + color: var(--text2); + display: flex; + justify-content: space-between; +} +.result-card .actions { + display: flex; + gap: 6px; +} +.result-card button { + flex: 1; + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; +} +.btn-dl { background: var(--accent); color: white; } +.btn-list { background: var(--bg3); color: var(--text); } +.btn-dl:hover, .btn-list:hover { opacity: 0.85; } + +.dl-item { + background: var(--bg); + border: 1px solid var(--bg3); + border-radius: var(--radius); + padding: 12px; + margin-bottom: 8px; +} +.dl-item .dl-name { + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; + word-break: break-all; +} +.dl-item .dl-bar { + height: 6px; + background: var(--bg3); + border-radius: 3px; + overflow: hidden; + margin-bottom: 4px; +} +.dl-item .dl-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.3s; +} +.dl-item .dl-bar-fill.done { background: var(--green); } +.dl-item .dl-bar-fill.error { background: var(--yellow); } +.dl-item .dl-info { + font-size: 11px; + color: var(--text2); + display: flex; + justify-content: space-between; +} + +.extract-controls { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.extract-controls input { + flex: 1; + min-width: 150px; + padding: 10px 14px; + border: 1px solid var(--bg3); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 14px; +} +.extract-controls button { + padding: 10px 20px; + border: none; + border-radius: var(--radius); + background: var(--accent2); + color: white; + font-size: 14px; + cursor: pointer; +} +#extract-status { + margin-top: 10px; + font-size: 13px; + color: var(--text2); +} \ No newline at end of file -- 2.52.0 From 9657aaa52329cf7da6b9b21a7391a57d74680157 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 21 Jun 2026 19:46:34 -0700 Subject: [PATCH 3/4] chore: docker compose, mise.toml, updated toolchain - Dockerfile: updated to serve mode with web/ embed, expose 8080 - docker-compose.yml: new, maps :8080 and output volume - Makefile: added serve, akaiutil, electron-* targets - mise.toml: new, go 1.26 + node 22, all build/run tasks - .gitignore: exclude built akaiutil binary --- .gitignore | 1 + Dockerfile | 8 ++++++-- Makefile | 41 ++++++++++++++++++++++++++++++++++++++++- docker-compose.yml | 9 +++++++++ mise.toml | 19 +++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 docker-compose.yml create mode 100644 mise.toml diff --git a/.gitignore b/.gitignore index 6e872b8..3aadeeb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ akaiutil-4.6.7/ # Build artifacts *.o *.obj +third_party/akaiutil/akaiutil # Data *.iso diff --git a/Dockerfile b/Dockerfile index 524bc90..09b2d77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,8 @@ RUN make FROM golang:1.26-alpine AS fetch-builder WORKDIR /src -COPY go.mod main.go /src/ +COPY go.mod main.go serve.go /src/ +COPY web/ /src/web/ RUN CGO_ENABLED=0 go build -o /fetch . # Stage 3: Runtime image @@ -24,10 +25,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /data/output/isos /data/output/wavs + COPY --from=akaiutil-builder /src/akaiutil/akaiutil /usr/local/bin/ COPY --from=fetch-builder /fetch /usr/local/bin/fetch COPY scripts/extract_wavs.sh /usr/local/bin/ WORKDIR /data +EXPOSE 8080 ENTRYPOINT ["fetch"] -CMD ["--help"] +CMD ["serve", "-p", "8080"] diff --git a/Makefile b/Makefile index 32e3bfd..2d5b0e4 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,60 @@ BIN := fetch IMAGE := akai-fetch REMOTE := dhg.lol +ELECTRON_DIR := electron build: CGO_ENABLED=0 go build -buildvcs=false -o $(BIN) . +akaiutil: + $(MAKE) -C third_party/akaiutil + +all: build akaiutil + docker: docker build -t $(IMAGE) . docker-run: docker run --rm -v "$(PWD)/output:/data" $(IMAGE) $(ARGS) +docker-up: + docker compose up --build + +docker-down: + docker compose down + +serve: build + ./$(BIN) serve + +# ─── Electron targets ────────────────────────────────────── + +electron-deps: + cd $(ELECTRON_DIR) && npm install + +electron-dev: build + cd $(ELECTRON_DIR) && npx electron . + +electron-build: build akaiutil + cd $(ELECTRON_DIR) && npx electron-builder --dir + +electron-build-mac: build akaiutil + cd $(ELECTRON_DIR) && npx electron-builder --mac --dir + +electron-build-linux: build akaiutil + cd $(ELECTRON_DIR) && npx electron-builder --linux --dir + +# ─── Server / remote ─────────────────────────────────────── + install: build scp $(BIN) $(REMOTE):/tmp/$(BIN) && \ ssh $(REMOTE) "sudo mv /tmp/$(BIN) /DATA/Downloads/akai/$(BIN) && sudo chmod +x /DATA/Downloads/akai/$(BIN)" clean: rm -f $(BIN) + rm -rf $(ELECTRON_DIR)/dist + rm -rf $(ELECTRON_DIR)/node_modules + $(MAKE) -C third_party/akaiutil clean -.PHONY: build docker docker-run install clean +.PHONY: build akaiutil all docker docker-run docker-up docker-down serve \ + electron-deps electron-dev electron-build electron-build-mac electron-build-linux \ + install clean diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e357416 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + akai-utils: + build: . + ports: + - "8080:8080" + volumes: + - ./output:/data/output + environment: + - AKAIUTIL=/usr/local/bin/akaiutil diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..038ef3b --- /dev/null +++ b/mise.toml @@ -0,0 +1,19 @@ +[tools] +go = "1.26" +node = "22" + +[env] +CGO_ENABLED = "0" + +[tasks] +build = "go build -buildvcs=false -o fetch ." +akaiutil = "make -C third_party/akaiutil" +all = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil" } +serve = { run = "go build -buildvcs=false -o fetch . && ./fetch serve" } +lint = "go vet ./..." +electron-deps = "cd electron && npm install" +electron-dev = { run = "go build -buildvcs=false -o fetch . && cd electron && npx electron ." } +electron-build = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil && cd electron && npx electron-builder --dir" } +electron-build-mac = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil && cd electron && npx electron-builder --mac --dir" } +docker-up = "docker compose up --build" +docker-build = "docker build -t akai-fetch ." -- 2.52.0 From 649a73984d016db8c2cf0e30e5fdf9fde81dd7e9 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 21 Jun 2026 19:46:38 -0700 Subject: [PATCH 4/4] docs: README and AGENTS.md - README.md: project overview, quickstart (mise/docker/build), architecture diagram, feature roadmap linking to issues #1-#9 - AGENTS.md: development guide covering toolchain, key patterns, API conventions, akaiutil integration, formatting rules --- AGENTS.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 99 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 AGENTS.md create mode 100644 README.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c5870bc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,121 @@ +# AGENTS.md — AKAI Utils Development Guide + +## Toolchain (mise) + +```bash +mise trust # trust the mise.toml config (first time only) +mise install # installs go 1.26, node 22 +``` + +All build/run tasks are defined in `mise.toml`: + +```bash +mise run build # → ./fetch +mise run serve # → starts HTTP server, opens browser +mise run electron-deps # installs electron + electron-builder +mise run electron-dev # runs electron in dev mode +mise run docker-up # docker compose up --build +mise run lint # go vet ./... +``` + +## Build & Run (manual) + +```bash +# Build Go binary (zero CGO) +go build -buildvcs=false -o fetch . # → ./fetch + +# Build and run web server +./fetch serve # → starts on free port, opens browser + +# Docker +docker compose up --build # serves on :8080 + +# Electron dev +(cd electron && npm install) +(cd electron && npx electron .) + +# Cross-compile for macOS (from Linux) +GOOS=darwin GOARCH=arm64 go build -o fetch-darwin-arm64 . +GOOS=darwin GOARCH=amd64 go build -o fetch-darwin-amd64 . +``` + +## Architecture + +Single binary (`fetch`) with subcommands. No external Go dependencies — pure stdlib. + +``` +main.go CLI entry, commands (search/list/download/extract/pipeline) +serve.go HTTP server + REST API + embedded web UI (embed.FS) +web/ui/ Static frontend (index.html, style.css, app.js) +electron/ Electron wrapper shell +scripts/ Bash helpers called via exec +third_party/ Vendored akaiutil C binary (GPLv2) +``` + +### Key patterns + +- **Subcommand dispatch**: `main.go:60` — switch on `os.Args[1]`, each case calls `cmdXxx(args)` +- **Flag parsing**: each command creates its own `flag.NewFlagSet` — avoids global flag state +- **Concurrency**: semaphore channel pattern (`sem := make(chan struct{}, N)`) for bounded parallelism +- **SSE progress**: `serve.go:handleProgress` — 500ms ticker, EventSource stream, auto-closes when all done +- **Script discovery**: `serve.go:findScript()` — tries multiple candidate paths, fallback chain +- **Zero-deps web UI**: vanilla JS, no bundler, no framework. API calls use `fetch()` + `EventSource` +- **Embed directive**: `//go:embed web/ui/*` in `serve.go` — frontend baked into the binary + +### akaiutil integration + +`scripts/extract_wavs.sh` shells out to `akaiutil` for: +- `df` — disk info (partitions, block counts) +- `dir` — list volumes/files +- `sample2wavall` — batch WAV extraction +- Many more commands available (see `docs/akaiutil.md`) + +All akaiutil calls use `-r` (read-only) flag. Write operations (S900 compress, partitioning, tagging) require removing `-r`. + +### Electron integration + +`electron/main.js` spawns the Go binary in `serve` mode: +1. Finds `fetch` binary next to app or in `process.resourcesPath` +2. Spawns `fetch serve -p 0 --no-browser` +3. Parses `stdout` for `Local: http://localhost:XXXX` to get port +4. Creates `BrowserWindow` pointing at that URL +5. Kills server on quit + +## Adding a new feature + +1. **New API endpoint**: add handler in `serve.go`, register in `cmdServe` mux +2. **New web UI section**: add HTML in `index.html`, CSS in `style.css`, JS in `app.js` +3. **New akaiutil wrapper**: create a bash script in `scripts/` or call akaiutil directly via `exec.Command` in Go +4. **New CLI subcommand**: add case in `main.go:60`, implement `cmdXxx(args)` function + +### API conventions + +- All responses have `Content-Type: application/json` and `Access-Control-Allow-Origin: *` +- Use `writeJSON(w, v)` for responses, it sets headers and marshals +- Download progress uses SSE (`text/event-stream`) with `data: \n\n` +- Error responses use `http.Error(w, msg, code)` or return JSON with `"error"` key + +## Format conventions + +- Go: standard `gofmt` (no custom formatter) +- Bash: `set -euo pipefail`, shellcheck-compatible +- JS: no semicolons (ASI style), `const` preferred over `let` +- CSS: CSS custom properties for theming (`:root { --bg: ... }`) + +## Testing + +No test framework yet. Manual verification: +```bash +go build -o /dev/null . # compile check +./fetch serve # manual smoke test +``` + +## Secrets & Tokens + +None required. archive.org API is public and unauthenticated. +AKAIUTIL environment variable controls the akaiutil binary path. + +## Project Repo + +Git remote: `git@git.notsosm.art:david/akai-utils.git` +Issues managed via `tea` CLI. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccc1c98 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# AKAI Utils + +AKAI sampler toolkit — search, download, browse, extract, and package AKAI S900/S950/S1000/S1100/S3000/CD3000 sample libraries. + +Two interfaces: +- **CLI** (`akai-fetch`) — search archive.org, download ISOs, extract WAVs +- **Web UI / Electron** — full visual browser with disk inspection, sample preview, and disk packaging + +## Quickstart + +### Toolchain (mise) +```bash +mise trust # first time only +mise install # installs go 1.26, node 22 +mise run build # → ./fetch +``` + +### Docker +```bash +docker compose up --build +# Open http://localhost:8080 +``` + +### Build from source +```bash +# CLI +mise run build +./fetch search --filter vocal + +# Web server +mise run serve +# Open http://localhost:8080 + +# Electron (macOS) +mise run electron-deps +mise run electron-build-mac +``` + +### CLI Usage +``` +akai-fetch search [flags] search archive.org for AKAI sampler ISOs +akai-fetch list list downloadable ISO files in an item +akai-fetch download [flags] download ISOs from archive.org +akai-fetch extract [flags] extract WAVs from downloaded ISOs +akai-fetch pipeline [flags] search → download → extract in one command +akai-fetch serve [flags] start HTTP server with web UI +``` + +## Architecture + +``` +fetch serve (Go HTTP server, embedded web UI) + ├── /api/search archive.org search + ├── /api/list list ISO files in an item + ├── /api/download download ISOs with SSE progress + ├── /api/extract extract WAVs via akaiutil + └── /api/progress SSE download progress stream + +electron/ Electron wrapper (spawns fetch serve as sidecar) + ├── main.js main process + ├── preload.js context bridge + └── package.json electron-builder config + +third_party/akaiutil/ Vendored akaiutil 4.6.7 (C, GPLv2) +scripts/extract_wavs.sh Bash wrapper for batch WAV extraction +``` + +## Tech Stack + +| Component | Tech | External Deps | +|-------------|--------------------|---------------| +| CLI server | Go 1.26, stdlib | 0 | +| Web UI | HTML/CSS/JS (vanilla) | 0 | +| Electron | Electron 33, electron-builder | Node deps | +| Extraction | akaiutil (C), bash | vendored | + +Zero Go dependencies. The web UI uses no framework — vanilla JS, Web Audio API, Server-Sent Events. + +## Feature Roadmap + +See [open issues](https://git.notsosm.art/david/akai-utils/issues). + +| # | Feature | Status | +|---|---------|--------| +| — | Search, download, extract, pipeline (CLI) | done | +| — | HTTP server + web UI + Electron shell | done | +| #1 | Disk Browser — browse AKAI filesystem in web UI | todo | +| #2 | Disk Inspector — detailed disk metadata | todo | +| #3 | WAV→Disk Packager — create AKAI ISOs from WAVs | todo | +| #4 | Batch S900 Compressor | todo | +| #5 | Partition Backup/Restore | todo | +| #6 | Direct-to-Disk Take Extraction | todo | +| #7 | TAR Bulk Import/Export | todo | +| #8 | Sample Tag Editor | todo | +| #9 | Audio Preview Player | todo | + +## License + +MIT. Contains vendored [akaiutil 4.6.7](https://github.com/kmi9000/akaiutil) (GPLv2, (c) Klaus Michael Indlekofer). -- 2.52.0