#!/usr/bin/env python3 """ Image Folder Watcher ==================== Überwacht einen Zielordner. Wenn ein Unterordner mit Bildern hineingeschoben wird, werden alle Bilder automatisch: 1. In einen "Backup"-Unterordner kopiert (Originale erhalten) 2. Umbenannt nach: _00, _01, ... 3. Auf 72 DPI und 1920px Breite skaliert (Höhe proportional) Unterstützte Formate: JPEG, PNG, WEBP, HEIC, HEIF, TIFF, BMP, GIF, AVIF Verwendung: python watcher.py /pfad/zum/überwachten/ordner Abhängigkeiten: pip install Pillow pillow-heif watchdog Für CachyOS/Arch: sudo pacman -S python-pillow python-watchdog pip install pillow-heif --break-system-packages (für HEIC/HEIF) """ import sys import os import re import shutil import time import logging import argparse import unicodedata from pathlib import Path from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from PIL import Image # HEIC/HEIF-Support optional laden try: from pillow_heif import register_heif_opener register_heif_opener() HEIF_SUPPORT = True except ImportError: HEIF_SUPPORT = False # ─── Konfiguration ─────────────────────────────────────────────────────────── TARGET_WIDTH = 1920 TARGET_DPI = 72 BACKUP_DIR_NAME = "Backup" OUTPUT_FORMAT = "JPEG" # Ausgabeformat für konvertierte Bilder (JPEG, PNG, WEBP) JPEG_QUALITY = 92 MAX_FILE_SIZE = 1_000_000 # Maximale Dateigröße in Bytes (1 MB) JPEG_QUALITY_MIN = 40 # Untergrenze für JPEG-Qualität bei Größenanpassung SUPPORTED_EXTENSIONS = { ".jpg", ".jpeg", ".png", ".webp", ".tiff", ".tif", ".bmp", ".gif", ".avif", ".heic", ".heif", } # Stabilisierungszeit: Warten bis keine neuen Dateien mehr kommen (Sekunden) # Höher setzen bei SMB/Netzwerk-Kopien vom Mac (Standard: 5s) STABILIZE_DELAY = 5.0 # ─── Logging ────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s │ %(levelname)-7s │ %(message)s", datefmt="%H:%M:%S", ) log = logging.getLogger("watcher") # ─── Bildverarbeitung ──────────────────────────────────────────────────────── def sanitize_name(name: str) -> str: """Macht einen Datei-/Ordnernamen web-sicher. - Unicode-Zeichen → ASCII-Äquivalent (ä→ae, ö→oe, ü→ue, ß→ss, é→e, ...) - Leerzeichen → Bindestrich - Sonderzeichen entfernen (nur a-z, 0-9, Bindestrich, Unterstrich) - Mehrfache Bindestriche zusammenfassen - Alles lowercase """ # Deutsche Umlaute explizit ersetzen replacements = { "ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", } for src, dst in replacements.items(): name = name.replace(src, dst) # Restliche Unicode-Zeichen → ASCII (é→e, ñ→n, etc.) name = unicodedata.normalize("NFKD", name) name = name.encode("ascii", "ignore").decode("ascii") # Leerzeichen und Unterstriche → Bindestrich name = name.replace(" ", "-").replace("_", "-") # Nur erlaubte Zeichen behalten: a-z, 0-9, Bindestrich name = re.sub(r"[^a-zA-Z0-9\-]", "", name) # Mehrfache Bindestriche zusammenfassen name = re.sub(r"-{2,}", "-", name) # Führende/trailing Bindestriche entfernen name = name.strip("-") # Lowercase name = name.lower() return name if name else "image" def get_image_files(folder: Path) -> list[Path]: """Sammelt alle unterstützten Bilddateien rekursiv (inkl. Unterordner). Ignoriert den Backup-Ordner.""" files = [] for f in sorted(folder.rglob("*")): if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS: # Backup-Ordner überspringen if BACKUP_DIR_NAME not in f.relative_to(folder).parts: files.append(f) return files def fix_permissions(folder: Path) -> None: """Versucht Schreibrechte rekursiv zu setzen. Auf SMB/NFS-Mounts schlägt chmod oft fehl – dann einfach weitermachen, da die Berechtigungen dort vom Mount selbst gesteuert werden.""" try: folder.chmod(0o755) for item in folder.rglob("*"): if item.is_file(): item.chmod(0o644) elif item.is_dir() and item.name != BACKUP_DIR_NAME: item.chmod(0o755) log.info(f" 🔓 Berechtigungen rekursiv korrigiert") except (PermissionError, OSError): log.info(f" ℹ️ chmod nicht möglich (Netzwerk-Mount?) – fahre trotzdem fort") def process_folder(folder: Path) -> None: """Verarbeitet einen neu erkannten Ordner mit Bildern.""" folder_name = folder.name log.info(f"📂 Verarbeite Ordner: {folder_name}") # ── 0. Berechtigungen korrigieren (Mac → Linux) ── fix_permissions(folder) images = get_image_files(folder) if not images: log.warning(f" Keine unterstützten Bilder in '{folder_name}' gefunden. Überspringe.") return log.info(f" {len(images)} Bild(er) gefunden.") # ── 1. Backup-Ordner erstellen und Originale sichern (Struktur erhalten) ── backup_dir = folder / BACKUP_DIR_NAME backup_dir.mkdir(exist_ok=True) log.info(f" 💾 Backup-Ordner: {backup_dir}") for img_path in images: # Relativen Pfad beibehalten für Unterordner-Struktur im Backup rel_path = img_path.relative_to(folder) backup_path = backup_dir / rel_path backup_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(img_path, backup_path) log.info(f" ↳ Backup: {rel_path}") # ── 2. Umbenennen und alle Bilder ins Hauptverzeichnis verschieben ── # Ordnername web-sicher machen safe_name = sanitize_name(folder_name) log.info(f" 🌐 Web-sicherer Name: '{folder_name}' → '{safe_name}'") # Bestimme Ausgabe-Endung ext_map = { "JPEG": ".jpg", "PNG": ".png", "WEBP": ".webp", } out_ext = ext_map.get(OUTPUT_FORMAT, ".jpg") # Erst alle in temporäre Namen verschieben (vermeidet Kollisionen) temp_files: list[tuple[Path, Path]] = [] for idx, img_path in enumerate(images): temp_name = f".tmp_{idx:04d}{img_path.suffix}" temp_path = folder / temp_name shutil.move(str(img_path), str(temp_path)) temp_files.append((temp_path, folder / f"{safe_name}-{idx:02d}{out_ext}")) # Dann in finale Namen umbenennen for temp_path, final_path in temp_files: if final_path.exists(): final_path.unlink() temp_path.rename(final_path) log.info(f" ✏️ Umbenannt → {final_path.name}") # Leere Unterordner aufräumen (außer Backup) for sub in sorted(folder.iterdir(), reverse=True): if sub.is_dir() and sub.name != BACKUP_DIR_NAME: try: sub.rmdir() # Nur wenn leer log.info(f" 🗑️ Leerer Unterordner entfernt: {sub.name}") except OSError: pass # Nicht leer, ignorieren # ── 3. Skalieren auf 1920px Breite, 72 DPI ── final_files = get_image_files(folder) for img_path in final_files: try: resize_image(img_path) except Exception as e: log.error(f" ❌ Fehler bei '{img_path.name}': {e}") log.info(f"✅ Ordner '{folder_name}' fertig verarbeitet!\n") def resize_image(img_path: Path) -> None: """Skaliert ein Bild auf TARGET_WIDTH px Breite (proportional), setzt 72 DPI, und stellt sicher dass die Dateigröße unter MAX_FILE_SIZE bleibt.""" with Image.open(img_path) as img: # EXIF-Rotation anwenden (wichtig bei Handy-Fotos!) from PIL import ImageOps img = ImageOps.exif_transpose(img) orig_w, orig_h = img.size if orig_w == 0: log.warning(f" ⚠️ Überspringe '{img_path.name}' (Breite = 0)") return # Neues Seitenverhältnis berechnen ratio = TARGET_WIDTH / orig_w new_h = round(orig_h * ratio) img_resized = img.resize((TARGET_WIDTH, new_h), Image.LANCZOS) # In RGB konvertieren falls nötig (z.B. RGBA → RGB für JPEG) if OUTPUT_FORMAT == "JPEG" and img_resized.mode in ("RGBA", "P", "LA"): background = Image.new("RGB", img_resized.size, (255, 255, 255)) if img_resized.mode == "P": img_resized = img_resized.convert("RGBA") background.paste(img_resized, mask=img_resized.split()[-1] if "A" in img_resized.mode else None) img_resized = background # Speichern mit DPI – bei JPEG iterativ Qualität senken bis < MAX_FILE_SIZE save_kwargs = {"dpi": (TARGET_DPI, TARGET_DPI)} if OUTPUT_FORMAT == "JPEG": quality = JPEG_QUALITY save_kwargs["optimize"] = True while quality >= JPEG_QUALITY_MIN: save_kwargs["quality"] = quality img_resized.save(img_path, format=OUTPUT_FORMAT, **save_kwargs) file_size = img_path.stat().st_size if file_size <= MAX_FILE_SIZE: size_kb = file_size / 1024 log.info( f" 📐 {img_path.name}: {orig_w}×{orig_h} → {TARGET_WIDTH}×{new_h} " f"@ {TARGET_DPI} DPI, Q{quality}, {size_kb:.0f} KB" ) return # Qualität reduzieren und nochmal versuchen quality -= 5 log.debug(f" {img_path.name}: {file_size/1024:.0f} KB > {MAX_FILE_SIZE/1024:.0f} KB – senke Qualität auf {quality}") # Untergrenze erreicht, trotzdem speichern file_size = img_path.stat().st_size size_kb = file_size / 1024 log.warning( f" ⚠️ {img_path.name}: {size_kb:.0f} KB – konnte nicht unter " f"{MAX_FILE_SIZE/1024:.0f} KB gebracht werden (Q{JPEG_QUALITY_MIN})" ) else: img_resized.save(img_path, format=OUTPUT_FORMAT, **save_kwargs) file_size = img_path.stat().st_size size_kb = file_size / 1024 log.info( f" 📐 {img_path.name}: {orig_w}×{orig_h} → {TARGET_WIDTH}×{new_h} " f"@ {TARGET_DPI} DPI, {size_kb:.0f} KB" ) # ─── Ordner-Überwachung ────────────────────────────────────────────────────── class FolderHandler(FileSystemEventHandler): """Erkennt neue Unterordner und verarbeitet sie nach Stabilisierung. Filtert temporäre Ordner (macOS SMB: .::TMPNAME:..., .DS_Store, ._*, etc.)""" # Muster für temporäre/versteckte Ordner die ignoriert werden sollen IGNORE_PREFIXES = (".", ".::TMPNAME", "._", "__MACOSX") def __init__(self): super().__init__() self._pending: dict[str, float] = {} self._processed: set[str] = set() def _should_ignore(self, dir_path: Path) -> bool: """Prüft ob ein Ordner ignoriert werden soll.""" name = dir_path.name if name == BACKUP_DIR_NAME: return True for prefix in self.IGNORE_PREFIXES: if name.startswith(prefix): return True return False def on_created(self, event): if event.is_directory: dir_path = Path(event.src_path) if self._should_ignore(dir_path): log.debug(f" Ignoriere temporären Ordner: {dir_path.name}") return if str(dir_path) not in self._processed: log.info(f"🔍 Neuer Ordner erkannt: {dir_path.name} – warte auf Stabilisierung...") self._pending[str(dir_path)] = time.time() def on_moved(self, event): """Fängt Umbenennungen ab – macOS SMB erstellt .::TMPNAME:... und benennt dann um.""" if event.is_directory: dest_path = Path(event.dest_path) src_path = Path(event.src_path) # Temporären Eintrag entfernen falls vorhanden src_str = str(src_path) if src_str in self._pending: del self._pending[src_str] if self._should_ignore(dest_path): return if str(dest_path) not in self._processed: log.info(f"🔍 Ordner umbenannt: {src_path.name} → {dest_path.name} – warte auf Stabilisierung...") self._pending[str(dest_path)] = time.time() def on_modified(self, event): """Auch Modifikationen tracken (Dateien die in bestehenden Ordner kommen).""" if event.is_directory: dir_path = Path(event.src_path) dir_str = str(dir_path) if self._should_ignore(dir_path): return if dir_str in self._pending: self._pending[dir_str] = time.time() def check_pending(self): """Prüft ob ausstehende Ordner stabil sind (keine neuen Dateien seit STABILIZE_DELAY).""" now = time.time() ready = [] for dir_str, last_change in list(self._pending.items()): if now - last_change >= STABILIZE_DELAY: ready.append(dir_str) for dir_str in ready: del self._pending[dir_str] folder = Path(dir_str) # Nochmal prüfen: existiert der Ordner noch und ist er kein Temp-Ordner? if folder.exists() and folder.is_dir() and not self._should_ignore(folder): self._processed.add(dir_str) try: process_folder(folder) except Exception as e: log.error(f"❌ Fehler bei Verarbeitung von '{folder.name}': {e}") # ─── Main ───────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="Überwacht einen Ordner und verarbeitet neue Bild-Unterordner automatisch.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Beispiel: python watcher.py ~/Bilder/Eingang Was passiert: 1. Du schiebst einen Ordner (z.B. "Produkt-Fotos") in ~/Bilder/Eingang 2. Das Tool wartet kurz bis alle Dateien da sind 3. Originale werden nach Produkt-Fotos/Backup/ kopiert 4. Bilder werden umbenannt zu Produkt-Fotos_00.jpg, Produkt-Fotos_01.jpg, ... 5. Bilder werden auf 1920px Breite / 72 DPI skaliert """, ) parser.add_argument( "watch_dir", type=str, help="Pfad zum Ordner der überwacht werden soll", ) parser.add_argument( "--width", type=int, default=TARGET_WIDTH, help=f"Zielbreite in Pixel (Standard: {TARGET_WIDTH})", ) parser.add_argument( "--dpi", type=int, default=TARGET_DPI, help=f"Ziel-DPI (Standard: {TARGET_DPI})", ) parser.add_argument( "--format", type=str, default=OUTPUT_FORMAT, choices=["JPEG", "PNG", "WEBP"], help=f"Ausgabeformat (Standard: {OUTPUT_FORMAT})", ) parser.add_argument( "--quality", type=int, default=JPEG_QUALITY, help=f"JPEG-Qualität 1-100 (Standard: {JPEG_QUALITY})", ) parser.add_argument( "--delay", type=float, default=STABILIZE_DELAY, help=f"Wartezeit in Sekunden bis Ordner als stabil gilt (Standard: {STABILIZE_DELAY})", ) args = parser.parse_args() # Konfiguration aus CLI-Argumenten in Modul-Variablen schreiben # WICHTIG: Die laufende Modul-Instanz referenzieren (beim direkten Start # ist das "__main__", nicht das per Name importierte "watcher"). Sonst # landen die CLI-Overrides im falschen Modul und greifen nicht. _self = sys.modules[__name__] _self.TARGET_WIDTH = args.width _self.TARGET_DPI = args.dpi _self.OUTPUT_FORMAT = args.format _self.JPEG_QUALITY = args.quality _self.STABILIZE_DELAY = args.delay watch_path = Path(args.watch_dir).expanduser().resolve() if not watch_path.exists(): log.info(f"📁 Erstelle Überwachungsordner: {watch_path}") watch_path.mkdir(parents=True, exist_ok=True) if not HEIF_SUPPORT: log.warning("⚠️ pillow-heif nicht installiert – HEIC/HEIF-Dateien werden nicht unterstützt.") log.warning(" Installiere mit: pip install pillow-heif --break-system-packages") log.info(f"{'═' * 60}") log.info(f" 🖼️ Image Folder Watcher gestartet") log.info(f" 📂 Überwache: {watch_path}") log.info(f" 📐 Ziel: {TARGET_WIDTH}px Breite, {TARGET_DPI} DPI, {OUTPUT_FORMAT}") log.info(f" ⏱️ Stabilisierung: {STABILIZE_DELAY}s") log.info(f"{'═' * 60}") log.info(f" Warte auf neue Ordner... (Strg+C zum Beenden)\n") handler = FolderHandler() observer = Observer() observer.schedule(handler, str(watch_path), recursive=False) observer.start() try: while True: handler.check_pending() time.sleep(0.5) except KeyboardInterrupt: log.info("\n👋 Watcher beendet.") observer.stop() observer.join() if __name__ == "__main__": main()