data_archiver.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. import os
  2. import sys
  3. import hashlib
  4. import shutil
  5. import threading
  6. import queue
  7. import logging
  8. from logging.handlers import RotatingFileHandler
  9. from datetime import datetime
  10. import tkinter as tk
  11. from tkinter import ttk, filedialog, messagebox
  12. import configparser
  13. # ---- Windows drive helpers need ctypes ----
  14. try:
  15. import ctypes
  16. HAS_CTYPES = True
  17. except Exception:
  18. HAS_CTYPES = False
  19. # ----------------------------
  20. # Configuration / Constants
  21. # ----------------------------
  22. DEFAULT_SOURCE = r"C:\Recordings"
  23. ARCHIVE_ROOT_NAME = "Archive" # created under destination root
  24. LOG_FILE = "archiver.log"
  25. INI_FILE = "archiver.ini"
  26. READ_CHUNK_SIZE = 8 * 1024 * 1024 # 8MB
  27. UI_POLL_MS = 100
  28. # Default extensions if INI is absent (comma-separated list in INI)
  29. DEFAULT_EXTENSIONS = [
  30. ".mkv", ".mp4", ".mov", ".avi",
  31. ".mxf", ".mp3", ".wav", ".flac", ".m4a"
  32. ]
  33. # ----------------------------
  34. # Logging setup
  35. # ----------------------------
  36. logger = logging.getLogger("archiver")
  37. logger.setLevel(logging.INFO)
  38. handler = RotatingFileHandler(LOG_FILE, maxBytes=2_000_000, backupCount=3, encoding="utf-8")
  39. formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
  40. handler.setFormatter(formatter)
  41. logger.addHandler(handler)
  42. # ----------------------------
  43. # Windows drive helpers
  44. # ----------------------------
  45. def _list_drive_letters_ctypes():
  46. drives = []
  47. if not HAS_CTYPES:
  48. return drives
  49. bitmask = ctypes.windll.kernel32.GetLogicalDrives()
  50. for i in range(26):
  51. if bitmask & (1 << i):
  52. drives.append(f"{chr(65 + i)}:\\")
  53. return drives
  54. def _get_drive_type(root):
  55. # 2=removable, 3=fixed, 4=network, 5=CD-ROM, 6=RAM
  56. if not HAS_CTYPES:
  57. return 0
  58. return ctypes.windll.kernel32.GetDriveTypeW(str(root))
  59. def _human_drive_type(drive_type):
  60. return {
  61. 2: "Removable",
  62. 3: "Fixed",
  63. 4: "Network",
  64. 5: "CD/DVD",
  65. 6: "RAM",
  66. 0: "Unknown"
  67. }.get(drive_type, "Unknown")
  68. def detect_archive_targets():
  69. """
  70. Show likely archive targets: Removable, Fixed (non-system), and Network.
  71. Skip system drive and CD/DVD. Verify readiness by listing root.
  72. """
  73. targets = []
  74. system_drive = (os.environ.get("SystemDrive") or "C:") + "\\"
  75. letters = _list_drive_letters_ctypes() if HAS_CTYPES else [f"{chr(65+i)}:\\" for i in range(2,26)]
  76. for root in letters:
  77. if root.upper() == system_drive.upper():
  78. continue
  79. try:
  80. dtype = _get_drive_type(root)
  81. if dtype in (2, 3, 4): # Removable, Fixed, Network
  82. try:
  83. os.listdir(root) # ensure accessible
  84. except Exception:
  85. continue
  86. targets.append((root, _human_drive_type(dtype)))
  87. except Exception:
  88. continue
  89. targets.sort(key=lambda t: t[0])
  90. return targets
  91. # ----------------------------
  92. # File operations
  93. # ----------------------------
  94. def compute_md5(file_path, stop_flag=None):
  95. hasher = hashlib.md5()
  96. with open(file_path, "rb") as f:
  97. while True:
  98. if stop_flag and stop_flag.is_set():
  99. return None
  100. chunk = f.read(READ_CHUNK_SIZE)
  101. if not chunk:
  102. break
  103. hasher.update(chunk)
  104. return hasher.hexdigest()
  105. def copy_with_progress(src, dst, progress_callback=None, stop_flag=None):
  106. total_size = os.path.getsize(src)
  107. copied = 0
  108. with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
  109. while True:
  110. if stop_flag and stop_flag.is_set():
  111. return False
  112. buf = fsrc.read(READ_CHUNK_SIZE)
  113. if not buf:
  114. break
  115. fdst.write(buf)
  116. copied += len(buf)
  117. if progress_callback and total_size > 0:
  118. progress_callback(copied, total_size)
  119. try:
  120. shutil.copystat(src, dst)
  121. except Exception:
  122. pass
  123. return True
  124. def ensure_dir(path):
  125. os.makedirs(path, exist_ok=True)
  126. def date_folders_for(path):
  127. ts = os.path.getmtime(path)
  128. dt = datetime.fromtimestamp(ts)
  129. year = f"{dt.year:04d}"
  130. ymd = dt.strftime("%Y-%m-%d")
  131. return year, ymd
  132. # ----------------------------
  133. # Worker thread & UI messaging
  134. # ----------------------------
  135. class ArchiverWorker(threading.Thread):
  136. def __init__(self, src_dir, dest_root, files, ui_queue, archive_root_name=ARCHIVE_ROOT_NAME):
  137. super().__init__(daemon=True)
  138. self.src_dir = src_dir
  139. self.dest_root = dest_root
  140. self.files = files # list of filenames (relative to src_dir)
  141. self.ui_queue = ui_queue
  142. self.stop_flag = threading.Event()
  143. self.archive_root_name = archive_root_name
  144. def log(self, level, msg):
  145. logger.log(level, msg)
  146. self.ui_queue.put(("log", msg))
  147. def progress(self, filename, bytes_done, bytes_total):
  148. self.ui_queue.put(("progress_file", filename, bytes_done, bytes_total))
  149. def overall_progress(self, done, total):
  150. self.ui_queue.put(("progress_overall", done, total))
  151. def run(self):
  152. total_files = len(self.files)
  153. files_done = 0
  154. self.overall_progress(files_done, total_files)
  155. for relname in self.files:
  156. if self.stop_flag.is_set():
  157. self.log(logging.WARNING, "Archiving cancelled by user.")
  158. break
  159. src = os.path.join(self.src_dir, relname)
  160. if not os.path.isfile(src):
  161. self.log(logging.WARNING, f"Skipping missing: {src}")
  162. files_done += 1
  163. self.overall_progress(files_done, total_files)
  164. continue
  165. try:
  166. self.log(logging.INFO, f"Starting: {src}")
  167. self.ui_queue.put(("status", f"Hashing source: {relname}"))
  168. md5_src = compute_md5(src, self.stop_flag)
  169. if md5_src is None:
  170. self.log(logging.warning, f"Cancelled while hashing: {src}")
  171. break
  172. self.log(logging.INFO, f"Source MD5: {md5_src}")
  173. year, ymd = date_folders_for(src)
  174. dest_dir = os.path.join(self.dest_root, self.archive_root_name, year, ymd)
  175. ensure_dir(dest_dir)
  176. dest = os.path.join(dest_dir, os.path.basename(src))
  177. if os.path.exists(dest):
  178. self.ui_queue.put(("status", f"Found existing: {os.path.relpath(dest, self.dest_root)}; verifying"))
  179. md5_existing = compute_md5(dest, self.stop_flag)
  180. if md5_existing == md5_src:
  181. self.log(logging.INFO, f"Destination already has identical file. Removing source: {src}")
  182. os.remove(src)
  183. files_done += 1
  184. self.overall_progress(files_done, total_files)
  185. continue
  186. else:
  187. base, ext = os.path.splitext(dest)
  188. idx = 1
  189. new_dest = f"{base} ({idx}){ext}"
  190. while os.path.exists(new_dest):
  191. idx += 1
  192. new_dest = f"{base} ({idx}){ext}"
  193. dest = new_dest
  194. self.log(logging.INFO, f"Destination exists; will use: {dest}")
  195. self.ui_queue.put(("status", f"Copying: {relname}"))
  196. ok = copy_with_progress(src, dest,
  197. progress_callback=lambda d, t: self.progress(relname, d, t),
  198. stop_flag=self.stop_flag)
  199. if not ok:
  200. self.log(logging.WARNING, f"Cancelled while copying: {src}")
  201. break
  202. self.ui_queue.put(("status", f"Verifying: {relname}"))
  203. md5_dst = compute_md5(dest, self.stop_flag)
  204. self.log(logging.INFO, f"Dest MD5: {md5_dst}")
  205. if md5_dst != md5_src:
  206. self.log(logging.ERROR, f"MD5 mismatch! Keeping source. Src={md5_src}, Dst={md5_dst}")
  207. self.ui_queue.put(("error", f"MD5 mismatch for: {relname}. Source kept."))
  208. try:
  209. os.remove(dest)
  210. except Exception:
  211. pass
  212. else:
  213. try:
  214. os.remove(src)
  215. self.log(logging.INFO, f"Verified & removed source: {src}")
  216. except Exception as e:
  217. self.log(logging.ERROR, f"Verified but failed to remove source: {src} ({e})")
  218. self.ui_queue.put(("error", f"Verified but failed to delete source: {relname}"))
  219. except Exception as e:
  220. self.log(logging.ERROR, f"Error on file {src}: {e}")
  221. self.ui_queue.put(("error", f"Error on {os.path.basename(src)}: {e}"))
  222. files_done += 1
  223. self.overall_progress(files_done, total_files)
  224. self.ui_queue.put(("done", None))
  225. # ----------------------------
  226. # GUI
  227. # ----------------------------
  228. class App(tk.Tk):
  229. def __init__(self):
  230. super().__init__()
  231. self.title("Generic Archiver")
  232. self.geometry("900x680")
  233. self.minsize(900, 680)
  234. self.ui_queue = queue.Queue()
  235. self.worker = None
  236. # Settings / INI
  237. self.config_path = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), INI_FILE)
  238. self.settings = self._load_settings()
  239. self.src_var = tk.StringVar(value=self.settings.get("source_dir") or DEFAULT_SOURCE)
  240. self.dest_var = tk.StringVar(value=self.settings.get("dest_dir") or "")
  241. self.extensions = self._parse_extensions(self.settings.get("extensions"))
  242. self.status_var = tk.StringVar(value="Ready.")
  243. self.dest_display_var = tk.StringVar(value=self.dest_var.get() or "No destination chosen")
  244. # Flag to suppress progress reset on programmatic selection changes
  245. self.suppress_selection_reset = False
  246. self._build_ui()
  247. self._populate_drives()
  248. # Initial scan is programmatic; do NOT reset progress on selection changes
  249. self._scan_source(user_initiated=False)
  250. # Reset progress bars only when the USER changes the selection
  251. self.tree.bind("<<TreeviewSelect>>", self._on_selection_changed)
  252. self.drive_combo.bind("<<ComboboxSelected>>", self._on_drive_selected)
  253. self.after(UI_POLL_MS, self._poll_queue)
  254. # ---------- Settings / INI ----------
  255. def _load_settings(self):
  256. cfg = configparser.ConfigParser()
  257. data = {}
  258. try:
  259. if os.path.exists(self.config_path):
  260. cfg.read(self.config_path, encoding="utf-8")
  261. s = cfg["settings"]
  262. data["source_dir"] = s.get("source_dir", "").strip()
  263. data["dest_dir"] = s.get("dest_dir", "").strip()
  264. data["extensions"] = s.get("extensions", "").strip()
  265. else:
  266. data["source_dir"] = ""
  267. data["dest_dir"] = ""
  268. data["extensions"] = ",".join(DEFAULT_EXTENSIONS)
  269. except Exception:
  270. data["source_dir"] = ""
  271. data["dest_dir"] = ""
  272. data["extensions"] = ",".join(DEFAULT_EXTENSIONS)
  273. return data
  274. def _save_settings_if_ready(self):
  275. """
  276. Save when the user has chosen at least a source and destination.
  277. We also persist the extensions list (editable by directly editing the INI).
  278. """
  279. source = self.src_var.get().strip()
  280. dest = self._resolve_destination_root().strip()
  281. if not source or not dest:
  282. return
  283. cfg = configparser.ConfigParser()
  284. cfg["settings"] = {
  285. "source_dir": source,
  286. "dest_dir": dest,
  287. "extensions": ",".join(self.extensions) if self.extensions else ""
  288. }
  289. try:
  290. with open(self.config_path, "w", encoding="utf-8") as f:
  291. cfg.write(f)
  292. self._log_ui(f"Settings saved to {self.config_path}")
  293. except Exception as e:
  294. self._log_ui(f"Failed to save settings: {e}")
  295. def _parse_extensions(self, ext_str):
  296. if not ext_str:
  297. return []
  298. parts = [p.strip().lower() for p in ext_str.split(",") if p.strip()]
  299. # Ensure they all start with '.'
  300. norm = []
  301. for p in parts:
  302. if not p.startswith("."):
  303. p = "." + p
  304. norm.append(p)
  305. return norm
  306. # ---------- UI ----------
  307. def _build_ui(self):
  308. pad = {"padx": 8, "pady": 6}
  309. # Source selection
  310. frm_src = ttk.Frame(self)
  311. frm_src.pack(fill="x", **pad)
  312. ttk.Label(frm_src, text="Source folder:").pack(side="left")
  313. ttk.Entry(frm_src, textvariable=self.src_var, width=70).pack(side="left", padx=6)
  314. ttk.Button(frm_src, text="Browse…", command=self._browse_src).pack(side="left")
  315. ttk.Button(frm_src, text="Rescan", command=lambda: self._scan_source(user_initiated=True)).pack(side="left", padx=(6,0))
  316. # Destination selection (drive or folder)
  317. frm_dest = ttk.Frame(self)
  318. frm_dest.pack(fill="x", **pad)
  319. ttk.Label(frm_dest, text="Destination drive:").pack(side="left")
  320. self.drive_combo = ttk.Combobox(frm_dest, width=24, state="readonly")
  321. self.drive_combo.pack(side="left", padx=6)
  322. ttk.Button(frm_dest, text="Refresh", command=self._populate_drives).pack(side="left")
  323. ttk.Label(frm_dest, text="or pick a folder:").pack(side="left", padx=(16, 6))
  324. ttk.Button(frm_dest, text="Pick Folder…", command=self._browse_dest).pack(side="left")
  325. # Destination indicator
  326. frm_dest_note = ttk.Frame(self)
  327. frm_dest_note.pack(fill="x", **pad)
  328. ttk.Label(frm_dest_note, text="Destination:").pack(side="left")
  329. self.dest_display_lbl = ttk.Label(frm_dest_note, textvariable=self.dest_display_var, foreground="#444")
  330. self.dest_display_lbl.pack(side="left", padx=6)
  331. # Extensions info (read-only hint)
  332. frm_ext = ttk.Frame(self)
  333. frm_ext.pack(fill="x", **pad)
  334. exts_text = ", ".join(self.extensions) if self.extensions else "(all files)"
  335. ttk.Label(frm_ext, text=f"Extensions: {exts_text} (edit in {INI_FILE})", foreground="#555").pack(side="left")
  336. # Files list (UPDATED COLUMNS: name, size, modified)
  337. frm_list = ttk.Frame(self)
  338. frm_list.pack(fill="both", expand=True, **pad)
  339. topbar = ttk.Frame(frm_list)
  340. topbar.pack(fill="x")
  341. ttk.Label(topbar, text="Files found:").pack(side="left")
  342. ttk.Button(topbar, text="Select All", command=lambda: self._select_all(True)).pack(side="left", padx=6)
  343. ttk.Button(topbar, text="Select None", command=lambda: self._select_all(False)).pack(side="left", padx=6)
  344. self.tree = ttk.Treeview(frm_list, columns=("name", "size", "modified"), show="headings", selectmode="extended")
  345. self.tree.heading("name", text="Filename")
  346. self.tree.heading("size", text="Size")
  347. self.tree.heading("modified", text="Modified")
  348. self.tree.column("name", width=440, anchor="w")
  349. self.tree.column("size", width=120, anchor="e")
  350. self.tree.column("modified", width=180, anchor="center")
  351. self.tree.pack(fill="both", expand=True)
  352. self.tree_scroll = ttk.Scrollbar(frm_list, orient="vertical", command=self.tree.yview)
  353. self.tree.configure(yscrollcommand=self.tree_scroll.set)
  354. self.tree_scroll.pack(side="right", fill="y")
  355. # Progress
  356. frm_prog = ttk.Frame(self)
  357. frm_prog.pack(fill="x", **pad)
  358. ttk.Label(frm_prog, text="File progress:").pack(anchor="w")
  359. self.pb_file = ttk.Progressbar(frm_prog, orient="horizontal", mode="determinate")
  360. self.pb_file.pack(fill="x", pady=(0, 4))
  361. ttk.Label(frm_prog, text="Overall progress:").pack(anchor="w")
  362. self.pb_overall = ttk.Progressbar(frm_prog, orient="horizontal", mode="determinate")
  363. self.pb_overall.pack(fill="x")
  364. # Action buttons
  365. frm_actions = ttk.Frame(self)
  366. frm_actions.pack(fill="x", **pad)
  367. self.btn_start = ttk.Button(frm_actions, text="Archive Selected", command=self._start_archive)
  368. self.btn_start.pack(side="left")
  369. self.btn_cancel = ttk.Button(frm_actions, text="Cancel", command=self._cancel, state="disabled")
  370. self.btn_cancel.pack(side="left", padx=6)
  371. ttk.Label(frm_actions, textvariable=self.status_var, foreground="#555").pack(side="right")
  372. # Log output
  373. frm_log = ttk.LabelFrame(self, text="Log")
  374. frm_log.pack(fill="both", expand=False, **pad)
  375. self.txt_log = tk.Text(frm_log, height=10, wrap="none")
  376. self.txt_log.pack(fill="both", expand=True)
  377. log_scroll = ttk.Scrollbar(frm_log, orient="vertical", command=self.txt_log.yview)
  378. self.txt_log.configure(yscrollcommand=log_scroll.set)
  379. log_scroll.pack(side="right", fill="y")
  380. # If INI had a dest_dir, reflect it in the destination label
  381. if self.dest_var.get():
  382. self.dest_display_var.set(self.dest_var.get())
  383. # ---------- Browsers / selectors ----------
  384. def _browse_src(self):
  385. chosen = filedialog.askdirectory(title="Choose Source Folder")
  386. if chosen:
  387. self.src_var.set(chosen)
  388. self._scan_source(user_initiated=True)
  389. self._save_settings_if_ready()
  390. # Do not reset progress here; will reset when user changes selection.
  391. def _browse_dest(self):
  392. chosen = filedialog.askdirectory(title="Choose Destination Folder")
  393. if chosen:
  394. self.dest_var.set(chosen)
  395. self.drive_combo.set("") # clear drive selection if manually chosen
  396. self.dest_display_var.set(chosen)
  397. self._log_ui(f"Manual destination set: {chosen}")
  398. self._save_settings_if_ready()
  399. def _on_drive_selected(self, _evt=None):
  400. # User picked a drive; reflect and save (if source exists)
  401. val = self.drive_combo.get()
  402. if val:
  403. dest_root = val.split()[0] # "E:\ (Fixed)" -> "E:\"
  404. self.dest_var.set("") # indicates we're using drive, not a manual folder
  405. self.dest_display_var.set(dest_root)
  406. self._save_settings_if_ready()
  407. # ---------- Drives ----------
  408. def _populate_drives(self):
  409. targets = detect_archive_targets()
  410. items = [f"{root} ({dtype})" for root, dtype in targets]
  411. self.drive_combo["values"] = items
  412. # If INI specified a folder, leave combo alone; otherwise preselect first drive
  413. if items and not self.dest_var.get():
  414. self.drive_combo.current(0)
  415. self.dest_display_var.set(items[0].split()[0])
  416. elif not items and not self.dest_var.get():
  417. self.dest_display_var.set("No destination chosen")
  418. # ---------- File list ----------
  419. def _scan_source(self, user_initiated: bool):
  420. """
  421. Refresh file list. If called programmatically (user_initiated=False),
  422. we suppress selection-reset behavior so progress bars don't clear.
  423. """
  424. try:
  425. self.suppress_selection_reset = not user_initiated
  426. self.tree.delete(*self.tree.get_children())
  427. src = self.src_var.get()
  428. if not os.path.isdir(src):
  429. self._log_ui(f"Source folder not found: {src}")
  430. self.status_var.set("Source folder not found.")
  431. return
  432. files = []
  433. want_exts = [e.lower() for e in (self.extensions or [])]
  434. for name in os.listdir(src):
  435. full = os.path.join(src, name)
  436. if not os.path.isfile(full):
  437. continue
  438. if want_exts:
  439. _, ext = os.path.splitext(name)
  440. if ext.lower() not in want_exts:
  441. continue # skip non-matching
  442. size = os.path.getsize(full)
  443. mtime = os.path.getmtime(full)
  444. files.append((name, size, mtime))
  445. files.sort(key=lambda x: x[2]) # by mtime ascending
  446. for name, size, mtime in files:
  447. human_size = self._human_bytes(size)
  448. mod = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
  449. # Use filename as iid; if duplicates exist, Treeview requires unique IDs. Add suffix if needed.
  450. iid = name
  451. if iid in self.tree.get_children(""):
  452. # unlikely here since we just cleared; keeping for safety if modified
  453. iid = f"{name}__{int(mtime)}"
  454. self.tree.insert("", "end", iid=iid, values=(name, human_size, mod))
  455. self.status_var.set(f"Found {len(files)} file(s).")
  456. finally:
  457. # Re-enable user selection reset if it was suppressed for programmatic updates
  458. self.suppress_selection_reset = False
  459. # ---------- Selection helpers ----------
  460. def _select_all(self, select=True):
  461. children = self.tree.get_children()
  462. if select:
  463. self.tree.selection_set(children)
  464. else:
  465. self.tree.selection_remove(children)
  466. def _get_selected_files(self):
  467. # Return filenames from the 'name' column for selected rows
  468. sels = list(self.tree.selection())
  469. names = []
  470. for iid in sels:
  471. vals = self.tree.item(iid, "values")
  472. if vals:
  473. names.append(vals[0])
  474. return names
  475. def _on_selection_changed(self, _evt=None):
  476. # Only reset progress bars when the USER changed selection (not programmatic updates)
  477. if self.suppress_selection_reset:
  478. return
  479. self._reset_progress_bars()
  480. def _reset_progress_bars(self):
  481. self.pb_file.config(value=0, maximum=100)
  482. sel_count = len(self._get_selected_files())
  483. self.pb_overall.config(value=0, maximum=(sel_count if sel_count else 1))
  484. # ---------- Destination resolution ----------
  485. def _resolve_destination_root(self):
  486. # If user picked a folder (dest_var), use it; otherwise use selected drive root
  487. if self.dest_var.get():
  488. return self.dest_var.get()
  489. val = self.drive_combo.get()
  490. if val:
  491. return val.split()[0]
  492. return ""
  493. # ---------- Archive workflow ----------
  494. def _start_archive(self):
  495. src = self.src_var.get()
  496. dst_root = self._resolve_destination_root()
  497. if not os.path.isdir(src):
  498. messagebox.showerror("Error", f"Source folder does not exist:\n{src}")
  499. return
  500. if not dst_root or not os.path.isdir(dst_root):
  501. messagebox.showerror("Error", "Please select a destination drive or folder.")
  502. return
  503. files = self._get_selected_files()
  504. if not files:
  505. messagebox.showinfo("Nothing selected", "Please select one or more files to archive.")
  506. return
  507. self._save_settings_if_ready()
  508. self.btn_start.config(state="disabled")
  509. self.btn_cancel.config(state="normal")
  510. self.pb_file.config(value=0, maximum=100)
  511. self.pb_overall.config(value=0, maximum=len(files))
  512. self.status_var.set("Starting…")
  513. self._log_ui(f"Archiving to: {os.path.join(dst_root, ARCHIVE_ROOT_NAME)}")
  514. self.worker = ArchiverWorker(src, dst_root, files, self.ui_queue, archive_root_name=ARCHIVE_ROOT_NAME)
  515. self.worker.start()
  516. def _cancel(self):
  517. if self.worker and self.worker.is_alive():
  518. self.worker.stop_flag.set()
  519. self._log_ui("Cancellation requested…")
  520. # ---------- Worker queue ----------
  521. def _poll_queue(self):
  522. try:
  523. while True:
  524. msg = self.ui_queue.get_nowait()
  525. self._handle_worker_message(msg)
  526. except queue.Empty:
  527. pass
  528. self.after(UI_POLL_MS, self._poll_queue)
  529. def _handle_worker_message(self, msg):
  530. kind = msg[0]
  531. if kind == "log":
  532. self._log_ui(msg[1])
  533. elif kind == "status":
  534. self.status_var.set(msg[1])
  535. elif kind == "progress_file":
  536. _, filename, done, total = msg
  537. pct = int((done / total) * 100) if total else 0
  538. self.pb_file.config(value=pct, maximum=100)
  539. elif kind == "progress_overall":
  540. _, done, total = msg
  541. self.pb_overall.config(value=done, maximum=total if total else 1)
  542. elif kind == "error":
  543. self._log_ui(f"ERROR: {msg[1]}")
  544. elif kind == "done":
  545. self._log_ui("All done.")
  546. self.status_var.set("Done.")
  547. self.btn_start.config(state="normal")
  548. self.btn_cancel.config(state="disabled")
  549. # Programmatic refresh of the list — do not reset progress bars
  550. self._scan_source(user_initiated=False)
  551. # ---------- Utils ----------
  552. def _log_ui(self, text):
  553. timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  554. self.txt_log.insert("end", f"{timestamp} {text}\n")
  555. self.txt_log.see("end")
  556. logger.info(text)
  557. @staticmethod
  558. def _human_bytes(n):
  559. for unit in ["B","KB","MB","GB","TB"]:
  560. if n < 1024.0:
  561. return f"{n:3.1f} {unit}"
  562. n /= 1024.0
  563. return f"{n:.1f} PB"
  564. def main():
  565. logger.info("=== Generic Archiver launched ===")
  566. app = App()
  567. app.mainloop()
  568. if __name__ == "__main__":
  569. main()