multi release
This commit is contained in:
		
							
								
								
									
										227
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										227
									
								
								main.go
									
									
									
									
									
								
							@@ -16,30 +16,37 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type FileQuality struct {
 | 
			
		||||
	Path     string
 | 
			
		||||
	Bitrate  int
 | 
			
		||||
	Bitdepth int
 | 
			
		||||
	Size     int64
 | 
			
		||||
	Path         string
 | 
			
		||||
	Bitrate      int
 | 
			
		||||
	Bitdepth     int
 | 
			
		||||
	Size         int64
 | 
			
		||||
	ReleaseID    string // MusicBrainz release ID or other unique identifier
 | 
			
		||||
	ReleaseYear  string // Release year from metadata
 | 
			
		||||
	CatalogNum   string // Catalog number
 | 
			
		||||
	ReleaseInfo  string // Human-readable release info for logging
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	logFile    *os.File
 | 
			
		||||
	armed      bool
 | 
			
		||||
	dryRun     bool
 | 
			
		||||
	forceMode  bool // Force processing even if releases appear different
 | 
			
		||||
	logWriter  *bufio.Writer
 | 
			
		||||
	version    = "1.0.0"
 | 
			
		||||
	version    = "1.1.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.BoolVar(&forceMode, "force", false, "Force processing even if releases appear different")
 | 
			
		||||
	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")
 | 
			
		||||
		fmt.Println("Use -force to process files even if they appear to be from different releases")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -67,12 +74,12 @@ func getCurrentTime() string {
 | 
			
		||||
	return time.Now().Format(dateFormat)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getMediaInfo(filePath string) (int, int, error) {
 | 
			
		||||
func getMediaInfo(filePath string) (int, int, string, string, string, 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)
 | 
			
		||||
		return 0, 0, "", "", "", fmt.Errorf("mediainfo bitrate failed: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	bitrateStr := strings.TrimSpace(string(bitrateOut))
 | 
			
		||||
	bitrate, err := strconv.Atoi(bitrateStr)
 | 
			
		||||
@@ -84,7 +91,7 @@ func getMediaInfo(filePath string) (int, int, error) {
 | 
			
		||||
	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)
 | 
			
		||||
		return 0, 0, "", "", "", fmt.Errorf("mediainfo bitdepth failed: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	bitdepthStr := strings.TrimSpace(string(bitdepthOut))
 | 
			
		||||
	bitdepth, err := strconv.Atoi(bitdepthStr)
 | 
			
		||||
@@ -92,7 +99,50 @@ func getMediaInfo(filePath string) (int, int, error) {
 | 
			
		||||
		bitdepth = 0
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return bitrate, bitdepth, nil
 | 
			
		||||
	// Get MusicBrainz release ID
 | 
			
		||||
	releaseIDCmd := exec.Command("mediainfo", "--Output=General;%MUSICBRAINZ_RELEASETRACKID%", filePath)
 | 
			
		||||
	releaseIDOut, err := releaseIDCmd.Output()
 | 
			
		||||
	var releaseID string
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		releaseID = strings.TrimSpace(string(releaseIDOut))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get release year
 | 
			
		||||
	yearCmd := exec.Command("mediainfo", "--Output=General;%Released_Date%", filePath)
 | 
			
		||||
	yearOut, err := yearCmd.Output()
 | 
			
		||||
	var year string
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		year = strings.TrimSpace(string(yearOut))
 | 
			
		||||
		// If no released date, try recorded date
 | 
			
		||||
		if year == "" {
 | 
			
		||||
			yearCmd = exec.Command("mediainfo", "--Output=General;%Recorded_Date%", filePath)
 | 
			
		||||
			yearOut, err := yearCmd.Output()
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				year = strings.TrimSpace(string(yearOut))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		// Extract just the year if it's a full date
 | 
			
		||||
		if len(year) >= 4 {
 | 
			
		||||
			year = year[0:4]
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get catalog number
 | 
			
		||||
	catalogCmd := exec.Command("mediainfo", "--Output=General;%CATALOGNUMBER%", filePath)
 | 
			
		||||
	catalogOut, err := catalogCmd.Output()
 | 
			
		||||
	var catalogNum string
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		catalogNum = strings.TrimSpace(string(catalogOut))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return bitrate, bitdepth, releaseID, year, catalogNum, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getReleaseInfo(filePath string) string {
 | 
			
		||||
	// Get a summary of release info for logging
 | 
			
		||||
	infoCmd := exec.Command("mediainfo", "--Output=General;Album: %Album%, Released: %Released_Date%, Recorded: %Recorded_Date%, Label: %Label%, CatalogNum: %CATALOGNUMBER%", filePath)
 | 
			
		||||
	infoOut, _ := infoCmd.Output()
 | 
			
		||||
	return strings.TrimSpace(string(infoOut))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func findDuplicateFiles(rootDir string) (map[string][]FileQuality, error) {
 | 
			
		||||
@@ -162,74 +212,143 @@ func bytesToHumanReadable(bytes int64) string {
 | 
			
		||||
	return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func processFileGroup(baseFile string, files []FileQuality) {
 | 
			
		||||
func processFileGroup(baseFile string, files []FileQuality) (int64, bool) {
 | 
			
		||||
	writeLog(fmt.Sprintf("Processing group for: %s", baseFile))
 | 
			
		||||
	processed := false
 | 
			
		||||
	var spaceRecovered int64 = 0
 | 
			
		||||
 | 
			
		||||
	// Get quality info for all files
 | 
			
		||||
	var bestFile FileQuality
 | 
			
		||||
	var totalSize int64
 | 
			
		||||
	releaseMap := make(map[string][]FileQuality)
 | 
			
		||||
 | 
			
		||||
	for i := range files {
 | 
			
		||||
		bitrate, bitdepth, err := getMediaInfo(files[i].Path)
 | 
			
		||||
		bitrate, bitdepth, releaseID, year, catalogNum, 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
 | 
			
		||||
		files[i].ReleaseID = releaseID
 | 
			
		||||
		files[i].ReleaseYear = year
 | 
			
		||||
		files[i].CatalogNum = catalogNum
 | 
			
		||||
		files[i].ReleaseInfo = getReleaseInfo(files[i].Path)
 | 
			
		||||
		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]
 | 
			
		||||
		// Create a unique key for grouping by release
 | 
			
		||||
		releaseKey := releaseID
 | 
			
		||||
		if releaseKey == "" {
 | 
			
		||||
			// Fallback if no MusicBrainz ID
 | 
			
		||||
			releaseKey = fmt.Sprintf("%s-%s", year, catalogNum)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		releaseMap[releaseKey] = append(releaseMap[releaseKey], files[i])
 | 
			
		||||
 | 
			
		||||
		writeLog(fmt.Sprintf("  - Found: %s (Bitrate: %d, Bitdepth: %d, Size: %s, Year: %s, CatalogNum: %s)",
 | 
			
		||||
			files[i].Path, files[i].Bitrate, files[i].Bitdepth, 
 | 
			
		||||
			bytesToHumanReadable(files[i].Size), files[i].ReleaseYear, files[i].CatalogNum))
 | 
			
		||||
		writeLog(fmt.Sprintf("    Release info: %s", files[i].ReleaseInfo))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
	// Check if files are from different releases
 | 
			
		||||
	if len(releaseMap) > 1 && !forceMode {
 | 
			
		||||
		writeLog("  ! Files appear to be from different releases. Skipping group (use -force to override).")
 | 
			
		||||
		for releaseKey, releaseFiles := range releaseMap {
 | 
			
		||||
			writeLog(fmt.Sprintf("    Release group: %s", releaseKey))
 | 
			
		||||
			for _, file := range releaseFiles {
 | 
			
		||||
				writeLog(fmt.Sprintf("      - %s", file.Path))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		writeLog(fmt.Sprintf("  * %s: %s -> %s", action, bestFile.Path, baseFile))
 | 
			
		||||
		bestFile.Path = baseFile
 | 
			
		||||
		return 0, false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Delete other files
 | 
			
		||||
	for _, file := range files {
 | 
			
		||||
		if file.Path != bestFile.Path {
 | 
			
		||||
			action := "Would delete"
 | 
			
		||||
	// Process each release group
 | 
			
		||||
	for releaseKey, releaseFiles := range releaseMap {
 | 
			
		||||
		if len(releaseFiles) <= 1 {
 | 
			
		||||
			// Skip if only one file for this release
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		writeLog(fmt.Sprintf("  Processing release group: %s", releaseKey))
 | 
			
		||||
		bestFile = FileQuality{}
 | 
			
		||||
		
 | 
			
		||||
		// Find best file for this release
 | 
			
		||||
		for _, file := range releaseFiles {
 | 
			
		||||
			// Determine the best file using quality then size as tiebreaker
 | 
			
		||||
			if file.Bitrate > bestFile.Bitrate ||
 | 
			
		||||
				(file.Bitrate == bestFile.Bitrate && file.Bitdepth > bestFile.Bitdepth) ||
 | 
			
		||||
				(file.Bitrate == bestFile.Bitrate && file.Bitdepth == bestFile.Bitdepth && file.Size > bestFile.Size) {
 | 
			
		||||
				bestFile = file
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if bestFile.Path == "" {
 | 
			
		||||
			writeLog("    - No valid files found in this release group")
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		writeLog(fmt.Sprintf("  -> Keeping: %s (Bitrate: %d, Bitdepth: %d, Size: %s)",
 | 
			
		||||
			bestFile.Path, bestFile.Bitrate, bestFile.Bitdepth, bytesToHumanReadable(bestFile.Size)))
 | 
			
		||||
 | 
			
		||||
		// If this is the primary file (without (n) in name), make sure the best quality version is it
 | 
			
		||||
		isBaseFile := (bestFile.Path == baseFile || 
 | 
			
		||||
		               strings.TrimSuffix(bestFile.Path, ".flac") == strings.TrimSuffix(baseFile, ".flac"))
 | 
			
		||||
		
 | 
			
		||||
		// Generate target filename
 | 
			
		||||
		targetFilename := baseFile
 | 
			
		||||
		if !isBaseFile && len(releaseMap) > 1 {
 | 
			
		||||
			// For multiple releases, append year/identifier to filename to avoid conflicts
 | 
			
		||||
			ext := filepath.Ext(baseFile)
 | 
			
		||||
			baseName := strings.TrimSuffix(baseFile, ext)
 | 
			
		||||
			
 | 
			
		||||
			// Use year or catalog number as identifier
 | 
			
		||||
			identifier := bestFile.ReleaseYear
 | 
			
		||||
			if identifier == "" && bestFile.CatalogNum != "" {
 | 
			
		||||
				identifier = bestFile.CatalogNum
 | 
			
		||||
			}
 | 
			
		||||
			
 | 
			
		||||
			if identifier != "" {
 | 
			
		||||
				targetFilename = fmt.Sprintf("%s [%s]%s", baseName, identifier, ext)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Rename best file to target name if needed
 | 
			
		||||
		if bestFile.Path != targetFilename {
 | 
			
		||||
			action := "Would rename"
 | 
			
		||||
			if armed && !dryRun {
 | 
			
		||||
				action = "Deleting"
 | 
			
		||||
				err := os.Remove(file.Path)
 | 
			
		||||
				action = "Renaming"
 | 
			
		||||
				err := os.Rename(bestFile.Path, targetFilename)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					writeLog(fmt.Sprintf("    ! Delete failed for %s: %v", file.Path, err))
 | 
			
		||||
					writeLog(fmt.Sprintf("    ! Rename failed: %v", err))
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			writeLog(fmt.Sprintf("  * %s: %s (Recovering %s)", action, file.Path, bytesToHumanReadable(file.Size)))
 | 
			
		||||
			writeLog(fmt.Sprintf("  * %s: %s -> %s", action, bestFile.Path, targetFilename))
 | 
			
		||||
			bestFile.Path = targetFilename
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Delete other files in this release group
 | 
			
		||||
		for _, file := range releaseFiles {
 | 
			
		||||
			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)))
 | 
			
		||||
				spaceRecovered += file.Size
 | 
			
		||||
				processed = true
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	writeLog(fmt.Sprintf("  Total recoverable space: %s", bytesToHumanReadable(totalSize-bestFile.Size)))
 | 
			
		||||
	writeLog(fmt.Sprintf("  Total potentially recoverable space: %s", bytesToHumanReadable(spaceRecovered)))
 | 
			
		||||
	return spaceRecovered, processed
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
@@ -262,6 +381,9 @@ func main() {
 | 
			
		||||
	} else {
 | 
			
		||||
		writeLog("ARMED MODE: Files will be renamed and deleted!")
 | 
			
		||||
	}
 | 
			
		||||
	if forceMode {
 | 
			
		||||
		writeLog("FORCE MODE: Will process even if releases appear different!")
 | 
			
		||||
	}
 | 
			
		||||
	writeLog("===============================")
 | 
			
		||||
 | 
			
		||||
	// Check mediainfo is available
 | 
			
		||||
@@ -285,17 +407,18 @@ func main() {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var totalRecoverableSpace int64
 | 
			
		||||
	processedGroups := 0
 | 
			
		||||
 | 
			
		||||
	for baseFile, group := range fileGroups {
 | 
			
		||||
		processFileGroup(baseFile, group)
 | 
			
		||||
		for _, file := range group {
 | 
			
		||||
			if file.Path != baseFile {
 | 
			
		||||
				totalRecoverableSpace += file.Size
 | 
			
		||||
			}
 | 
			
		||||
		spaceRecovered, processed := processFileGroup(baseFile, group)
 | 
			
		||||
		if processed {
 | 
			
		||||
			processedGroups++
 | 
			
		||||
			totalRecoverableSpace += spaceRecovered
 | 
			
		||||
		}
 | 
			
		||||
		writeLog("")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	writeLog(fmt.Sprintf("Processed %d of %d file groups", processedGroups, len(fileGroups)))
 | 
			
		||||
	writeLog(fmt.Sprintf("Total recoverable space: %s", bytesToHumanReadable(totalRecoverableSpace)))
 | 
			
		||||
	writeLog(fmt.Sprintf("Cleanup completed at %s", getCurrentTime()))
 | 
			
		||||
	writeLog("===============================")
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user