From 103ec07f926195ff6940a4709c3ad9486fa03c11 Mon Sep 17 00:00:00 2001 From: Joren Date: Wed, 2 Apr 2025 20:14:49 +0200 Subject: [PATCH] Files --- go.mod | 3 + main.go | 281 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 go.mod create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..11b7cfe --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module Duparr + +go 1.24.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..7646c21 --- /dev/null +++ b/main.go @@ -0,0 +1,281 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "os" + "os/exec" + "io" + "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 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 %d bytes)", action, file.Path, file.Size)) + } + } + + writeLog(fmt.Sprintf(" Total recoverable space: %d bytes", 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("") + } + + for baseFile, group := range fileGroups { + processFileGroup(baseFile, group) + writeLog("") + } + + writeLog(fmt.Sprintf("Cleanup completed at %s", getCurrentTime())) + writeLog("===============================") +}