Files
2gopus/go-version/main.go
2026-03-22 00:54:34 -07:00

353 lines
9.7 KiB
Go

package main
import (
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type AudioFile struct {
Path string
Format string
Size int64
Modified time.Time
}
type Config struct {
InputDir string
OutputDir string
Bitrate int
SampleRate int
Channels int
Recursive bool
DeleteOriginal bool
Threads int
Verbose bool
DryRun bool
Format string
Compression int
}
var supportedFormats = map[string]bool{
".mp3": true,
".wav": true,
".flac": true,
".ogg": true,
".m4a": true,
".aac": true,
".wma": true,
}
func main() {
config := parseFlags()
if config.Verbose {
fmt.Printf("2gopus - Audio to Opus Converter\n")
fmt.Printf("Input Directory: %s\n", config.InputDir)
fmt.Printf("Output Directory: %s\n", config.OutputDir)
fmt.Printf("Bitrate: %d kbps\n", config.Bitrate)
fmt.Printf("Compression: %d\n", config.Compression)
fmt.Printf("Recursive: %t\n", config.Recursive)
fmt.Printf("Threads: %d\n", config.Threads)
fmt.Printf("Dry Run: %t\n", config.DryRun)
fmt.Println()
}
audioFiles, err := scanAudioFiles(config)
if err != nil {
log.Fatalf("Error scanning for audio files: %v", err)
}
if len(audioFiles) == 0 {
fmt.Println("No audio files found")
return
}
fmt.Printf("Found %d audio files\n", len(audioFiles))
if config.DryRun {
fmt.Println("Dry run - would convert the following files:")
for _, file := range audioFiles {
fmt.Printf(" %s -> %s\n", file.Path, getOutputPath(file.Path, config))
}
return
}
err = convertFiles(audioFiles, config)
if err != nil {
log.Fatalf("Error converting files: %v", err)
}
fmt.Println("Conversion completed successfully!")
if !config.DeleteOriginal && !config.DryRun {
fmt.Print("\nDelete original files? (y/N): ")
var response string
fmt.Scanln(&response)
if strings.ToLower(strings.TrimSpace(response)) == "y" {
fmt.Println("Deleting original files...")
deleted := 0
for _, file := range audioFiles {
if err := os.Remove(file.Path); err != nil {
fmt.Printf("Warning: failed to delete %s: %v\n", file.Path, err)
} else {
deleted++
}
}
fmt.Printf("Deleted %d files\n", deleted)
}
}
}
func parseFlags() *Config {
config := &Config{}
flag.StringVar(&config.InputDir, "input", ".", "Input directory to scan for audio files")
flag.StringVar(&config.OutputDir, "output", ".", "Output directory for converted files")
flag.IntVar(&config.Bitrate, "bitrate", 200, "Target bitrate in kbps (VBR mode)")
flag.IntVar(&config.SampleRate, "samplerate", 48000, "Sample rate for output")
flag.IntVar(&config.Channels, "channels", 2, "Number of channels (1=mono, 2=stereo)")
flag.BoolVar(&config.Recursive, "recursive", true, "Scan subdirectories recursively")
flag.BoolVar(&config.DeleteOriginal, "delete", false, "Delete original files after conversion")
flag.IntVar(&config.Threads, "threads", 4, "Number of concurrent conversion threads")
flag.BoolVar(&config.Verbose, "verbose", false, "Enable verbose output")
flag.BoolVar(&config.DryRun, "dry-run", false, "Show what would be converted without actually converting")
flag.StringVar(&config.Format, "format", "opus", "Output format (opus, ogg)")
flag.IntVar(&config.Compression, "compression", 0, "Compression level (0=fastest, 10=slowest)")
var help bool
flag.BoolVar(&help, "help", false, "Show help message")
flag.BoolVar(&help, "h", false, "Show help message")
flag.Parse()
if help {
showHelp()
os.Exit(0)
}
return config
}
func showHelp() {
fmt.Println("2gopus - Audio to Opus Converter")
fmt.Println()
fmt.Println("USAGE:")
fmt.Println(" 2gopus [OPTIONS]")
fmt.Println()
fmt.Println("OPTIONS:")
fmt.Println(" -input string")
fmt.Println(" Input directory to scan for audio files (default: \".\")")
fmt.Println(" -output string")
fmt.Println(" Output directory for converted files (default: \".\")")
fmt.Println(" -bitrate int")
fmt.Println(" Target bitrate in kbps, VBR mode (default: 200)")
fmt.Println(" -samplerate int")
fmt.Println(" Sample rate for output (default: 48000)")
fmt.Println(" -channels int")
fmt.Println(" Number of channels 1=mono, 2=stereo (default: 2)")
fmt.Println(" -recursive")
fmt.Println(" Scan subdirectories recursively (default: true)")
fmt.Println(" -delete")
fmt.Println(" Delete original files after conversion (default: false)")
fmt.Println(" -threads int")
fmt.Println(" Number of concurrent conversion threads (default: 4)")
fmt.Println(" -verbose")
fmt.Println(" Enable verbose output (default: false)")
fmt.Println(" -dry-run")
fmt.Println(" Show what would be converted without actually converting (default: false)")
fmt.Println(" -format string")
fmt.Println(" Output format: opus, ogg (default: \"opus\")")
fmt.Println(" -compression int")
fmt.Println(" Compression level 0=fastest, 10=slowest (default: 0)")
fmt.Println(" -help, -h")
fmt.Println(" Show this help message")
fmt.Println()
fmt.Println("SUPPORTED FORMATS:")
fmt.Println(" Input: MP3, WAV, FLAC, OGG, M4A, AAC, WMA")
fmt.Println(" Output: Opus, OGG")
fmt.Println()
fmt.Println("EXAMPLES:")
fmt.Println(" 2gopus # Convert all audio files in current directory")
fmt.Println(" 2gopus -input ./music -output ./converted # Convert files from ./music to ./converted")
fmt.Println(" 2gopus -bitrate 192 -compression 10 # High quality, slow compression")
fmt.Println(" 2gopus -dry-run # Preview what would be converted")
fmt.Println(" 2gopus -threads 8 -delete # Fast conversion with 8 threads, delete originals")
}
func scanAudioFiles(config *Config) ([]AudioFile, error) {
var audioFiles []AudioFile
err := filepath.Walk(config.InputDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if supportedFormats[ext] {
audioFiles = append(audioFiles, AudioFile{
Path: path,
Format: ext,
Size: info.Size(),
Modified: info.ModTime(),
})
}
return nil
})
return audioFiles, err
}
func getOutputPath(inputPath string, config *Config) string {
dir := filepath.Dir(inputPath)
baseName := strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))
outputExt := ".opus"
if config.Format == "ogg" {
outputExt = ".ogg"
}
if config.OutputDir != "." {
return filepath.Join(config.OutputDir, baseName+outputExt)
}
return filepath.Join(dir, baseName+outputExt)
}
func convertFiles(audioFiles []AudioFile, config *Config) error {
err := os.MkdirAll(config.OutputDir, 0755)
if err != nil {
return fmt.Errorf("failed to create output directory: %v", err)
}
semaphore := make(chan struct{}, config.Threads)
var wg sync.WaitGroup
var mu sync.Mutex
var errors []error
var completed int
var failed int
total := len(audioFiles)
startTime := time.Now()
for _, audioFile := range audioFiles {
wg.Add(1)
go func(file AudioFile) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
outputPath := getOutputPath(file.Path, config)
if config.Verbose {
fmt.Printf("Converting: %s -> %s\n", file.Path, outputPath)
}
err := convertFile(file.Path, outputPath, config)
mu.Lock()
if err != nil {
errors = append(errors, fmt.Errorf("failed to convert %s: %v", file.Path, err))
failed++
} else {
completed++
}
if !config.Verbose {
elapsed := time.Since(startTime)
remaining := total - completed - failed
var eta time.Duration
if completed > 0 {
eta = time.Duration(float64(elapsed) / float64(completed) * float64(remaining))
}
fileName := filepath.Base(file.Path)
if len(fileName) > 40 {
fileName = fileName[:37] + "..."
}
fmt.Printf("\r[%d/%d] %.1f%% | OK: %d | Fail: %d | ETA: %s | %s%s",
completed+failed, total,
float64(completed+failed)/float64(total)*100,
completed, failed,
eta.Round(time.Second),
fileName,
strings.Repeat(" ", 10))
}
mu.Unlock()
if err != nil {
return
}
if config.DeleteOriginal {
err = os.Remove(file.Path)
if err != nil {
mu.Lock()
errors = append(errors, fmt.Errorf("failed to delete original %s: %v", file.Path, err))
mu.Unlock()
}
}
}(audioFile)
}
wg.Wait()
if !config.Verbose {
fmt.Println()
}
if len(errors) > 0 {
return fmt.Errorf("conversion errors: %v", errors)
}
return nil
}
func convertFile(inputPath, outputPath string, config *Config) error {
if config.Verbose {
fmt.Printf("Converting %s to %s\n", inputPath, outputPath)
fmt.Printf(" Bitrate: %d kbps (VBR)\n", config.Bitrate)
fmt.Printf(" Compression: %d\n", config.Compression)
fmt.Printf(" Sample Rate: %d Hz\n", config.SampleRate)
fmt.Printf(" Channels: %d\n", config.Channels)
fmt.Printf(" Format: %s\n", config.Format)
}
cmd := exec.Command("ffmpeg", "-i", inputPath)
cmd.Args = append(cmd.Args, "-c:a", "libopus")
cmd.Args = append(cmd.Args, "-vbr", "on")
cmd.Args = append(cmd.Args, "-b:a", fmt.Sprintf("%dk", config.Bitrate))
cmd.Args = append(cmd.Args, "-compression_level", fmt.Sprintf("%d", config.Compression))
cmd.Args = append(cmd.Args, "-ar", fmt.Sprintf("%d", config.SampleRate))
if config.Channels == 1 {
cmd.Args = append(cmd.Args, "-ac", "1")
} else {
cmd.Args = append(cmd.Args, "-ac", "2")
}
if config.Format == "ogg" {
cmd.Args = append(cmd.Args, "-f", "ogg")
}
cmd.Args = append(cmd.Args, "-y", outputPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("ffmpeg conversion failed: %v", err)
}
return nil
}