Updates
Welcome to For our hearts to be. • Read our latest featured articles below.

Duplicate File Finder -Here's how to find those duplicated files, python script available in this post


Over time, our computers can collect a lot of duplicate files. Photos copied into different folders, old video backups, or documents saved more than once can take up a surprising amount of storage.

Duplicate File Finder is a simple Windows tool that helps you find those duplicate files quickly. It checks the actual contents of files, so it can recognize duplicates even when they have different names.

How to use it

Using the tool is pretty simple. First, open DuplicateFileFinder.py with Python. Once the program starts, click Add Folder and choose the folder you want to check.

You can add more than one folder if you want to compare files between different locations. After that, click Start Scan and let the program check the files.

When the scan finishes, the results will show the duplicate groups, file sizes, file types, and their locations.

If you want to remove extra copies, select the files you don't need and click Move Selected to Recycle Bin. The tool keeps the files in the Windows Recycle Bin instead of permanently deleting them, so you have a chance to recover them if you change your mind.

You can also export the results to a CSV file if you want to keep a list of the duplicates.

A small tip

For your first scan, I recommend starting with a folder such as Downloads, Pictures, or Videos. This lets you see how the program works before scanning your entire drive.

It’s a simple little tool, but it can be useful when your storage is getting full and you want to clean things up without manually searching through hundreds or thousands of files.

/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
import csv
import hashlib
import os
import queue
import threading
import tkinter as tk
from collections import defaultdict
from pathlib import Path
from tkinter import filedialog, messagebox, ttk


APP_NAME = "Duplicate File Finder"
HASH_BUFFER_SIZE = 1024 * 1024

IMAGE_EXTENSIONS = {
    ".jpg", ".jpeg", ".png", ".gif", ".bmp",
    ".webp", ".tif", ".tiff", ".ico"
}

VIDEO_EXTENSIONS = {
    ".mp4", ".mkv", ".avi", ".mov", ".wmv",
    ".flv", ".webm", ".m4v", ".3gp", ".mpeg",
    ".mpg", ".ts"
}


