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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user