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 => ` +