598 lines
21 KiB
Python
598 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
10_collect_metadata.py — Sběr metadat ze všech fotek
|
|
|
|
Pro každý soubor obrázku extrahuje:
|
|
• Informace ze souborového systému (cesta, velikost, mtime, ctime)
|
|
• SHA256 hash souboru + pixel hash (EXIF-orientation-aware)
|
|
• Perceptuální hashe (pHash, dHash) pro detekci duplikátů
|
|
• Všechny EXIF tagy přes ExifRead (primární parser)
|
|
• GPS souřadnice přepočtené na decimal degrees
|
|
• IPTC metadata (keywords, popis, autor)
|
|
• XMP metadata (incl. Apple obličeje, screenshoty)
|
|
|
|
Výstup: JSONL soubor — jeden řádek = jeden objekt = jedna fotka
|
|
Chyby: samostatný .log soubor
|
|
|
|
Použití:
|
|
python 10_collect_metadata.py
|
|
python 10_collect_metadata.py --source //tower/photosnahrani
|
|
python 10_collect_metadata.py --resume # přeskočí již zpracované soubory
|
|
python 10_collect_metadata.py --limit 100 # jen prvních 100 (pro test)
|
|
python 10_collect_metadata.py --dry-run # jen spočítá soubory, nic nezpracuje
|
|
python 10_collect_metadata.py --workers 4 # paralelní zpracování
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import signal
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
sys.stderr.reconfigure(encoding="utf-8")
|
|
|
|
import logging
|
|
import exifread
|
|
logging.getLogger("exifread").setLevel(logging.CRITICAL)
|
|
import imagehash
|
|
from PIL import Image, ImageOps, IptcImagePlugin
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Konfigurace
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".heic", ".tiff", ".tif", ".webp", ".bmp"}
|
|
SOURCE = Path("//tower/photosnahrani")
|
|
OUTPUT_DIR = Path(__file__).parent / "output"
|
|
OUTPUT_JSONL = OUTPUT_DIR / "10_metadata.jsonl"
|
|
ERROR_LOG = OUTPUT_DIR / "10_errors.log"
|
|
|
|
RESUME = True
|
|
WORKERS = 2
|
|
|
|
IPTC_TAG_NAMES = {
|
|
(2, 5): "ObjectName",
|
|
(2, 10): "Urgency",
|
|
(2, 15): "Category",
|
|
(2, 20): "SupplementalCategories",
|
|
(2, 25): "Keywords",
|
|
(2, 40): "SpecialInstructions",
|
|
(2, 55): "DateCreated",
|
|
(2, 60): "TimeCreated",
|
|
(2, 80): "Byline",
|
|
(2, 85): "BylineTitle",
|
|
(2, 90): "City",
|
|
(2, 92): "SubLocation",
|
|
(2, 95): "ProvinceState",
|
|
(2, 100): "CountryCode",
|
|
(2, 101): "CountryName",
|
|
(2, 103): "OriginalTransmissionReference",
|
|
(2, 105): "Headline",
|
|
(2, 110): "Credit",
|
|
(2, 115): "Source",
|
|
(2, 116): "Copyright",
|
|
(2, 118): "Contact",
|
|
(2, 120): "Caption",
|
|
(2, 122): "WriterEditor",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GPS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _rational_to_float(r) -> float:
|
|
if hasattr(r, "numerator") and hasattr(r, "denominator"):
|
|
return r.numerator / r.denominator if r.denominator != 0 else 0.0
|
|
return float(r)
|
|
|
|
|
|
def _dms_to_decimal(vals) -> float:
|
|
d = _rational_to_float(vals[0])
|
|
m = _rational_to_float(vals[1])
|
|
s = _rational_to_float(vals[2])
|
|
return d + m / 60.0 + s / 3600.0
|
|
|
|
|
|
def extract_gps(raw_tags: dict) -> dict:
|
|
"""Přepočítá GPS DMS z ExifRead raw tagů na decimal degrees."""
|
|
result = {}
|
|
try:
|
|
lat_tag = raw_tags.get("GPS GPSLatitude")
|
|
lat_ref = raw_tags.get("GPS GPSLatitudeRef")
|
|
lon_tag = raw_tags.get("GPS GPSLongitude")
|
|
lon_ref = raw_tags.get("GPS GPSLongitudeRef")
|
|
|
|
if lat_tag and lon_tag:
|
|
lat = _dms_to_decimal(lat_tag.values)
|
|
lon = _dms_to_decimal(lon_tag.values)
|
|
|
|
if lat_ref and str(lat_ref).strip().upper().startswith("S"):
|
|
lat = -lat
|
|
if lon_ref and str(lon_ref).strip().upper().startswith("W"):
|
|
lon = -lon
|
|
|
|
result["gps_lat"] = round(lat, 7)
|
|
result["gps_lon"] = round(lon, 7)
|
|
|
|
alt_tag = raw_tags.get("GPS GPSAltitude")
|
|
alt_ref = raw_tags.get("GPS GPSAltitudeRef")
|
|
if alt_tag and alt_tag.values:
|
|
alt = _rational_to_float(alt_tag.values[0])
|
|
# ref==1 znamená pod mořem
|
|
if alt_ref and alt_ref.values and alt_ref.values[0] == 1:
|
|
alt = -alt
|
|
result["gps_alt"] = round(alt, 2)
|
|
|
|
except Exception as e:
|
|
result["gps_error"] = str(e)
|
|
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON serializace
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_serializable(obj):
|
|
"""Rekurzivně převede vše co JSON nezná (IFDRational, bytes, tuple…) na základní typy."""
|
|
if hasattr(obj, "numerator") and hasattr(obj, "denominator"):
|
|
try:
|
|
return float(obj)
|
|
except Exception:
|
|
return str(obj)
|
|
if isinstance(obj, dict):
|
|
return {str(k): _make_serializable(v) for k, v in obj.items()}
|
|
if isinstance(obj, (list, tuple)):
|
|
return [_make_serializable(x) for x in obj]
|
|
if isinstance(obj, bytes):
|
|
return obj[:200].decode("utf-8", errors="replace")
|
|
try:
|
|
json.dumps(obj)
|
|
return obj
|
|
except (TypeError, ValueError):
|
|
return str(obj)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hashe
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def file_hash_sha256(path: Path, chunk: int = 65536) -> str:
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
while data := f.read(chunk):
|
|
h.update(data)
|
|
return h.hexdigest()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# IPTC
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_iptc(raw_iptc) -> dict:
|
|
if not raw_iptc:
|
|
return {}
|
|
out = {}
|
|
for key, value in raw_iptc.items():
|
|
name = IPTC_TAG_NAMES.get(key, f"IPTC_{key[0]}_{key[1]}")
|
|
if isinstance(value, bytes):
|
|
value = value.decode("utf-8", errors="replace")
|
|
elif isinstance(value, list):
|
|
value = [
|
|
v.decode("utf-8", errors="replace") if isinstance(v, bytes) else v
|
|
for v in value
|
|
]
|
|
out[name] = value
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# XMP
|
|
# ---------------------------------------------------------------------------
|
|
|
|
XMP_PATTERNS = {
|
|
"creator_tool": r'xmp:CreatorTool="([^"]+)"',
|
|
"create_date": r'xmp:CreateDate="([^"]+)"',
|
|
"modify_date": r'xmp:ModifyDate="([^"]+)"',
|
|
"rating": r'xmp:Rating="([^"]+)"',
|
|
"label": r'xmp:Label="([^"]+)"',
|
|
"title": r'<dc:title[^>]*>.*?<rdf:li[^>]*>([^<]+)</rdf:li>',
|
|
"description": r'<dc:description[^>]*>.*?<rdf:li[^>]*>([^<]+)</rdf:li>',
|
|
"creator": r'<dc:creator[^>]*>.*?<rdf:li[^>]*>([^<]+)</rdf:li>',
|
|
"subject_block": r'<dc:subject[^>]*>(.*?)</dc:subject>',
|
|
}
|
|
|
|
|
|
def _parse_xmp(xmp_raw) -> dict:
|
|
if not xmp_raw:
|
|
return {}
|
|
if isinstance(xmp_raw, bytes):
|
|
xmp_raw = xmp_raw.decode("utf-8", errors="replace")
|
|
|
|
out = {}
|
|
for name, pat in XMP_PATTERNS.items():
|
|
m = re.search(pat, xmp_raw, re.DOTALL)
|
|
if m:
|
|
out[name] = m.group(1).strip()
|
|
|
|
if "subject_block" in out:
|
|
kws = re.findall(r"<rdf:li[^>]*>([^<]+)</rdf:li>", out.pop("subject_block"))
|
|
if kws:
|
|
out["keywords"] = kws
|
|
|
|
face_count = len(re.findall(r'mwg-rs:Type="Face"', xmp_raw))
|
|
if face_count:
|
|
out["face_regions_count"] = face_count
|
|
|
|
# Apple face names (pokud jsou pojmenované v Photos)
|
|
face_names = re.findall(r'mwg-rs:Name="([^"]+)"', xmp_raw)
|
|
if face_names:
|
|
out["face_names"] = face_names
|
|
|
|
out["_xmp_bytes"] = len(xmp_raw)
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hlavní sběr dat pro jednu fotku
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def collect_photo(path: Path, base_path: Path) -> dict:
|
|
"""Vrátí dict se všemi daty o jedné fotce. Výjimky zachytí, nikdy nevyhodí."""
|
|
record: dict = {}
|
|
stat = path.stat()
|
|
|
|
# Souborový systém
|
|
record["file_path"] = str(path)
|
|
record["file_path_relative"] = str(path.relative_to(base_path)) if path.is_relative_to(base_path) else None
|
|
record["file_name"] = path.name
|
|
record["file_stem"] = path.stem
|
|
record["file_ext"] = path.suffix.lower()
|
|
record["file_size"] = stat.st_size
|
|
record["mtime"] = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
|
record["mtime_ts"] = stat.st_mtime
|
|
record["ctime"] = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc).isoformat()
|
|
|
|
# SHA256 souboru (streamovaný — nepotřebuje načíst celý do RAM)
|
|
try:
|
|
record["sha256_file"] = file_hash_sha256(path)
|
|
except Exception as e:
|
|
record["sha256_file"] = None
|
|
record["sha256_file_error"] = str(e)
|
|
|
|
# ExifRead — primární parser: všechny tagy + GPS
|
|
record["exif"] = {}
|
|
try:
|
|
with open(path, "rb") as f:
|
|
raw_tags = exifread.process_file(f, details=True)
|
|
|
|
record.update(extract_gps(raw_tags))
|
|
|
|
for k, v in raw_tags.items():
|
|
if "Thumbnail" in k and "JPEGInterchangeFormat" not in k:
|
|
continue
|
|
record["exif"][k] = str(v)
|
|
except Exception as e:
|
|
record["exif_error"] = str(e)
|
|
|
|
# Pillow — jeden open pro vše: rozměry, pixel hash, perceptual hash, IPTC, XMP
|
|
try:
|
|
with Image.open(path) as img:
|
|
record["format"] = img.format
|
|
record["mode"] = img.mode
|
|
record["width"] = img.width
|
|
record["height"] = img.height
|
|
record["megapixels"] = round((img.width * img.height) / 1_000_000, 2)
|
|
record["has_transparency"] = img.mode in ("RGBA", "LA") or "transparency" in img.info
|
|
dpi = img.info.get("dpi")
|
|
record["dpi"] = list(dpi) if isinstance(dpi, tuple) else dpi
|
|
record["icc_profile"] = "icc_profile" in img.info
|
|
record["embedded_thumbnail"] = "thumbnail" in img.info
|
|
|
|
# IPTC
|
|
try:
|
|
record["iptc"] = _parse_iptc(IptcImagePlugin.getiptcinfo(img))
|
|
except Exception as e:
|
|
record["iptc"] = {"_error": str(e)}
|
|
|
|
# XMP
|
|
record["xmp"] = _parse_xmp(img.info.get("xmp"))
|
|
|
|
# Pixel hash + perceptuální hashe (EXIF orientation aware)
|
|
try:
|
|
img_r = ImageOps.exif_transpose(img)
|
|
if img_r.mode != "RGB":
|
|
img_r = img_r.convert("RGB")
|
|
pixels = img_r.tobytes()
|
|
record["sha256_pixels"] = hashlib.sha256(pixels).hexdigest()
|
|
|
|
ph = imagehash.phash(img_r)
|
|
dh = imagehash.dhash(img_r)
|
|
record["phash"] = str(ph)
|
|
record["dhash"] = str(dh)
|
|
# Celočíselná forma pro DB (BIGINT, signed)
|
|
ph_int = int(str(ph), 16)
|
|
record["phash_int"] = ph_int if ph_int < 2**63 else ph_int - 2**64
|
|
except Exception as e:
|
|
record["pixel_hash_error"] = str(e)
|
|
record["sha256_pixels"] = None
|
|
record["phash"] = None
|
|
record["dhash"] = None
|
|
record["phash_int"] = None
|
|
|
|
except Exception as e:
|
|
record["pil_error"] = str(e)
|
|
|
|
record["collected_at"] = datetime.now(tz=timezone.utc).isoformat()
|
|
return record
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Procházení adresáře
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def iter_photos(source: Path):
|
|
"""Generátor: rekurzivně vrací cesty k obrázkům."""
|
|
for root, dirs, files in os.walk(source):
|
|
# Skrytá adresáře ignorovat
|
|
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
for fname in files:
|
|
if Path(fname).suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
p = Path(root) / fname
|
|
# Přeskočit symlinky které vedou mimo share (WinError 3 - \\mnt\user\...)
|
|
try:
|
|
p.stat()
|
|
except OSError:
|
|
continue
|
|
yield p
|
|
|
|
|
|
def count_photos(source: Path) -> int:
|
|
return sum(1 for _ in iter_photos(source))
|
|
|
|
|
|
def load_processed_paths(jsonl_path: Path) -> set:
|
|
"""Načte sadu file_path ze stávajícího JSONL pro resume."""
|
|
processed = set()
|
|
if not jsonl_path.exists():
|
|
return processed
|
|
with open(jsonl_path, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
obj = json.loads(line)
|
|
fp = obj.get("file_path")
|
|
if fp:
|
|
processed.add(fp)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return processed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Progress
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class Progress:
|
|
def __init__(self, total: int | None):
|
|
self.total = total
|
|
self.done = 0
|
|
self.errors = 0
|
|
self.skipped = 0
|
|
self.start = time.monotonic()
|
|
|
|
def tick(self, ok: bool = True):
|
|
if ok:
|
|
self.done += 1
|
|
else:
|
|
self.errors += 1
|
|
|
|
def skip(self):
|
|
self.skipped += 1
|
|
|
|
def report(self, current_file: str = "") -> str:
|
|
elapsed = time.monotonic() - self.start
|
|
rate = self.done / elapsed if elapsed > 0 else 0
|
|
eta_str = ""
|
|
if self.total and rate > 0:
|
|
remaining = (self.total - self.done - self.skipped) / rate
|
|
h, r = divmod(int(remaining), 3600)
|
|
m, s = divmod(r, 60)
|
|
eta_str = f" ETA {h:02d}:{m:02d}:{s:02d}"
|
|
|
|
total_str = f"/{self.total}" if self.total else ""
|
|
pct = f" ({100*(self.done+self.skipped)/self.total:.1f}%)" if self.total else ""
|
|
name = Path(current_file).name[:40] if current_file else ""
|
|
return (
|
|
f"\r {self.done+self.skipped}{total_str}{pct}"
|
|
f" ok={self.done} err={self.errors} skip={self.skipped}"
|
|
f" {rate:.1f} f/s{eta_str} {name:<40}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shutdown handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_shutdown = False
|
|
|
|
def _handle_sigint(sig, frame):
|
|
global _shutdown
|
|
print("\n\n[!] Přerušeno uživatelem — dočišťuji...")
|
|
_shutdown = True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="10_collect_metadata.py — Sběr metadat ze všech fotek",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument("--source", type=Path, default=SOURCE,
|
|
help=f"Zdrojová složka (default: {SOURCE})")
|
|
parser.add_argument("--output", type=Path, default=OUTPUT_JSONL,
|
|
help=f"Výstupní JSONL soubor (default: {OUTPUT_JSONL})")
|
|
parser.add_argument("--resume", action="store_true", default=RESUME,
|
|
help=f"Přeskočit soubory, které jsou již v JSONL (default: {RESUME})")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="Jen spočítat soubory, nic nezpracovat")
|
|
parser.add_argument("--limit", type=int, default=0,
|
|
help="Zpracovat maximálně N fotek (0 = vše)")
|
|
parser.add_argument("--workers", type=int, default=WORKERS,
|
|
help=f"Počet paralelních vláken (default: {WORKERS})")
|
|
args = parser.parse_args()
|
|
|
|
source: Path = args.source
|
|
output: Path = args.output
|
|
error_log: Path = output.parent / (output.stem + "_errors.log")
|
|
|
|
print(f"[10_collect_metadata]")
|
|
print(f" Zdroj: {source}")
|
|
print(f" Výstup: {output}")
|
|
print(f" Resume: {args.resume}")
|
|
print(f" Limit: {args.limit or 'vše'}")
|
|
print(f" Workers: {args.workers}")
|
|
print()
|
|
|
|
if not source.exists():
|
|
print(f"[ERROR] Zdrojová složka neexistuje: {source}")
|
|
sys.exit(1)
|
|
|
|
# Dry run — jen spočítat
|
|
if args.dry_run:
|
|
print("Dry run — procházím a počítám...", end=" ", flush=True)
|
|
n = count_photos(source)
|
|
print(f"{n} fotek nalezeno v {source}")
|
|
return
|
|
|
|
# Připravit výstupní adresář
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Resume: načíst již zpracované cesty
|
|
processed = set()
|
|
if args.resume and output.exists():
|
|
print(f" Načítám již zpracované záznamy z {output.name}...", end=" ", flush=True)
|
|
processed = load_processed_paths(output)
|
|
print(f"{len(processed)} souborů")
|
|
|
|
# Spočítat celkový počet (pro ETA)
|
|
print(" Počítám soubory...", end=" ", flush=True)
|
|
all_files = list(iter_photos(source))
|
|
total = len(all_files)
|
|
print(f"{total} fotek")
|
|
|
|
if args.limit:
|
|
all_files = all_files[: args.limit]
|
|
|
|
signal.signal(signal.SIGINT, _handle_sigint)
|
|
|
|
progress = Progress(total=min(total, args.limit) if args.limit else total)
|
|
flush_every = 50 # zápis po N zpracovaných
|
|
|
|
out_f = open(output, "a", encoding="utf-8", buffering=1)
|
|
err_f = open(error_log, "a", encoding="utf-8", buffering=1)
|
|
err_f.write(f"\n--- Session {datetime.now().isoformat()} ---\n")
|
|
|
|
def process_one(path: Path) -> tuple[dict | None, bool]:
|
|
"""Vrátí (record, skipped)."""
|
|
if str(path) in processed:
|
|
return None, True
|
|
try:
|
|
record = collect_photo(path, source)
|
|
return record, False
|
|
except Exception as e:
|
|
return {"file_path": str(path), "fatal_error": str(e),
|
|
"collected_at": datetime.now(tz=timezone.utc).isoformat()}, False
|
|
|
|
print(f"\n Zpracovávám...\n")
|
|
|
|
batch: list[str] = []
|
|
processed_count = 0
|
|
|
|
if args.workers > 1:
|
|
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
|
futures = {pool.submit(process_one, p): p for p in all_files}
|
|
for future in as_completed(futures):
|
|
if _shutdown:
|
|
pool.shutdown(wait=False, cancel_futures=True)
|
|
break
|
|
path = futures[future]
|
|
try:
|
|
record, skipped = future.result()
|
|
except Exception as e:
|
|
progress.tick(ok=False)
|
|
err_f.write(f"{path}\t{e}\n")
|
|
print(progress.report(str(path)), end="", flush=True)
|
|
continue
|
|
|
|
if skipped:
|
|
progress.skip()
|
|
else:
|
|
has_error = "fatal_error" in record or "pil_error" in record
|
|
progress.tick(ok=not has_error)
|
|
if has_error:
|
|
err_f.write(f"{path}\t{record.get('fatal_error') or record.get('pil_error')}\n")
|
|
batch.append(json.dumps(_make_serializable(record), ensure_ascii=False))
|
|
if len(batch) >= flush_every:
|
|
out_f.write("\n".join(batch) + "\n")
|
|
batch.clear()
|
|
|
|
processed_count += 1
|
|
print(progress.report(str(path)), end="", flush=True)
|
|
else:
|
|
for path in all_files:
|
|
if _shutdown:
|
|
break
|
|
record, skipped = process_one(path)
|
|
|
|
if skipped:
|
|
progress.skip()
|
|
else:
|
|
has_error = "fatal_error" in record or "pil_error" in record
|
|
progress.tick(ok=not has_error)
|
|
if has_error:
|
|
err_f.write(f"{path}\t{record.get('fatal_error') or record.get('pil_error')}\n")
|
|
batch.append(json.dumps(_make_serializable(record), ensure_ascii=False))
|
|
if len(batch) >= flush_every:
|
|
out_f.write("\n".join(batch) + "\n")
|
|
batch.clear()
|
|
|
|
processed_count += 1
|
|
print(progress.report(str(path)), end="", flush=True)
|
|
|
|
# Flush zbytku
|
|
if batch:
|
|
out_f.write("\n".join(batch) + "\n")
|
|
|
|
out_f.close()
|
|
err_f.close()
|
|
|
|
elapsed = time.monotonic() - progress.start
|
|
print(f"\n\n{'='*60}")
|
|
print(f" Dokončeno za {elapsed:.1f}s")
|
|
print(f" Zpracováno: {progress.done}")
|
|
print(f" Přeskočeno: {progress.skipped}")
|
|
print(f" Chyby: {progress.errors}")
|
|
print(f" Výstup: {output}")
|
|
if progress.errors:
|
|
print(f" Chybový log: {error_log}")
|
|
print(f"{'='*60}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|