Files
akai-utils/main.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

625 lines
17 KiB
Go

package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
iaSearchURL = "https://archive.org/advancedsearch.php"
iaBaseURL = "https://archive.org"
userAgent = "akai-fetch/1.0"
)
type SearchResult struct {
Response struct {
NumFound int `json:"numFound"`
Docs []SearchDoc `json:"docs"`
} `json:"response"`
}
type SearchDoc struct {
Identifier string `json:"identifier"`
Title string `json:"title"`
Downloads int `json:"downloads"`
Format []string `json:"format"`
}
type MetadataResult struct {
Files []MetadataFile `json:"files"`
}
type MetadataFile struct {
Name string `json:"name"`
Size string `json:"size"`
Format string `json:"format"`
Source string `json:"source"`
}
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "search":
cmdSearch(args)
case "list":
cmdList(args)
case "download":
cmdDownload(args)
case "extract":
cmdExtract(args)
case "pipeline":
cmdPipeline(args)
case "serve":
cmdServe(args)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println(`akai-fetch — AKAI sampler ISO downloader & extractor
Usage:
akai-fetch search [flags] search archive.org for AKAI sampler ISOs
akai-fetch list <identifier> list downloadable ISO files in an item
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")
-filter "term" extra term AND-ed onto the query (e.g. "vocal")
-limit 30 max results (default: 30)
-min-downloads 0 minimum download count filter
Download flags:
-i "identifier" archive.org item identifier
-f "file.txt" file with list of identifiers (one per line)
-dir "./isos" output directory (default: current dir)
-jobs 2 concurrent downloads (default: 2)
Extract flags:
-dir "./isos" directory containing ISO files
-out "./wavs" output directory for WAV files
-tool "./extract_wavs.sh" path to extraction script
-jobs 2 concurrent extractions (default: 2)
Pipeline flags:
-query "akai sampler" search query (default: "subject:akai AND subject:sampler")
-filter "term" extra term AND-ed onto the query
-limit 30 max results
-dir "./output" base output directory
-download-jobs 2 concurrent downloads
-extract-jobs 2 concurrent extractions
Serve flags:
-p, --port 0 HTTP port (0 = auto-assign)
--host 127.0.0.1 HTTP bind address (use 0.0.0.0 for Docker)
--no-browser don't open browser automatically`)
}
// ─── Search ────────────────────────────────────────────────────────────
func cmdSearch(args []string) {
fs := flag.NewFlagSet("search", flag.ExitOnError)
query := fs.String("query", "subject:akai AND subject:sampler", "search query")
filter := fs.String("filter", "", "extra term AND-ed onto the query")
limit := fs.Int("limit", 30, "max results")
minDL := fs.Int("min-downloads", 0, "minimum download count")
_ = fs.Parse(args)
q := *query
if *filter != "" {
q = "(" + q + ") AND (" + *filter + ")"
}
results := searchArchive(q, *limit)
sort.Slice(results, func(i, j int) bool {
return results[i].Downloads > results[j].Downloads
})
fmt.Printf("Found %d items:\n\n", len(results))
for _, r := range results {
if *minDL > 0 && r.Downloads < *minDL {
continue
}
fmt.Printf(" %-40s downloads: %-8d title: %s\n", r.Identifier, r.Downloads, truncate(r.Title, 50))
}
}
func searchArchive(query string, limit int) []SearchDoc {
params := url.Values{}
params.Set("q", query)
params.Set("fl[]", "identifier,title,downloads,format")
params.Set("sort[]", "downloads desc")
params.Set("rows", strconv.Itoa(limit))
params.Set("page", "1")
params.Set("output", "json")
u := iaSearchURL + "?" + params.Encode()
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("GET", u, nil)
if err != nil {
fatal("http request: %v", err)
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
fatal("search request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fatal("read response: %v", err)
}
var sr SearchResult
if err := json.Unmarshal(body, &sr); err != nil {
fatal("parse response: %v", err)
}
return sr.Response.Docs
}
// ─── List ──────────────────────────────────────────────────────────────
func cmdList(args []string) {
if len(args) < 1 {
fatal("usage: akai-fetch list <identifier>")
}
identifier := args[0]
files := getItemFiles(identifier)
fmt.Printf("Files in %s:\n\n", identifier)
for _, f := range files {
size := f.Size
if size == "" {
size = "?"
}
fmt.Printf(" %-60s %s\n", f.Name, size)
}
}
func getItemFiles(identifier string) []MetadataFile {
u := fmt.Sprintf("%s/metadata/%s", iaBaseURL, url.PathEscape(identifier))
client := &http.Client{Timeout: 30 * time.Second}
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
fatal("metadata request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fatal("read metadata: %v", err)
}
var mr struct {
Files []MetadataFile `json:"files"`
}
if err := json.Unmarshal(body, &mr); err != nil {
fatal("parse metadata: %v", err)
}
var isos []MetadataFile
for _, f := range mr.Files {
if strings.HasSuffix(strings.ToLower(f.Name), ".iso") {
isos = append(isos, f)
}
}
return isos
}
// ─── Download ──────────────────────────────────────────────────────────
type downloadJob struct {
Identifier string
Name string
URL string
OutPath string
Size int64
}
func cmdDownload(args []string) {
fs := flag.NewFlagSet("download", flag.ExitOnError)
identifier := fs.String("i", "", "archive.org item identifier")
idFile := fs.String("f", "", "file with identifiers (one per line)")
outDir := fs.String("dir", ".", "output directory")
jobs := fs.Int("jobs", 2, "concurrent downloads")
_ = fs.Parse(args)
os.MkdirAll(*outDir, 0755)
var identifiers []string
if *identifier != "" {
identifiers = append(identifiers, *identifier)
}
if *idFile != "" {
data, err := os.ReadFile(*idFile)
if err != nil {
fatal("read id file: %v", err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
identifiers = append(identifiers, line)
}
}
}
if len(identifiers) == 0 {
fatal("provide -i <identifier> or -f <file>")
}
var jobsList []downloadJob
for _, id := range identifiers {
files := getItemFiles(id)
if len(files) == 0 {
fmt.Printf("[%s] no ISO files found\n", id)
continue
}
for _, f := range files {
size, _ := strconv.ParseInt(f.Size, 10, 64)
jobsList = append(jobsList, downloadJob{
Identifier: id,
Name: f.Name,
URL: fmt.Sprintf("%s/download/%s/%s", iaBaseURL, url.PathEscape(id), url.PathEscape(f.Name)),
OutPath: filepath.Join(*outDir, f.Name),
Size: size,
})
}
}
if len(jobsList) == 0 {
fmt.Println("no download jobs")
return
}
fmt.Printf("Downloading %d file(s) with %d concurrent job(s)...\n\n", len(jobsList), *jobs)
var mu sync.Mutex
sem := make(chan struct{}, *jobs)
var wg sync.WaitGroup
var totalDownloaded int64
for _, job := range jobsList {
wg.Add(1)
go func(j downloadJob) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
size, err := downloadFile(j)
mu.Lock()
totalDownloaded += size
mu.Unlock()
if err != nil {
fmt.Printf(" FAIL %-40s %v\n", j.Name, err)
} else {
fmt.Printf(" OK %-40s %s\n", j.Name, humanSize(size))
}
}(job)
}
wg.Wait()
fmt.Printf("\nDownloaded %s total\n", humanSize(totalDownloaded))
}
func downloadFile(job downloadJob) (int64, error) {
// Check if file already exists and has the expected size
if fi, err := os.Stat(job.OutPath); err == nil {
if job.Size > 0 && fi.Size() == job.Size {
return fi.Size(), nil
}
}
// Check if partial file exists for resume
var existingSize int64
if fi, err := os.Stat(job.OutPath); err == nil {
existingSize = fi.Size()
}
client := &http.Client{Timeout: 0}
req, err := http.NewRequest("GET", job.URL, nil)
if err != nil {
return 0, fmt.Errorf("request: %w", err)
}
req.Header.Set("User-Agent", userAgent)
if existingSize > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", existingSize))
}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return 0, fmt.Errorf("HTTP %d", resp.StatusCode)
}
outFile, err := os.OpenFile(job.OutPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return 0, fmt.Errorf("open: %w", err)
}
defer outFile.Close()
written, err := io.Copy(outFile, resp.Body)
if err != nil {
return 0, fmt.Errorf("download: %w", err)
}
return written + existingSize, nil
}
// ─── Extract ───────────────────────────────────────────────────────────
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")
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")
}
os.MkdirAll(*outDir, 0755)
entries, err := os.ReadDir(*isoDir)
if err != nil {
fatal("read dir: %v", err)
}
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 {
fmt.Println("no ISO files found in", *isoDir)
return
}
fmt.Printf("Extracting %d ISO(s) with %d concurrent job(s)...\n", len(isos), *jobs)
sem := make(chan struct{}, *jobs)
var wg sync.WaitGroup
var mu sync.Mutex
results := map[string]string{}
for _, iso := range isos {
wg.Add(1)
go func(isoPath string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
out := *outDir
result, err := extractISO(toolPath, isoPath, out)
mu.Lock()
results[filepath.Base(isoPath)] = result
mu.Unlock()
if err != nil {
fmt.Printf(" FAIL %-40s %v\n", filepath.Base(isoPath), err)
} else {
fmt.Printf(" OK %-40s %s\n", filepath.Base(isoPath), result)
}
}(iso)
}
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 {
name := path
if ext := filepath.Ext(name); ext != "" {
name = name[:len(name)-len(ext)]
}
// Matches extract_wavs.sh sanitisation
var buf strings.Builder
for _, r := range name {
if r == ' ' {
buf.WriteRune('_')
} else if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '.' || r == '-' {
buf.WriteRune(r)
} else {
buf.WriteRune('_')
}
}
// Collapse multiple underscores
s := buf.String()
for strings.Contains(s, "__") {
s = strings.ReplaceAll(s, "__", "_")
}
// Strip leading/trailing _ and .
s = strings.Trim(s, "_.")
return s
}
// ─── Pipeline ──────────────────────────────────────────────────────────
func cmdPipeline(args []string) {
fs := flag.NewFlagSet("pipeline", flag.ExitOnError)
query := fs.String("query", "subject:akai AND subject:sampler", "search query")
filter := fs.String("filter", "", "extra term AND-ed onto the query")
limit := fs.Int("limit", 10, "max results")
baseDir := fs.String("dir", "./output", "base output directory")
dlJobs := fs.Int("download-jobs", 2, "concurrent downloads")
extJobs := fs.Int("extract-jobs", 2, "concurrent extractions")
_ = fs.Parse(args)
isoDir := filepath.Join(*baseDir, "isos")
wavDir := filepath.Join(*baseDir, "wavs")
toolPath := findScript("extract_wavs.sh")
// Remaining args after flags are treated as explicit identifiers
identifiers := fs.Args()
if len(identifiers) == 0 {
fmt.Println("=== Phase 1: Search ===")
q := *query
if *filter != "" {
q = "(" + q + ") AND (" + *filter + ")"
}
fmt.Printf("Query: %s Limit: %d\n\n", q, *limit)
results := searchArchive(q, *limit)
sort.Slice(results, func(i, j int) bool {
return results[i].Downloads > results[j].Downloads
})
if len(results) == 0 {
fmt.Println("No results found.")
return
}
for _, r := range results {
identifiers = append(identifiers, r.Identifier)
}
fmt.Printf("Found %d items\n\n", len(identifiers))
} else {
fmt.Printf("=== Processing %d specific item(s) ===\n\n", len(identifiers))
}
if err := os.MkdirAll(*baseDir, 0755); err != nil {
fatal("create output dir: %v", err)
}
idFile := filepath.Join(*baseDir, "identifiers.txt")
var idContent strings.Builder
for _, id := range identifiers {
idContent.WriteString(id + "\n")
}
if err := os.WriteFile(idFile, []byte(idContent.String()), 0644); err != nil {
fatal("write ids: %v", err)
}
fmt.Printf("Identifiers saved to %s\n\n", idFile)
fmt.Println("=== Phase 2: Download ===")
cmdDownload([]string{"-f", idFile, "-dir", isoDir, "-jobs", fmt.Sprintf("%d", *dlJobs)})
fmt.Println("\n=== Phase 3: Extract ===")
cmdExtract([]string{"-dir", isoDir, "-out", wavDir, "-tool", toolPath, "-jobs", fmt.Sprintf("%d", *extJobs)})
fmt.Printf("\n=== Pipeline Complete ===\n")
fmt.Printf(" ISOs: %s\n", isoDir)
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) {
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
os.Exit(1)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
if n < 3 {
return s[:n]
}
return s[:n-3] + "..."
}
func humanSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%dB", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
func init() {
// Ensure we can parse flags even with -count or other test flags
flag.CommandLine.Parse([]string{})
}