""" 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). Bezpečné pro souběžný běh na více strojích (ON CONFLICT v SQL). Předpoklad pro hostname a ukládání cest do DB: Skript běží na dvou Unraid serverech s různým přístupem k zálohovacímu share: hostname = Tower1 (druhý Unraid server — vlastník zálohy): Zálohovací share je nativní: /mnt/user/ZalohaVsechObrazku/ Cesta se ukládá do DB beze změny — je již v kanonickém tvaru. hostname = tower (první Unraid server — zálohu zapisuje přes remote mount): Zálohovací share je mountován jako: /mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku/ Fyzické kopírování probíhá přes tento remote mount, ale do DB se ukládá vždy nativní Tower1 cesta: /mnt/user/ZalohaVsechObrazku/... (PATH_NORMALIZE_MAP zajistí přepis prefixu před INSERTem). Tím jsou cesty v DB jednotné bez ohledu na to, který server zálohu pořídil. """ import os import sys import time 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"} ZALOHA_DIR_MAP = { "Tower1": Path("/mnt/user/ZalohaVsechObrazku"), "tower": Path("/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku"), } ZALOHA_DIR_DEFAULT = Path("/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku") # Normalizace cesty pro DB — vždy ukládáme nativní Tower1 cestu, # bez ohledu na to, přes jaký mount skript soubor fyzicky zapsal. PATH_NORMALIZE_MAP = { "/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku": "/mnt/user/ZalohaVsechObrazku", } EXCLUDED_DIR_NAMES_LOWER = {"zalohavsechobrazku", "zálohavsechobrázku", "zálohavsechobrazku"} BATCH_SIZE = 500 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_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 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 normalize_path_for_db(path: Path) -> str: """Převede fyzickou cestu zálohy na nativní Tower1 cestu pro uložení do DB.""" s = str(path) for remote, native in PATH_NORMALIZE_MAP.items(): if s.startswith(remote): return native + s[len(remote):] return s def dest_path_for(source: Path, hostname: str) -> Path: 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(): return shutil.copy2(source, dest) def iter_jpeg_files(base: Path): for root, dirs, files in os.walk(base, followlinks=False): root_path = Path(root) if ZALOHA_DIR in (root_path, *root_path.parents) or root_path == ZALOHA_DIR: dirs.clear() continue dirs[:] = [ d for d in dirs if root_path / d != ZALOHA_DIR and d.lower() not in EXCLUDED_DIR_NAMES_LOWER ] 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): 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(SOURCE_BASE): 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(): 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) with conn.cursor() as cur: cur.execute(SQL_CREATE_TABLES) conn.commit() log.info("Tabulky: OK") stats = {"nalezeno": 0, "kopirovano": 0, "duplicit": 0, "chyb": 0, "preskoceno": 0} 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()