# AI Agent Briefing — PDF OCR Hotfolder **Zuletzt aktualisiert:** 2026-09-23 **Version:** 0.7.0 **Status:** Multi-Instanz-Betrieb, Preflight-Checks (inkl. veraPDF-Binary), Fehlerzählung, Wiederaufnahme aus `working/`, Kollisionsschutz auf allen Schreibpfaden, Bewachung des Verzeichnis-Watches und ein abgesicherter Updater (venv-Rebuild, Backup, Verifikation, Versionssprung-Meldung, Rauchtest). `install.sh` und `update.sh` teilen sich `lib/common.sh`. Test-Suite grün (**254 pytest-Tests**). Ein Produktiv-Einsatz ist im Repo (README/CHANGELOG) nicht dokumentiert — die bisherigen Fixes stammen aus Issues #1–#6, aus Vorbereitungen auf Debian 13 und aus einer Durchsicht auf stille Datenverlust-Pfade (0.7.0), nicht aus einem belegten Dauerbetrieb. > **Betriebsabläufe stehen nicht hier**, sondern in: > [README.md](README.md) (Einstieg, Layout, Config-Überblick) · > [docs/INSTALLATION.md](docs/INSTALLATION.md) (Erstinstallation, Instanzen, Konfigurationsreferenz) · > [docs/UPDATE.md](docs/UPDATE.md) (Update, Backup, Rollback, `--check-config`) · > [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md) (Debian-Major-Upgrade, venv-Rebuild, Pins). > Dieses Briefing beschreibt **wie der Code aufgebaut ist und warum** — Schritt-für-Schritt-Anleitungen gehören in die drei Dokumente, nicht hierher. ## 🎯 Projektziel Eingehende gescannte PDFs werden automatisch durch OCR (ocrmypdf + Tesseract) in durchsuchbare PDFs (optional PDF/A) umgewandelt und nach Wahl in einen Ordner / Nextcloud / per SFTP weitergegeben. Ersetzt das alte Bash-Tool `pdf-tool` (im Workspace). ## 📁 Projekt-Struktur ``` pdf-ocr-hotfolder/ ├── pdf_ocr_hotfolder/ │ ├── __init__.py # Versionsstring (__version__) │ ├── __main__.py # CLI (argparse: --config, --once, --check-config, --version) │ ├── config.py # TOML-Loader, Dataclasses, ConfigError, Warnungen │ ├── service.py # HotfolderService (watchdog + ThreadPool), Preflight, Resume, Zähler, Observer-Bewachung │ ├── processor.py # ocrmypdf-Call, veraPDF, Ausgabename, Original-Entsorgung, Kollisionsschutz │ └── uploaders.py # folder, nextcloud (WebDAV), sftp, E-Mail-Notify ├── lib/ │ └── common.sh # gemeinsam fuer install.sh + update.sh: Logging, require_root, │ # Layout-Konstanten, apt-Paketliste, venv_is_healthy() ├── tests/ # pytest-Suite (254 Tests, ocrmypdf wird gemockt) │ ├── conftest.py # Fixtures tmp_config / dummy_pdf │ ├── test_check_config.py # --check-config, Exit 0/1/2 │ ├── test_config_errors.py │ ├── test_config_warnings.py # Legacy- und Unbekannt-Warnungen │ ├── test_dispose_failure.py # Original nicht entsorgbar -> Erfolg + warning │ ├── test_error_collision.py # error/ ueberschreibt nichts │ ├── test_error_counting.py │ ├── test_ghostscript_version.py │ ├── test_incoming_non_pdf.py # Sammelmeldung fuer Fremddateien │ ├── test_log_stream.py # Logging geht nach stdout │ ├── test_observer_watchdog.py # toter Observer -> EXIT_OBSERVER_DEAD │ ├── test_ocr_timeout.py │ ├── test_once_exit_code.py │ ├── test_outgoing_collision.py # outgoing/ + Archiv ueberschreiben nichts │ ├── test_output_naming.py │ ├── test_preflight.py │ ├── test_relative_paths.py # relative Pfade -> ConfigError │ ├── test_resume_working.py # Wiederaufnahme + __ocr_-Fragmente │ ├── test_startup_toml_error.py # kaputtes TOML beim Dienststart -> Exit 2 │ ├── test_upload_folder.py │ └── test_verapdf_preflight.py # Binary-Pruefung + VeraPdfUnavailable ├── systemd/ │ ├── pdf-ocr-hotfolder@.service # Template-Unit (Instanz = %i), TimeoutStopSec=300, │ │ # RestartPreventExitStatus=2 │ └── lxc-compat.conf # Drop-in-Vorlage: Hardening für LXC abschalten ├── docs/ │ ├── INSTALLATION.md # Erstinstallation, Konfigurationsreferenz, Exit-Codes, Troubleshooting │ ├── UPDATE.md # update.sh, Backup/Rollback, --check-config, Config-Drift │ └── OS-UPGRADE.md # Debian 12 -> 13 -> 14, venv-Rebuild, Pins ├── pytest.ini # testpaths = tests ├── config.example.toml ├── install.sh # Interaktiver Installer + Instanz-Manager, ~500 Zeilen ├── update.sh # Updater (--help, --rebuild-venv, --no-smoke-test), ~1270 Zeilen ├── requirements.txt # feste Pins (ocrmypdf 17.x — 16.x ist unbrauchbar, s. 0.6.1) ├── VERSION ├── CHANGELOG.md ├── README.md └── AI_AGENT_BRIEFING.md ``` ## 🔧 Stack | Komponente | Technologie | |------------|-------------| | Sprache | Python 3.11+ (für `tomllib` aus stdlib) | | OCR | `ocrmypdf` (als Library, nicht via Subprozess; Import ist lazy) | | Engine | Tesseract | | Watcher | `watchdog` | | HTTP | `requests` (Nextcloud WebDAV) | | SFTP | `paramiko` | | Email | `smtplib` (stdlib) | | Tests | `pytest` | | Service | systemd (Template-Unit) | Die vier Python-Deps sind in `requirements.txt` **fest gepinnt** (`==`), damit ein Update nicht ungefragt einen Major-Sprung einzieht (ocrmypdf 16 → 17 würde alle Instanzen auf einmal reißen). Geprüft gegen Python 3.11 (Debian 12) und 3.13 (Debian 13), Wheels für beide vorhanden. Anheben nur mit Testmaschine — [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md#pins-in-requirementstxt). ## 🖥️ Installations-Layout (Multi-Instanz) | Pfad | Inhalt | |------|--------| | `/opt/pdf-ocr-hotfolder/` | Code + venv (für alle Instanzen gemeinsam) | | `/opt/pdf-ocr-hotfolder/lib/common.sh` | Kopie der gemeinsamen Shell-Bibliothek; `update.sh` sourct sie, wenn es nicht aus dem Repo läuft | | `/opt/pdf-ocr-hotfolder/.repo_path` | Pfad zum Repo, aus dem installiert wurde (nutzt `update.sh`) | | `/etc/pdf-ocr-hotfolder/.toml` | Config pro Instanz (mode 640, root:) | | `/etc/systemd/system/pdf-ocr-hotfolder@.service` | Template-Unit | | `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/lxc-compat.conf` | Drop-in für Container (optional) | | `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/user.conf` | Drop-in für abweichenden User (optional) | | `/var/lib/pdf-ocr-hotfolder//{incoming,working,outgoing,error}/` | Daten pro Instanz | | `/var/backups/pdf-ocr-hotfolder/` | Update-Backups (0600, letzte 5) | Ein eigenes Logverzeichnis gibt es **nicht** (seit 0.4.1 auch nicht mehr vom Installer angelegt): `_setup_logging()` nutzt `logging.basicConfig()` ohne FileHandler, alles geht nach stdout → journald. Der Stream wird seit 0.7.0 **explizit** auf `sys.stdout` gesetzt — der `basicConfig()`-Default ist **stderr**, und README wie `docs/INSTALLATION.md` versprachen stdout. ```bash journalctl -u pdf-ocr-hotfolder@ -f # eine Instanz mitlesen journalctl -u 'pdf-ocr-hotfolder@*' --since today # alle Instanzen, heute ``` ## 👤 Service-User - Basis-Install legt Default-User `pdfocr` an (als System-User, falls nicht schon vorhanden) - Beim Anlegen einer Instanz fragt der Installer nach dem Service-User (default `pdfocr`) - Wird ein **abweichender** User gewählt, wird ein systemd-Drop-in erstellt (`pdf-ocr-hotfolder@.service.d/user.conf`) mit `User=/Group=` Override - Existierende User (lokal oder AD via SSSD/Winbind) werden übernommen, primäre Gruppe via `id -gn` ermittelt - Bei AD-Usern mit lokaler UID werden Datei-Berechtigungen über die UID gesetzt — transparent ## 🗂️ Instanz-Management (Kurzfassung) `install.sh` ist gleichzeitig **Installer und Instanz-Manager**. Der komplette Ablauf inklusive aller fünf Abfragen steht in [docs/INSTALLATION.md](docs/INSTALLATION.md#die-abfragen-pro-instanz). Für die Arbeit am Code zählt: - Basis-Install wird an `venv` + Template-Unit erkannt und übersprungen — **außer** die venv passt nicht mehr zum System-Python, dann läuft er zur Reparatur erneut (`venv_is_healthy()` aus `lib/common.sh`, Befunde über `report_venv_issues`). - In Containern (`systemd-detect-virt --container`) bietet der Installer das LXC-Drop-in an **und** prüft, ob `systemd-journald` läuft. Tut es das nicht, warnt er (der Dienst loggt ausschließlich nach journald), nennt den `ImportCredential=`-Drop-in für den Debian-13-Fall und fragt, ob fortgefahren werden soll. - Abfragen pro Instanz: Name, Basis-Pfad, Service-User, **OCR-Sprachen**, **Original archivieren?** — `LANGS`/`ORIG_MODE`/`ARCHIVE_DIR` sind `local` in `create_instance()`, gelten also **instanz-lokal** und nicht global. - Sprachprüfung gegen `tesseract --list-langs`, fehlende Pakete werden als `tesseract-ocr-` angeboten (Unterstrich → Bindestrich). Ablehnung führt nicht zum Abbruch, sondern zurück zur Sprach-Abfrage. - Das Archiv-Verzeichnis darf **nicht** `incoming/`/`outgoing/`/`working/`/`error/` sein — im Eingang würde das Original endlos neu aufgegriffen. - `.toml` wird aus `config.example.toml` per `sed` erzeugt. Substituiert werden die vier `[paths]`-Zeilen **sowie** `[ocr].languages`, `[output].original_on_success` und `[output].archive_dir`. Die Ausdrücke sind am Zeilenanfang verankert (`^key[[:space:]]*=`), damit die deutschen Kommentarzeilen über den Keys nicht getroffen werden; Pfad-Variablen laufen vorher durch `sed_escape_repl()` (maskiert `\`, `&`, `|`). Nach dem sed-Lauf liest `config_value()` die drei Keys zurück und vergleicht sie mit der Eingabe. - Die apt-Paketliste steht als **einzige Quelle** in `lib/common.sh` zwischen den Marken `# --- BEGIN apt-packages` / `# --- END apt-packages` in der Funktion `pdf_ocr_apt_packages()`. `install.sh` bekommt sie durchs Sourcen; **`update.sh` schneidet den Block zusätzlich per `sed` aus der Repo-Fassung heraus und evaluiert ihn**, weil gesourct evtl. die ältere installierte Kopie wurde. Marken und Funktionsname dürfen sich nicht ändern, ohne `update.sh` anzupassen. - Instanz wird sofort `enable --now` gestartet. Löschen macht der Installer nicht, das steht als Handgriff in [docs/INSTALLATION.md](docs/INSTALLATION.md#instanz-manuell-löschen). ## 🧰 `lib/common.sh` — gemeinsame Shell-Bibliothek Seit 0.7.0 sourcen `install.sh` und `update.sh` dieselbe Datei. Sie führt beim Sourcen **nichts** aus, was das System anfasst, und enthält nur Definitionen: | Inhalt | Details | |--------|---------| | Ausgabe | `log_info`/`log_warn`/`log_error`/`log_step`, Farbkonstanten | | Rechte | `require_root ""` | | Layout | `INSTALL_DIR`, `CONFIG_DIR`, `DATA_ROOT`, `SYSTEMD_DIR`, `DEFAULT_USER`, `SERVICE_TEMPLATE`, `LXC_DROPIN_DIR`, `LXC_DROPIN`, `COMMON_LIB_REL` — alle per `: "${X:=…}"`, also aus der Umgebung überschreibbar (Tests) | | Pakete | `pdf_ocr_apt_packages()` zwischen den BEGIN/END-Marken | | venv | `py_mm()`, `pyvenv_cfg_mm()`, `venv_is_healthy()`, `report_venv_issues()` | Drei Dinge, die man dabei wissen muss: - **`venv_is_healthy()` gibt es nur noch einmal.** Vorher hatte jedes der beiden Skripte eine eigene Fassung, und die in `install.sh` war die schlankere: sie verglich nur `major.minor` des venv-Interpreters mit dem System-Python und hätte den Distro-Upgrade-Fall über `pyvenv.cfg` nicht bemerkt. Erhalten geblieben ist die gründliche Fassung (Verzeichnis, ausführbarer Interpreter, Interpreter **läuft**, Version == System-Python, `pyvenv.cfg` == Interpreter). Sie setzt `VENV_ISSUES` und gibt nichts selbst aus — dafür ist `report_venv_issues()` da. - **`lib/` wird mitinstalliert.** `install.sh` **und** `update.sh` kopieren es nach `/opt/pdf-ocr-hotfolder/lib/`, jeweils mit vorherigem `rm -rf "${INSTALL_DIR:?}/lib"`. Es liegt damit auch im Update-Backup (das sichert `$INSTALL_DIR` ohne venv). - **Fundreihenfolge in `update.sh`:** erst `$SCRIPT_DIR/lib/common.sh` (Repo), dann `${INSTALL_DIR}/lib/common.sh`. Fehlt sie überall, bricht das Skript **sofort** ab — ein `command not found` mitten im Lauf wäre die schlechtere Nachricht. `install.sh` sucht nur neben sich und verlangt das vollständige Repo. ## 🔄 Update-Verhalten (Kurzfassung) Vollständig: [docs/UPDATE.md](docs/UPDATE.md). Für die Arbeit am Skript wichtig: - `update.sh` hat `--help` und `--rebuild-venv`, läuft mit `set -Eeuo pipefail` und hat ab dem Stoppen der Instanzen einen **ERR/INT/TERM-Trap**: er sagt, ob auf der Platte schon getauscht wurde (`TOUCHED`), startet die vorher laufenden Instanzen wieder und nennt Backup + Rollback-Befehl. - Reihenfolge: Instanzen erfassen → apt-Sync → venv-Health → stoppen → Backup → Code → Deps/venv → Units → chown → `--check-config` → starten + verifizieren → Zusammenfassung (Soll gegen Ist, Exit 1 bei Regression/Config-Fehler). - **Instanz-Erfassung** deckt `list-units --all` (inkl. `activating`/`failed`), `list-unit-files` und die Configs unter `/etc/pdf-ocr-hotfolder/` ab. Drei Gruppen: `PREV_OK`, `PREV_BROKEN`, `PREV_STOPPED` — bewusst gestoppte bleiben gestoppt. - **Verifikation**: `verify_unit()` wartet `VERIFY_WAIT` (6 s) und prüft `is-active`, `is-failed` **und** `NRestarts` — sonst würde ein Crash-Loop bei `Type=simple` als Erfolg durchgehen. Vorher `reset-failed`. - **venv-Health** (`venv_is_healthy()` aus `lib/common.sh`): Verzeichnis, ausführbarer Interpreter, Interpreter **läuft** überhaupt, `major.minor` == System-Python, `pyvenv.cfg` stimmt mit dem Interpreter überein. Bei Drift wird auch ohne `--rebuild-venv` neu gebaut. - **`rebuild_venv()` ist ganz oder gar nicht**: alte venv nach `venv.old-`, neu bauen, Requirements installieren, **erst bei Erfolg** die alte löschen; scheitert etwas, wird zurückgerollt und hart abgebrochen. `pip_install_requirements()` übersetzt pip-Fehler in eine Ansage mit dem gescheiterten Paketnamen und dem Hinweis "requirements.txt anheben". - **apt-Sync** läuft auch beim Update (`sync_system_packages()`), ist idempotent und fasst nachinstallierte Sprachpakete nicht an (kein purge/autoremove). Fehlschläge setzen nur `APT_WARN`, sie brechen nicht ab. - **Backup** (`create_backup()`): Code, `/etc/pdf-ocr-hotfolder/`, Template-Unit, alle Drop-ins und ein `pip-freeze.txt` der alten venv. **Ohne** venv und **ohne** Datenverzeichnisse. `umask 077` + `chmod 600 root:root`, weil die Configs Klartext-Passwörter enthalten. Rotation: letzte `BACKUP_KEEP` = 5. - **LXC-Drop-in** wird beim Update aus dem Repo nachgezogen, **falls es installiert ist** — sonst würde ein neu ergänzter Hardening-Schalter in der Template-Unit alle Container-Instanzen reißen (Issue #4 redux). - Configs unter `/etc/pdf-ocr-hotfolder/` werden **nie** überschrieben. Das Repo muss erhalten bleiben — `update.sh` kopiert daraus (`.repo_path`). - `PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.sh` lädt nur die Funktionen, ohne irgendetwas zu tun — dafür sind `INSTALL_DIR`, `CONFIG_DIR`, `SYSTEMD_DIR`, `BACKUP_DIR`, `TAR_ROOT`, `VERIFY_WAIT`, `BACKUP_KEEP` überschreibbar. Die Vorgaben stehen in `lib/common.sh` und sind dort ebenfalls überschreibbar gehalten (`: "${X:=…}"`). ## ⚙️ Konfiguration (Überblick) Key-für-Key-Referenz: [docs/INSTALLATION.md](docs/INSTALLATION.md#konfigurationsreferenz). Vollständiges Beispiel mit Kommentaren: `config.example.toml`. | Sektion | Zweck | |---------|-------| | `[paths]` | `incoming`, `outgoing`, `working`, `error` — **Pflicht** und **absolut**, fehlt einer oder ist relativ → `ConfigError` + Exit 2 | | `[ocr]` | `languages`, `jobs`, `skip_text`, `oversample`, `pdfa_level`, `deskew`, `clean`, `max_workers`, `timeout` (Sekunden **pro Seite**) | | `[output]` | `name_mode` (`prefix`/`suffix`/`none`), `name_tag`, `original_on_success` (`delete`/`archive`), `archive_dir` (absolut) | | `[verapdf]` | `enabled`, `binary`, `flavour` — optionale PDF/A-Validierung per CLI | | `[upload.folder]` | `enabled`, `target` (leer = `[paths].outgoing`, dann No-op; sonst absolut) | | `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` | | `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` | | `[notify.email]` | `enabled`, SMTP-Daten, `from_addr`, `to_addrs`, `on` = `always`/`errors`/`never` | | `[logging]` | `level` = DEBUG/INFO/WARNING/ERROR | **Absolute Pfade sind Pflicht** (`_require_absolute()` in `config.py`, seit 0.7.0): `[paths]`-Einträge, `[output].archive_dir` und `[upload.folder].target`. Ein relativer Pfad wurde gegen das `WorkingDirectory` der Unit aufgelöst, landete also still unter `/opt/pdf-ocr-hotfolder/` — der Scanner schrieb dann woanders hin als der Dienst schaute, ohne dass irgendwo ein Fehler auftauchte. Leere Werte bleiben erlaubt (beide Keys sind optional). Unbekannte Keys werden beim Laden zwar ignoriert, aber **nicht mehr still**: `_collect_unknown_keys()` sammelt sie in `Config.unknown_keys` (Format `[ocr].langauges`, auch ganze unbekannte Sektionen und Upload-/Notify-Targets), `unknown_key_warnings()` macht Meldungen daraus. Ein Tippfehler fällt damit auf. ### Warnungen statt Überraschungen `config.py` kennt zwei Warnungsquellen, beide über `config_warnings()` gebündelt — der Text steht **nur dort**, weil ihn sowohl der Dienststart (`_log_config_warnings()` → `log.warning`) als auch `--check-config` ausgibt: - `legacy_warnings()`: `[ocr].timeout >= LEGACY_TIMEOUT_THRESHOLD` (900) deutet auf den alten Gesamt-Timeout-Wert 1800 hin (Richtwert `RECOMMENDED_PAGE_TIMEOUT` = 300); gesetztes `pdfa_level` weist auf den Ghostscript-Bug hin. - `unknown_key_warnings()`: siehe oben. ### `--check-config` `python -m pdf_ocr_hotfolder --check-config --config ` lädt die Config, zeigt Pfade/Sprachen/Timeout/PDF/A sowie veraPDF-Binary und -Flavour (bzw. `(aus)`), fährt `check_preflight()` und `check_output_config()` und gibt die Warnungen aus. Exit-Codes: `CHECK_OK=0`, `CHECK_WARN=1`, `CHECK_ERROR=2`. Hat Vorrang vor `--once`. `update.sh` wertet genau diese Codes aus und erkennt an der argparse-Meldung, wenn der installierte Code das Flag noch nicht kennt. ### Exit-Codes des Prozesses | Code | Woher | Bedeutung | |------|-------|-----------| | `0` | `main()` | regulärer Stopp, `--once` ohne Fehler, Config sauber | | `1` | `main()` | `--once` mit `error_count > 0`; `--check-config` mit Warnungen | | `2` | `main()` | `ConfigError`, `TOMLDecodeError`, `OSError` beim Laden, `PreflightError` | | `3` | `service.EXIT_OBSERVER_DEAD` | watchdog-Observer gestorben — Neustart **erwünscht** | `run()` gibt seit 0.7.0 einen `int` zurück (vorher `None`), `main()` reicht ihn durch. Die Unit setzt **`RestartPreventExitStatus=2`**: Config-/Preflight-Fehler heilt kein Neustart, die Instanz bleibt sichtbar `failed` stehen statt im 5-Sekunden-Takt zu kreisen (das Start-Rate-Limit greift bei `RestartSec=5` nie). Exit 3 ist **bewusst nicht** 2, damit `Restart=on-failure` dort greift. Anwender-Sicht: [docs/INSTALLATION.md](docs/INSTALLATION.md#exit-codes). ## 🔄 Verarbeitungs-Flow **Beim Start (`run()` wie `run_once()`), vor allem anderen** — beide rufen dasselbe `_preflight()`: 1. `check_preflight(pdfa_level, skip_text, verapdf_enabled, verapdf_binary)` — `tesseract` und `gs` müssen im PATH sein; zusätzlich wird die Ghostscript-Version gegen den 10.0.0–10.02.0-Bug geprüft, und zwar unter genau der Bedingung, unter der ocrmypdf selbst abbricht (`_gs_block_reason()`: betroffene GS-Version **und** `skip_text` **und** (`pdfa_level` gesetzt **oder** ocrmypdf < 17)) 2. `check_verapdf_binary()` — nur bei `[verapdf].enabled`: `binary` darf nicht leer sein und muss über `resolve_verapdf_binary()` auffindbar **und ausführbar** sein (Pfad mit `/` direkt geprüft, nackter Name über `shutil.which`) 3. `check_output_config()` — validiert `original_on_success`, `archive_dir` (Pflicht bei `archive`) und `name_mode` 4. Scheitert eines davon → `PreflightError`, CLI beendet sich mit **Exit-Code 2** (ebenso bei kaputter/unlesbarer/fehlender Config) 5. `ensure_dirs()`, dann `_scan_existing()`: **zuerst `working/`**, danach `incoming/`; Dateien ohne `.pdf`-Endung meldet `_report_non_pdf()` als **eine** Sammelzeile (Anzahl + bis zu 3 Beispiele), nur beim Start-Scan **Wiederaufnahme aus `working/` (`_scan_working()`):** `process_pdf()` verschiebt das Original vor dem OCR nach `working/`. Wird der Dienst dort abgeschossen (SIGKILL nach `TimeoutStopSec`), blieb es früher liegen und wurde nie wieder angefasst — stiller Datenverlust. Jetzt: - Dateien mit dem Präfix `OCR_TEMP_PREFIX` (`__ocr_`) sind **unvollständige Fragmente** des abgebrochenen ocrmypdf-Laufs: als Eingabe unbrauchbar, als Ergebnis wertlos → werden **gelöscht** (mit `log.warning`). - Echte PDFs werden **an Ort und Stelle** wiederaufgenommen; `process_pdf()` erkennt das über `_is_same_file()` und verschiebt nicht erneut. - Liegt in `incoming/` eine gleichnamige, andere Datei, bekommt die wiederaufgenommene per `_free_resume_name()` einen Zeitstempel angehängt — sonst würden beide dieselbe working- und outgoing-Datei beanspruchen. - Liegt in `working/` bereits eine **andere** Datei desselben Namens, bricht `process_pdf()` für die neue ab und lässt sie in `incoming/` liegen, statt den laufenden Vorgang stillschweigend zu überschreiben. **Im Betrieb (`_wait_loop()`):** die Schleife wartet nicht nur auf den Stopp, sie prüft **sekündlich `self._observer.is_alive()`**. Stirbt der Observer (erschöpftes `fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis), blieb die Unit früher `active (running)` und verarbeitete stumm nichts mehr. Jetzt: `log.error` mit den möglichen Ursachen und `return EXIT_OBSERVER_DEAD` (3), damit `Restart=on-failure` den Watch neu aufsetzt. Bei regulärem Stopp wird die Prüfung übersprungen, sonst gäbe es dort einen Fehlalarm. **Pro Datei:** 1. `watchdog` triggert auf `created`/`moved`/`closed` in `incoming/` 2. `_wait_until_stable()` wartet, bis die Datei nicht mehr wächst (max. ~60s) 3. Move nach `working/` (entfällt bei Wiederaufnahme) 4. `ocrmypdf.ocr()` als **Library-Call** (kein Subprozess-Start pro PDF), Ziel ist `working/__ocr_` 5. Optional: veraPDF-Validierung (CLI-Subprozess). **FAIL** → OCR-Ergebnis nach `error/`, Original folgt `original_on_success` (bei `archive` also erhalten). **`VeraPdfUnavailable`** → Original **und** Ergebnis nach `error/`, das Original wird weder gelöscht noch archiviert 6. Move nach `outgoing/` unter dem laut `[output]` gebauten Namen (`build_output_name()`), vorher durch `_collision_free_path()` — `ProcessResult.output` trägt den **tatsächlich** geschriebenen Pfad 7. Original in `working/` wird laut `original_on_success` **gelöscht** oder nach `archive_dir` **archiviert** (Kollision → Timestamp-Suffix). `_dispose_original()` wirft nicht, sondern liefert bei Misserfolg einen Meldungstext → `ProcessResult.warning` 8. Aktive Upload-Targets ausführen (folder/nextcloud/sftp) 9. E-Mail-Notify je nach `[notify.email].on` — bei gesetztem `warning` als **„OK mit Warnung"** und mit `success=False` an `notify_email()`, damit sie auch bei `on = "errors"` zugestellt wird **Kollisionsschutz (`_collision_free_path()` in `processor.py`):** existiert das Ziel, wird `scan.pdf` zu `scan_.pdf`; ist auch das belegt (zwei Dateien in derselben Sekunde, mehrere Worker), wird zusätzlich hochgezählt. Benutzt von `outgoing/`, `_dispose_original()` (Archiv), `_move_to_error()` — und damit auch `_rescue_to_error()` — sowie `upload_folder()` in `uploaders.py`. Jeder dieser Pfade überschrieb vorher still. **`uploaders.py` importiert dafür `_collision_free_path` aus `processor.py`** — die einzige Abhängigkeit in diese Richtung. **Fehlerbehandlung:** | Fehlerfall | Zählt als Fehler | Wo liegt die Datei danach | |------------|------------------|----------------------------| | Stabilitäts-Check läuft in den Timeout | ja | bleibt in `incoming/`, wird beim nächsten Lauf erneut versucht | | Datei verschwindet vor der Verarbeitung | nein | — | | In `working/` liegt schon eine andere Datei gleichen Namens | ja | bleibt in `incoming/` | | OCR wirft (ocrmypdf) | ja | `error/` | | veraPDF FAIL | ja | OCR-Ergebnis nach `error/`, Original laut `original_on_success` (`delete` → weg, `archive` → `archive_dir`; seit 0.4.1) | | veraPDF nicht befragbar (`VeraPdfUnavailable`) | ja | **Original UND Ergebnis** nach `error/`; das Original wird weder gelöscht noch archiviert (seit 0.7.0) | | Original lässt sich nicht entsorgen (`_dispose_original`) | **nein** — gilt als Erfolg | Ergebnis in `outgoing/`, Upload läuft; Original bleibt in `working/` und wird beim nächsten Start erneut verarbeitet. `log.error` + Mail „OK mit Warnung" | | Beliebige Exception aus `process_pdf()` (z.B. `shutil.move` nach `outgoing/`) | ja | `_rescue_to_error()` sucht in `incoming/` und `working/` und verschiebt nach `error/` | | Mindestens ein Upload-Ziel schlägt fehl | ja | PDF bleibt **bewusst in `outgoing/`** (das OCR war ja erfolgreich), Fehler-Mail nennt die Ziele | Der Service läuft in allen Fällen weiter (kein `exit 1` wie im alten Bash-Tool). Im `--once`-Modus liefert die CLI **Exit-Code 1**, sobald `error_count > 0` ist, sonst 0. ## 🧠 Performance-Entscheidungen - **ocrmypdf als Library** statt `subprocess`: spart Python-Interpreter-Start pro PDF - **ThreadPool** mit `max_workers` (default 2) — selbst wenn selten >1 PDF gleichzeitig kommt, blockiert ein langsamer Scan keinen schnellen - **`--jobs` an ocrmypdf**: Tesseract parallelisiert Seiten innerhalb eines PDFs - **`skip_text=True`**: bereits OCR-haltige Seiten werden nicht neu verarbeitet - **Stabilitäts-Check** statt magic-file `new` (alte Bash-Krücke) - **`upload_folder()` nutzt `shutil.copyfile()`** statt `read_bytes()`/`write_bytes()` — große PDFs landen nicht komplett im RAM - veraPDF nur wenn `enabled=true` (JVM-Start ist teuer) ## ⚠️ Fallstricke - **Ghostscript 10.0.0–10.02.0 zerschießt OCR.** Das ist der Debian-12-Default. ocrmypdf verweigert damit die Arbeit — aber die Bedingung dafür hängt an der **ocrmypdf-Version**, und genau daran ist 0.6.0 gescheitert: - **ocrmypdf ≤ 16.x**: die Prüfung in `builtin_plugins/ghostscript.py::check_options()` läuft **bedingungslos**. `skip_text = true` allein reicht — `output_type` wird nicht geprüft. Auf Debian 12 scheitert damit **jede** Datei. - **ocrmypdf ≥ 17.0**: derselbe Block steckt in einem `if options.output_type.startswith('pdfa'):`. Ohne PDF/A wird Ghostscript nicht angefasst. `pdfa_level = ""` ist deshalb **kein** Schutz für sich genommen — es wirkt nur mit ocrmypdf ≥ 17. `requirements.txt` pinnt daher 17.x; ein Downgrade auf 16.x macht jede Debian-12-Instanz unbrauchbar, bei grünem `systemctl status`. Der Preflight bildet die reale Bedingung ab (`_gs_block_reason()`) und bricht mit Exit 2 ab, `--check-config` meldet denselben Zustand als Fehler. `redo_ocr` ist bewusst **nicht** in der Bedingung: die Config kennt keinen solchen Key. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an) oder `skip_text = false`. - **`[ocr].timeout` ist ein Timeout PRO SEITE**, kein Gesamt-Timeout pro PDF. Der Wert geht als `tesseract_timeout` (ocrmypdf-Option `--tesseract-timeout`) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default `1800` in einer Config stehen hat, gibt Tesseract 30 Minuten **je Seite** — Richtwert ist 300, ab 900 warnt `--check-config`. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu überspringen**, deshalb wird bei `0` (oder negativ) gar nichts übergeben und der ocrmypdf-Default greift. - **`TimeoutStopSec=300` in der Unit ist Absicht.** Ein laufendes OCR soll beim Stoppen zu Ende laufen dürfen — ein `systemctl stop` kann deshalb pro Instanz bis zu 5 Minuten dauern, und `update.sh` (das nacheinander stoppt) entsprechend länger. Bei SIGKILL bliebe das Original in `working/` liegen; das wird zwar wiederaufgenommen, kostet aber den kompletten Durchlauf. - **Die venv hängt an der Python-Version der Distribution.** Nach einem Debian-Major-Upgrade ist `venv/bin/python` tot (systemd: `203/EXEC`) oder eine andere Version als das System-Python. Der Weg dahin und zurück steht in [docs/OS-UPGRADE.md](docs/OS-UPGRADE.md); im Code prüfen beide Skripte das mit **demselben** `venv_is_healthy()` aus `lib/common.sh` (seit 0.7.0 — vorher hatte jedes eine eigene Fassung, und die in `install.sh` war die schwächere). - **`incoming/` darf nicht auf CIFS/NFS liegen.** Der Hotfolder hängt vollständig an inotify, und inotify sieht nur Änderungen des lokalen Kernels. Schreibt ein anderer Rechner über SMB/NFS in ein gemountetes Verzeichnis, entsteht **gar kein Event** — der Dienst meldet `active (running)`, arbeitet beim Start-Scan den Bestand ab und bemerkt danach nichts mehr. Betriebsvorgabe: ext4, xfs oder zfs, `incoming/` lokal ([docs/INSTALLATION.md](docs/INSTALLATION.md#dateisystem-ext4-xfs-oder-zfs)). - **Debian 13 in LXC auf Proxmox: journald scheitert mit `243/CREDENTIALS`.** systemd ≥ 255 (Debian 13 hat 257) setzt `ImportCredential=journal.*`; der Hilfsprozess `(sd-mkdcreds)` mountet dafür, und das AppArmor-Profil des Proxmox-Hosts blockiert das. Da der Dienst ausschließlich nach journald loggt, gibt es dann **keine** Logs. Betrifft jede Debian-13-LXC auf Proxmox 8.4 (Debian 12 mit systemd 252 nicht) und legt auch logind, networkd, console-getty und tmpfiles-setup lahm. Abhilfe und Hintergrund: [docs/INSTALLATION.md](docs/INSTALLATION.md#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials). In 0.6.3 stand hier noch, das sei ein Schaden auf genau einer Maschine — das war falsch. - **Ein nicht aufrufbares veraPDF war bis 0.6.3 der gefährlichste Fehler des Dienstes.** `run_verapdf()` lieferte für ein fehlendes Binary, einen Timeout oder eine leere Ausgabe schlicht `False` — also ein inhaltliches FAIL-Urteil. Damit wanderte jedes OCR-Ergebnis nach `error/` und das Original wurde laut `original_on_success` entsorgt, beim Default `delete` also gelöscht. Scan für Scan, bei grünem `systemctl status`. Seit 0.7.0: Preflight-Prüfung (Exit 2) **und** `VeraPdfUnavailable` als eigene Ausnahme, die ausdrücklich kein Urteil über die Datei ist. - **systemd-Hardening bricht in LXC-Containern** (`Error 226/NAMESPACE` durch `PrivateTmp`, `ProtectSystem` usw., Issue #4). Gegenmittel ist das Drop-in `systemd/lxc-compat.conf` nach `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/`; der Installer erkennt Container via `systemd-detect-virt --container` und bietet es an, `update.sh` zieht ein vorhandenes Drop-in nach. - **Das Paket wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert.** Gestartet wird per `python -m pdf_ocr_hotfolder`, gefunden wird das Modul nur über das Arbeitsverzeichnis — `WorkingDirectory=/opt/pdf-ocr-hotfolder` in der Unit ist daher Pflicht, nicht Kosmetik (Issue #5). Auch `update.sh` ruft `--check-config` deshalb mit `cd "$INSTALL_DIR"` auf. - **Relative Pfade in der Config waren still falsch.** Sie wurden gegen `WorkingDirectory=/opt/pdf-ocr-hotfolder` aufgelöst, nicht gegen das Verzeichnis der Config. Seit 0.7.0 `ConfigError` + Exit 2 für `[paths]`, `[output].archive_dir`, `[upload.folder].target`. **Das ist der einzige Fall, in dem ein Update von 0.6.x eine bisher „laufende" Instanz stoppt** — gewollt, siehe [docs/UPDATE.md](docs/UPDATE.md#relative-pfade--fehler-seit-070). - **Klartext-Passwörter in der Instanz-Config**: SMTP-, Nextcloud- und SFTP-Zugangsdaten stehen unverschlüsselt in `/etc/pdf-ocr-hotfolder/.toml`. Deshalb `chmod 640` und `chown root:`, und `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr`. **Das Update-Backup enthält diese Configs** und ist deshalb `0600 root:root` in einem `700`-Verzeichnis. Beim Debuggen weder Config noch Backup in ein Ticket kopieren. - **Das Update-Backup enthält die venv NICHT.** Ein Rollback per `tar -xzf … -C /` holt den Paketstand also nicht zurück, und `tar` löscht nichts, was neu hinzugekommen ist. Grenzen des Rollbacks: [docs/UPDATE.md](docs/UPDATE.md#grenzen-des-rollbacks). ## 🛠️ Entwicklung Lokaler Test ohne Installation: ```bash cd ~/dev/gitea.sonith.de/pdf-ocr-hotfolder python3 -m venv venv && source venv/bin/activate pip install -r requirements.txt cp config.example.toml /tmp/config.toml # Pfade in /tmp/config.toml auf Test-Verzeichnisse anpassen python -m pdf_ocr_hotfolder --config /tmp/config.toml ``` Tests (aus dem Repo-Root, `pytest.ini` setzt `testpaths = tests`): ```bash pytest # aktuell 254 Tests ``` `ocrmypdf` muss dafür **nicht** installiert sein: der Import in `processor.py` ist lazy, und `tests/test_ocr_timeout.py` schiebt ein Dummy-Modul in `sys.modules`. Die übrigen Tests mocken `process_pdf` bzw. arbeiten nur auf Config-Ebene. veraPDF wird über `subprocess.run` gemockt, der watchdog-Observer über ein Fake-Objekt mit `is_alive()`. ## 📋 Roadmap / TODO - [x] Tests (`pytest`) für `processor` und `uploaders` — 254 Tests - [x] Wiederaufnahme abgebrochener Läufe aus `working/` - [x] Config-Prüfung ohne Verarbeitung (`--check-config`) + Auswertung im Updater - [x] Updater übersteht Debian-Major-Upgrades (venv-Rebuild, Pins, Rollback) - [x] Stille Datenverlust-Pfade geschlossen: Kollisionsschutz in `outgoing/`, Archiv, `error/` und Ordner-Upload; veraPDF-Preflight + `VeraPdfUnavailable`; relative Pfade als Config-Fehler - [x] Toter watchdog-Observer wird erkannt (Exit 3) und getestet - [ ] Test-Lücken schließen: `run_ocr()` läuft nur gegen ein gemocktes ocrmypdf — es gibt keinen Test mit einer echten PDF-Datei. Der `_Handler`-Eventpfad ist weiterhin ungetestet (getestet ist nur die Observer-**Bewachung** in `_wait_loop()`). `upload_nextcloud()` und `upload_sftp()` sind ungetestet (nur `upload_folder()`). **`install.sh`/`update.sh`/`lib/common.sh` haben keine automatisierten Tests** — die `LIB_ONLY`-Schnittstelle in `update.sh` ist dafür vorbereitet, aber ungenutzt; `lib/common.sh` wäre jetzt die einfachste Stelle zum Anfangen, weil sie beim Sourcen nichts tut. - [ ] Prometheus-Metriken (verarbeitete PDFs, Fehlerquote, Laufzeit) - [ ] CLI-Subkommandos: `pdf-ocr-hotfolder reprocess ` - [ ] Instanz-Löschung in `install.sh` statt als Handarbeit - [ ] Optional: S3/MinIO Upload-Target - [ ] Docker-Image für Setups ohne systemd ## 🔑 Repo - **Repo:** https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder - **Owner:** sonith_ug - **SSH-User ist `gitea`, nicht `git`:** `gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git` - **Versionierung:** Semver (PATCH bei jedem Build, MINOR bei Features, MAJOR manuell) - **Tags:** `v{VERSION}`, automatischer Push nach Commit ## 📞 Kontakt **Maintainer:** Dominik Höfling (Sonith GmbH)