Jesse Strickland 1 miesiąc temu
commit
d7d15bbc42
2 zmienionych plików z 651 dodań i 0 usunięć
  1. 5 0
      archiver.ini
  2. 646 0
      data_archiver.py

+ 5 - 0
archiver.ini

@@ -0,0 +1,5 @@
+[settings]
+source_dir = C:\Recordings
+dest_dir = E:\
+extensions = .mkv,.mp4,.mov,.avi,.mxf,.mp3,.wav,.flac,.m4a
+

+ 646 - 0
data_archiver.py

@@ -0,0 +1,646 @@
+import os
+import sys
+import hashlib
+import shutil
+import threading
+import queue
+import logging
+from logging.handlers import RotatingFileHandler
+from datetime import datetime
+import tkinter as tk
+from tkinter import ttk, filedialog, messagebox
+import configparser
+
+# ---- Windows drive helpers need ctypes ----
+try:
+    import ctypes
+    HAS_CTYPES = True
+except Exception:
+    HAS_CTYPES = False
+
+# ----------------------------
+# Configuration / Constants
+# ----------------------------
+DEFAULT_SOURCE = r"C:\Recordings"
+ARCHIVE_ROOT_NAME = "Archive"  # created under destination root
+LOG_FILE = "archiver.log"
+INI_FILE = "archiver.ini"
+READ_CHUNK_SIZE = 8 * 1024 * 1024  # 8MB
+UI_POLL_MS = 100
+
+# Default extensions if INI is absent (comma-separated list in INI)
+DEFAULT_EXTENSIONS = [
+    ".mkv", ".mp4", ".mov", ".avi",
+    ".mxf", ".mp3", ".wav", ".flac", ".m4a"
+]
+
+# ----------------------------
+# Logging setup
+# ----------------------------
+logger = logging.getLogger("archiver")
+logger.setLevel(logging.INFO)
+handler = RotatingFileHandler(LOG_FILE, maxBytes=2_000_000, backupCount=3, encoding="utf-8")
+formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
+handler.setFormatter(formatter)
+logger.addHandler(handler)
+
+# ----------------------------
+# Windows drive helpers
+# ----------------------------
+def _list_drive_letters_ctypes():
+    drives = []
+    if not HAS_CTYPES:
+        return drives
+    bitmask = ctypes.windll.kernel32.GetLogicalDrives()
+    for i in range(26):
+        if bitmask & (1 << i):
+            drives.append(f"{chr(65 + i)}:\\")
+    return drives
+
+def _get_drive_type(root):
+    # 2=removable, 3=fixed, 4=network, 5=CD-ROM, 6=RAM
+    if not HAS_CTYPES:
+        return 0
+    return ctypes.windll.kernel32.GetDriveTypeW(str(root))
+
+def _human_drive_type(drive_type):
+    return {
+        2: "Removable",
+        3: "Fixed",
+        4: "Network",
+        5: "CD/DVD",
+        6: "RAM",
+        0: "Unknown"
+    }.get(drive_type, "Unknown")
+
+def detect_archive_targets():
+    """
+    Show likely archive targets: Removable, Fixed (non-system), and Network.
+    Skip system drive and CD/DVD. Verify readiness by listing root.
+    """
+    targets = []
+    system_drive = (os.environ.get("SystemDrive") or "C:") + "\\"
+    letters = _list_drive_letters_ctypes() if HAS_CTYPES else [f"{chr(65+i)}:\\" for i in range(2,26)]
+    for root in letters:
+        if root.upper() == system_drive.upper():
+            continue
+        try:
+            dtype = _get_drive_type(root)
+            if dtype in (2, 3, 4):  # Removable, Fixed, Network
+                try:
+                    os.listdir(root)  # ensure accessible
+                except Exception:
+                    continue
+                targets.append((root, _human_drive_type(dtype)))
+        except Exception:
+            continue
+    targets.sort(key=lambda t: t[0])
+    return targets
+
+# ----------------------------
+# File operations
+# ----------------------------
+def compute_md5(file_path, stop_flag=None):
+    hasher = hashlib.md5()
+    with open(file_path, "rb") as f:
+        while True:
+            if stop_flag and stop_flag.is_set():
+                return None
+            chunk = f.read(READ_CHUNK_SIZE)
+            if not chunk:
+                break
+            hasher.update(chunk)
+    return hasher.hexdigest()
+
+def copy_with_progress(src, dst, progress_callback=None, stop_flag=None):
+    total_size = os.path.getsize(src)
+    copied = 0
+    with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
+        while True:
+            if stop_flag and stop_flag.is_set():
+                return False
+            buf = fsrc.read(READ_CHUNK_SIZE)
+            if not buf:
+                break
+            fdst.write(buf)
+            copied += len(buf)
+            if progress_callback and total_size > 0:
+                progress_callback(copied, total_size)
+    try:
+        shutil.copystat(src, dst)
+    except Exception:
+        pass
+    return True
+
+def ensure_dir(path):
+    os.makedirs(path, exist_ok=True)
+
+def date_folders_for(path):
+    ts = os.path.getmtime(path)
+    dt = datetime.fromtimestamp(ts)
+    year = f"{dt.year:04d}"
+    ymd = dt.strftime("%Y-%m-%d")
+    return year, ymd
+
+# ----------------------------
+# Worker thread & UI messaging
+# ----------------------------
+class ArchiverWorker(threading.Thread):
+    def __init__(self, src_dir, dest_root, files, ui_queue, archive_root_name=ARCHIVE_ROOT_NAME):
+        super().__init__(daemon=True)
+        self.src_dir = src_dir
+        self.dest_root = dest_root
+        self.files = files  # list of filenames (relative to src_dir)
+        self.ui_queue = ui_queue
+        self.stop_flag = threading.Event()
+        self.archive_root_name = archive_root_name
+
+    def log(self, level, msg):
+        logger.log(level, msg)
+        self.ui_queue.put(("log", msg))
+
+    def progress(self, filename, bytes_done, bytes_total):
+        self.ui_queue.put(("progress_file", filename, bytes_done, bytes_total))
+
+    def overall_progress(self, done, total):
+        self.ui_queue.put(("progress_overall", done, total))
+
+    def run(self):
+        total_files = len(self.files)
+        files_done = 0
+        self.overall_progress(files_done, total_files)
+
+        for relname in self.files:
+            if self.stop_flag.is_set():
+                self.log(logging.WARNING, "Archiving cancelled by user.")
+                break
+
+            src = os.path.join(self.src_dir, relname)
+            if not os.path.isfile(src):
+                self.log(logging.WARNING, f"Skipping missing: {src}")
+                files_done += 1
+                self.overall_progress(files_done, total_files)
+                continue
+
+            try:
+                self.log(logging.INFO, f"Starting: {src}")
+                self.ui_queue.put(("status", f"Hashing source: {relname}"))
+                md5_src = compute_md5(src, self.stop_flag)
+                if md5_src is None:
+                    self.log(logging.warning, f"Cancelled while hashing: {src}")
+                    break
+                self.log(logging.INFO, f"Source MD5: {md5_src}")
+
+                year, ymd = date_folders_for(src)
+                dest_dir = os.path.join(self.dest_root, self.archive_root_name, year, ymd)
+                ensure_dir(dest_dir)
+                dest = os.path.join(dest_dir, os.path.basename(src))
+
+                if os.path.exists(dest):
+                    self.ui_queue.put(("status", f"Found existing: {os.path.relpath(dest, self.dest_root)}; verifying"))
+                    md5_existing = compute_md5(dest, self.stop_flag)
+                    if md5_existing == md5_src:
+                        self.log(logging.INFO, f"Destination already has identical file. Removing source: {src}")
+                        os.remove(src)
+                        files_done += 1
+                        self.overall_progress(files_done, total_files)
+                        continue
+                    else:
+                        base, ext = os.path.splitext(dest)
+                        idx = 1
+                        new_dest = f"{base} ({idx}){ext}"
+                        while os.path.exists(new_dest):
+                            idx += 1
+                            new_dest = f"{base} ({idx}){ext}"
+                        dest = new_dest
+                        self.log(logging.INFO, f"Destination exists; will use: {dest}")
+
+                self.ui_queue.put(("status", f"Copying: {relname}"))
+                ok = copy_with_progress(src, dest,
+                                        progress_callback=lambda d, t: self.progress(relname, d, t),
+                                        stop_flag=self.stop_flag)
+                if not ok:
+                    self.log(logging.WARNING, f"Cancelled while copying: {src}")
+                    break
+
+                self.ui_queue.put(("status", f"Verifying: {relname}"))
+                md5_dst = compute_md5(dest, self.stop_flag)
+                self.log(logging.INFO, f"Dest MD5: {md5_dst}")
+
+                if md5_dst != md5_src:
+                    self.log(logging.ERROR, f"MD5 mismatch! Keeping source. Src={md5_src}, Dst={md5_dst}")
+                    self.ui_queue.put(("error", f"MD5 mismatch for: {relname}. Source kept."))
+                    try:
+                        os.remove(dest)
+                    except Exception:
+                        pass
+                else:
+                    try:
+                        os.remove(src)
+                        self.log(logging.INFO, f"Verified & removed source: {src}")
+                    except Exception as e:
+                        self.log(logging.ERROR, f"Verified but failed to remove source: {src} ({e})")
+                        self.ui_queue.put(("error", f"Verified but failed to delete source: {relname}"))
+
+            except Exception as e:
+                self.log(logging.ERROR, f"Error on file {src}: {e}")
+                self.ui_queue.put(("error", f"Error on {os.path.basename(src)}: {e}"))
+
+            files_done += 1
+            self.overall_progress(files_done, total_files)
+
+        self.ui_queue.put(("done", None))
+
+# ----------------------------
+# GUI
+# ----------------------------
+class App(tk.Tk):
+    def __init__(self):
+        super().__init__()
+        self.title("Generic Archiver")
+        self.geometry("900x680")
+        self.minsize(900, 680)
+
+        self.ui_queue = queue.Queue()
+        self.worker = None
+
+        # Settings / INI
+        self.config_path = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), INI_FILE)
+        self.settings = self._load_settings()
+
+        self.src_var = tk.StringVar(value=self.settings.get("source_dir") or DEFAULT_SOURCE)
+        self.dest_var = tk.StringVar(value=self.settings.get("dest_dir") or "")
+        self.extensions = self._parse_extensions(self.settings.get("extensions"))
+        self.status_var = tk.StringVar(value="Ready.")
+        self.dest_display_var = tk.StringVar(value=self.dest_var.get() or "No destination chosen")
+
+        # Flag to suppress progress reset on programmatic selection changes
+        self.suppress_selection_reset = False
+
+        self._build_ui()
+        self._populate_drives()
+        # Initial scan is programmatic; do NOT reset progress on selection changes
+        self._scan_source(user_initiated=False)
+
+        # Reset progress bars only when the USER changes the selection
+        self.tree.bind("<<TreeviewSelect>>", self._on_selection_changed)
+        self.drive_combo.bind("<<ComboboxSelected>>", self._on_drive_selected)
+
+        self.after(UI_POLL_MS, self._poll_queue)
+
+    # ---------- Settings / INI ----------
+    def _load_settings(self):
+        cfg = configparser.ConfigParser()
+        data = {}
+        try:
+            if os.path.exists(self.config_path):
+                cfg.read(self.config_path, encoding="utf-8")
+                s = cfg["settings"]
+                data["source_dir"] = s.get("source_dir", "").strip()
+                data["dest_dir"] = s.get("dest_dir", "").strip()
+                data["extensions"] = s.get("extensions", "").strip()
+            else:
+                data["source_dir"] = ""
+                data["dest_dir"] = ""
+                data["extensions"] = ",".join(DEFAULT_EXTENSIONS)
+        except Exception:
+            data["source_dir"] = ""
+            data["dest_dir"] = ""
+            data["extensions"] = ",".join(DEFAULT_EXTENSIONS)
+        return data
+
+    def _save_settings_if_ready(self):
+        """
+        Save when the user has chosen at least a source and destination.
+        We also persist the extensions list (editable by directly editing the INI).
+        """
+        source = self.src_var.get().strip()
+        dest = self._resolve_destination_root().strip()
+        if not source or not dest:
+            return
+        cfg = configparser.ConfigParser()
+        cfg["settings"] = {
+            "source_dir": source,
+            "dest_dir": dest,
+            "extensions": ",".join(self.extensions) if self.extensions else ""
+        }
+        try:
+            with open(self.config_path, "w", encoding="utf-8") as f:
+                cfg.write(f)
+            self._log_ui(f"Settings saved to {self.config_path}")
+        except Exception as e:
+            self._log_ui(f"Failed to save settings: {e}")
+
+    def _parse_extensions(self, ext_str):
+        if not ext_str:
+            return []
+        parts = [p.strip().lower() for p in ext_str.split(",") if p.strip()]
+        # Ensure they all start with '.'
+        norm = []
+        for p in parts:
+            if not p.startswith("."):
+                p = "." + p
+            norm.append(p)
+        return norm
+
+    # ---------- UI ----------
+    def _build_ui(self):
+        pad = {"padx": 8, "pady": 6}
+
+        # Source selection
+        frm_src = ttk.Frame(self)
+        frm_src.pack(fill="x", **pad)
+        ttk.Label(frm_src, text="Source folder:").pack(side="left")
+        ttk.Entry(frm_src, textvariable=self.src_var, width=70).pack(side="left", padx=6)
+        ttk.Button(frm_src, text="Browse…", command=self._browse_src).pack(side="left")
+        ttk.Button(frm_src, text="Rescan", command=lambda: self._scan_source(user_initiated=True)).pack(side="left", padx=(6,0))
+
+        # Destination selection (drive or folder)
+        frm_dest = ttk.Frame(self)
+        frm_dest.pack(fill="x", **pad)
+        ttk.Label(frm_dest, text="Destination drive:").pack(side="left")
+        self.drive_combo = ttk.Combobox(frm_dest, width=24, state="readonly")
+        self.drive_combo.pack(side="left", padx=6)
+        ttk.Button(frm_dest, text="Refresh", command=self._populate_drives).pack(side="left")
+
+        ttk.Label(frm_dest, text="or pick a folder:").pack(side="left", padx=(16, 6))
+        ttk.Button(frm_dest, text="Pick Folder…", command=self._browse_dest).pack(side="left")
+
+        # Destination indicator
+        frm_dest_note = ttk.Frame(self)
+        frm_dest_note.pack(fill="x", **pad)
+        ttk.Label(frm_dest_note, text="Destination:").pack(side="left")
+        self.dest_display_lbl = ttk.Label(frm_dest_note, textvariable=self.dest_display_var, foreground="#444")
+        self.dest_display_lbl.pack(side="left", padx=6)
+
+        # Extensions info (read-only hint)
+        frm_ext = ttk.Frame(self)
+        frm_ext.pack(fill="x", **pad)
+        exts_text = ", ".join(self.extensions) if self.extensions else "(all files)"
+        ttk.Label(frm_ext, text=f"Extensions: {exts_text}   (edit in {INI_FILE})", foreground="#555").pack(side="left")
+
+        # Files list (UPDATED COLUMNS: name, size, modified)
+        frm_list = ttk.Frame(self)
+        frm_list.pack(fill="both", expand=True, **pad)
+
+        topbar = ttk.Frame(frm_list)
+        topbar.pack(fill="x")
+        ttk.Label(topbar, text="Files found:").pack(side="left")
+        ttk.Button(topbar, text="Select All", command=lambda: self._select_all(True)).pack(side="left", padx=6)
+        ttk.Button(topbar, text="Select None", command=lambda: self._select_all(False)).pack(side="left", padx=6)
+
+        self.tree = ttk.Treeview(frm_list, columns=("name", "size", "modified"), show="headings", selectmode="extended")
+        self.tree.heading("name", text="Filename")
+        self.tree.heading("size", text="Size")
+        self.tree.heading("modified", text="Modified")
+        self.tree.column("name", width=440, anchor="w")
+        self.tree.column("size", width=120, anchor="e")
+        self.tree.column("modified", width=180, anchor="center")
+        self.tree.pack(fill="both", expand=True)
+        self.tree_scroll = ttk.Scrollbar(frm_list, orient="vertical", command=self.tree.yview)
+        self.tree.configure(yscrollcommand=self.tree_scroll.set)
+        self.tree_scroll.pack(side="right", fill="y")
+
+        # Progress
+        frm_prog = ttk.Frame(self)
+        frm_prog.pack(fill="x", **pad)
+        ttk.Label(frm_prog, text="File progress:").pack(anchor="w")
+        self.pb_file = ttk.Progressbar(frm_prog, orient="horizontal", mode="determinate")
+        self.pb_file.pack(fill="x", pady=(0, 4))
+        ttk.Label(frm_prog, text="Overall progress:").pack(anchor="w")
+        self.pb_overall = ttk.Progressbar(frm_prog, orient="horizontal", mode="determinate")
+        self.pb_overall.pack(fill="x")
+
+        # Action buttons
+        frm_actions = ttk.Frame(self)
+        frm_actions.pack(fill="x", **pad)
+        self.btn_start = ttk.Button(frm_actions, text="Archive Selected", command=self._start_archive)
+        self.btn_start.pack(side="left")
+        self.btn_cancel = ttk.Button(frm_actions, text="Cancel", command=self._cancel, state="disabled")
+        self.btn_cancel.pack(side="left", padx=6)
+        ttk.Label(frm_actions, textvariable=self.status_var, foreground="#555").pack(side="right")
+
+        # Log output
+        frm_log = ttk.LabelFrame(self, text="Log")
+        frm_log.pack(fill="both", expand=False, **pad)
+        self.txt_log = tk.Text(frm_log, height=10, wrap="none")
+        self.txt_log.pack(fill="both", expand=True)
+        log_scroll = ttk.Scrollbar(frm_log, orient="vertical", command=self.txt_log.yview)
+        self.txt_log.configure(yscrollcommand=log_scroll.set)
+        log_scroll.pack(side="right", fill="y")
+
+        # If INI had a dest_dir, reflect it in the destination label
+        if self.dest_var.get():
+            self.dest_display_var.set(self.dest_var.get())
+
+    # ---------- Browsers / selectors ----------
+    def _browse_src(self):
+        chosen = filedialog.askdirectory(title="Choose Source Folder")
+        if chosen:
+            self.src_var.set(chosen)
+            self._scan_source(user_initiated=True)
+            self._save_settings_if_ready()
+            # Do not reset progress here; will reset when user changes selection.
+
+    def _browse_dest(self):
+        chosen = filedialog.askdirectory(title="Choose Destination Folder")
+        if chosen:
+            self.dest_var.set(chosen)
+            self.drive_combo.set("")  # clear drive selection if manually chosen
+            self.dest_display_var.set(chosen)
+            self._log_ui(f"Manual destination set: {chosen}")
+            self._save_settings_if_ready()
+
+    def _on_drive_selected(self, _evt=None):
+        # User picked a drive; reflect and save (if source exists)
+        val = self.drive_combo.get()
+        if val:
+            dest_root = val.split()[0]  # "E:\ (Fixed)" -> "E:\"
+            self.dest_var.set("")       # indicates we're using drive, not a manual folder
+            self.dest_display_var.set(dest_root)
+            self._save_settings_if_ready()
+
+    # ---------- Drives ----------
+    def _populate_drives(self):
+        targets = detect_archive_targets()
+        items = [f"{root} ({dtype})" for root, dtype in targets]
+        self.drive_combo["values"] = items
+        # If INI specified a folder, leave combo alone; otherwise preselect first drive
+        if items and not self.dest_var.get():
+            self.drive_combo.current(0)
+            self.dest_display_var.set(items[0].split()[0])
+        elif not items and not self.dest_var.get():
+            self.dest_display_var.set("No destination chosen")
+
+    # ---------- File list ----------
+    def _scan_source(self, user_initiated: bool):
+        """
+        Refresh file list. If called programmatically (user_initiated=False),
+        we suppress selection-reset behavior so progress bars don't clear.
+        """
+        try:
+            self.suppress_selection_reset = not user_initiated
+
+            self.tree.delete(*self.tree.get_children())
+            src = self.src_var.get()
+            if not os.path.isdir(src):
+                self._log_ui(f"Source folder not found: {src}")
+                self.status_var.set("Source folder not found.")
+                return
+
+            files = []
+            want_exts = [e.lower() for e in (self.extensions or [])]
+            for name in os.listdir(src):
+                full = os.path.join(src, name)
+                if not os.path.isfile(full):
+                    continue
+                if want_exts:
+                    _, ext = os.path.splitext(name)
+                    if ext.lower() not in want_exts:
+                        continue  # skip non-matching
+                size = os.path.getsize(full)
+                mtime = os.path.getmtime(full)
+                files.append((name, size, mtime))
+
+            files.sort(key=lambda x: x[2])  # by mtime ascending
+            for name, size, mtime in files:
+                human_size = self._human_bytes(size)
+                mod = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
+                # Use filename as iid; if duplicates exist, Treeview requires unique IDs. Add suffix if needed.
+                iid = name
+                if iid in self.tree.get_children(""):
+                    # unlikely here since we just cleared; keeping for safety if modified
+                    iid = f"{name}__{int(mtime)}"
+                self.tree.insert("", "end", iid=iid, values=(name, human_size, mod))
+
+            self.status_var.set(f"Found {len(files)} file(s).")
+        finally:
+            # Re-enable user selection reset if it was suppressed for programmatic updates
+            self.suppress_selection_reset = False
+
+    # ---------- Selection helpers ----------
+    def _select_all(self, select=True):
+        children = self.tree.get_children()
+        if select:
+            self.tree.selection_set(children)
+        else:
+            self.tree.selection_remove(children)
+
+    def _get_selected_files(self):
+        # Return filenames from the 'name' column for selected rows
+        sels = list(self.tree.selection())
+        names = []
+        for iid in sels:
+            vals = self.tree.item(iid, "values")
+            if vals:
+                names.append(vals[0])
+        return names
+
+    def _on_selection_changed(self, _evt=None):
+        # Only reset progress bars when the USER changed selection (not programmatic updates)
+        if self.suppress_selection_reset:
+            return
+        self._reset_progress_bars()
+
+    def _reset_progress_bars(self):
+        self.pb_file.config(value=0, maximum=100)
+        sel_count = len(self._get_selected_files())
+        self.pb_overall.config(value=0, maximum=(sel_count if sel_count else 1))
+
+    # ---------- Destination resolution ----------
+    def _resolve_destination_root(self):
+        # If user picked a folder (dest_var), use it; otherwise use selected drive root
+        if self.dest_var.get():
+            return self.dest_var.get()
+        val = self.drive_combo.get()
+        if val:
+            return val.split()[0]
+        return ""
+
+    # ---------- Archive workflow ----------
+    def _start_archive(self):
+        src = self.src_var.get()
+        dst_root = self._resolve_destination_root()
+        if not os.path.isdir(src):
+            messagebox.showerror("Error", f"Source folder does not exist:\n{src}")
+            return
+        if not dst_root or not os.path.isdir(dst_root):
+            messagebox.showerror("Error", "Please select a destination drive or folder.")
+            return
+        files = self._get_selected_files()
+        if not files:
+            messagebox.showinfo("Nothing selected", "Please select one or more files to archive.")
+            return
+
+        self._save_settings_if_ready()
+
+        self.btn_start.config(state="disabled")
+        self.btn_cancel.config(state="normal")
+        self.pb_file.config(value=0, maximum=100)
+        self.pb_overall.config(value=0, maximum=len(files))
+        self.status_var.set("Starting…")
+
+        self._log_ui(f"Archiving to: {os.path.join(dst_root, ARCHIVE_ROOT_NAME)}")
+        self.worker = ArchiverWorker(src, dst_root, files, self.ui_queue, archive_root_name=ARCHIVE_ROOT_NAME)
+        self.worker.start()
+
+    def _cancel(self):
+        if self.worker and self.worker.is_alive():
+            self.worker.stop_flag.set()
+            self._log_ui("Cancellation requested…")
+
+    # ---------- Worker queue ----------
+    def _poll_queue(self):
+        try:
+            while True:
+                msg = self.ui_queue.get_nowait()
+                self._handle_worker_message(msg)
+        except queue.Empty:
+            pass
+        self.after(UI_POLL_MS, self._poll_queue)
+
+    def _handle_worker_message(self, msg):
+        kind = msg[0]
+        if kind == "log":
+            self._log_ui(msg[1])
+        elif kind == "status":
+            self.status_var.set(msg[1])
+        elif kind == "progress_file":
+            _, filename, done, total = msg
+            pct = int((done / total) * 100) if total else 0
+            self.pb_file.config(value=pct, maximum=100)
+        elif kind == "progress_overall":
+            _, done, total = msg
+            self.pb_overall.config(value=done, maximum=total if total else 1)
+        elif kind == "error":
+            self._log_ui(f"ERROR: {msg[1]}")
+        elif kind == "done":
+            self._log_ui("All done.")
+            self.status_var.set("Done.")
+            self.btn_start.config(state="normal")
+            self.btn_cancel.config(state="disabled")
+            # Programmatic refresh of the list — do not reset progress bars
+            self._scan_source(user_initiated=False)
+
+    # ---------- Utils ----------
+    def _log_ui(self, text):
+        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        self.txt_log.insert("end", f"{timestamp}  {text}\n")
+        self.txt_log.see("end")
+        logger.info(text)
+
+    @staticmethod
+    def _human_bytes(n):
+        for unit in ["B","KB","MB","GB","TB"]:
+            if n < 1024.0:
+                return f"{n:3.1f} {unit}"
+            n /= 1024.0
+        return f"{n:.1f} PB"
+
+def main():
+    logger.info("=== Generic Archiver launched ===")
+    app = App()
+    app.mainloop()
+
+if __name__ == "__main__":
+    main()