Cross-platform support (macOS + Linux): venv-based install, launchd & systemd autostart, CLI-override bugfix

This commit is contained in:
claude-ai
2026-06-03 18:50:27 +00:00
commit db8f6ad976
7 changed files with 749 additions and 0 deletions
+465
View File
@@ -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: <Ordnername>_00, <Ordnername>_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()