| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646 |
- 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()
|