Files
pdf-ocr-hotfolder/AI_AGENT_BRIEFING.md
T
techadmin 1b67c846a2 fix: Updater meldet den LXC-Drop-in nur noch, wenn sich wirklich etwas aendert (v0.7.2)
Nachlese aus der Verifikation von v0.7.1 auf einem frischen Debian 12.

- update.sh meldete "LXC-Drop-in nachgezogen ✓" auch bei unveraenderter
  Datei — dieselbe Klasse Unwahrheit wie das Ghostscript-Haekchen, das in
  v0.7.1 behoben wurde. Jetzt wird verglichen und ehrlich gemeldet.
- "Quelle wieder entfernt" ist Teil der schlechten Nachricht und kommt als
  WARN statt INFO.

254 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 02:14:27 +02:00

37 KiB
Raw Blame History

AI Agent Briefing — PDF OCR Hotfolder

Zuletzt aktualisiert: 2026-09-23 Version: 0.7.2 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 (Einstieg, Layout, Config-Überblick) · docs/INSTALLATION.md (Erstinstallation, Instanzen, Konfigurationsreferenz) · docs/UPDATE.md (Update, Backup, Rollback, --check-config) · 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.

🖥️ 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/<instanz>.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@<instanz>.service.d/user.conf Drop-in für abweichenden User (optional)
/var/lib/pdf-ocr-hotfolder/<instanz>/{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.

journalctl -u pdf-ocr-hotfolder@<instanz> -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@<instanz>.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. 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-<code> 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.
  • <instanz>.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.

🧰 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 "<gemeinter Aufruf>"
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. 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-<ts>, 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 (im Archiv unter opt/pdf-ocr-hotfolder/, damit es beim Entpacken nach / nicht im Wurzelverzeichnis landet). 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. 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 <datei> 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.

🔄 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_<zielname>
  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_<YYYYmmdd-HHMMSS>.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 — und die Falle dabei: pdfa_level = "" lassen (Default), skip_text = false setzen, oder eine Distribution mit neuerem Ghostscript (Debian 13: 10.05.1). Ein Ghostscript-Upgrade auf Debian 12 gibt es nicht — bookworm-backports enthält kein Ghostscript-Paket (am echten Paketindex geprüft: 2606 Pakete, ghostscript nicht darunter). Bis einschließlich v0.7.0 haben Preflight-Meldung, Config-Warnung, config.example.toml, README und alle drei docs-Dateien genau dieses Upgrade empfohlen — ein Rat, der nie funktionieren konnte. Falls er irgendwo wieder auftaucht: ersatzlos streichen. Planungsaussage für den Admin: wer auf Debian 12 PDF/A mit skip_text = true braucht, hat dort keinen Weg — das muss vor der Installation entschieden werden, nicht beim Preflight-Abbruch.

  • [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; 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).

  • 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. In 0.6.3 stand hier noch, das sei ein Schaden auf genau einer Maschine — das war falsch. Nach der Reparatur die Instanzen einmal neu starten (systemctl restart 'pdf-ocr-hotfolder@*'): wer gestartet wurde, während journald tot war, hat danach ein leeres Journal (-- No entries --) — das sieht aus wie eine gescheiterte Reparatur, ist aber nur der fehlende Neustart.

  • 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.

  • Klartext-Passwörter in der Instanz-Config: SMTP-, Nextcloud- und SFTP-Zugangsdaten stehen unverschlüsselt in /etc/pdf-ocr-hotfolder/<instanz>.toml. Deshalb chmod 640 und chown root:<service-gruppe>, 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.

  • Auf einem frischen Proxmox-Debian-Template (12 und 13) fehlen sudo und git. sudo ./install.sh scheitert dort mit sudo: command not found — als root direkt (./install.sh) laeuft alles; die Skripte brauchen root-Rechte, nicht sudo. Und ohne git gibt es keinen Clone. Beides steht jetzt in den Voraussetzungen (docs/INSTALLATION.md). Wer Doku aendert: sudo nicht ueberall streichen — die meisten Admins haben es; beide Wege nennen.

  • systemctl start kann kein Glob. stop 'pdf-ocr-hotfolder@*' trifft alle laufenden Instanzen (die Units sind geladen), start 'pdf-ocr-hotfolder@*' versucht eine Instanz namens * zu starten und scheitert. Nach einem Rollback muss deshalb jede Instanz einzeln gestartet werden (docs/UPDATE.md). restart geht wieder mit Glob.

🛠️ Entwicklung

Lokaler Test ohne Installation:

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):

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

  • Tests (pytest) für processor und uploaders — 254 Tests
  • Wiederaufnahme abgebrochener Läufe aus working/
  • Config-Prüfung ohne Verarbeitung (--check-config) + Auswertung im Updater
  • Updater übersteht Debian-Major-Upgrades (venv-Rebuild, Pins, Rollback)
  • Stille Datenverlust-Pfade geschlossen: Kollisionsschutz in outgoing/, Archiv, error/ und Ordner-Upload; veraPDF-Preflight + VeraPdfUnavailable; relative Pfade als Config-Fehler
  • 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 <error-file>
  • 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
  • Clone per HTTPS (Normalfall, ohne Credentials): https://gitea.sonith.de/sonith_ug/pdf-ocr-hotfolder.git — das ist der Weg, den die Installations-Doku nennt und der im Erstinstallations-Test benutzt wurde.
  • SSH nur mit Deploy-Key — und der 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)