Files
fotkyBuzalkovi/00 PictureCollector/collect_pictures_linux.py
T
2026-05-27 12:50:13 +02:00

356 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()