From db8f6ad976c8d5c2c24b1d87b7f08f2c3bc69af6 Mon Sep 17 00:00:00 2001 From: claude-ai Date: Wed, 3 Jun 2026 18:50:27 +0000 Subject: [PATCH] Cross-platform support (macOS + Linux): venv-based install, launchd & systemd autostart, CLI-override bugfix --- .gitignore | 5 + README.md | 83 +++++ install.sh | 147 ++++++++ launchd/image-watcher.plist.template | 30 ++ requirements.txt | 3 + systemd/image-watcher.service.template | 16 + watcher.py | 465 +++++++++++++++++++++++++ 7 files changed, 749 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 install.sh create mode 100644 launchd/image-watcher.plist.template create mode 100644 requirements.txt create mode 100644 systemd/image-watcher.service.template create mode 100644 watcher.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dde9041 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +.venv-test/ +__pycache__/ +*.pyc +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..a577bbb --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Image Folder Watcher + +Überwacht einen Ordner. Sobald ein **Unterordner mit Bildern** hineingeschoben +wird, werden alle Bilder automatisch: + +1. In einen `Backup/`-Unterordner kopiert (Originale bleiben erhalten) +2. Web-sicher umbenannt: `-00.jpg`, `-01.jpg`, … + (Umlaute → `ae/oe/ue/ss`, Leerzeichen → `-`, alles klein) +3. Auf **1920 px Breite** (proportional) und **72 DPI** skaliert +4. Als JPEG unter **1 MB** gehalten (Qualität wird bei Bedarf abgesenkt) + +Unterstützte Formate: JPEG, PNG, WEBP, TIFF, BMP, GIF, AVIF, HEIC/HEIF. + +Läuft auf **macOS** und **Linux** (gleiche Codebasis). + +## Installation + +```bash +git clone git@git.datenhimmel.work:claude-ai/image-folder-watcher.git +cd image-folder-watcher +./install.sh +``` + +Das Script: +- prüft/installiert Python (Homebrew auf macOS, pacman/apt/dnf/zypper auf Linux), +- legt ein **virtuelles Environment** an (`~/image-folder-watcher/.venv`) und + installiert Pillow, watchdog und pillow-heif darin – **kein** + `--break-system-packages` nötig, +- erstellt den Überwachungsordner + (`~/Pictures/Eingang` auf macOS, `~/Bilder/Eingang` auf Linux), +- bietet optional Autostart an (launchd auf macOS, systemd auf Linux). + +Eigenen Pfad festlegen: + +```bash +WATCH_DIR="$HOME/mein/eingang" ./install.sh +``` + +## Manuell starten + +```bash +~/image-folder-watcher/.venv/bin/python ~/image-folder-watcher/watcher.py "<überwachter-ordner>" +``` + +Mit Optionen: + +```bash +... watcher.py "" --width 1920 --dpi 72 --format JPEG --quality 92 --delay 5 +``` + +| Option | Bedeutung | Standard | +|-------------|---------------------------------------------|----------| +| `--width` | Zielbreite in Pixel | 1920 | +| `--dpi` | Ziel-DPI | 72 | +| `--format` | Ausgabeformat (JPEG/PNG/WEBP) | JPEG | +| `--quality` | JPEG-Qualität (1–100) | 92 | +| `--delay` | Wartezeit bis Ordner als "fertig" gilt (s) | 5 | + +> Bei Netzwerk-/SMB-Kopien (z. B. vom Mac auf einen Linux-Share) `--delay` +> höher setzen, damit das Tool wartet, bis wirklich alle Dateien angekommen sind. + +## Autostart + +**macOS (launchd):** +```bash +launchctl print gui/$(id -u)/work.datenhimmel.image-watcher # Status +launchctl bootout gui/$(id -u)/work.datenhimmel.image-watcher # Stoppen/Entfernen +tail -f ~/image-folder-watcher/watcher.out.log # Logs +``` + +**Linux (systemd):** +```bash +sudo systemctl status image-watcher +journalctl -u image-watcher -f +``` + +## Plattform-Hinweise + +- macOS nutzt FSEvents, Linux nutzt inotify – `watchdog` wählt das automatisch. +- Auf macOS gibt es keine `--break-system-packages`-Problematik, weil alles im + venv landet. +- Der systemd-Dienst läuft als System-Service unter deinem User; der + launchd-Agent läuft als User-Agent (startet beim Login). diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9d6e8b2 --- /dev/null +++ b/install.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# ══════════════════════════════════════════════════════════════ +# Image Folder Watcher – plattformübergreifende Installation +# Unterstützt: macOS (launchd) und Linux (systemd) +# Nutzt ein Python-venv – kein --break-system-packages nötig. +# ══════════════════════════════════════════════════════════════ + +set -euo pipefail + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +info() { echo -e "${YELLOW}$*${NC}"; } +ok() { echo -e "${GREEN}$*${NC}"; } +err() { echo -e "${RED}$*${NC}" >&2; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OS="$(uname -s)" + +echo -e "${GREEN}══════════════════════════════════════════════════${NC}" +echo -e "${GREEN} Image Folder Watcher – Installation (${OS})${NC}" +echo -e "${GREEN}══════════════════════════════════════════════════${NC}" + +INSTALL_DIR="${INSTALL_DIR:-$HOME/image-folder-watcher}" +VENV_DIR="$INSTALL_DIR/.venv" + +# Sinnvolle Standard-Überwachungsordner je Plattform (per ENV überschreibbar) +if [[ "$OS" == "Darwin" ]]; then + WATCH_DIR="${WATCH_DIR:-$HOME/Pictures/Eingang}" +else + WATCH_DIR="${WATCH_DIR:-$HOME/Bilder/Eingang}" +fi + +# ── 1. Python sicherstellen + System-Pakete ────────────────────────────────── +info "\n[1/4] Prüfe Python & installiere ggf. System-Voraussetzungen..." + +ensure_python_linux() { + if command -v pacman &>/dev/null; then + sudo pacman -S --needed --noconfirm python + elif command -v apt-get &>/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y python3 python3-venv python3-pip + elif command -v dnf &>/dev/null; then + sudo dnf install -y python3 python3-pip + elif command -v zypper &>/dev/null; then + sudo zypper install -y python3 python3-pip + else + err " Kein bekannter Paketmanager (pacman/apt/dnf/zypper) gefunden." + err " Bitte Python 3 + venv manuell installieren und Script erneut starten." + exit 1 + fi +} + +ensure_python_macos() { + if ! command -v python3 &>/dev/null; then + if command -v brew &>/dev/null; then + brew install python + else + err " Python 3 nicht gefunden und Homebrew ist nicht installiert." + err " Installiere Python von https://www.python.org/downloads/ ODER" + err " Homebrew von https://brew.sh und starte dann erneut." + exit 1 + fi + fi + # Xcode Command Line Tools liefern viele Build-Voraussetzungen; pillow-heif + # nutzt aber vorgebaute Wheels, daher i.d.R. nicht zwingend nötig. +} + +case "$OS" in + Darwin) ensure_python_macos ;; + Linux) ensure_python_linux ;; + *) err " Nicht unterstütztes OS: $OS"; exit 1 ;; +esac + +PYTHON="$(command -v python3)" +ok " ✅ Python: $PYTHON ($($PYTHON --version))" + +# ── 2. venv anlegen & Abhängigkeiten installieren ──────────────────────────── +info "\n[2/4] Erstelle virtuelle Umgebung & installiere Abhängigkeiten..." +mkdir -p "$INSTALL_DIR" +if [[ ! -d "$VENV_DIR" ]]; then + "$PYTHON" -m venv "$VENV_DIR" +fi +"$VENV_DIR/bin/pip" install --upgrade pip --quiet +"$VENV_DIR/bin/pip" install -r "$SCRIPT_DIR/requirements.txt" +ok " ✅ Pillow, watchdog, pillow-heif (HEIC/HEIF) installiert." + +# ── 3. Script + Überwachungsordner ─────────────────────────────────────────── +info "\n[3/4] Kopiere Script nach $INSTALL_DIR & erstelle Ordner..." +cp "$SCRIPT_DIR/watcher.py" "$INSTALL_DIR/watcher.py" +chmod +x "$INSTALL_DIR/watcher.py" +mkdir -p "$WATCH_DIR" +ok " ✅ Überwachungsordner: $WATCH_DIR" + +VENV_PYTHON="$VENV_DIR/bin/python" + +# ── 4. Autostart (optional, plattformabhängig) ─────────────────────────────── +info "\n[4/4] Autostart einrichten?" +read -rp "Autostart-Dienst installieren? [j/N] " INSTALL_SERVICE +if [[ "$INSTALL_SERVICE" =~ ^[jJyY]$ ]]; then + if [[ "$OS" == "Darwin" ]]; then + # ── macOS: launchd User-Agent ── + LABEL="work.datenhimmel.image-watcher" + PLIST_DIR="$HOME/Library/LaunchAgents" + PLIST="$PLIST_DIR/$LABEL.plist" + mkdir -p "$PLIST_DIR" + sed -e "s|__LABEL__|$LABEL|g" \ + -e "s|__PYTHON__|$VENV_PYTHON|g" \ + -e "s|__SCRIPT__|$INSTALL_DIR/watcher.py|g" \ + -e "s|__WATCH_DIR__|$WATCH_DIR|g" \ + -e "s|__WORKDIR__|$INSTALL_DIR|g" \ + "$SCRIPT_DIR/launchd/image-watcher.plist.template" > "$PLIST" + # Neu laden (bootout ignoriert Fehler falls noch nicht geladen) + launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$PLIST" + ok " ✅ launchd-Agent installiert & gestartet!" + echo " Status: launchctl print gui/$(id -u)/$LABEL | head" + echo " Stoppen: launchctl bootout gui/$(id -u)/$LABEL" + echo " Logs: tail -f $INSTALL_DIR/watcher.out.log" + else + # ── Linux: systemd System-Service ── + SERVICE_NAME="image-watcher.service" + TMP_SERVICE="$INSTALL_DIR/$SERVICE_NAME" + sed -e "s|__PYTHON__|$VENV_PYTHON|g" \ + -e "s|__SCRIPT__|$INSTALL_DIR/watcher.py|g" \ + -e "s|__WATCH_DIR__|$WATCH_DIR|g" \ + -e "s|__WORKDIR__|$INSTALL_DIR|g" \ + -e "s|__USER__|$USER|g" \ + "$SCRIPT_DIR/systemd/image-watcher.service.template" > "$TMP_SERVICE" + sudo cp "$TMP_SERVICE" "/etc/systemd/system/$SERVICE_NAME" + sudo systemctl daemon-reload + sudo systemctl enable "$SERVICE_NAME" + sudo systemctl restart "$SERVICE_NAME" + ok " ✅ systemd-Service installiert & gestartet!" + echo " Status: sudo systemctl status image-watcher" + echo " Logs: journalctl -u image-watcher -f" + fi +fi + +echo -e "\n${GREEN}══════════════════════════════════════════════════${NC}" +ok " ✅ Installation abgeschlossen!" +echo -e "${GREEN}══════════════════════════════════════════════════${NC}" +echo "" +echo " Manuell starten:" +echo " $VENV_PYTHON $INSTALL_DIR/watcher.py \"$WATCH_DIR\"" +echo "" +echo " Mit Optionen:" +echo " $VENV_PYTHON $INSTALL_DIR/watcher.py \"$WATCH_DIR\" --width 1920 --dpi 72 --format JPEG" +echo "" diff --git a/launchd/image-watcher.plist.template b/launchd/image-watcher.plist.template new file mode 100644 index 0000000..3bf787a --- /dev/null +++ b/launchd/image-watcher.plist.template @@ -0,0 +1,30 @@ + + + + + Label + __LABEL__ + + ProgramArguments + + __PYTHON__ + __SCRIPT__ + __WATCH_DIR__ + + + WorkingDirectory + __WORKDIR__ + + + RunAtLoad + + KeepAlive + + + StandardOutPath + __WORKDIR__/watcher.out.log + StandardErrorPath + __WORKDIR__/watcher.err.log + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e75c79c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Pillow>=10.0 +watchdog>=3.0 +pillow-heif>=0.13 diff --git a/systemd/image-watcher.service.template b/systemd/image-watcher.service.template new file mode 100644 index 0000000..0f521ec --- /dev/null +++ b/systemd/image-watcher.service.template @@ -0,0 +1,16 @@ +[Unit] +Description=Image Folder Watcher – Automatische Bildverarbeitung +After=network.target + +[Service] +Type=simple +User=__USER__ +ExecStart=__PYTHON__ __SCRIPT__ __WATCH_DIR__ +WorkingDirectory=__WORKDIR__ +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/watcher.py b/watcher.py new file mode 100644 index 0000000..bafd3bb --- /dev/null +++ b/watcher.py @@ -0,0 +1,465 @@ +#!/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()