This commit is contained in:
Joren 2025-04-02 20:14:49 +02:00
parent 137be85df1
commit 103ec07f92
Signed by: Joren
GPG Key ID: 280E33DFBC0F1B55
2 changed files with 284 additions and 0 deletions

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module Duparr
go 1.24.1

281
main.go Normal file
View 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("===============================")
}