Najeebsk

SEARCH-FILE-ZIP-SAVE.py

Dec 4th, 2025 (edited)
145
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 32.80 KB | None | 0 0
  1. """
  2. Advanced File Scanner & Manager (Final)
  3. - Window: 1150x720 (kept as requested)
  4. - Multi-extension scanning
  5. - Include hidden/subfolders option
  6. - Checkbox per file + Select All/Unselect All
  7. - Image preview, Video thumbnail (ffmpeg if available), Text preview
  8. - Zip selected files, Delete, Move, Export CSV/TXT
  9. - Find duplicates (MD5)
  10. - Progress bar for operations (scan/zip/hash/delete/move)
  11. - Threaded heavy tasks to avoid GUI freeze
  12. - Both vertical and horizontal scrollbars for results area
  13. Author: Najeeb Shah Khan Edition (final)
  14. """
  15.  
  16. import os
  17. import sys
  18. import threading
  19. import zipfile
  20. import hashlib
  21. import csv
  22. import shutil
  23. import time
  24. import subprocess
  25. from pathlib import Path
  26. from queue import Queue, Empty
  27. import tempfile
  28.  
  29. import tkinter as tk
  30. from tkinter import filedialog, messagebox, simpledialog, StringVar, IntVar
  31.  
  32. # Optional nicer theme
  33. try:
  34.     import ttkbootstrap as tb
  35.     from ttkbootstrap.constants import *
  36.     THEME_BACKEND = "ttkbootstrap"
  37. except Exception:
  38.     THEME_BACKEND = "tkinter.ttk"
  39.     import tkinter.ttk as ttk
  40.  
  41. from PIL import Image, ImageTk, UnidentifiedImageError
  42.  
  43. # ---------------- Helpers ----------------
  44. def format_size(size):
  45.     if size < 1024:
  46.         return f"{size} B"
  47.     for unit in ["KB", "MB", "GB", "TB"]:
  48.         size /= 1024.0
  49.         if size < 1024.0:
  50.             return f"{size:.2f} {unit}"
  51.     return f"{size:.2f} PB"
  52.  
  53. def normalize_ext_list(ext_input):
  54.     if not ext_input:
  55.         return []
  56.     parts = [p.strip().lower() for p in ext_input.split(",") if p.strip()]
  57.     norm = []
  58.     for p in parts:
  59.         p = p.lstrip("*").lstrip(".")
  60.         if p:
  61.             norm.append("." + p)
  62.     return norm
  63.  
  64. def is_video_by_ext(path):
  65.     video_exts = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"}
  66.     return Path(path).suffix.lower() in video_exts
  67.  
  68. def md5_for_file(path, chunk_size=4 * 1024 * 1024):
  69.     # large-chunk MD5 for speed
  70.     h = hashlib.md5()
  71.     try:
  72.         with open(path, "rb") as f:
  73.             while True:
  74.                 chunk = f.read(chunk_size)
  75.                 if not chunk:
  76.                     break
  77.                 h.update(chunk)
  78.     except Exception:
  79.         return None
  80.     return h.hexdigest()
  81.  
  82. def ffmpeg_available():
  83.     return shutil.which("ffmpeg") is not None
  84.  
  85. # ---------------- Main App ----------------
  86. class FileManagerApp:
  87.     def __init__(self, root):
  88.         self.root = root
  89.         self.root.title("Najeeb Advanced File Scanner & Manager - Final")
  90.         self.root.geometry("1150x720")  # as requested - keep this
  91.         self.root.minsize(1024, 640)
  92.  
  93.         # style
  94.         if THEME_BACKEND == "ttkbootstrap":
  95.             self.style = tb.Style(theme="darkly")
  96.             self.container = tb.Frame(self.root)
  97.             self.container.pack(fill="both", expand=True)
  98.             self.Button = tb.Button
  99.             self.Label = tb.Label
  100.             self.Entry = tb.Entry
  101.             self.Frame = tb.Frame
  102.             self.LabelFrame = tb.Labelframe if hasattr(tb, "Labelframe") else tb.Frame
  103.             self.Progressbar = tb.Progressbar
  104.             self.Checkbutton = tb.Checkbutton
  105.         else:
  106.             self.style = None
  107.             import tkinter.ttk as ttk
  108.             self.container = ttk.Frame(self.root)
  109.             self.container.pack(fill="both", expand=True)
  110.             self.Button = ttk.Button
  111.             self.Label = ttk.Label
  112.             self.Entry = ttk.Entry
  113.             self.Frame = ttk.Frame
  114.             self.LabelFrame = ttk.Labelframe
  115.             self.Progressbar = ttk.Progressbar
  116.             # fallback checkbutton
  117.             from tkinter import Checkbutton as TkCheck
  118.             self.Checkbutton = TkCheck
  119.  
  120.         # Data structures
  121.         self.file_items = []  # list of dicts: {path, size, var(IntVar), md5}
  122.         self.filtered = []
  123.         self.image_cache = {}  # to prevent GC of PhotoImage references
  124.         self.scan_thread = None
  125.         self.op_lock = threading.Lock()  # prevent multiple ops at same time
  126.         self.queue = Queue()  # for thread->UI messages
  127.  
  128.         self._build_ui()
  129.         self._periodic_process_queue()
  130.  
  131.     # ---------------- UI BUILD ----------------
  132.     def _build_ui(self):
  133.         top = self.Frame(self.container)
  134.         top.pack(fill="x", padx=8, pady=6)
  135.  
  136.         # Drive / Folder
  137.         drives = [f"{chr(d)}:\\" for d in range(65, 91) if os.path.exists(f"{chr(d)}:\\")]
  138.         start_path = drives[0] if drives else os.path.expanduser("~")
  139.         self.drive_entry = self.Entry(top, width=40)
  140.         self.drive_entry.pack(side="left", padx=(2,6))
  141.         self.drive_entry.insert(0, start_path)
  142.         self.Button(top, text="Browse...", command=self.browse_drive).pack(side="left", padx=6)
  143.  
  144.         # Extensions
  145.         self.Label(top, text="Extensions:").pack(side="left", padx=(8,2))
  146.         self.ext_entry = self.Entry(top, width=24)
  147.         self.ext_entry.pack(side="left")
  148.         self.ext_entry.insert(0, ".jpg,.png,.mp4")
  149.  
  150.         # include hidden
  151.         self.include_hidden = IntVar(value=1)
  152.         chk = self.Checkbutton(top, text="Include hidden/subfolders", variable=self.include_hidden)
  153.         chk.pack(side="left", padx=8)
  154.  
  155.         # Scan button
  156.         self.scan_btn = self.Button(top, text="Scan", command=self.start_scan)
  157.         self.scan_btn.pack(side="left", padx=6)
  158.  
  159.         # Toggle theme (ttkbootstrap only)
  160.         if THEME_BACKEND == "ttkbootstrap":
  161.             self.Button(top, text="Toggle Dark/Light", command=self.toggle_theme).pack(side="right", padx=6)
  162.  
  163.         # Middle frames
  164.         mid = self.Frame(self.container)
  165.         mid.pack(fill="both", expand=True, padx=8, pady=6)
  166.  
  167.         # Left: list + controls
  168.         left = self.Frame(mid)
  169.         left.pack(side="left", fill="both", expand=True)
  170.  
  171.         # Search
  172.         sframe = self.Frame(left)
  173.         sframe.pack(fill="x")
  174.         self.Label(sframe, text="Search:").pack(side="left", padx=4)
  175.         self.search_var = StringVar()
  176.         self.search_entry = self.Entry(sframe, textvariable=self.search_var, width=40)
  177.         self.search_entry.pack(side="left", padx=4)
  178.         self.search_var.trace_add("write", lambda *a: self.apply_filter())
  179.  
  180.         # Control buttons
  181.         ctrl = self.Frame(left)
  182.         ctrl.pack(fill="x", pady=4)
  183.         self.Button(ctrl, text="Select All", command=self.select_all).pack(side="left", padx=3)
  184.         self.Button(ctrl, text="Unselect All", command=self.unselect_all).pack(side="left", padx=3)
  185.         self.Button(ctrl, text="Zip Selected", command=self.zip_selected).pack(side="left", padx=3)
  186.         self.Button(ctrl, text="Export List", command=self.export_list_dialog).pack(side="left", padx=3)
  187.         self.Button(ctrl, text="Find Duplicates", command=self.find_duplicates_thread).pack(side="left", padx=3)
  188.  
  189.         # Canvas area with both scrollbars
  190.         canvas_container = self.Frame(left)
  191.         canvas_container.pack(fill="both", expand=True)
  192.  
  193.         # Horizontal and vertical scrollbars
  194.         self.h_scroll = tk.Scrollbar(canvas_container, orient="horizontal")
  195.         self.v_scroll = tk.Scrollbar(canvas_container, orient="vertical")
  196.         self.canvas = tk.Canvas(canvas_container, bd=0, highlightthickness=0,
  197.                                 xscrollcommand=self.h_scroll.set, yscrollcommand=self.v_scroll.set)
  198.         self.h_scroll.config(command=self.canvas.xview)
  199.         self.v_scroll.config(command=self.canvas.yview)
  200.         self.h_scroll.pack(side="bottom", fill="x")
  201.         self.v_scroll.pack(side="right", fill="y")
  202.         self.canvas.pack(side="left", fill="both", expand=True)
  203.  
  204.         # frame inside canvas
  205.         if THEME_BACKEND == "ttkbootstrap":
  206.             self.list_frame = tb.Frame(self.canvas)
  207.         else:
  208.             self.list_frame = tk.Frame(self.canvas)
  209.         self.canvas_window = self.canvas.create_window((0,0), window=self.list_frame, anchor="nw")
  210.         self.list_frame.bind("<Configure>", lambda e: self._on_list_frame_configure())
  211.         self.canvas.bind('<Configure>', lambda e: self._on_canvas_configure())
  212.  
  213.         # Right: preview + info + actions
  214.         right = self.Frame(mid, width=360)
  215.         right.pack(side="right", fill="y", padx=(8,0))
  216.  
  217.         # Preview area (image OR text)
  218.         preview_box = self.LabelFrame(right, text="Preview", width=360, height=360)
  219.         preview_box.pack(pady=4)
  220.         preview_box.pack_propagate(False)
  221.         self.preview_inner = tk.Frame(preview_box)
  222.         self.preview_inner.pack(fill="both", expand=True)
  223.  
  224.         # Image label (for images/video thumbnail)
  225.         self.preview_label = tk.Label(self.preview_inner, text="Select a file to preview", anchor="center", justify="center", wraplength=320)
  226.         self.preview_label.pack(fill="both", expand=True)
  227.  
  228.         # Text preview (hidden by default)
  229.         self.preview_textbox = tk.Text(self.preview_inner, wrap="word", state="disabled")
  230.         # do not pack yet; pack when needed
  231.  
  232.         # Info area
  233.         info_box = self.LabelFrame(right, text="File Info")
  234.         info_box.pack(fill="x", pady=6)
  235.         self.info_text = tk.Text(info_box, height=8, width=44, wrap="word")
  236.         self.info_text.pack(fill="both", expand=True)
  237.  
  238.         # Actions
  239.         actions = self.Frame(right)
  240.         actions.pack(fill="x", pady=6)
  241.         self.Button(actions, text="Delete Selected", command=self.delete_selected).pack(fill="x", pady=4)
  242.         self.Button(actions, text="Move Selected", command=self.move_selected).pack(fill="x", pady=4)
  243.         self.Button(actions, text="Play Selected (Video)", command=self.play_selected).pack(fill="x", pady=4)
  244.  
  245.         # Bottom status & progress
  246.         bottom = self.Frame(self.container)
  247.         bottom.pack(fill="x", padx=8, pady=6)
  248.         self.status_var = StringVar(value="Idle")
  249.         self.Label(bottom, textvariable=self.status_var).pack(side="left")
  250.         self.progress = self.Progressbar(bottom, mode="determinate", maximum=100)
  251.         self.progress.pack(side="right", fill="x", expand=True)
  252.  
  253.     # ---------------- Canvas helpers ----------------
  254.     def _on_list_frame_configure(self):
  255.         self.canvas.configure(scrollregion=self.canvas.bbox("all"))
  256.  
  257.     def _on_canvas_configure(self):
  258.         # keep width of inner frame to canvas width (helps horizontal)
  259.         canvas_width = self.canvas.winfo_width()
  260.         self.canvas.itemconfig(self.canvas_window, width=canvas_width)
  261.  
  262.     # ---------------- Utility ----------------
  263.     def browse_drive(self):
  264.         p = filedialog.askdirectory()
  265.         if p:
  266.             self.drive_entry.delete(0, "end")
  267.             self.drive_entry.insert(0, p)
  268.  
  269.     def toggle_theme(self):
  270.         if THEME_BACKEND != "ttkbootstrap":
  271.             messagebox.showinfo("Not available", "Theme toggle requires 'ttkbootstrap'. Install via: pip install ttkbootstrap")
  272.             return
  273.         current = self.style.theme.name
  274.         new = "flatly" if "dark" in current else "darkly"
  275.         self.style.theme_use(new)
  276.  
  277.     def _periodic_process_queue(self):
  278.         # called via after to process messages from background threads
  279.         try:
  280.             while True:
  281.                 func, args = self.queue.get_nowait()
  282.                 try:
  283.                     func(*args)
  284.                 except Exception as e:
  285.                     print("Queue callback error:", e)
  286.         except Empty:
  287.             pass
  288.         self.root.after(100, self._periodic_process_queue)
  289.  
  290.     def _safe_ui_call(self, func, *args):
  291.         # background threads should use this to schedule UI updates
  292.         self.queue.put((func, args))
  293.  
  294.     # ---------------- Scanning ----------------
  295.     def start_scan(self):
  296.         if self.op_lock.locked():
  297.             messagebox.showwarning("Busy", "Another operation is in progress. Wait or cancel.")
  298.             return
  299.         path = self.drive_entry.get().strip()
  300.         if not path or not os.path.exists(path):
  301.             messagebox.showerror("Error", "Select a valid drive/folder path.")
  302.             return
  303.         self.scan_btn.config(state="disabled")
  304.         self._clear_list_ui()
  305.         t = threading.Thread(target=self.scan_files_thread, args=(path,), daemon=True)
  306.         t.start()
  307.  
  308.     def scan_files_thread(self, root_path):
  309.         with self.op_lock:
  310.             self._safe_ui_call(self._set_status, "Scanning...")
  311.             self._safe_ui_call(self._set_progress_mode_indeterminate)
  312.             exts = normalize_ext_list(self.ext_entry.get().strip())
  313.             include_hidden = bool(self.include_hidden.get())
  314.             found = []
  315.             count = 0
  316.             # Walk and add incrementally to UI for fast results
  317.             for dirpath, dirnames, filenames in os.walk(root_path):
  318.                 # filter hidden dirs if requested
  319.                 if not include_hidden:
  320.                     dirnames[:] = [d for d in dirnames if not d.startswith(".")]
  321.                 for f in filenames:
  322.                     full = os.path.join(dirpath, f)
  323.                     try:
  324.                         if not include_hidden:
  325.                             if os.name == "nt":
  326.                                 try:
  327.                                     import ctypes
  328.                                     attrs = ctypes.windll.kernel32.GetFileAttributesW(full)
  329.                                     if attrs != -1 and bool(attrs & 2):  # hidden
  330.                                         continue
  331.                                 except Exception:
  332.                                     pass
  333.                             if f.startswith("."):
  334.                                 continue
  335.                         if not exts or Path(full).suffix.lower() in exts:
  336.                             size = os.path.getsize(full)
  337.                             item = {"path": full, "size": size, "var": IntVar(value=0), "md5": None}
  338.                             found.append(item)
  339.                             count += 1
  340.                             # update UI every 50 found to keep responsive
  341.                             if count % 50 == 0:
  342.                                 self._safe_ui_call(self._extend_file_items, found.copy())
  343.                                 found.clear()
  344.                     except Exception:
  345.                         continue
  346.             # push remaining
  347.             if found:
  348.                 self._safe_ui_call(self._extend_file_items, found.copy())
  349.             # finalize
  350.             self._safe_ui_call(self._set_status, f"Scan complete: {len(self.file_items)} files")
  351.             self._safe_ui_call(self._set_progress_value, 0)
  352.             self._safe_ui_call(self.scan_btn.config, {'state': 'normal'})
  353.  
  354.     def _extend_file_items(self, new_items):
  355.         # Called in UI thread via queue
  356.         for it in new_items:
  357.             self.file_items.append(it)
  358.         self.filtered = list(self.file_items)
  359.         self._populate_list_ui()
  360.  
  361.     def _clear_list_ui(self):
  362.         for w in self.list_frame.winfo_children():
  363.             w.destroy()
  364.         self.file_items.clear()
  365.         self.filtered.clear()
  366.         self.image_cache.clear()
  367.         self._update_status_line()
  368.  
  369.     def _populate_list_ui(self):
  370.         for widget in self.list_frame.winfo_children():
  371.             widget.destroy()
  372.         for idx, item in enumerate(self.filtered):
  373.             frame = tk.Frame(self.list_frame)
  374.             frame.pack(fill="x", padx=2, pady=1)
  375.             cb = tk.Checkbutton(frame, text=os.path.basename(item["path"]) + "  —  " + format_size(item["size"]),
  376.                                  variable=item["var"], anchor="w", justify="left", wraplength=700)
  377.             cb.pack(side="left", anchor="w", fill="x", expand=True)
  378.             btn = self.Button(frame, text="Preview", width=9, command=lambda p=item["path"]: self.preview_file(p))
  379.             btn.pack(side="right", padx=6)
  380.         self._on_list_frame_configure()
  381.         self._update_status_line()
  382.  
  383.     def apply_filter(self):
  384.         q = self.search_var.get().lower().strip()
  385.         if not q:
  386.             self.filtered = list(self.file_items)
  387.         else:
  388.             self.filtered = [it for it in self.file_items if q in it["path"].lower() or q in os.path.basename(it["path"]).lower()]
  389.         self._populate_list_ui()
  390.  
  391.     def select_all(self):
  392.         for it in self.filtered:
  393.             it["var"].set(1)
  394.         self._update_status_line()
  395.  
  396.     def unselect_all(self):
  397.         for it in self.filtered:
  398.             it["var"].set(0)
  399.         self._update_status_line()
  400.  
  401.     def get_selected_items(self):
  402.         return [it for it in self.file_items if it["var"].get()]
  403.  
  404.     # ---------------- Status / Progress helpers ----------------
  405.     def _set_status(self, text):
  406.         self.status_var.set(text)
  407.         self._update_status_line()
  408.  
  409.     def _update_status_line(self):
  410.         total = len(self.file_items)
  411.         selected = len(self.get_selected_items())
  412.         size_total = sum(it["size"] for it in self.file_items)
  413.         self.status_var.set(f"Total: {total} | Selected: {selected} | Size: {format_size(size_total)}")
  414.  
  415.     def _set_progress_mode_indeterminate(self):
  416.         try:
  417.             self.progress.config(mode="indeterminate")
  418.             self.progress.start(10)
  419.         except Exception:
  420.             pass
  421.  
  422.     def _set_progress_mode_determinate(self, maximum=100):
  423.         try:
  424.             self.progress.stop()
  425.             self.progress.config(mode="determinate", maximum=maximum)
  426.             self.progress["value"] = 0
  427.         except Exception:
  428.             pass
  429.  
  430.     def _set_progress_value(self, val):
  431.         try:
  432.             self.progress.config(mode="determinate")
  433.             self.progress["value"] = val
  434.         except Exception:
  435.             pass
  436.  
  437.     # ---------------- Preview (image/video/text) ----------------
  438.     def preview_file(self, path):
  439.         # schedule preview in background to avoid blocking (especially reading large files)
  440.         threading.Thread(target=self._preview_file_thread, args=(path,), daemon=True).start()
  441.  
  442.     def _preview_file_thread(self, path):
  443.         # send file info first
  444.         try:
  445.             size = os.path.getsize(path)
  446.             mtime = time.ctime(os.path.getmtime(path))
  447.             info = f"Path: {path}\nSize: {format_size(size)}\nExtension: {Path(path).suffix}\nModified: {mtime}\n"
  448.         except Exception as e:
  449.             info = f"Error reading file info: {e}\n"
  450.         self._safe_ui_call(self._show_info_text, info)
  451.  
  452.         ext = Path(path).suffix.lower()
  453.         # image preview
  454.         if ext in (".jpg", ".jpeg", ".png", ".gif", ".bmp"):
  455.             def load_image():
  456.                 try:
  457.                     img = Image.open(path)
  458.                     img.thumbnail((340, 340))
  459.                     img_tk = ImageTk.PhotoImage(img)
  460.                     # keep reference
  461.                     self.image_cache["preview"] = img_tk
  462.                     self._safe_ui_call(self._show_image_preview, img_tk)
  463.                 except UnidentifiedImageError:
  464.                     self._safe_ui_call(self._show_text_preview_message, "Image preview failed (unsupported format).")
  465.                 except Exception as e:
  466.                     self._safe_ui_call(self._show_text_preview_message, f"Image preview error: {e}")
  467.             threading.Thread(target=load_image, daemon=True).start()
  468.             return
  469.  
  470.         # text preview
  471.         if ext in (".txt", ".log", ".ini", ".cfg", ".py", ".json", ".csv"):
  472.             def load_text():
  473.                 try:
  474.                     with open(path, "r", encoding="utf-8", errors="ignore") as f:
  475.                         data = f.read(1024 * 200)  # first 200 KB
  476.                     if not data:
  477.                         data = "[empty file]"
  478.                     self._safe_ui_call(self._show_text_preview, data)
  479.                 except Exception as e:
  480.                     self._safe_ui_call(self._show_text_preview_message, f"Text preview error: {e}")
  481.             threading.Thread(target=load_text, daemon=True).start()
  482.             return
  483.  
  484.         # video preview - try ffmpeg thumbnail
  485.         if is_video_by_ext(path):
  486.             if ffmpeg_available():
  487.                 tmp_dir = tempfile.gettempdir()
  488.                 thumb_path = os.path.join(tmp_dir, f"thumb_{abs(hash(path)) % 1000000}.jpg")
  489.                 cmd = [
  490.                     "ffmpeg", "-y", "-i", path,
  491.                     "-vf", "select=eq(n\\,0)", "-frames:v", "1", "-q:v", "2", thumb_path
  492.                 ]
  493.                 # run and then load
  494.                 try:
  495.                     subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=20)
  496.                     if os.path.exists(thumb_path):
  497.                         try:
  498.                             img = Image.open(thumb_path)
  499.                             img.thumbnail((340, 340))
  500.                             img_tk = ImageTk.PhotoImage(img)
  501.                             self.image_cache["preview"] = img_tk
  502.                             self._safe_ui_call(self._show_image_preview, img_tk)
  503.                             # remove thumb file after some time
  504.                             # schedule deletion
  505.                             def clean():
  506.                                 try:
  507.                                     os.remove(thumb_path)
  508.                                 except Exception:
  509.                                     pass
  510.                             threading.Timer(15.0, clean).start()
  511.                             return
  512.                         except Exception:
  513.                             pass
  514.                 except Exception:
  515.                     pass
  516.             # fallback - no ffmpeg or failed
  517.             self._safe_ui_call(self._show_text_preview_message, "Video preview not available (install ffmpeg for thumbnails).")
  518.             return
  519.  
  520.         # unknown type
  521.         self._safe_ui_call(self._show_text_preview_message, "Preview not available for this file type.")
  522.  
  523.     def _show_info_text(self, txt):
  524.         self.info_text.delete("1.0", "end")
  525.         self.info_text.insert("end", txt)
  526.  
  527.     def _show_image_preview(self, img_tk):
  528.         # hide text preview if visible
  529.         try:
  530.             self.preview_textbox.pack_forget()
  531.         except Exception:
  532.             pass
  533.         self.preview_label.config(image=img_tk, text="")
  534.         self.preview_label.image = img_tk
  535.  
  536.     def _show_text_preview(self, text):
  537.         # hide image label content
  538.         self.preview_label.config(image="", text="")
  539.         # show text box
  540.         try:
  541.             self.preview_textbox.pack(fill="both", expand=True)
  542.         except Exception:
  543.             pass
  544.         self.preview_textbox.config(state="normal")
  545.         self.preview_textbox.delete("1.0", "end")
  546.         self.preview_textbox.insert("end", text)
  547.         self.preview_textbox.config(state="disabled")
  548.  
  549.     def _show_text_preview_message(self, msg):
  550.         try:
  551.             self.preview_textbox.pack_forget()
  552.         except Exception:
  553.             pass
  554.         self.preview_label.config(image="", text=msg)
  555.  
  556.     # ---------------- Play Selected (open default player) ----------------
  557.     def play_selected(self):
  558.         sel = self.get_selected_items()
  559.         if not sel:
  560.             messagebox.showwarning("No selection", "Select at least one file to play (video).")
  561.             return
  562.         for it in sel:
  563.             p = it["path"]
  564.             if is_video_by_ext(p):
  565.                 try:
  566.                     if sys.platform.startswith("win"):
  567.                         os.startfile(p)
  568.                     elif sys.platform.startswith("darwin"):
  569.                         subprocess.call(["open", p])
  570.                     else:
  571.                         subprocess.call(["xdg-open", p])
  572.                     return
  573.                 except Exception as e:
  574.                     messagebox.showerror("Error", f"Could not open file: {e}")
  575.                     return
  576.         messagebox.showinfo("No video found", "Selected files do not include a recognized video file.")
  577.  
  578.     # ---------------- Zip / Export / Delete / Move ----------------
  579.     def zip_selected(self):
  580.         sel = self.get_selected_items()
  581.         if not sel:
  582.             messagebox.showwarning("No files", "No files selected to zip.")
  583.             return
  584.         save_path = filedialog.asksaveasfilename(defaultextension=".zip", filetypes=[("Zip files", "*.zip")])
  585.         if not save_path:
  586.             return
  587.         t = threading.Thread(target=self._zip_thread, args=(sel, save_path), daemon=True)
  588.         t.start()
  589.  
  590.     def _zip_thread(self, items, zip_path):
  591.         with self.op_lock:
  592.             self._safe_ui_call(self._set_status, "Zipping...")
  593.             self._safe_ui_call(self._set_progress_mode_determinate, len(items))
  594.             try:
  595.                 with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
  596.                     for idx, it in enumerate(items, 1):
  597.                         z.write(it["path"], arcname=os.path.basename(it["path"]))
  598.                         self._safe_ui_call(self._set_progress_value, idx)
  599.                 self._safe_ui_call(self._set_status, f"Zip saved: {zip_path}")
  600.                 self._safe_ui_call(messagebox.showinfo, "Done", f"Zip saved: {zip_path}")
  601.             except Exception as e:
  602.                 self._safe_ui_call(messagebox.showerror, "Error", f"Failed to create zip: {e}")
  603.             finally:
  604.                 self._safe_ui_call(self._set_progress_value, 0)
  605.                 self._safe_ui_call(self._update_status_line)
  606.  
  607.     def export_list_dialog(self):
  608.         if not self.file_items:
  609.             messagebox.showwarning("Empty", "No files to export.")
  610.             return
  611.         typ = messagebox.askquestion("Export CSV?", "Export to CSV? (Yes => CSV, No => TXT)")
  612.         if typ == "yes":
  613.             self.export_csv()
  614.         else:
  615.             self.export_txt()
  616.  
  617.     def export_txt(self):
  618.         path = filedialog.asksaveasfilename(defaultextension=".txt", filetypes=[("Text files", "*.txt")])
  619.         if not path:
  620.             return
  621.         try:
  622.             with open(path, "w", encoding="utf-8") as f:
  623.                 for it in self.file_items:
  624.                     f.write(f"{it['path']}\t{it['size']}\n")
  625.             messagebox.showinfo("Exported", f"List exported to {path}")
  626.         except Exception as e:
  627.             messagebox.showerror("Error", str(e))
  628.  
  629.     def export_csv(self):
  630.         path = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV files", "*.csv")])
  631.         if not path:
  632.             return
  633.         try:
  634.             with open(path, "w", newline="", encoding="utf-8") as csvfile:
  635.                 writer = csv.writer(csvfile)
  636.                 writer.writerow(["path", "size_bytes"])
  637.                 for it in self.file_items:
  638.                     writer.writerow([it["path"], it["size"]])
  639.             messagebox.showinfo("Exported", f"CSV exported to {path}")
  640.         except Exception as e:
  641.             messagebox.showerror("Error", str(e))
  642.  
  643.     def delete_selected(self):
  644.         sel = self.get_selected_items()
  645.         if not sel:
  646.             messagebox.showwarning("No selection", "Select files to delete.")
  647.             return
  648.         ok = messagebox.askyesno("Confirm Delete", f"Delete {len(sel)} files permanently? This cannot be undone.")
  649.         if not ok:
  650.             return
  651.         t = threading.Thread(target=self._delete_thread, args=(sel,), daemon=True)
  652.         t.start()
  653.  
  654.     def _delete_thread(self, items):
  655.         with self.op_lock:
  656.             self._safe_ui_call(self._set_status, "Deleting...")
  657.             self._safe_ui_call(self._set_progress_mode_determinate, len(items))
  658.             failures = []
  659.             for idx, it in enumerate(items, 1):
  660.                 try:
  661.                     os.remove(it["path"])
  662.                 except Exception as e:
  663.                     failures.append((it["path"], str(e)))
  664.                 self._safe_ui_call(self._set_progress_value, idx)
  665.             # remove successfully deleted from file_items
  666.             self.file_items = [it for it in self.file_items if not (it in items and not os.path.exists(it["path"]))]
  667.             self._safe_ui_call(self.apply_filter)
  668.             if failures:
  669.                 self._safe_ui_call(messagebox.showwarning, "Some failed", f"{len(failures)} files failed to delete.")
  670.             else:
  671.                 self._safe_ui_call(messagebox.showinfo, "Deleted", "Selected files deleted.")
  672.             self._safe_ui_call(self._set_progress_value, 0)
  673.             self._safe_ui_call(self._update_status_line)
  674.  
  675.     def move_selected(self):
  676.         sel = self.get_selected_items()
  677.         if not sel:
  678.             messagebox.showwarning("No selection", "Select files to move.")
  679.             return
  680.         dest = filedialog.askdirectory()
  681.         if not dest:
  682.             return
  683.         t = threading.Thread(target=self._move_thread, args=(sel, dest), daemon=True)
  684.         t.start()
  685.  
  686.     def _move_thread(self, items, dest):
  687.         with self.op_lock:
  688.             self._safe_ui_call(self._set_status, "Moving files...")
  689.             self._safe_ui_call(self._set_progress_mode_determinate, len(items))
  690.             failures = []
  691.             for idx, it in enumerate(items, 1):
  692.                 try:
  693.                     shutil.move(it["path"], dest)
  694.                 except Exception as e:
  695.                     failures.append((it["path"], str(e)))
  696.                 self._safe_ui_call(self._set_progress_value, idx)
  697.             # remove moved ones from list
  698.             self.file_items = [it for it in self.file_items if not it["var"].get()]
  699.             self._safe_ui_call(self.apply_filter)
  700.             if failures:
  701.                 self._safe_ui_call(messagebox.showwarning, "Some failed", f"{len(failures)} failed to move.")
  702.             else:
  703.                 self._safe_ui_call(messagebox.showinfo, "Moved", f"Moved {len(items)} files to {dest}")
  704.             self._safe_ui_call(self._set_progress_value, 0)
  705.             self._safe_ui_call(self._update_status_line)
  706.  
  707.     # ---------------- Duplicate Detection ----------------
  708.     def find_duplicates_thread(self):
  709.         if not self.file_items:
  710.             messagebox.showwarning("Empty", "No files to check.")
  711.             return
  712.         t = threading.Thread(target=self.find_duplicates, daemon=True)
  713.         t.start()
  714.  
  715.     def find_duplicates(self):
  716.         with self.op_lock:
  717.             self._safe_ui_call(self._set_status, "Finding duplicates (hashing)...")
  718.             paths = [it["path"] for it in self.file_items]
  719.             total = len(paths)
  720.             if total == 0:
  721.                 self._safe_ui_call(self._set_status, "No files to check.")
  722.                 return
  723.             self._safe_ui_call(self._set_progress_mode_determinate, total)
  724.             hash_map = {}
  725.             for idx, p in enumerate(paths, 1):
  726.                 h = md5_for_file(p)
  727.                 if h is None:
  728.                     continue
  729.                 hash_map.setdefault(h, []).append(p)
  730.                 self._safe_ui_call(self._set_progress_value, idx)
  731.                 self._safe_ui_call(self._set_status, f"Hashing... {idx}/{total}")
  732.             groups = [v for v in hash_map.values() if len(v) > 1]
  733.             if not groups:
  734.                 self._safe_ui_call(messagebox.showinfo, "Duplicates", "No duplicate files found.")
  735.                 self._safe_ui_call(self._set_status, "No duplicates found.")
  736.                 self._safe_ui_call(self._set_progress_value, 0)
  737.                 return
  738.             # present groups window (UI thread)
  739.             out = "Duplicate groups found:\n\n"
  740.             for gi, g in enumerate(groups, 1):
  741.                 out += f"Group {gi} ({len(g)} files):\n"
  742.                 for p in g:
  743.                     out += f"  {p}\n"
  744.                 out += "\n"
  745.             def show_and_mark():
  746.                 win = tk.Toplevel(self.root)
  747.                 win.title("Duplicate Groups")
  748.                 txt = tk.Text(win, width=100, height=30)
  749.                 txt.pack(fill="both", expand=True)
  750.                 txt.insert("1.0", out)
  751.                 def mark_all_duplicates():
  752.                     for g in groups:
  753.                         for p in g[1:]:
  754.                             for it in self.file_items:
  755.                                 if it["path"] == p:
  756.                                     it["var"].set(1)
  757.                     messagebox.showinfo("Marked", "Marked duplicate files (except first of each group).")
  758.                     win.destroy()
  759.                     self.apply_filter()
  760.                 b = self.Button(win, text="Mark duplicates (select for deletion/move/zip)", command=mark_all_duplicates)
  761.                 b.pack(pady=6)
  762.             self._safe_ui_call(show_and_mark)
  763.             self._safe_ui_call(self._set_progress_value, 0)
  764.             self._safe_ui_call(self._set_status, f"Found {len(groups)} duplicate groups.")
  765.  
  766. # ---------------- Run ----------------
  767. def main():
  768.     root = None
  769.     if THEME_BACKEND == "ttkbootstrap":
  770.         root = tb.Window(themename="darkly")
  771.     else:
  772.         root = tk.Tk()
  773.     app = FileManagerApp(root)
  774.     root.mainloop()
  775.  
  776. if __name__ == "__main__":
  777.     main()
  778.  
Add Comment
Please, Sign In to add comment