Files
akai-utils/main.go
T
casaos 815d43ee97 WIP
2026-06-21 22:05:18 -04:00

612 lines
16 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)
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
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`)
}
// ─── 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")
}
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
}
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{})
}