notebookVb

This commit is contained in:
administrator
2026-05-25 06:39:49 +02:00
parent 4d8888a598
commit db84c3e501
3 changed files with 291 additions and 107 deletions
+74 -50
View File
@@ -10,6 +10,7 @@ Tabulky:
zdrojove_soubory všechny nalezené zdrojové soubory (i duplikáty) 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 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 import os
@@ -37,20 +38,15 @@ DB_CONFIG = {
SOURCE_BASE = Path("/mnt/user") SOURCE_BASE = Path("/mnt/user")
JPEG_EXTENSIONS = {".jpg", ".jpeg"} 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 = { ZALOHA_DIR_MAP = {
"Tower1": Path("/mnt/user/ZalohaVsechObrazku"), # Tower1: lokální share "Tower1": Path("/mnt/user/ZalohaVsechObrazku"),
"tower": Path("/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku"), # tower: NFS mount na Tower1 "tower": Path("/mnt/remotes/TOWER1.LAN_ZalohaVsechObrazku"),
} }
ZALOHA_DIR_DEFAULT = 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_LOWER = {"zalohavsechobrazku", "zálohavsechobrázku", "zálohavsechobrazku"}
EXCLUDED_DIR_NAMES = {
"ZalohaVsechObrazku", BATCH_SIZE = 500
"ZalohaVšechObrázků",
"zalohavsechobrazku", # lowercase varianta pro jistotu
}
LOG_FILE = Path(__file__).parent / "collect_pictures.log" 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); 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 = """ SQL_INSERT_ZALOHA = """
INSERT INTO zaloha_obrazku (blake3_hash, cesta_zalohy, nazev_souboru, velikost) INSERT INTO zaloha_obrazku (blake3_hash, cesta_zalohy, nazev_souboru, velikost)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
ON CONFLICT (blake3_hash) DO NOTHING
RETURNING id RETURNING id
""" """
@@ -112,6 +106,8 @@ VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (hostname, cesta_zdroje) DO NOTHING ON CONFLICT (hostname, cesta_zdroje) DO NOTHING
""" """
SQL_GET_ZALOHA_ID = "SELECT id FROM zaloha_obrazku WHERE blake3_hash = %s"
# ── Pomocné funkce ──────────────────────────────────────────────────────────── # ── Pomocné funkce ────────────────────────────────────────────────────────────
def compute_blake3(path: Path, chunk: int = 1 << 20) -> str: 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: 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: try:
relative = source.relative_to(SOURCE_BASE) relative = source.relative_to(SOURCE_BASE)
except ValueError: except ValueError:
@@ -139,52 +129,77 @@ def dest_path_for(source: Path, hostname: str) -> Path:
def copy_to_backup(source: Path, dest: Path) -> None: def copy_to_backup(source: Path, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists(): if dest.exists():
# Soubor na cílovém místě už je — nepřepisujeme, záloha platí
return return
shutil.copy2(source, dest) 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): 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): for root, dirs, files in os.walk(base, followlinks=False):
root_path = Path(root) 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: if ZALOHA_DIR in (root_path, *root_path.parents) or root_path == ZALOHA_DIR:
dirs.clear() dirs.clear()
continue continue
# Odfiltrovat vyloučené adresáře ze subadresářů (podle jména, kdekoliv ve stromě)
dirs[:] = [ dirs[:] = [
d for d in 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: for fname in files:
if Path(fname).suffix.lower() in JPEG_EXTENSIONS: if Path(fname).suffix.lower() in JPEG_EXTENSIONS:
yield root_path / fname 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 ───────────────────────────────────────────────────────────── # ── Hlavní logika ─────────────────────────────────────────────────────────────
def process(conn, hostname): def process(conn, hostname):
cur = conn.cursor()
log.info(f"Hostname zdroje: {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} 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): for source in iter_jpeg_files(SOURCE_BASE):
stats["nalezeno"] += 1 stats["nalezeno"] += 1
src_str = str(source) src_str = str(source)
# Přeskočit zdroje, které už jsou v DB zpracovány (pro tento hostname) if src_str in known_sources:
cur.execute(SQL_SOURCE_EXISTS, (hostname, src_str))
if cur.fetchone():
stats["preskoceno"] += 1 stats["preskoceno"] += 1
if stats["preskoceno"] % 500 == 0: if stats["preskoceno"] % 5000 == 0:
log.info(f"Přeskočeno (již v DB): {stats['preskoceno']}") log.info(f"Přeskočeno (již v DB): {stats['preskoceno']}")
continue continue
@@ -196,19 +211,13 @@ def process(conn, hostname):
stats["chyb"] += 1 stats["chyb"] += 1
continue continue
# Existuje už záloha s tímto hashem? zaloha_id = known_hashes.get(hash_val)
cur.execute(SQL_HASH_EXISTS, (hash_val,))
row = cur.fetchone()
if row: if zaloha_id is not None:
# Duplikát jen zapíšeme zdroj, nekopírujeme # Hash známý z prefetch — duplikát, jen zapíšeme zdroj
zaloha_id = row[0]
cur.execute(SQL_INSERT_ZDROJ, (hostname, src_str, source.name, velikost, hash_val, zaloha_id))
conn.commit()
stats["duplicit"] += 1 stats["duplicit"] += 1
log.debug(f"DUPLIKÁT {source.name} (zaloha_id={zaloha_id})")
else: else:
# Nový unikátní soubor — zkopírovat a zapsat # Nový hash — zkopírovat a zapsat do zaloha_obrazku
dest = dest_path_for(source, hostname) dest = dest_path_for(source, hostname)
try: try:
copy_to_backup(source, dest) copy_to_backup(source, dest)
@@ -217,21 +226,36 @@ def process(conn, hostname):
stats["chyb"] += 1 stats["chyb"] += 1
continue continue
cur = conn.cursor()
cur.execute(SQL_INSERT_ZALOHA, (hash_val, str(dest), source.name, velikost)) cur.execute(SQL_INSERT_ZALOHA, (hash_val, str(dest), source.name, velikost))
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] zaloha_id = cur.fetchone()[0]
cur.execute(SQL_INSERT_ZDROJ, (hostname, src_str, source.name, velikost, hash_val, zaloha_id)) cur.close()
conn.commit() conn.commit()
known_hashes[hash_val] = zaloha_id
stats["kopirovano"] += 1 stats["kopirovano"] += 1
log.info(f"ZKOPÍROVÁNO [{stats['kopirovano']:>6}] {source}") 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( log.info(
f"Průběh: nalezeno={stats['nalezeno']} " f"Průběh: nalezeno={stats['nalezeno']} "
f"nových={stats['kopirovano']} duplikátů={stats['duplicit']} " f"nových={stats['kopirovano']} duplikátů={stats['duplicit']} "
f"chyb={stats['chyb']} přeskočeno={stats['preskoceno']}" f"chyb={stats['chyb']} přeskočeno={stats['preskoceno']}"
) )
cur.close() flush_zdroje()
return stats return stats
@@ -254,12 +278,12 @@ def main():
log.error(f"Nelze se připojit k DB: {e}") log.error(f"Nelze se připojit k DB: {e}")
sys.exit(1) sys.exit(1)
# Vytvoř tabulky pokud neexistují
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(SQL_CREATE_TABLES) cur.execute(SQL_CREATE_TABLES)
conn.commit() conn.commit()
log.info("Tabulky: OK") log.info("Tabulky: OK")
stats = {"nalezeno": 0, "kopirovano": 0, "duplicit": 0, "chyb": 0, "preskoceno": 0}
try: try:
stats = process(conn, hostname) stats = process(conn, hostname)
except KeyboardInterrupt: except KeyboardInterrupt:
+83 -49
View File
@@ -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 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 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 import os
@@ -23,6 +24,7 @@ from datetime import datetime
import blake3 import blake3
import psycopg2 import psycopg2
from psycopg2.extras import execute_values
# ── Konfigurace ─────────────────────────────────────────────────────────────── # ── Konfigurace ───────────────────────────────────────────────────────────────
@@ -34,26 +36,25 @@ DB_CONFIG = {
"database": "fotky_buzalkovi", "database": "fotky_buzalkovi",
} }
# Záloha vždy na Tower1 přes UNC
ZALOHA_DIR = Path(r"\\Tower1\ZalohaVsechObrazku") ZALOHA_DIR = Path(r"\\Tower1\ZalohaVsechObrazku")
JPEG_EXTENSIONS = {".jpg", ".jpeg"} JPEG_EXTENSIONS = {".jpg", ".jpeg"}
# Adresáře přeskočené podle JMÉNA (kdekoliv ve stromě, case-insensitive) EXCLUDED_DIR_NAMES_LOWER = {
EXCLUDED_DIR_NAMES = { "zalohavsechobrazku",
"ZalohaVsechObrazku", "zálohavsechobrázku",
"ZalohaechObrázků", "lohavsechobrazku",
"$Recycle.Bin", "$recycle.bin",
"$RECYCLE.BIN", "system volume information",
"System Volume Information", "recovery",
"Recovery",
} }
# Celé cesty které se přeskočí (včetně podadresářů)
EXCLUDED_FULL_PATHS = { 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" LOG_FILE = Path(__file__).parent / "collect_pictures_windows.log"
# ── Logging ─────────────────────────────────────────────────────────────────── # ── Logging ───────────────────────────────────────────────────────────────────
@@ -69,14 +70,12 @@ logging.basicConfig(
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# ── SQL (stejné jako Linux verze) ───────────────────────────────────────────── # ── SQL ───────────────────────────────────────────────────────────────────────
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_INSERT_ZALOHA = """ SQL_INSERT_ZALOHA = """
INSERT INTO zaloha_obrazku (blake3_hash, cesta_zalohy, nazev_souboru, velikost) INSERT INTO zaloha_obrazku (blake3_hash, cesta_zalohy, nazev_souboru, velikost)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
ON CONFLICT (blake3_hash) DO NOTHING
RETURNING id RETURNING id
""" """
@@ -86,10 +85,11 @@ VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (hostname, cesta_zdroje) DO NOTHING ON CONFLICT (hostname, cesta_zdroje) DO NOTHING
""" """
SQL_GET_ZALOHA_ID = "SELECT id FROM zaloha_obrazku WHERE blake3_hash = %s"
# ── Pomocné funkce ──────────────────────────────────────────────────────────── # ── Pomocné funkce ────────────────────────────────────────────────────────────
def get_local_drives() -> list[Path]: 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 DRIVE_FIXED = 3
drives = [] drives = []
bitmask = ctypes.windll.kernel32.GetLogicalDrives() bitmask = ctypes.windll.kernel32.GetLogicalDrives()
@@ -103,13 +103,7 @@ def get_local_drives() -> list[Path]:
return drives 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: 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: for excl in EXCLUDED_FULL_PATHS:
try: try:
path.relative_to(excl) 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: def dest_path_for(source: Path, hostname: str) -> Path:
""" drive_letter = source.drive.rstrip(":")
Zkonstruuje cílovou cestu v záloze. relative = source.relative_to(source.drive + "\\")
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"
return ZALOHA_DIR / hostname / drive_letter / relative 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]): def iter_jpeg_files(drives: list[Path]):
"""Generátor: prochází všechny lokální disky, vrací JPG/JPEG soubory."""
for drive in drives: for drive in drives:
log.info(f"Skenuji disk: {drive}") log.info(f"Skenuji disk: {drive}")
for root, dirs, files in os.walk(drive, followlinks=False): for root, dirs, files in os.walk(drive, followlinks=False):
root_path = Path(root) root_path = Path(root)
# Přeskočit celé vyloučené cesty (C:\Windows apod.)
if is_excluded_path(root_path): if is_excluded_path(root_path):
dirs.clear() dirs.clear()
continue continue
# Odfiltrovat vyloučené adresáře podle jména
dirs[:] = [ dirs[:] = [
d for d in 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: for fname in files:
if Path(fname).suffix.lower() in JPEG_EXTENSIONS: if Path(fname).suffix.lower() in JPEG_EXTENSIONS:
yield root_path / fname 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 ───────────────────────────────────────────────────────────── # ── Hlavní logika ─────────────────────────────────────────────────────────────
def process(conn, hostname, drives): 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} 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): for source in iter_jpeg_files(drives):
stats["nalezeno"] += 1 stats["nalezeno"] += 1
src_str = str(source) src_str = str(source)
cur.execute(SQL_SOURCE_EXISTS, (hostname, src_str)) if src_str in known_sources:
if cur.fetchone():
stats["preskoceno"] += 1 stats["preskoceno"] += 1
if stats["preskoceno"] % 500 == 0: if stats["preskoceno"] % 5000 == 0:
log.info(f"Přeskočeno (již v DB): {stats['preskoceno']}") log.info(f"Přeskočeno (již v DB): {stats['preskoceno']}")
continue continue
@@ -191,15 +218,10 @@ def process(conn, hostname, drives):
stats["chyb"] += 1 stats["chyb"] += 1
continue continue
cur.execute(SQL_HASH_EXISTS, (hash_val,)) zaloha_id = known_hashes.get(hash_val)
row = cur.fetchone()
if row: if zaloha_id is not None:
zaloha_id = row[0]
cur.execute(SQL_INSERT_ZDROJ, (hostname, src_str, source.name, velikost, hash_val, zaloha_id))
conn.commit()
stats["duplicit"] += 1 stats["duplicit"] += 1
log.debug(f"DUPLIKÁT {source.name} (zaloha_id={zaloha_id})")
else: else:
dest = dest_path_for(source, hostname) dest = dest_path_for(source, hostname)
try: try:
@@ -209,21 +231,35 @@ def process(conn, hostname, drives):
stats["chyb"] += 1 stats["chyb"] += 1
continue continue
cur = conn.cursor()
cur.execute(SQL_INSERT_ZALOHA, (hash_val, str(dest), source.name, velikost)) cur.execute(SQL_INSERT_ZALOHA, (hash_val, str(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] zaloha_id = cur.fetchone()[0]
cur.execute(SQL_INSERT_ZDROJ, (hostname, src_str, source.name, velikost, hash_val, zaloha_id)) cur.close()
conn.commit() conn.commit()
known_hashes[hash_val] = zaloha_id
stats["kopirovano"] += 1 stats["kopirovano"] += 1
log.info(f"ZKOPÍROVÁNO [{stats['kopirovano']:>6}] {source}") 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( log.info(
f"Průběh: nalezeno={stats['nalezeno']} " f"Průběh: nalezeno={stats['nalezeno']} "
f"nových={stats['kopirovano']} duplikátů={stats['duplicit']} " f"nových={stats['kopirovano']} duplikátů={stats['duplicit']} "
f"chyb={stats['chyb']} přeskočeno={stats['preskoceno']}" f"chyb={stats['chyb']} přeskočeno={stats['preskoceno']}"
) )
cur.close() flush_zdroje()
return stats return stats
@@ -245,20 +281,18 @@ def main():
log.error(f"Nelze se připojit k DB: {e}") log.error(f"Nelze se připojit k DB: {e}")
sys.exit(1) sys.exit(1)
stats = {"nalezeno": 0, "kopirovano": 0, "duplicit": 0, "chyb": 0, "preskoceno": 0}
try: try:
stats = process(conn, hostname, drives) stats = process(conn, hostname, drives)
except KeyboardInterrupt: except KeyboardInterrupt:
log.warning("Přerušeno uživatelem — dosavadní záznamy jsou uloženy.") log.warning("Přerušeno uživatelem — dosavadní záznamy jsou uloženy.")
conn.rollback() conn.rollback()
stats = {}
except Exception as e: except Exception as e:
log.error(f"Neočekávaná chyba: {e}", exc_info=True) log.error(f"Neočekávaná chyba: {e}", exc_info=True)
conn.rollback() conn.rollback()
stats = {}
finally: finally:
conn.close() conn.close()
if stats:
log.info("-" * 60) log.info("-" * 60)
log.info(f"Nalezeno JPG/JPEG: {stats['nalezeno']}") log.info(f"Nalezeno JPG/JPEG: {stats['nalezeno']}")
log.info(f"Zkopírováno nových: {stats['kopirovano']}") log.info(f"Zkopírováno nových: {stats['kopirovano']}")
+126
View File
@@ -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č