Files
This commit is contained in:
parent
137be85df1
commit
103ec07f92
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("===============================")
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user