mirror of
https://github.com/barkeser2002/duplicate-file-finder.git
synced 2026-09-25 01:49:50 +03:00
313 lines
9.4 KiB
Python
Executable File
313 lines
9.4 KiB
Python
Executable File
#!/usr/bin/python3
|
|
import argparse
|
|
import hashlib
|
|
import os
|
|
import stat
|
|
import sys
|
|
import time
|
|
|
|
version = "0.10.0"
|
|
|
|
|
|
# Flags
|
|
verbose_output = None
|
|
output_immediately = None
|
|
trial_delete = None
|
|
delete_shorter = None
|
|
|
|
# Globals
|
|
stdout = ""
|
|
megabytes_scanned = 0
|
|
failed_delete_count = 0
|
|
|
|
# Constants
|
|
BYTES_IN_A_MEGABYTE = 1048576
|
|
BYTES_TO_SCAN = 4096
|
|
SCAN_SIZE_MB = BYTES_TO_SCAN / BYTES_IN_A_MEGABYTE
|
|
|
|
|
|
def clear_globals_for_unittests():
|
|
global stdout
|
|
global megabytes_scanned
|
|
stdout = ""
|
|
megabytes_scanned = 0
|
|
|
|
|
|
def set_verbose_output(b):
|
|
global verbose_output
|
|
verbose_output = b
|
|
|
|
|
|
def set_output_immediately(b):
|
|
global output_immediately
|
|
output_immediately = b
|
|
|
|
|
|
def set_trial_delete(b):
|
|
global trial_delete
|
|
trial_delete = b
|
|
|
|
|
|
def set_delete_shorter(b):
|
|
global delete_shorter
|
|
delete_shorter = b
|
|
|
|
|
|
class fileFullHash:
|
|
|
|
full: dict = {}
|
|
|
|
def __init__(self):
|
|
self.full.clear()
|
|
|
|
def search_duplicate(self, snip_file_path, current_file_path):
|
|
current_file_hash = self.hash_full(current_file_path)
|
|
if current_file_hash in self.full:
|
|
return self.full[current_file_hash]
|
|
|
|
snip_file_hash = self.hash_full(snip_file_path)
|
|
self.full[snip_file_hash] = snip_file_path
|
|
if current_file_hash in self.full:
|
|
return self.full[current_file_hash]
|
|
|
|
self.full[current_file_hash] = current_file_path
|
|
return False
|
|
|
|
def hash_full(self, file_path):
|
|
global megabytes_scanned
|
|
verbose("...calculating full hash of " + file_path)
|
|
file_hash = hashlib.blake2b()
|
|
with open(file_path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(BYTES_TO_SCAN), b""):
|
|
file_hash.update(chunk)
|
|
megabytes_scanned += SCAN_SIZE_MB
|
|
return file_hash.hexdigest()
|
|
|
|
|
|
def hash_snip(file_path):
|
|
global megabytes_scanned
|
|
verbose("...calculating hash snippet of " + file_path)
|
|
snip_hash = hashlib.blake2b()
|
|
try:
|
|
with open(file_path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(BYTES_TO_SCAN), b""):
|
|
snip_hash.update(chunk)
|
|
f.close()
|
|
megabytes_scanned += SCAN_SIZE_MB
|
|
return snip_hash.hexdigest()
|
|
except PermissionError:
|
|
output("PermissionError: " + file_path + "\n")
|
|
return "PermissionError:" + file_path
|
|
|
|
|
|
def verbose(out):
|
|
if verbose_output:
|
|
return output(out)
|
|
return
|
|
|
|
|
|
def output(out):
|
|
global stdout
|
|
if output_immediately:
|
|
unicode_output(out)
|
|
else:
|
|
stdout += out + "\n"
|
|
|
|
|
|
def unicode_output(out):
|
|
# when printing directly to the windows console stdout, unicode errors tend to be ignored automatically
|
|
# if the user redirects stdout to a file, unicode errors can occur
|
|
# this code outputs the best it can and flags errors in the output
|
|
try:
|
|
print(out, flush=True)
|
|
except UnicodeEncodeError:
|
|
try:
|
|
print(out.encode("utf8").decode(sys.stdout.encoding))
|
|
except UnicodeDecodeError:
|
|
print(out.encode("utf8").decode(sys.stdout.encoding, errors="ignore") + " <-- UnicodeDecodeError")
|
|
|
|
|
|
def dff(path, delete_duplicates=False):
|
|
output("\n" + time.strftime("%X : ") + "Finding duplicate files at " + path + "\n")
|
|
start_time = time.time()
|
|
|
|
sizes = fileSizes()
|
|
sizes.find_files_with_duplicate_file_size(path)
|
|
|
|
snip = dict()
|
|
full_hash = fileFullHash()
|
|
|
|
duplicate_count = 0
|
|
file_count = 0
|
|
|
|
for current_file_path in sizes.files_list:
|
|
file_count += 1
|
|
verbose("Processing file " + current_file_path)
|
|
current_file_snip_hash = hash_snip(current_file_path)
|
|
if current_file_snip_hash in snip:
|
|
dupe_file_path = full_hash.search_duplicate(snip[current_file_snip_hash], current_file_path)
|
|
if dupe_file_path:
|
|
display_duplicate_and_optionally_delete(dupe_file_path, current_file_path, delete_duplicates)
|
|
duplicate_count += 1
|
|
else:
|
|
verbose("...first 4096 bytes are the same, but files are different")
|
|
else:
|
|
snip[current_file_snip_hash] = current_file_path
|
|
|
|
output(
|
|
"\n"
|
|
+ time.strftime("%X : ")
|
|
+ str(duplicate_count)
|
|
+ " duplicate files found, "
|
|
+ str(file_count)
|
|
+ " files and "
|
|
+ str(megabytes_scanned)
|
|
+ " megabytes scanned in "
|
|
+ str(round(time.time() - start_time, 3))
|
|
+ " seconds, "
|
|
+ str(sizes.file_count)
|
|
+ " files assessed"
|
|
)
|
|
|
|
if failed_delete_count:
|
|
output("\n" + "failed to delete " + str(failed_delete_count) + " duplicates - rerun script")
|
|
|
|
return stdout
|
|
|
|
|
|
class fileSizes:
|
|
|
|
sizes: dict = {}
|
|
files_to_process: dict = {}
|
|
files_list: list = [] # want to process files in os.walk order, not some unknown order
|
|
file_count = 0
|
|
|
|
def __init__(self):
|
|
self.sizes.clear()
|
|
self.files_to_process.clear()
|
|
self.files_list.clear()
|
|
|
|
def find_files_with_duplicate_file_size(self, path):
|
|
self.file_count = 0
|
|
for root, _, files in sorted(os.walk(path)):
|
|
files.sort()
|
|
for file_name in files:
|
|
self.file_count += 1
|
|
current_file_path = os.path.join(root, file_name)
|
|
verbose("Checking size of file " + current_file_path)
|
|
try:
|
|
file_size = os.path.getsize(current_file_path)
|
|
except FileNotFoundError: # in case of symlink to nowhere
|
|
continue
|
|
if file_size > 0:
|
|
self.add_file(current_file_path, file_size)
|
|
|
|
def add_file(self, current_file_path, size):
|
|
if size in self.sizes:
|
|
verbose(current_file_path + " has non unique file size [" + str(size) + " bytes]")
|
|
self.add_original_file_to_process_list(self.sizes[size])
|
|
self.add_file_to_process_list(current_file_path)
|
|
else:
|
|
self.sizes[size] = current_file_path
|
|
|
|
def add_original_file_to_process_list(self, original_file_path):
|
|
if original_file_path in self.files_to_process:
|
|
verbose(original_file_path + " is a known size duplicate")
|
|
return
|
|
self.add_file_to_process_list(original_file_path)
|
|
|
|
def add_file_to_process_list(self, file_path):
|
|
self.files_to_process[file_path] = True
|
|
self.files_list.append(file_path)
|
|
verbose(file_path + " added to process list")
|
|
|
|
|
|
def display_duplicate_and_optionally_delete(previously_hashed_file_path, current_file_path, delete_duplicates):
|
|
|
|
current_file_message = " "
|
|
previously_hashed_file_message = ""
|
|
if delete_duplicates:
|
|
previously_hashed_file_message, current_file_message = delete_duplicate_and_get_message(
|
|
previously_hashed_file_path, current_file_path
|
|
)
|
|
|
|
output(
|
|
current_file_message
|
|
+ current_file_path
|
|
+ "\n is dupe of "
|
|
+ previously_hashed_file_path
|
|
+ previously_hashed_file_message
|
|
+ "\n"
|
|
)
|
|
|
|
|
|
def delete_duplicate_and_get_message(previously_hashed_file_path, current_file_path):
|
|
|
|
delete_file_path = current_file_path
|
|
previously_hashed_file_message = ""
|
|
current_file_message = "deleted ... "
|
|
|
|
if delete_shorter:
|
|
if len(os.path.basename(previously_hashed_file_path)) < len(os.path.basename(current_file_path)):
|
|
delete_file_path = previously_hashed_file_path
|
|
previously_hashed_file_message = " ... deleted"
|
|
current_file_message = " "
|
|
|
|
if not trial_delete:
|
|
try:
|
|
os.chmod(delete_file_path, stat.S_IWRITE)
|
|
os.remove(delete_file_path)
|
|
except FileNotFoundError:
|
|
# only the previously hashed file could have been deleted and
|
|
# not the current file (unless user is deleting files outside of this script!)
|
|
previously_hashed_file_message = " ... already deleted"
|
|
global failed_delete_count
|
|
failed_delete_count += 1
|
|
|
|
return previously_hashed_file_message, current_file_message
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="Find duplicate files in target path and sub folders.")
|
|
parser.add_argument("--path", dest="path", required=False, action="store", help="Target path")
|
|
parser.add_argument("--version", action="version", version=version)
|
|
parser.add_argument(
|
|
"--verbose", action="store_true", dest="verbose", default=False, help="Will output extra info on logic"
|
|
)
|
|
parser.add_argument(
|
|
"--delayed",
|
|
action="store_true",
|
|
dest="output_delayed",
|
|
default=False,
|
|
help="Will display stdout at end instead of immediately",
|
|
)
|
|
parser.add_argument(
|
|
"--delete", action="store_true", dest="delete", default=False, help="Deletes any duplicate files found"
|
|
)
|
|
parser.add_argument(
|
|
"--trial",
|
|
action="store_true",
|
|
dest="trial_delete",
|
|
default=False,
|
|
help="Displays files to delete without actually deleting them - use with --delete",
|
|
)
|
|
parser.add_argument(
|
|
"--shorter",
|
|
action="store_true",
|
|
dest="delete_shorter",
|
|
default=False,
|
|
help="Deletes file with shorter name rather than always current file - use with --delete",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
set_verbose_output(args.verbose)
|
|
set_output_immediately(not args.output_delayed)
|
|
set_trial_delete(args.trial_delete)
|
|
set_delete_shorter(args.delete_shorter)
|
|
|
|
if args.path:
|
|
dff(args.path, args.delete)
|
|
if not output_immediately:
|
|
print("\nResults...\n")
|
|
unicode_output(stdout)
|
|
sys.exit()
|