Cross-platform support (macOS + Linux): venv-based install, launchd & systemd autostart, CLI-override bugfix
This commit is contained in:
+465
@@ -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()
|
||||
Reference in New Issue
Block a user