Cross-platform support (macOS + Linux): venv-based install, launchd & systemd autostart, CLI-override bugfix
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.venv/
|
||||||
|
.venv-test/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
@@ -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: `<ordnername>-00.jpg`, `<ordnername>-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 "<ordner>" --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).
|
||||||
Executable
+147
@@ -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 ""
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>__LABEL__</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>__PYTHON__</string>
|
||||||
|
<string>__SCRIPT__</string>
|
||||||
|
<string>__WATCH_DIR__</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>__WORKDIR__</string>
|
||||||
|
|
||||||
|
<!-- Beim Login automatisch starten und am Leben halten -->
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>__WORKDIR__/watcher.out.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>__WORKDIR__/watcher.err.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Pillow>=10.0
|
||||||
|
watchdog>=3.0
|
||||||
|
pillow-heif>=0.13
|
||||||
@@ -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
|
||||||
+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