notebookVb
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
+126
@@ -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č
|
||||
Reference in New Issue
Block a user