Files
This commit is contained in:
		
							
								
								
									
										281
									
								
								main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										281
									
								
								main.go
									
									
									
									
									
										Normal file
									
								
							@@ -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("===============================")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user