353 lines
9.7 KiB
Go
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
|
|
}
|