Files
akai-utils/serve.go
T
david bc12130dd6 fix: ensure output dirs exist in extract and API extract
cmdExtract and handleAPIExtract were missing os.MkdirAll calls
while cmdDownload, cmdPipeline, and handleAPIDownload all had
them. The bash script creates dirs internally but this is not
robust — create dirs in Go before shelling out.

Closes #13
2026-06-21 22:04:47 -07:00

342 lines
7.6 KiB
Go

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
host := "127.0.0.1"
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 "--host":
if i+1 < len(args) {
host = 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("%s:%d", host, port)
listener, err := net.Listen("tcp", addr)
if err != nil {
fatal("listen: %v", err)
}
realPort := listener.Addr().(*net.TCPAddr).Port
realHost := "0.0.0.0"
if ip := listener.Addr().(*net.TCPAddr).IP; ip != nil {
realHost = ip.String()
}
fmt.Printf("\n AKAI Utils Server\n")
fmt.Printf(" =================\n\n")
if realHost == "0.0.0.0" || realHost == "::" || realHost == "" || realHost == "<nil>" {
fmt.Printf(" Local: http://localhost:%d\n", realPort)
if realHost == "0.0.0.0" {
fmt.Printf(" Network: http://<host-ip>:%d\n", realPort)
}
} else {
fmt.Printf(" Local: http://%s:%d\n", realHost, realPort)
}
fmt.Printf(" API: http://localhost:%d/api/\n", realPort)
fmt.Printf(" Quit: Ctrl+C\n\n")
if openBrowser {
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"
}
os.MkdirAll(outDir, 0755)
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)
}