""" collect_pictures.py ------------------- Prochází všechny shares pod /mnt/user/ (kromě ZalohaVsechObrazku), najde JPG/JPEG soubory, zkopíruje je (deduplikace BLAKE3) do /mnt/user/ZalohaVsechObrazku/ a zapíše záznamy do PostgreSQL. Tabulky: zaloha_obrazku – jedna fyzická záloha na unikátní BLAKE3 hash zdrojove_soubory – všechny nalezené zdrojové soubory (i duplikáty) Bezpečné pro opakované spuštění (pokračuje tam, kde skončilo). """ import os import sys import shutil import socket 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", } SOURCE_BASE = Path("/mnt/user") JPEG_EXTENSIONS = {".jpg", ".jpeg"} # Cílová zálohovací složka podle hostname — vždy fyzicky na Tower1 # Klíč = socket.gethostname(), hodnota = lokální cesta k záloze ZALOHA_DIR_MAP = { "Tower1": Path("/mnt/user/ZalohaVsechObrazku"), # Tower1: lokální share "tower": Path("/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku"), # tower: NFS mount na Tower1 } ZALOHA_DIR_DEFAULT = Path("/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku") # Adresáře které se NIKDY nepoužijí jako zdroj (porovnává se jméno složky, kdekoliv ve stromě) EXCLUDED_DIR_NAMES = { "ZalohaVsechObrazku", "ZalohaVšechObrázků", "zalohavsechobrazku", # lowercase varianta pro jistotu } LOG_FILE = Path(__file__).parent / "collect_pictures.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_CREATE_TABLES = """ CREATE TABLE IF NOT EXISTS zaloha_obrazku ( id SERIAL PRIMARY KEY, blake3_hash VARCHAR(64) UNIQUE NOT NULL, cesta_zalohy TEXT NOT NULL, nazev_souboru VARCHAR(512) NOT NULL, velikost BIGINT, datum_kopirovani TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS zdrojove_soubory ( id SERIAL PRIMARY KEY, hostname VARCHAR(255) NOT NULL, cesta_zdroje TEXT NOT NULL, nazev_souboru VARCHAR(512) NOT NULL, velikost BIGINT, datum_nalezeni TIMESTAMP DEFAULT NOW(), blake3_hash VARCHAR(64) NOT NULL, zaloha_id INTEGER REFERENCES zaloha_obrazku(id), UNIQUE (hostname, cesta_zdroje) ); CREATE INDEX IF NOT EXISTS idx_zaloha_hash ON zaloha_obrazku (blake3_hash); CREATE INDEX IF NOT EXISTS idx_zdroj_hash ON zdrojove_soubory (blake3_hash); CREATE INDEX IF NOT EXISTS idx_zdroj_zaloha ON zdrojove_soubory (zaloha_id); CREATE INDEX IF NOT EXISTS idx_zdroj_host ON zdrojove_soubory (hostname); """ SQL_HASH_EXISTS = "SELECT id, cesta_zalohy FROM zaloha_obrazku WHERE blake3_hash = %s" SQL_SOURCE_EXISTS = "SELECT id FROM zdrojove_soubory WHERE hostname = %s AND cesta_zdroje = %s" SQL_INSERT_ZALOHA = """ INSERT INTO zaloha_obrazku (blake3_hash, cesta_zalohy, nazev_souboru, velikost) VALUES (%s, %s, %s, %s) 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 """ # ── Pomocné funkce ──────────────────────────────────────────────────────────── 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: """ Záloha vždy na TOWER1 pod /mnt/user/ZalohaVsechObrazku/{hostname}/... Příklad: tower /mnt/user/Foto/2023/img.jpg → /mnt/user/ZalohaVsechObrazku/tower/Foto/2023/img.jpg tower1 /mnt/user/Foto/2023/img.jpg → /mnt/user/ZalohaVsechObrazku/tower1/Foto/2023/img.jpg """ try: relative = source.relative_to(SOURCE_BASE) except ValueError: relative = Path(source.name) return ZALOHA_DIR / hostname / relative def copy_to_backup(source: Path, dest: Path) -> None: dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): # Soubor na cílovém místě už je — nepřepisujeme, záloha platí return shutil.copy2(source, dest) def is_excluded_dir(name: str) -> bool: """Vrátí True pokud jméno adresáře patří do seznamu vyloučených (case-insensitive).""" return name.lower() in {n.lower() for n in EXCLUDED_DIR_NAMES} def iter_jpeg_files(base: Path): """Generátor: vrací Path na každý JPG/JPEG v base (rekurzivně). Přeskočí ZALOHA_DIR a jakýkoliv adresář jehož jméno je v EXCLUDED_DIR_NAMES. """ for root, dirs, files in os.walk(base, followlinks=False): root_path = Path(root) # Neprocházet zálohovací složku (podle plné cesty) if ZALOHA_DIR in (root_path, *root_path.parents) or root_path == ZALOHA_DIR: dirs.clear() continue # Odfiltrovat vyloučené adresáře ze subadresářů (podle jména, kdekoliv ve stromě) dirs[:] = [ d for d in dirs if root_path / d != ZALOHA_DIR and not is_excluded_dir(d) ] for fname in files: if Path(fname).suffix.lower() in JPEG_EXTENSIONS: yield root_path / fname # ── Hlavní logika ───────────────────────────────────────────────────────────── def process(conn, hostname): cur = conn.cursor() log.info(f"Hostname zdroje: {hostname}") stats = {"nalezeno": 0, "kopirovano": 0, "duplicit": 0, "chyb": 0, "preskoceno": 0} for source in iter_jpeg_files(SOURCE_BASE): stats["nalezeno"] += 1 src_str = str(source) # Přeskočit zdroje, které už jsou v DB zpracovány (pro tento hostname) cur.execute(SQL_SOURCE_EXISTS, (hostname, src_str)) if cur.fetchone(): stats["preskoceno"] += 1 if stats["preskoceno"] % 500 == 0: log.info(f"Přeskočeno (již v DB): {stats['preskoceno']}") continue 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 # Existuje už záloha s tímto hashem? cur.execute(SQL_HASH_EXISTS, (hash_val,)) row = cur.fetchone() if row: # Duplikát — jen zapíšeme zdroj, nekopírujeme zaloha_id = row[0] cur.execute(SQL_INSERT_ZDROJ, (hostname, src_str, source.name, velikost, hash_val, zaloha_id)) conn.commit() stats["duplicit"] += 1 log.debug(f"DUPLIKÁT {source.name} (zaloha_id={zaloha_id})") else: # Nový unikátní soubor — zkopírovat a zapsat 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 cur.execute(SQL_INSERT_ZALOHA, (hash_val, str(dest), source.name, velikost)) zaloha_id = cur.fetchone()[0] cur.execute(SQL_INSERT_ZDROJ, (hostname, src_str, source.name, velikost, hash_val, zaloha_id)) conn.commit() stats["kopirovano"] += 1 log.info(f"ZKOPÍROVÁNO [{stats['kopirovano']:>6}] {source}") if stats["nalezeno"] % 1000 == 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']}" ) cur.close() return stats def main(): global ZALOHA_DIR hostname = socket.gethostname() ZALOHA_DIR = ZALOHA_DIR_MAP.get(hostname, ZALOHA_DIR_DEFAULT) 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"Zdroj: {SOURCE_BASE}") 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) # Vytvoř tabulky pokud neexistují with conn.cursor() as cur: cur.execute(SQL_CREATE_TABLES) conn.commit() log.info("Tabulky: OK") try: stats = process(conn, hostname) except KeyboardInterrupt: log.warning("Přerušeno uživatelem (Ctrl+C) — 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()