package main import ( "bufio" "flag" "fmt" "io" "log" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "time" ) type FileQuality struct { Path string Bitrate int Bitdepth int Size int64 } var ( logFile *os.File armed bool dryRun bool logWriter *bufio.Writer version = "1.0.0" dateFormat = "2006-01-02 15:04:05" ) func init() { flag.BoolVar(&armed, "armed", false, "Enable actual file operations (rename/delete)") flag.BoolVar(&dryRun, "dry-run", false, "Simulate operations without making changes") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "FLAC Duplicate Cleaner v%s\n", version) fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options] [directory]\n", os.Args[0]) flag.PrintDefaults() fmt.Println("\nBy default, runs in dry-run mode showing what would be done") fmt.Println("Specify -armed to actually perform operations") } } func initLogging(logFilename string) error { var err error logFile, err = os.OpenFile(logFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } logWriter = bufio.NewWriter(logFile) log.SetOutput(io.MultiWriter(os.Stdout, logWriter)) return nil } func closeLogging() { logWriter.Flush() logFile.Close() } func writeLog(message string) { log.Println(message) } func getCurrentTime() string { return time.Now().Format(dateFormat) } func getMediaInfo(filePath string) (int, int, error) { // Get bitrate bitrateCmd := exec.Command("mediainfo", "--Output=Audio;%BitRate%", filePath) bitrateOut, err := bitrateCmd.Output() if err != nil { return 0, 0, fmt.Errorf("mediainfo bitrate failed: %w", err) } bitrateStr := strings.TrimSpace(string(bitrateOut)) bitrate, err := strconv.Atoi(bitrateStr) if err != nil { bitrate = 0 } // Get bitdepth bitdepthCmd := exec.Command("mediainfo", "--Output=Audio;%BitDepth%", filePath) bitdepthOut, err := bitdepthCmd.Output() if err != nil { return 0, 0, fmt.Errorf("mediainfo bitdepth failed: %w", err) } bitdepthStr := strings.TrimSpace(string(bitdepthOut)) bitdepth, err := strconv.Atoi(bitdepthStr) if err != nil { bitdepth = 0 } return bitrate, bitdepth, nil } func findDuplicateFiles(rootDir string) (map[string][]FileQuality, error) { dupePattern := regexp.MustCompile(`(?i)(.+)( \(\d+\))\.flac$`) fileGroups := make(map[string][]FileQuality) err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { if err != nil { return fmt.Errorf("access error %q: %w", path, err) } if info.IsDir() { return nil } baseName := strings.ToLower(filepath.Base(path)) if !strings.HasSuffix(baseName, ".flac") { return nil } // Check if file matches the duplicate pattern matches := dupePattern.FindStringSubmatch(path) if len(matches) > 1 { baseFile := matches[1] + ".flac" fileInfo, err := os.Stat(path) if err != nil { return fmt.Errorf("stat failed for %q: %w", path, err) } fileGroups[baseFile] = append(fileGroups[baseFile], FileQuality{Path: path, Size: fileInfo.Size()}) } else { // Include the original file in the group baseFile := strings.TrimSuffix(path, ".flac") + ".flac" fileInfo, err := os.Stat(path) if err != nil { return fmt.Errorf("stat failed for %q: %w", path, err) } fileGroups[baseFile] = append(fileGroups[baseFile], FileQuality{Path: path, Size: fileInfo.Size()}) } return nil }) if err != nil { return nil, fmt.Errorf("error walking directory: %w", err) } // Filter out groups that don't have duplicates for key, group := range fileGroups { if len(group) <= 1 { delete(fileGroups, key) } } return fileGroups, nil } func bytesToHumanReadable(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp]) } func processFileGroup(baseFile string, files []FileQuality) { writeLog(fmt.Sprintf("Processing group for: %s", baseFile)) // Get quality info for all files var bestFile FileQuality var totalSize int64 for i := range files { bitrate, bitdepth, err := getMediaInfo(files[i].Path) if err != nil { writeLog(fmt.Sprintf(" - Error getting media info for %s: %v", files[i].Path, err)) continue } files[i].Bitrate = bitrate files[i].Bitdepth = bitdepth totalSize += files[i].Size writeLog(fmt.Sprintf(" - Found: %s (Bitrate: %d, Bitdepth: %d, Size: %d bytes)", files[i].Path, files[i].Bitrate, files[i].Bitdepth, files[i].Size)) // Determine the best file using quality then size as tiebreaker if files[i].Bitrate > bestFile.Bitrate || (files[i].Bitrate == bestFile.Bitrate && files[i].Bitdepth > bestFile.Bitdepth) || (files[i].Bitrate == bestFile.Bitrate && files[i].Bitdepth == bestFile.Bitdepth && files[i].Size > bestFile.Size) { bestFile = files[i] } } if bestFile.Path == "" { writeLog(" - No valid files found in group") return } writeLog(fmt.Sprintf(" -> Keeping: %s (Bitrate: %d, Bitdepth: %d, Size: %d bytes)", bestFile.Path, bestFile.Bitrate, bestFile.Bitdepth, bestFile.Size)) // Rename best file to original name if it's not already if bestFile.Path != baseFile { action := "Would rename" if armed && !dryRun { action = "Renaming" err := os.Rename(bestFile.Path, baseFile) if err != nil { writeLog(fmt.Sprintf(" ! Rename failed: %v", err)) return } } writeLog(fmt.Sprintf(" * %s: %s -> %s", action, bestFile.Path, baseFile)) bestFile.Path = baseFile } // Delete other files for _, file := range files { if file.Path != bestFile.Path { action := "Would delete" if armed && !dryRun { action = "Deleting" err := os.Remove(file.Path) if err != nil { writeLog(fmt.Sprintf(" ! Delete failed for %s: %v", file.Path, err)) continue } } writeLog(fmt.Sprintf(" * %s: %s (Recovering %s)", action, file.Path, bytesToHumanReadable(file.Size))) } } writeLog(fmt.Sprintf(" Total recoverable space: %s", bytesToHumanReadable(totalSize-bestFile.Size))) } func main() { flag.Parse() // Default to dry-run mode unless armed is explicitly set if !armed { dryRun = true } // Get working directory dir := "." if flag.NArg() > 0 { dir = flag.Arg(0) } // Initialize logging err := initLogging("dupe_cleanup.log") if err != nil { log.Fatalf("Error initializing logging: %v", err) } defer closeLogging() writeLog("===============================") writeLog(fmt.Sprintf("FLAC Duplicate Cleaner v%s", version)) writeLog(fmt.Sprintf("Started at %s", getCurrentTime())) writeLog(fmt.Sprintf("Processing directory: %s", dir)) if dryRun { writeLog("DRY-RUN MODE: No files will be renamed or deleted.") } else { writeLog("ARMED MODE: Files will be renamed and deleted!") } writeLog("===============================") // Check mediainfo is available _, err = exec.LookPath("mediainfo") if err != nil { writeLog("Error: 'mediainfo' command not found. Please install MediaInfo package.") os.Exit(1) } fileGroups, err := findDuplicateFiles(dir) if err != nil { writeLog(fmt.Sprintf("Error finding duplicate files: %v", err)) os.Exit(1) } if len(fileGroups) == 0 { writeLog("No duplicate FLAC files found.") } else { writeLog(fmt.Sprintf("Found %d groups of duplicate files", len(fileGroups))) writeLog("") } var totalRecoverableSpace int64 for baseFile, group := range fileGroups { processFileGroup(baseFile, group) for _, file := range group { if file.Path != baseFile { totalRecoverableSpace += file.Size } } writeLog("") } writeLog(fmt.Sprintf("Total recoverable space: %s", bytesToHumanReadable(totalRecoverableSpace))) writeLog(fmt.Sprintf("Cleanup completed at %s", getCurrentTime())) writeLog("===============================") }