""" collect_pictures_windows.py --------------------------- Windows verze zálohovacího skriptu. Prochází všechny lokální disky (ne síťové, ne CD), hledá JPG/JPEG, kopíruje (deduplikace BLAKE3) na Tower1 přes UNC cestu a zapisuje do PostgreSQL. Cesta zálohy: \\Tower1\ZalohaVsechObrazku\{hostname}\{písmeno_disku}\{cesta} Příklad: \\Tower1\ZalohaVsechObrazku\JMENO-PC\D\Foto\2023\img.jpg Bezpečné pro opakované spuštění (pokračuje tam kde skončilo). Bezpečné pro souběžný běh na více strojích (ON CONFLICT v SQL). Předpoklad pro ukládání cest do DB: Ať už je hostname Windows stroje jakýkoliv, Tower1 (druhý Unraid server) je vždy dostupný jako \\Tower1\ZalohaVsechObrazku\... Fyzické kopírování probíhá přes tuto UNC cestu, ale do DB se ukládá vždy nativní Linux cesta Tower1, tj. /mnt/user/ZalohaVsechObrazku/... (\\Tower1 → /mnt/user, zpětná lomítka → dopředná lomítka). Tím jsou cesty v DB jednotné bez ohledu na to, který stroj zálohu pořídil. """ import os import sys import time import shutil import socket import string import ctypes import logging from pathlib import Path from datetime import datetime import blake3 import psycopg2 from psycopg2.extras import execute_values # ── Konfigurace ─────────────────────────────────────────────────────────────── DB_CONFIG = { "host": "192.168.1.76", "port": 5432, "user": "vladimir.buzalka", "password": "Vlado7309208104++", "database": "fotky_buzalkovi", } ZALOHA_DIR = Path(r"\\Tower1\ZalohaVsechObrazku") # Normalizace cesty pro DB — Windows UNC cestu převedeme na nativní Tower1 Linux cestu. # \\Tower1\ZalohaVsechObrazku\... → /mnt/user/ZalohaVsechObrazku/... WINDOWS_UNC_PREFIX = r"\\Tower1" LINUX_NATIVE_PREFIX = "/mnt/user" def normalize_path_for_db(path: Path) -> str: """Převede \\Tower1\ZalohaVsechObrazku\... na /mnt/user/ZalohaVsechObrazku/...""" s = str(path) if s.startswith(WINDOWS_UNC_PREFIX): return LINUX_NATIVE_PREFIX + s[len(WINDOWS_UNC_PREFIX):].replace("\\", "/") return s JPEG_EXTENSIONS = {".jpg", ".jpeg"} EXCLUDED_DIR_NAMES_LOWER = { "zalohavsechobrazku", "zálohavsechobrázku", "zálohavsechobrazku", "$recycle.bin", "system volume information", "recovery", } EXCLUDED_FULL_PATHS = { Path(os.environ.get("WINDIR", r"C:\Windows")), } BATCH_SIZE = 500 LOG_FILE = Path(__file__).parent / "collect_pictures_windows.log" # ── Logging ─────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", handlers=[ logging.StreamHandler(sys.stdout), logging.FileHandler(LOG_FILE, encoding="utf-8"), ], ) log = logging.getLogger(__name__) # ── SQL ─────────────────────────────────────────────────────────────────────── SQL_INSERT_ZALOHA = """ INSERT INTO zaloha_obrazku (blake3_hash, cesta_zalohy, nazev_souboru, velikost) VALUES (%s, %s, %s, %s) ON CONFLICT (blake3_hash) DO NOTHING RETURNING id """ SQL_INSERT_ZDROJ = """ INSERT INTO zdrojove_soubory (hostname, cesta_zdroje, nazev_souboru, velikost, blake3_hash, zaloha_id) VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (hostname, cesta_zdroje) DO NOTHING """ SQL_GET_ZALOHA_ID = "SELECT id FROM zaloha_obrazku WHERE blake3_hash = %s" # ── Pomocné funkce ──────────────────────────────────────────────────────────── def get_local_drives() -> list[Path]: DRIVE_FIXED = 3 drives = [] bitmask = ctypes.windll.kernel32.GetLogicalDrives() for letter in string.ascii_uppercase: if bitmask & 1: drive = Path(f"{letter}:\\") drive_type = ctypes.windll.kernel32.GetDriveTypeW(str(drive)) if drive_type == DRIVE_FIXED: drives.append(drive) bitmask >>= 1 return drives def is_excluded_path(path: Path) -> bool: for excl in EXCLUDED_FULL_PATHS: try: path.relative_to(excl) return True except ValueError: pass return False def compute_blake3(path: Path, chunk: int = 1 << 20) -> str: h = blake3.blake3() with open(path, "rb") as f: while data := f.read(chunk): h.update(data) return h.hexdigest() def dest_path_for(source: Path, hostname: str) -> Path: drive_letter = source.drive.rstrip(":") relative = source.relative_to(source.drive + "\\") return ZALOHA_DIR / hostname / drive_letter / relative def copy_to_backup(source: Path, dest: Path) -> None: dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): return shutil.copy2(source, dest) def iter_jpeg_files(drives: list[Path]): for drive in drives: log.info(f"Skenuji disk: {drive}") for root, dirs, files in os.walk(drive, followlinks=False): root_path = Path(root) if is_excluded_path(root_path): dirs.clear() continue dirs[:] = [ d for d in dirs if d.lower() not in EXCLUDED_DIR_NAMES_LOWER and not is_excluded_path(root_path / d) ] for fname in files: if Path(fname).suffix.lower() in JPEG_EXTENSIONS: yield root_path / fname def load_known_sources(conn, hostname: str) -> set[str]: with conn.cursor("src_cursor") as cur: cur.itersize = 10000 cur.execute("SELECT cesta_zdroje FROM zdrojove_soubory WHERE hostname = %s", (hostname,)) return {row[0] for row in cur} def load_known_hashes(conn) -> dict[str, int]: with conn.cursor("hash_cursor") as cur: cur.itersize = 10000 cur.execute("SELECT blake3_hash, id FROM zaloha_obrazku") return {row[0]: row[1] for row in cur} # ── Hlavní logika ───────────────────────────────────────────────────────────── def process(conn, hostname, drives): log.info(f"Hostname zdroje: {hostname}") log.info("Načítám známé zdroje z DB...") known_sources = load_known_sources(conn, hostname) log.info(f"Známých zdrojů v DB: {len(known_sources)}") log.info("Načítám známé hashe z DB...") known_hashes = load_known_hashes(conn) log.info(f"Známých hashů v DB: {len(known_hashes)}") stats = {"nalezeno": 0, "kopirovano": 0, "duplicit": 0, "chyb": 0, "preskoceno": 0} pending_zdroje = [] def flush_zdroje(): if not pending_zdroje: return cur = conn.cursor() execute_values( cur, """INSERT INTO zdrojove_soubory (hostname, cesta_zdroje, nazev_souboru, velikost, blake3_hash, zaloha_id) VALUES %s ON CONFLICT (hostname, cesta_zdroje) DO NOTHING""", pending_zdroje, ) conn.commit() cur.close() pending_zdroje.clear() for source in iter_jpeg_files(drives): stats["nalezeno"] += 1 src_str = str(source) if src_str in known_sources: stats["preskoceno"] += 1 if stats["preskoceno"] % 5000 == 0: log.info(f"Přeskočeno (již v DB): {stats['preskoceno']}") continue t_start = time.perf_counter() try: velikost = source.stat().st_size hash_val = compute_blake3(source) except (OSError, PermissionError) as e: log.warning(f"CHYBA čtení: {source} → {e}") stats["chyb"] += 1 continue t_hash = time.perf_counter() zaloha_id = known_hashes.get(hash_val) if zaloha_id is not None: stats["duplicit"] += 1 vel_mb = velikost / (1024 * 1024) log.info( f"DUPLIKÁT {source.name} " f"({vel_mb:.1f} MB, hash={t_hash - t_start:.2f}s)" ) else: dest = dest_path_for(source, hostname) try: copy_to_backup(source, dest) except (OSError, shutil.Error) as e: log.warning(f"CHYBA kopírování: {source} → {e}") stats["chyb"] += 1 continue t_copy = time.perf_counter() cur = conn.cursor() cur.execute(SQL_INSERT_ZALOHA, (hash_val, normalize_path_for_db(dest), source.name, velikost)) row = cur.fetchone() if row: zaloha_id = row[0] else: cur.execute(SQL_GET_ZALOHA_ID, (hash_val,)) zaloha_id = cur.fetchone()[0] cur.close() conn.commit() t_db = time.perf_counter() known_hashes[hash_val] = zaloha_id stats["kopirovano"] += 1 vel_mb = velikost / (1024 * 1024) log.info( f"ZKOPÍROVÁNO [{stats['kopirovano']:>6}] {source.name} " f"({vel_mb:.1f} MB, hash={t_hash - t_start:.2f}s " f"copy={t_copy - t_hash:.2f}s db={t_db - t_copy:.2f}s " f"celkem={t_db - t_start:.2f}s)" ) pending_zdroje.append((hostname, src_str, source.name, velikost, hash_val, zaloha_id)) known_sources.add(src_str) if len(pending_zdroje) >= BATCH_SIZE: flush_zdroje() if stats["nalezeno"] % 5000 == 0: log.info( f"Průběh: nalezeno={stats['nalezeno']} " f"nových={stats['kopirovano']} duplikátů={stats['duplicit']} " f"chyb={stats['chyb']} přeskočeno={stats['preskoceno']}" ) flush_zdroje() return stats def main(): hostname = socket.gethostname() drives = get_local_drives() log.info("=" * 60) log.info(f"Spuštění: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") log.info(f"Hostname: {hostname}") log.info(f"Disky: {[str(d) for d in drives]}") log.info(f"Záloha: {ZALOHA_DIR}\\{hostname}\\") try: conn = psycopg2.connect(**DB_CONFIG) conn.autocommit = False log.info("PostgreSQL: připojeno") except Exception as e: log.error(f"Nelze se připojit k DB: {e}") sys.exit(1) stats = {"nalezeno": 0, "kopirovano": 0, "duplicit": 0, "chyb": 0, "preskoceno": 0} try: stats = process(conn, hostname, drives) except KeyboardInterrupt: log.warning("Přerušeno uživatelem — dosavadní záznamy jsou uloženy.") conn.rollback() except Exception as e: log.error(f"Neočekávaná chyba: {e}", exc_info=True) conn.rollback() finally: conn.close() log.info("-" * 60) log.info(f"Nalezeno JPG/JPEG: {stats['nalezeno']}") log.info(f"Zkopírováno nových: {stats['kopirovano']}") log.info(f"Duplikátů (hash): {stats['duplicit']}") log.info(f"Přeskočeno (v DB): {stats['preskoceno']}") log.info(f"Chyb: {stats['chyb']}") log.info("Hotovo.") if __name__ == "__main__": main()