diff --git a/00 PictureCollector/collect_pictures.py b/00 PictureCollector/collect_pictures.py index 4eb6ab2..2e0d843 100644 --- a/00 PictureCollector/collect_pictures.py +++ b/00 PictureCollector/collect_pictures.py @@ -10,6 +10,7 @@ Tabulky: 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). """ import os @@ -37,20 +38,15 @@ DB_CONFIG = { 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 + "Tower1": Path("/mnt/user/ZalohaVsechObrazku"), + "tower": Path("/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku"), } 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 -} +EXCLUDED_DIR_NAMES_LOWER = {"zalohavsechobrazku", "zálohavsechobrázku", "zálohavsechobrazku"} + +BATCH_SIZE = 500 LOG_FILE = Path(__file__).parent / "collect_pictures.log" @@ -97,12 +93,10 @@ 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) +ON CONFLICT (blake3_hash) DO NOTHING RETURNING id """ @@ -112,6 +106,8 @@ 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: @@ -123,12 +119,6 @@ def compute_blake3(path: Path, chunk: int = 1 << 20) -> str: 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: @@ -139,52 +129,77 @@ def dest_path_for(source: Path, hostname: str) -> Path: 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) + 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): - cur = conn.cursor() 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) - # 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(): + if src_str in known_sources: stats["preskoceno"] += 1 - if stats["preskoceno"] % 500 == 0: + if stats["preskoceno"] % 5000 == 0: log.info(f"Přeskočeno (již v DB): {stats['preskoceno']}") continue @@ -196,19 +211,13 @@ def process(conn, hostname): stats["chyb"] += 1 continue - # Existuje už záloha s tímto hashem? - cur.execute(SQL_HASH_EXISTS, (hash_val,)) - row = cur.fetchone() + zaloha_id = known_hashes.get(hash_val) - 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() + if zaloha_id is not None: + # Hash známý z prefetch — duplikát, jen zapíšeme zdroj stats["duplicit"] += 1 - log.debug(f"DUPLIKÁT {source.name} (zaloha_id={zaloha_id})") else: - # Nový unikátní soubor — zkopírovat a zapsat + # Nový hash — zkopírovat a zapsat do zaloha_obrazku dest = dest_path_for(source, hostname) try: copy_to_backup(source, dest) @@ -217,21 +226,36 @@ def process(conn, hostname): stats["chyb"] += 1 continue + cur = conn.cursor() 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)) + row = cur.fetchone() + if row: + zaloha_id = row[0] + else: + # Jiný stroj vložil mezitím stejný hash — ON CONFLICT, získáme existující ID + cur.execute(SQL_GET_ZALOHA_ID, (hash_val,)) + zaloha_id = cur.fetchone()[0] + cur.close() conn.commit() + + known_hashes[hash_val] = zaloha_id stats["kopirovano"] += 1 log.info(f"ZKOPÍROVÁNO [{stats['kopirovano']:>6}] {source}") - if stats["nalezeno"] % 1000 == 0: + 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']}" ) - cur.close() + flush_zdroje() return stats @@ -254,12 +278,12 @@ def main(): 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") + stats = {"nalezeno": 0, "kopirovano": 0, "duplicit": 0, "chyb": 0, "preskoceno": 0} try: stats = process(conn, hostname) except KeyboardInterrupt: diff --git a/00 PictureCollector/collect_pictures_windows.py b/00 PictureCollector/collect_pictures_windows.py index a538e78..4d35227 100644 --- a/00 PictureCollector/collect_pictures_windows.py +++ b/00 PictureCollector/collect_pictures_windows.py @@ -9,6 +9,7 @@ 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). """ import os @@ -23,6 +24,7 @@ from datetime import datetime import blake3 import psycopg2 +from psycopg2.extras import execute_values # ── Konfigurace ─────────────────────────────────────────────────────────────── @@ -34,26 +36,25 @@ DB_CONFIG = { "database": "fotky_buzalkovi", } -# Záloha vždy na Tower1 přes UNC ZALOHA_DIR = Path(r"\\Tower1\ZalohaVsechObrazku") JPEG_EXTENSIONS = {".jpg", ".jpeg"} -# Adresáře přeskočené podle JMÉNA (kdekoliv ve stromě, case-insensitive) -EXCLUDED_DIR_NAMES = { - "ZalohaVsechObrazku", - "ZalohaVšechObrázků", - "$Recycle.Bin", - "$RECYCLE.BIN", - "System Volume Information", - "Recovery", +EXCLUDED_DIR_NAMES_LOWER = { + "zalohavsechobrazku", + "zálohavsechobrázku", + "zálohavsechobrazku", + "$recycle.bin", + "system volume information", + "recovery", } -# Celé cesty které se přeskočí (včetně podadresářů) EXCLUDED_FULL_PATHS = { - Path(os.environ.get("WINDIR", r"C:\Windows")), # C:\Windows + Path(os.environ.get("WINDIR", r"C:\Windows")), } +BATCH_SIZE = 500 + LOG_FILE = Path(__file__).parent / "collect_pictures_windows.log" # ── Logging ─────────────────────────────────────────────────────────────────── @@ -69,14 +70,12 @@ logging.basicConfig( ) log = logging.getLogger(__name__) -# ── SQL (stejné jako Linux verze) ───────────────────────────────────────────── - -SQL_HASH_EXISTS = "SELECT id FROM zaloha_obrazku WHERE blake3_hash = %s" -SQL_SOURCE_EXISTS = "SELECT id FROM zdrojove_soubory WHERE hostname = %s AND cesta_zdroje = %s" +# ── 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 """ @@ -86,10 +85,11 @@ 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]: - """Vrátí seznam lokálních pevných disků (typ FIXED), přeskočí síťové a CD.""" DRIVE_FIXED = 3 drives = [] bitmask = ctypes.windll.kernel32.GetLogicalDrives() @@ -103,13 +103,7 @@ def get_local_drives() -> list[Path]: return drives -def is_excluded_dir(name: str) -> bool: - """Vrátí True pokud jméno adresáře patří do vyloučených (case-insensitive).""" - return name.lower() in {n.lower() for n in EXCLUDED_DIR_NAMES} - - def is_excluded_path(path: Path) -> bool: - """Vrátí True pokud cesta začíná některou z EXCLUDED_FULL_PATHS.""" for excl in EXCLUDED_FULL_PATHS: try: path.relative_to(excl) @@ -128,12 +122,8 @@ def compute_blake3(path: Path, chunk: int = 1 << 20) -> str: def dest_path_for(source: Path, hostname: str) -> Path: - """ - Zkonstruuje cílovou cestu v záloze. - D:\Foto\2023\img.jpg → \\Tower1\ZalohaVsechObrazku\JMENO-PC\D\Foto\2023\img.jpg - """ - drive_letter = source.drive.rstrip(":") # "D:" → "D" - relative = source.relative_to(source.drive + "\\") # "Foto\2023\img.jpg" + drive_letter = source.drive.rstrip(":") + relative = source.relative_to(source.drive + "\\") return ZALOHA_DIR / hostname / drive_letter / relative @@ -145,41 +135,78 @@ def copy_to_backup(source: Path, dest: Path) -> None: def iter_jpeg_files(drives: list[Path]): - """Generátor: prochází všechny lokální disky, vrací JPG/JPEG soubory.""" for drive in drives: log.info(f"Skenuji disk: {drive}") for root, dirs, files in os.walk(drive, followlinks=False): root_path = Path(root) - # Přeskočit celé vyloučené cesty (C:\Windows apod.) if is_excluded_path(root_path): dirs.clear() continue - # Odfiltrovat vyloučené adresáře podle jména dirs[:] = [ d for d in dirs - if not is_excluded_dir(d) and not is_excluded_path(root_path / d) + 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): - cur = conn.cursor() + 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) - cur.execute(SQL_SOURCE_EXISTS, (hostname, src_str)) - if cur.fetchone(): + if src_str in known_sources: stats["preskoceno"] += 1 - if stats["preskoceno"] % 500 == 0: + if stats["preskoceno"] % 5000 == 0: log.info(f"Přeskočeno (již v DB): {stats['preskoceno']}") continue @@ -191,15 +218,10 @@ def process(conn, hostname, drives): stats["chyb"] += 1 continue - cur.execute(SQL_HASH_EXISTS, (hash_val,)) - row = cur.fetchone() + zaloha_id = known_hashes.get(hash_val) - if row: - zaloha_id = row[0] - cur.execute(SQL_INSERT_ZDROJ, (hostname, src_str, source.name, velikost, hash_val, zaloha_id)) - conn.commit() + if zaloha_id is not None: stats["duplicit"] += 1 - log.debug(f"DUPLIKÁT {source.name} (zaloha_id={zaloha_id})") else: dest = dest_path_for(source, hostname) try: @@ -209,21 +231,35 @@ def process(conn, hostname, drives): stats["chyb"] += 1 continue + cur = conn.cursor() 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)) + 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() + + known_hashes[hash_val] = zaloha_id stats["kopirovano"] += 1 log.info(f"ZKOPÍROVÁNO [{stats['kopirovano']:>6}] {source}") - if stats["nalezeno"] % 1000 == 0: + 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']}" ) - cur.close() + flush_zdroje() return stats @@ -245,26 +281,24 @@ def main(): 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() - stats = {} except Exception as e: log.error(f"Neočekávaná chyba: {e}", exc_info=True) conn.rollback() - stats = {} finally: conn.close() - if stats: - 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("-" * 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.") diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..b965b0e --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,126 @@ +# FotkyBuzalkovi — kontext pro import do nové konverzace + +> Přečti tento soubor na začátku konverzace. Obsahuje vše potřebné pro navázání bez opakování. + +--- + +## Co je projekt + +Systém pro zálohu, organizaci a tagování ~200 000 rodinných fotek. Lokální provoz, bez webových uživatelů. Rodina vlastní Nikon D80, naposledy foceno Vánoce 2025. + +--- + +## Infrastruktura + +| Server | IP | Hostname | Role | +|---------|---------------|----------|------| +| tower | 192.168.1.76 | `tower` | hlavní NAS (Unraid), spouští skripty | +| tower1 | 192.168.1.50 | `Tower1` | archivní NAS (Unraid), fyzická záloha fotek | + +**SSH:** root přístupy — tower: `root/7309208104`, tower1: `root/Vlado7309208104++` +**SSH klíče:** sdílené mezi tower a tower1. + +**PostgreSQL:** `192.168.1.76:5432`, user `vladimir.buzalka`, heslo `Vlado7309208104++`, DB `fotky_buzalkovi` + +**Záloha fotek:** vždy fyzicky na Tower1 → `/mnt/user/ZalohaVsechObrazku` +- Z tower přes NFS: `/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku` +- Z Windows PC přes UNC: `\\Tower1\ZalohaVsechObrazku` + +--- + +## Klíčová architektonická rozhodnutí + +- **Databáze:** pouze PostgreSQL + filesystem (MongoDB a Redis vyřazeny) +- **Identita fotky:** 3 úrovně hashů — `sha256_file` (byte), `sha256_pixels` (pixely), `phash` (vizuální podobnost). BLAKE3 pro deduplikaci zálohy. +- **EXIF parser:** ExifRead je primární (Pillow má GPS bug) +- **Embedding** (CLIP/pgvector) — odloženo na později + +--- + +## DB tabulky v fotky_buzalkovi + +### Starší (z předchozí fáze) +- `photos` — sha256_file UNIQUE, phash, EXIF JSONB, indexy +- `tags`, `photo_tags` — tagování + +### Zálohovací pipeline (vytvořeno 2026-05-24) +```sql +zaloha_obrazku + id SERIAL PK, blake3_hash VARCHAR(64) UNIQUE, cesta_zalohy TEXT, + nazev_souboru VARCHAR(512), velikost BIGINT, datum_kopirovani TIMESTAMP + +zdrojove_soubory + id SERIAL PK, hostname VARCHAR(255), cesta_zdroje TEXT, + nazev_souboru VARCHAR(512), velikost BIGINT, datum_nalezeni TIMESTAMP, + blake3_hash VARCHAR(64), zaloha_id INTEGER → FK zaloha_obrazku(id) + UNIQUE(hostname, cesta_zdroje) +``` + +--- + +## Zálohovací skripty + +### Linux — servery (Unraid) +**Soubor:** `00 PictureCollector/collect_pictures.py` +**Nasazen jako Unraid User Script "CollectPictures" na obou serverech:** +- `/boot/config/plugins/user.scripts/scripts/CollectPictures/` + +**Logika:** +1. Skenuje `/mnt/user/` rekurzivně +2. Přeskočí adresáře v `EXCLUDED_DIR_NAMES` (case-insensitive): `ZalohaVsechObrazku`, `ZalohaVšechObrázků` +3. Pro každý JPG/JPEG spočítá BLAKE3 hash +4. Nový hash → zkopíruje do zálohy + zapíše do obou tabulek +5. Duplikát → jen zapíše do `zdrojove_soubory`, nekopíruje +6. Bezpečné pro opakované spuštění — přeskočí soubory již v DB + +**ZALOHA_DIR podle hostname (automaticky):** +```python +"Tower1" → /mnt/user/ZalohaVsechObrazku # lokální +"tower" → /mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku # NFS +``` + +**Cesta zálohy:** `ZalohaVsechObrazku/{hostname}/Foto/2023/img.jpg` + +**Závislosti:** `blake3`, `psycopg2-binary` — nainstalováno na obou serverech 2026-05-24. + +--- + +### Windows — libovolný PC v síti +**Soubor:** `00 PictureCollector/collect_pictures_windows.py` + +**Rozdíly:** +- Skenuje všechny `DRIVE_FIXED` disky (ne síťové, ne CD) — detekce přes `ctypes` +- Záloha přes UNC: `\\Tower1\ZalohaVsechObrazku` +- Cesta zálohy: `ZalohaVsechObrazku\{hostname}\{disk}\cesta` (např. `…\JMENO-PC\D\Foto\img.jpg`) +- Navíc přeskakuje: `C:\Windows`, `$Recycle.Bin`, `System Volume Information`, `Recovery` +- Instalace: `pip install blake3 psycopg2-binary` + +--- + +## Pomocné skripty (Windows, lokálně v 00 PictureCollector\) + +| Soubor | Účel | +|--------|------| +| `ssh_deploy.py` | Nasadí skript na tower | +| `ssh_deploy_tower1.py` | Nasadí skript na Tower1 | +| `stats.py` | Statistiky zálohy z DB (počty, velikosti, per hostname) | +| `create_tables.py` | Vytvoří tabulky v DB | +| `verify_tables.py` | Ověří strukturu tabulek | +| `clear_tables.py` | TRUNCATE obou tabulek (pozor!) | + +--- + +## Stav k 2026-05-25 + +- collect_pictures.py běží na tower (probíhá, 100TB NAS) — k 2026-05-25 ráno: 54 GB / 36 598 unikátních souborů +- Tower1 spuštěn, přidal 104 souborů +- Windows skript připraven, zatím nespuštěn na žádném PC + +--- + +## Otevřené otázky + +1. Co s "sirotky" bez EXIF — importovat s mtime / odmítnout / označit? +2. Při shodě `sha256_pixels` — přeskočit / sloučit metadata / uložit oba jako související? +3. Storage layout — nechat in-place / `archiv/YYYY/MM/` / content-addressable? +4. Navázat prací s daty až doběhnou servery — EXIF analýza, podobné fotky, organizace, prohlížeč