Sync local files: AGENTS.md update, new projects (browser-os, image-folder-watcher, docker-compose), add .gitignore
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).
|
||||
+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
|
||||
@@ -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