class DuplicateFileFinder:
    def __init__(self, root):
        self.root = root

        self.root.title(APP_NAME)
        self.root.geometry("1100x720")
        self.root.minsize(900, 600)

        self.folders = []
        self.files = []
        self.duplicate_groups = []

        self.selected_files = set()
        self.item_to_path = {}
        self.path_to_item = {}

        self.scan_thread = None
        self.stop_scan_event = threading.Event()
        self.scan_queue = queue.Queue()

        self.total_files = 0
        self.scanned_files = 0
        self.duplicate_count = 0
        self.wasted_space = 0

        self.preview_photo = None

        self.build_ui()
        self.process_scan_queue()

    # ---------------------------------------------------------
    # UI
    # ---------------------------------------------------------

    def build_ui(self):
        style = ttk.Style()

        try:
            style.theme_use("vista")
        except tk.TclError:
            pass

        style.configure(
            "Title.TLabel",
            font=("Segoe UI", 20, "bold")
        )

        style.configure(
            "Subtitle.TLabel",
            font=("Segoe UI", 10)
        )

        style.configure(
            "Stat.TLabel",
            font=("Segoe UI", 10, "bold")
        )

        # Main container
        main = ttk.Frame(self.root, padding=14)
        main.pack(fill="both", expand=True)

        # Header
        header = ttk.Frame(main)
        header.pack(fill="x", pady=(0, 12))

        ttk.Label(
            header,
            text=APP_NAME,
            style="Title.TLabel"
        ).pack(anchor="w")

        ttk.Label(
            header,
            text="Find identical files and safely remove unnecessary copies.",
            style="Subtitle.TLabel"
        ).pack(anchor="w", pady=(3, 0))

        # Folder controls
        folder_frame = ttk.LabelFrame(
            main,
            text="Folders to scan",
            padding=10
        )
        folder_frame.pack(fill="x", pady=(0, 10))

        folder_list_frame = ttk.Frame(folder_frame)
        folder_list_frame.pack(fill="x")

        self.folder_listbox = tk.Listbox(
            folder_list_frame,
            height=4,
            selectmode=tk.EXTENDED,
            font=("Segoe UI", 9)
        )

        self.folder_listbox.pack(
            side="left",
            fill="both",
            expand=True
        )

        folder_scroll = ttk.Scrollbar(
            folder_list_frame,
            orient="vertical",
            command=self.folder_listbox.yview
        )

        folder_scroll.pack(side="right", fill="y")

        self.folder_listbox.configure(
            yscrollcommand=folder_scroll.set
        )

        folder_buttons = ttk.Frame(folder_frame)
        folder_buttons.pack(fill="x", pady=(8, 0))

        self.add_folder_button = ttk.Button(
            folder_buttons,
            text="Add Folder",
            command=self.add_folder
        )
        self.add_folder_button.pack(side="left", padx=(0, 6))

        self.remove_folder_button = ttk.Button(
            folder_buttons,
            text="Remove Selected",
            command=self.remove_folder
        )
        self.remove_folder_button.pack(side="left", padx=(0, 6))

        self.clear_folders_button = ttk.Button(
            folder_buttons,
            text="Clear",
            command=self.clear_folders
        )
        self.clear_folders_button.pack(side="left")

        # Scan options
        options_frame = ttk.LabelFrame(
            main,
            text="Scan options",
            padding=10
        )
        options_frame.pack(fill="x", pady=(0, 10))

        self.include_hidden_var = tk.BooleanVar(value=False)
        self.include_system_var = tk.BooleanVar(value=False)

        ttk.Checkbutton(
            options_frame,
            text="Include hidden files",
            variable=self.include_hidden_var
        ).pack(side="left", padx=(0, 18))

        ttk.Checkbutton(
            options_frame,
            text="Include system files",
            variable=self.include_system_var
        ).pack(side="left", padx=(0, 18))

        ttk.Label(
            options_frame,
            text="Minimum file size:"
        ).pack(side="left", padx=(10, 5))

        self.min_size_var = tk.StringVar(value="0")

        min_size_entry = ttk.Entry(
            options_frame,
            textvariable=self.min_size_var,
            width=10
        )
        min_size_entry.pack(side="left")

        ttk.Label(
            options_frame,
            text="MB"
        ).pack(side="left", padx=(5, 0))

        # Buttons
        action_frame = ttk.Frame(main)
        action_frame.pack(fill="x", pady=(0, 10))

        self.scan_button = ttk.Button(
            action_frame,
            text="Start Scan",
            command=self.start_scan
        )
        self.scan_button.pack(side="left", padx=(0, 6))

        self.stop_button = ttk.Button(
            action_frame,
            text="Stop Scan",
            command=self.stop_scan,
            state="disabled"
        )
        self.stop_button.pack(side="left", padx=(0, 6))

        self.select_all_button = ttk.Button(
            action_frame,
            text="Select All Duplicates",
            command=self.select_all_duplicates,
            state="disabled"
        )
        self.select_all_button.pack(side="left", padx=(0, 6))

        self.clear_selection_button = ttk.Button(
            action_frame,
            text="Clear Selection",
            command=self.clear_selection,
            state="disabled"
        )
        self.clear_selection_button.pack(side="left", padx=(0, 6))

        self.export_button = ttk.Button(
            action_frame,
            text="Export CSV",
            command=self.export_csv,
            state="disabled"
        )
        self.export_button.pack(side="right")

        # Statistics
        stats_frame = ttk.Frame(main)
        stats_frame.pack(fill="x", pady=(0, 8))

        self.files_label = ttk.Label(
            stats_frame,
            text="Files scanned: 0",
            style="Stat.TLabel"
        )
        self.files_label.pack(side="left", padx=(0, 25))

        self.duplicates_label = ttk.Label(
            stats_frame,
            text="Duplicates: 0",
            style="Stat.TLabel"
        )
        self.duplicates_label.pack(side="left", padx=(0, 25))

        self.groups_label = ttk.Label(
            stats_frame,
            text="Groups: 0",
            style="Stat.TLabel"
        )
        self.groups_label.pack(side="left", padx=(0, 25))

        self.space_label = ttk.Label(
            stats_frame,
            text="Wasted space: 0 B",
            style="Stat.TLabel"
        )
        self.space_label.pack(side="left")

        # Progress
        self.progress = ttk.Progressbar(
            main,
            mode="determinate"
        )
        self.progress.pack(fill="x", pady=(0, 5))

        self.status_label = ttk.Label(
            main,
            text="Ready."
        )
        self.status_label.pack(anchor="w", pady=(0, 8))

        # Results
        results_frame = ttk.LabelFrame(
            main,
            text="Duplicate files",
            padding=8
        )
        results_frame.pack(fill="both", expand=True)

        tree_container = ttk.Frame(results_frame)
        tree_container.pack(fill="both", expand=True)

        columns = (
            "selected",
            "name",
            "size",
            "type",
            "path"
        )

        self.tree = ttk.Treeview(
            tree_container,
            columns=columns,
            show="headings",
            selectmode="browse"
        )

        self.tree.heading(
            "selected",
            text="✓"
        )
        self.tree.heading(
            "name",
            text="File"
        )
        self.tree.heading(
            "size",
            text="Size"
        )
        self.tree.heading(
            "type",
            text="Type"
        )
        self.tree.heading(
            "path",
            text="Location"
        )

        self.tree.column(
            "selected",
            width=45,
            anchor="center",
            stretch=False
        )
        self.tree.column(
            "name",
            width=220,
            anchor="w"
        )
        self.tree.column(
            "size",
            width=100,
            anchor="e"
        )
        self.tree.column(
            "type",
            width=90,
            anchor="center"
        )
        self.tree.column(
            "path",
            width=550,
            anchor="w"
        )

        tree_scroll_y = ttk.Scrollbar(
            tree_container,
            orient="vertical",
            command=self.tree.yview
        )

        tree_scroll_x = ttk.Scrollbar(
            tree_container,
            orient="horizontal",
            command=self.tree.xview
        )

        self.tree.configure(
            yscrollcommand=tree_scroll_y.set,
            xscrollcommand=tree_scroll_x.set
        )

        self.tree.grid(
            row=0,
            column=0,
            sticky="nsew"
        )

        tree_scroll_y.grid(
            row=0,
            column=1,
            sticky="ns"
        )

        tree_scroll_x.grid(
            row=1,
            column=0,
            sticky="ew"
        )

        tree_container.rowconfigure(0, weight=1)
        tree_container.columnconfigure(0, weight=1)

        self.tree.bind(
            "<Button-1>",
            self.on_tree_click
        )

        self.tree.bind(
            "<Double-1>",
            self.on_tree_double_click
        )

        # Bottom controls
        bottom_frame = ttk.Frame(main)
        bottom_frame.pack(fill="x", pady=(10, 0))

        self.selection_label = ttk.Label(
            bottom_frame,
            text="Selected: 0 files"
        )
        self.selection_label.pack(side="left")

        self.delete_button = ttk.Button(
            bottom_frame,
            text="Move Selected to Recycle Bin",
            command=self.delete_selected,
            state="disabled"
        )
        self.delete_button.pack(side="right")

    # ---------------------------------------------------------
    # Folder handling
    # ---------------------------------------------------------

    def add_folder(self):
        folder = filedialog.askdirectory(
            title="Select folder to scan"
        )

        if not folder:
            return

        folder = os.path.abspath(folder)

        if folder not in self.folders:
            self.folders.append(folder)
            self.folder_listbox.insert(
                tk.END,
                folder
            )

    def remove_folder(self):
        selected = list(
            self.folder_listbox.curselection()
        )

        if not selected:
            return

        for index in reversed(selected):
            self.folder_listbox.delete(index)
            del self.folders[index]

    def clear_folders(self):
        self.folders.clear()
        self.folder_listbox.delete(
            0,
            tk.END
        )

    # ---------------------------------------------------------
    # Scanning
    # ---------------------------------------------------------

    def start_scan(self):
        if not self.folders:
            messagebox.showwarning(
                APP_NAME,
                "Please add at least one folder to scan."
            )
            return

        try:
            minimum_mb = float(
                self.min_size_var.get().strip()
            )

            if minimum_mb < 0:
                raise ValueError

        except ValueError:
            messagebox.showerror(
                APP_NAME,
                "Minimum file size must be a valid number."
            )
            return

        self.clear_results()

        self.stop_scan_event.clear()

        self.scan_button.configure(
            state="disabled"
        )

        self.stop_button.configure(
            state="normal"
        )

        self.select_all_button.configure(
            state="disabled"
        )

        self.clear_selection_button.configure(
            state="disabled"
        )

        self.delete_button.configure(
            state="disabled"
        )

        self.export_button.configure(
            state="disabled"
        )

        self.progress.configure(
            value=0,
            maximum=100
        )

        self.status_label.configure(
            text="Preparing scan..."
        )

        self.scan_thread = threading.Thread(
            target=self.scan_worker,
            args=(minimum_mb,),
            daemon=True
        )

        self.scan_thread.start()

    def stop_scan(self):
        if self.scan_thread and self.scan_thread.is_alive():
            self.stop_scan_event.set()

            self.status_label.configure(
                text="Stopping scan..."
            )

            self.stop_button.configure(
                state="disabled"
            )

    def scan_worker(self, minimum_mb):
        try:
            minimum_bytes = int(
                minimum_mb * 1024 * 1024
            )

            self.scan_queue.put(
                ("status", "Collecting files...")
            )

            files = []

            for folder in self.folders:
                if self.stop_scan_event.is_set():
                    self.scan_queue.put(
                        ("stopped", None)
                    )
                    return

                self.collect_files(
                    folder,
                    files,
                    minimum_bytes
                )

            if self.stop_scan_event.is_set():
                self.scan_queue.put(
                    ("stopped", None)
                )
                return

            self.files = files

            self.scan_queue.put(
                (
                    "total_files",
                    len(files)
                )
            )

            self.scan_queue.put(
                (
                    "status",
                    f"Found {len(files):,} files. Comparing sizes..."
                )
            )

            size_groups = defaultdict(list)

            for path in files:
                if self.stop_scan_event.is_set():
                    self.scan_queue.put(
                        ("stopped", None)
                    )
                    return

                try:
                    size = os.path.getsize(path)
                    size_groups[size].append(path)
                except OSError:
                    continue

            candidates = []

            for paths in size_groups.values():
                if len(paths) > 1:
                    candidates.extend(paths)

            self.scan_queue.put(
                (
                    "candidate_count",
                    len(candidates)
                )
            )

            if not candidates:
                self.scan_queue.put(
                    (
                        "finished",
                        []
                    )
                )
                return

            self.scan_queue.put(
                (
                    "status",
                    f"Hashing {len(candidates):,} possible duplicate files..."
                )
            )

            hash_groups = defaultdict(list)

            total_candidates = len(candidates)

            for index, path in enumerate(candidates, 1):
                if self.stop_scan_event.is_set():
                    self.scan_queue.put(
                        ("stopped", None)
                    )
                    return

                file_hash = self.calculate_hash(path)

                if file_hash:
                    hash_groups[
                        (os.path.getsize(path), file_hash)
                    ].append(path)

                self.scan_queue.put(
                    (
                        "hash_progress",
                        (
                            index,
                            total_candidates,
                            path
                        )
                    )
                )

            duplicate_groups = []

            for group in hash_groups.values():
                if len(group) > 1:
                    group = sorted(
                        group,
                        key=lambda item: (
                            os.path.basename(item).lower(),
                            item.lower()
                        )
                    )

                    duplicate_groups.append(
                        group
                    )

            duplicate_groups.sort(
                key=lambda group: (
                    -os.path.getsize(group[0]),
                    group[0].lower()
                )
            )

            self.scan_queue.put(
                (
                    "finished",
                    duplicate_groups
                )
            )

        except Exception as error:
            self.scan_queue.put(
                (
                    "error",
                    str(error)
                )
            )

    def collect_files(
        self,
        folder,
        result,
        minimum_bytes
    ):
        try:
            for root, directories, filenames in os.walk(
                folder,
                topdown=True,
                followlinks=False
            ):
                if self.stop_scan_event.is_set():
                    return

                # Remove inaccessible/system folders where possible.
                filtered_directories = []

                for directory in directories:
                    full_path = os.path.join(
                        root,
                        directory
                    )

                    try:
                        attributes = os.stat(
                            full_path
                        ).st_file_attributes

                        hidden = bool(
                            attributes & 0x2
                        )

                        system = bool(
                            attributes & 0x4
                        )

                        if hidden and not self.include_hidden_var.get():
                            continue

                        if system and not self.include_system_var.get():
                            continue

                    except (OSError, AttributeError):
                        pass

                    filtered_directories.append(
                        directory
                    )

                directories[:] = filtered_directories

                for filename in filenames:
                    if self.stop_scan_event.is_set():
                        return

                    full_path = os.path.join(
                        root,
                        filename
                    )

                    try:
                        attributes = os.stat(
                            full_path
                        ).st_file_attributes

                        hidden = bool(
                            attributes & 0x2
                        )

                        system = bool(
                            attributes & 0x4
                        )

                        if hidden and not self.include_hidden_var.get():
                            continue

                        if system and not self.include_system_var.get():
                            continue

                    except (OSError, AttributeError):
                        continue

                    try:
                        if not os.path.isfile(full_path):
                            continue

                        size = os.path.getsize(
                            full_path
                        )

                        if size < minimum_bytes:
                            continue

                        result.append(
                            os.path.abspath(full_path)
                        )

                    except OSError:
                        continue

        except (OSError, PermissionError):
            return

    def calculate_hash(self, path):
        sha256 = hashlib.sha256()

        try:
            with open(
                path,
                "rb"
            ) as file:
                while True:
                    chunk = file.read(
                        HASH_BUFFER_SIZE
                    )

                    if not chunk:
                        break

                    sha256.update(
                        chunk
                    )

            return sha256.hexdigest()

        except (
            OSError,
            PermissionError
        ):
            return None

    # ---------------------------------------------------------
    # Scan queue
    # ---------------------------------------------------------

    def process_scan_queue(self):
        try:
            while True:
                event, data = self.scan_queue.get_nowait()

                if event == "status":
                    self.status_label.configure(
                        text=data
                    )

                elif event == "total_files":
                    self.total_files = data

                    self.files_label.configure(
                        text=f"Files found: {data:,}"
                    )

                elif event == "candidate_count":
                    self.progress.configure(
                        maximum=max(data, 1),
                        value=0
                    )

                elif event == "hash_progress":
                    index, total, path = data

                    self.progress.configure(
                        maximum=max(total, 1),
                        value=index
                    )

                    self.files_label.configure(
                        text=f"Hashing: {index:,} / {total:,}"
                    )

                    self.status_label.configure(
                        text=f"Checking: {os.path.basename(path)}"
                    )

                elif event == "finished":
                    self.finish_scan(
                        data
                    )

                elif event == "stopped":
                    self.scan_finished_stopped()

                elif event == "error":
                    self.scan_error(
                        data
                    )

        except queue.Empty:
            pass

        self.root.after(
            100,
            self.process_scan_queue
        )

    def finish_scan(self, groups):
        self.duplicate_groups = groups

        self.duplicate_count = sum(
            len(group)
            for group in groups
        )

        self.wasted_space = 0

        for group in groups:
            try:
                size = os.path.getsize(
                    group[0]
                )

                self.wasted_space += (
                    size * (len(group) - 1)
                )

            except OSError:
                pass

        self.display_results()

        self.scan_button.configure(
            state="normal"
        )

        self.stop_button.configure(
            state="disabled"
        )

        if groups:
            self.select_all_button.configure(
                state="normal"
            )

            self.clear_selection_button.configure(
                state="normal"
            )

            self.export_button.configure(
                state="normal"
            )

        self.status_label.configure(
            text=(
                f"Scan complete. "
                f"{len(groups):,} duplicate groups found."
            )
        )

    def scan_finished_stopped(self):
        self.scan_button.configure(
            state="normal"
        )

        self.stop_button.configure(
            state="disabled"
        )

        self.status_label.configure(
            text="Scan stopped."
        )

    def scan_error(self, error):
        self.scan_button.configure(
            state="normal"
        )

        self.stop_button.configure(
            state="disabled"
        )

        self.status_label.configure(
            text="Scan failed."

        )

        messagebox.showerror(
            APP_NAME,
            f"The scan encountered an error:\n\n{error}"
        )

    # ---------------------------------------------------------
    # Results
    # ---------------------------------------------------------

    def clear_results(self):
        for item in self.tree.get_children():
            self.tree.delete(item)

        self.files = []
        self.duplicate_groups = []

        self.selected_files.clear()

        self.item_to_path.clear()
        self.path_to_item.clear()

        self.duplicate_count = 0
        self.wasted_space = 0

        self.files_label.configure(
            text="Files scanned: 0"
        )

        self.duplicates_label.configure(
            text="Duplicates: 0"
        )

        self.groups_label.configure(
            text="Groups: 0"
        )

        self.space_label.configure(
            text="Wasted space: 0 B"
        )

        self.selection_label.configure(
            text="Selected: 0 files"
        )

    def display_results(self):
        self.clear_tree_only()

        for group_number, group in enumerate(
            self.duplicate_groups,
            1
        ):
            size = 0

            try:
                size = os.path.getsize(
                    group[0]
                )
            except OSError:
                pass

            for duplicate_number, path in enumerate(
                group,
                1
            ):
                item_id = self.tree.insert(
                    "",
                    "end",
                    values=(
                        "☐",
                        os.path.basename(path),
                        self.format_size(size),
                        self.get_file_type(path),
                        path
                    )
                )

                self.item_to_path[item_id] = path
                self.path_to_item[path] = item_id

                if duplicate_number == 1:
                    self.tree.item(
                        item_id,
                        tags=("original",)
                    )

        self.tree.tag_configure(
            "original",
            font=("Segoe UI", 9, "bold")
        )

        self.duplicates_label.configure(
            text=f"Duplicates: {self.duplicate_count:,}"
        )

        self.groups_label.configure(
            text=f"Groups: {len(self.duplicate_groups):,}"
        )

        self.space_label.configure(
            text=(
                "Wasted space: "
                f"{self.format_size(self.wasted_space)}"
            )
        )

    def clear_tree_only(self):
        for item in self.tree.get_children():
            self.tree.delete(item)

        self.item_to_path.clear()
        self.path_to_item.clear()

        self.selected_files.clear()

    # ---------------------------------------------------------
    # Selection
    # ---------------------------------------------------------

    def on_tree_click(self, event):
        region = self.tree.identify(
            "region",
            event.x,
            event.y
        )

        if region != "cell":
            return

        column = self.tree.identify_column(
            event.x
        )

        if column != "#1":
            return

        item_id = self.tree.identify_row(
            event.y
        )

        if not item_id:
            return

        self.toggle_item(
            item_id
        )

    def toggle_item(self, item_id):
        path = self.item_to_path.get(
            item_id
        )

        if not path:
            return

        if path in self.selected_files:
            self.selected_files.remove(
                path
            )

            self.tree.set(
                item_id,
                "selected",
                "☐"
            )
        else:
            self.selected_files.add(
                path
            )

            self.tree.set(
                item_id,
                "selected",
                "☑"
            )

        self.update_selection_status()

    def update_selection_status(self):
        selected_size = 0

        for path in self.selected_files:
            try:
                selected_size += os.path.getsize(
                    path
                )
            except OSError:
                pass

        self.selection_label.configure(
            text=(
                f"Selected: {len(self.selected_files):,} files"
                f"  |  "
                f"{self.format_size(selected_size)}"
            )
        )

        if self.selected_files:
            self.delete_button.configure(
                state="normal"
            )
        else:
            self.delete_button.configure(
                state="disabled"
            )

    def select_all_duplicates(self):
        self.selected_files.clear()

        # Keep the first file in every duplicate group.
        # Select all other copies.
        for group in self.duplicate_groups:
            for path in group[1:]:
                self.selected_files.add(
                    path
                )

        self.refresh_checkboxes()
        self.update_selection_status()

    def clear_selection(self):
        self.selected_files.clear()

        self.refresh_checkboxes()
        self.update_selection_status()

    def refresh_checkboxes(self):
        for item_id, path in self.item_to_path.items():
            if path in self.selected_files:
                self.tree.set(
                    item_id,
                    "selected",
                    "☑"
                )
            else:
                self.tree.set(
                    item_id,
                    "selected",
                    "☐"
                )

    # ---------------------------------------------------------
    # File actions
    # ---------------------------------------------------------

    def delete_selected(self):
        if not self.selected_files:
            return

        selected = list(
            self.selected_files
        )

        total_size = 0

        for path in selected:
            try:
                total_size += os.path.getsize(
                    path
                )
            except OSError:
                pass

        answer = messagebox.askyesno(
            APP_NAME,
            (
                f"Move {len(selected):,} selected file(s) "
                f"to the Recycle Bin?\n\n"
                f"Total size: {self.format_size(total_size)}\n\n"
                "The files will not be permanently deleted."
            )
        )

        if not answer:
            return

        moved = 0
        failed = []

        for path in selected:
            try:
                self.move_to_recycle_bin(
                    path
                )

                moved += 1

            except Exception as error:
                failed.append(
                    f"{path}\n{error}"
                )

        self.selected_files.clear()

        self.remove_missing_results()

        self.update_selection_status()

        if failed:
            messagebox.showwarning(
                APP_NAME,
                (
                    f"Moved {moved:,} file(s) to the Recycle Bin.\n\n"
                    f"{len(failed):,} file(s) could not be moved."
                )
            )
        else:
            messagebox.showinfo(
                APP_NAME,
                (
                    f"Moved {moved:,} file(s) to the Recycle Bin."
                )
            )

    def move_to_recycle_bin(self, path):
        # Windows Shell API.
        if os.name != "nt":
            raise RuntimeError(
                "Recycle Bin support in this version is for Windows."
            )

        import ctypes
        from ctypes import wintypes

        class SHFILEOPSTRUCTW(
            ctypes.Structure
        ):
            _fields_ = [
                ("hwnd", wintypes.HWND),
                ("wFunc", ctypes.c_uint),
                ("pFrom", wintypes.LPCWSTR),
                ("pTo", wintypes.LPCWSTR),
                ("fFlags", ctypes.c_uint16),
                ("fAnyOperationsAborted", wintypes.BOOL),
                ("hNameMappings", wintypes.LPVOID),
                ("lpszProgressTitle", wintypes.LPCWSTR),
            ]

        FO_DELETE = 0x0003
        FOF_ALLOWUNDO = 0x0040
        FOF_NOCONFIRMATION = 0x0010
        FOF_SILENT = 0x0004

        file_operation = SHFILEOPSTRUCTW()

        file_operation.hwnd = self.root.winfo_id()
        file_operation.wFunc = FO_DELETE

        # Double null terminated.
        file_operation.pFrom = (
            path + "\0"
        )

        file_operation.pTo = None

        file_operation.fFlags = (
            FOF_ALLOWUNDO
            | FOF_NOCONFIRMATION
            | FOF_SILENT
        )

        result = ctypes.windll.shell32.SHFileOperationW(
            ctypes.byref(file_operation)
        )

        if result != 0:
            raise OSError(
                f"Windows Shell error code: {result}"
            )

        if file_operation.fAnyOperationsAborted:
            raise OSError(
                "Operation was cancelled."
            )

    def remove_missing_results(self):
        new_groups = []

        for group in self.duplicate_groups:
            remaining = []

            for path in group:
                if os.path.exists(path):
                    remaining.append(
                        path
                    )

            if len(remaining) > 1:
                new_groups.append(
                    remaining
                )

        self.duplicate_groups = new_groups

        self.duplicate_count = sum(
            len(group)
            for group in new_groups
        )

        self.wasted_space = 0

        for group in new_groups:
            try:
                size = os.path.getsize(
                    group[0]
                )

                self.wasted_space += (
                    size * (len(group) - 1)
                )

            except OSError:
                pass

        self.display_results()

        if new_groups:
            self.select_all_button.configure(
                state="normal"
            )

            self.clear_selection_button.configure(
                state="normal"
            )

            self.export_button.configure(
                state="normal"
            )
        else:
            self.select_all_button.configure(
                state="disabled"
            )

            self.clear_selection_button.configure(
                state="disabled"
            )

            self.export_button.configure(
                state="disabled"
            )

            self.delete_button.configure(
                state="disabled"
            )

    # ---------------------------------------------------------
    # Preview / open
    # ---------------------------------------------------------

    def on_tree_double_click(self, event):
        item_id = self.tree.identify_row(
            event.y
        )

        if not item_id:
            return

        path = self.item_to_path.get(
            item_id
        )

        if not path:
            return

        if not os.path.exists(path):
            messagebox.showwarning(
                APP_NAME,
                "This file no longer exists."
            )
            return

        self.open_file_location(
            path
        )

    def open_file_location(self, path):
        if os.name == "nt":
            import subprocess

            subprocess.Popen(
                [
                    "explorer",
                    "/select,",
                    os.path.normpath(path)
                ]
            )

        elif os.name == "darwin":
            import subprocess

            subprocess.Popen(
                [
                    "open",
                    "-R",
                    path
                ]
            )

        else:
            import subprocess

            subprocess.Popen(
                [
                    "xdg-open",
                    os.path.dirname(path)
                ]
            )

    # ---------------------------------------------------------
    # CSV
    # ---------------------------------------------------------

    def export_csv(self):
        if not self.duplicate_groups:
            return

        filename = filedialog.asksaveasfilename(
            title="Export duplicate results",
            defaultextension=".csv",
            filetypes=[
                (
                    "CSV files",
                    "*.csv"
                ),
                (
                    "All files",
                    "*.*"
                )
            ]
        )

        if not filename:
            return

        try:
            with open(
                filename,
                "w",
                newline="",
                encoding="utf-8-sig"
            ) as file:
                writer = csv.writer(
                    file
                )

                writer.writerow(
                    [
                        "Group",
                        "File",
                        "Size",
                        "Type",
                        "Path"
                    ]
                )

                for group_number, group in enumerate(
                    self.duplicate_groups,
                    1
                ):
                    for path in group:
                        try:
                            size = os.path.getsize(
                                path
                            )
                        except OSError:
                            size = 0

                        writer.writerow(
                            [
                                group_number,
                                os.path.basename(path),
                                self.format_size(size),
                                self.get_file_type(path),
                                path
                            ]
                        )

            messagebox.showinfo(
                APP_NAME,
                f"Results exported successfully:\n\n{filename}"
            )

        except OSError as error:
            messagebox.showerror(
                APP_NAME,
                f"Could not export the CSV file:\n\n{error}"
            )

    # ---------------------------------------------------------
    # Helpers
    # ---------------------------------------------------------

    @staticmethod
    def format_size(size):
        units = [
            "B",
            "KB",
            "MB",
            "GB",
            "TB",
            "PB"
        ]

        size = float(size)

        for unit in units:
            if size < 1024:
                if unit == "B":
                    return f"{int(size):,} {unit}"

                return f"{size:.2f} {unit}"

            size /= 1024

        return f"{size:.2f} EB"

    @staticmethod
    def get_file_type(path):
        extension = Path(path).suffix.lower()

        if extension in IMAGE_EXTENSIONS:
            return "Image"

        if extension in VIDEO_EXTENSIONS:
            return "Video"

        if extension:
            return extension[1:].upper()

        return "File"


def main():
    root = tk.Tk()

    app = DuplicateFileFinder(
        root
    )

    root.mainloop()


if __name__ == "__main__":
    main()
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

Comments