Vor dem Rollout durchgesehen und die verbliebenen Stellen geschlossen, an denen etwas schiefgehen konnte, ohne dass es irgendwo sichtbar wurde. Datenverlust: - veraPDF: das in [verapdf].binary konfigurierte Programm wird im Preflight geprueft. Bisher galt bei falschem Pfad JEDE Datei als "nicht konform" — Ergebnis nach error/, Original geloescht (Default delete). run_verapdf() trennt jetzt ausserdem ein echtes FAIL-Urteil von einer Stoerung (VeraPdfUnavailable: nicht startbar, abgestuerzt, kein PASS/FAIL in der Ausgabe). Bei Stoerung wandern Original UND Ergebnis nach error/, das Original wird nicht entsorgt. - Gleichnamige Dateien wurden in outgoing/, error/ und beim Ordner-Upload mit abweichendem target kommentarlos ueberschrieben. Jetzt Zeitstempel daneben, mit Warnung; ProcessResult.output traegt den echten Pfad. Robustheit: - Kaputtes oder nicht lesbares TOML beim Start: Exit 2 statt Traceback. - RestartPreventExitStatus=2 in der Unit — Exit 2 (Config/Preflight) laeuft nicht mehr endlos neu, die Instanz bleibt sichtbar failed stehen. - Toter watchdog-Observer wird erkannt: Exit 3, systemd setzt den Watch neu auf. Vorher blieb die Unit "active" und verarbeitete nichts mehr. - Relative Pfade in [paths]/archive_dir/target sind ein Config-Fehler statt still unter /opt zu landen. - Fehler beim Archivieren entwertet den Durchlauf nicht mehr: Upload und Mail laufen, Sichtbarkeit ueber log.error + "OK mit Warnung"-Mail. - Nicht-PDFs in incoming/ werden beim Start-Scan gesammelt gemeldet. - Logging explizit nach stdout (die Doku versprach das schon). Struktur: - Neue lib/common.sh, von install.sh und update.sh gesourct. Die doppelte venv_is_healthy() gibt es nur noch einmal, in der gruendlichen Fassung — die schlanke in install.sh haette eine nach einem Distro-Sprung kaputte venv als gesund durchgewunken (nachgewiesen). - install.sh warnt in Containern, wenn systemd-journald nicht laeuft. Doku: Dateisystem-Festlegung (ext4/xfs/zfs, kein CIFS/NFS wegen inotify), Debian 13 in LXC auf Proxmox scheitert an journald (243/CREDENTIALS, AppArmor blockiert sd-mkdcreds) inkl. Abhilfe, echte Speicher-Messwerte, Exit-Code-Tabelle. 254 Tests gruen (vorher 152). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
34 KiB
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 (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 |
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
pdfocran (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) mitUser=/Group=Override - Existierende User (lokal oder AD via SSSD/Winbind) werden übernommen, primäre Gruppe via
id -gnermittelt - 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()auslib/common.sh, Befunde überreport_venv_issues). - In Containern (
systemd-detect-virt --container) bietet der Installer das LXC-Drop-in an und prüft, obsystemd-journaldläuft. Tut es das nicht, warnt er (der Dienst loggt ausschließlich nach journald), nennt denImportCredential=-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_DIRsindlocalincreate_instance(), gelten also instanz-lokal und nicht global. - Sprachprüfung gegen
tesseract --list-langs, fehlende Pakete werden alstesseract-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>.tomlwird ausconfig.example.tomlpersederzeugt. Substituiert werden die vier[paths]-Zeilen sowie[ocr].languages,[output].original_on_successund[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 durchsed_escape_repl()(maskiert\,&,|). Nach dem sed-Lauf liestconfig_value()die drei Keys zurück und vergleicht sie mit der Eingabe.- Die apt-Paketliste steht als einzige Quelle in
lib/common.shzwischen den Marken# --- BEGIN apt-packages/# --- END apt-packagesin der Funktionpdf_ocr_apt_packages().install.shbekommt sie durchs Sourcen;update.shschneidet den Block zusätzlich persedaus der Repo-Fassung heraus und evaluiert ihn, weil gesourct evtl. die ältere installierte Kopie wurde. Marken und Funktionsname dürfen sich nicht ändern, ohneupdate.shanzupassen. - Instanz wird sofort
enable --nowgestartet. 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 ininstall.shwar die schlankere: sie verglich nurmajor.minordes venv-Interpreters mit dem System-Python und hätte den Distro-Upgrade-Fall überpyvenv.cfgnicht bemerkt. Erhalten geblieben ist die gründliche Fassung (Verzeichnis, ausführbarer Interpreter, Interpreter läuft, Version == System-Python,pyvenv.cfg== Interpreter). Sie setztVENV_ISSUESund gibt nichts selbst aus — dafür istreport_venv_issues()da.lib/wird mitinstalliert.install.shundupdate.shkopieren es nach/opt/pdf-ocr-hotfolder/lib/, jeweils mit vorherigemrm -rf "${INSTALL_DIR:?}/lib". Es liegt damit auch im Update-Backup (das sichert$INSTALL_DIRohne 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 — eincommand not foundmitten im Lauf wäre die schlechtere Nachricht.install.shsucht 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.shhat--helpund--rebuild-venv, läuft mitset -Eeuo pipefailund 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-filesund die Configs unter/etc/pdf-ocr-hotfolder/ab. Drei Gruppen:PREV_OK,PREV_BROKEN,PREV_STOPPED— bewusst gestoppte bleiben gestoppt. - Verifikation:
verify_unit()wartetVERIFY_WAIT(6 s) und prüftis-active,is-failedundNRestarts— sonst würde ein Crash-Loop beiType=simpleals Erfolg durchgehen. Vorherreset-failed. - venv-Health (
venv_is_healthy()auslib/common.sh): Verzeichnis, ausführbarer Interpreter, Interpreter läuft überhaupt,major.minor== System-Python,pyvenv.cfgstimmt mit dem Interpreter überein. Bei Drift wird auch ohne--rebuild-venvneu gebaut. rebuild_venv()ist ganz oder gar nicht: alte venv nachvenv.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 nurAPT_WARN, sie brechen nicht ab. - Backup (
create_backup()): Code,/etc/pdf-ocr-hotfolder/, Template-Unit, alle Drop-ins und einpip-freeze.txtder alten venv. Ohne venv und ohne Datenverzeichnisse.umask 077+chmod 600 root:root, weil die Configs Klartext-Passwörter enthalten. Rotation: letzteBACKUP_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.shkopiert daraus (.repo_path). PDF_OCR_UPDATE_LIB_ONLY=1 source ./update.shlädt nur die Funktionen, ohne irgendetwas zu tun — dafür sindINSTALL_DIR,CONFIG_DIR,SYSTEMD_DIR,BACKUP_DIR,TAR_ROOT,VERIFY_WAIT,BACKUP_KEEPüberschreibbar. Die Vorgaben stehen inlib/common.shund 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 (RichtwertRECOMMENDED_PAGE_TIMEOUT= 300); gesetztespdfa_levelweist 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():
check_preflight(pdfa_level, skip_text, verapdf_enabled, verapdf_binary)—tesseractundgsmü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 undskip_textund (pdfa_levelgesetzt oder ocrmypdf < 17))check_verapdf_binary()— nur bei[verapdf].enabled:binarydarf nicht leer sein und muss überresolve_verapdf_binary()auffindbar und ausführbar sein (Pfad mit/direkt geprüft, nackter Name übershutil.which)check_output_config()— validiertoriginal_on_success,archive_dir(Pflicht beiarchive) undname_mode- Scheitert eines davon →
PreflightError, CLI beendet sich mit Exit-Code 2 (ebenso bei kaputter/unlesbarer/fehlender Config) ensure_dirs(), dann_scan_existing(): zuerstworking/, danachincoming/; 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 (mitlog.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, brichtprocess_pdf()für die neue ab und lässt sie inincoming/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:
watchdogtriggert aufcreated/moved/closedinincoming/_wait_until_stable()wartet, bis die Datei nicht mehr wächst (max. ~60s)- Move nach
working/(entfällt bei Wiederaufnahme) ocrmypdf.ocr()als Library-Call (kein Subprozess-Start pro PDF), Ziel istworking/__ocr_<zielname>- Optional: veraPDF-Validierung (CLI-Subprozess). FAIL → OCR-Ergebnis nach
error/, Original folgtoriginal_on_success(beiarchivealso erhalten).VeraPdfUnavailable→ Original und Ergebnis nacherror/, das Original wird weder gelöscht noch archiviert - Move nach
outgoing/unter dem laut[output]gebauten Namen (build_output_name()), vorher durch_collision_free_path()—ProcessResult.outputträgt den tatsächlich geschriebenen Pfad - Original in
working/wird lautoriginal_on_successgelöscht oder nacharchive_dirarchiviert (Kollision → Timestamp-Suffix)._dispose_original()wirft nicht, sondern liefert bei Misserfolg einen Meldungstext →ProcessResult.warning - Aktive Upload-Targets ausführen (folder/nextcloud/sftp)
- E-Mail-Notify je nach
[notify.email].on— bei gesetztemwarningals „OK mit Warnung" und mitsuccess=Falseannotify_email(), damit sie auch beion = "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 --jobsan ocrmypdf: Tesseract parallelisiert Seiten innerhalb eines PDFsskip_text=True: bereits OCR-haltige Seiten werden nicht neu verarbeitet- Stabilitäts-Check statt magic-file
new(alte Bash-Krücke) upload_folder()nutztshutil.copyfile()stattread_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 = trueallein reicht —output_typewird 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.txtpinnt daher 17.x; ein Downgrade auf 16.x macht jede Debian-12-Instanz unbrauchbar, bei grünemsystemctl status. Der Preflight bildet die reale Bedingung ab (_gs_block_reason()) und bricht mit Exit 2 ab,--check-configmeldet denselben Zustand als Fehler.redo_ocrist bewusst nicht in der Bedingung: die Config kennt keinen solchen Key. Abhilfe: Ghostscript ≥ 10.02.1 aus bookworm-backports (der Installer bietet das an) oderskip_text = false. - ocrmypdf ≤ 16.x: die Prüfung in
-
[ocr].timeoutist ein Timeout PRO SEITE, kein Gesamt-Timeout pro PDF. Der Wert geht alstesseract_timeout(ocrmypdf-Option--tesseract-timeout) durch; ocrmypdf kennt kein Dokument-Timeout. Wer noch den alten Default1800in einer Config stehen hat, gibt Tesseract 30 Minuten je Seite — Richtwert ist 300, ab 900 warnt--check-config. Ein durchgereichtes0würde ocrmypdf dazu bringen, OCR still zu überspringen, deshalb wird bei0(oder negativ) gar nichts übergeben und der ocrmypdf-Default greift. -
TimeoutStopSec=300in der Unit ist Absicht. Ein laufendes OCR soll beim Stoppen zu Ende laufen dürfen — einsystemctl stopkann deshalb pro Instanz bis zu 5 Minuten dauern, undupdate.sh(das nacheinander stoppt) entsprechend länger. Bei SIGKILL bliebe das Original inworking/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/pythontot (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 demselbenvenv_is_healthy()auslib/common.sh(seit 0.7.0 — vorher hatte jedes eine eigene Fassung, und die ininstall.shwar 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 meldetactive (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) setztImportCredential=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. -
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 schlichtFalse— also ein inhaltliches FAIL-Urteil. Damit wanderte jedes OCR-Ergebnis nacherror/und das Original wurde lautoriginal_on_successentsorgt, beim Defaultdeletealso gelöscht. Scan für Scan, bei grünemsystemctl status. Seit 0.7.0: Preflight-Prüfung (Exit 2) undVeraPdfUnavailableals eigene Ausnahme, die ausdrücklich kein Urteil über die Datei ist. -
systemd-Hardening bricht in LXC-Containern (
Error 226/NAMESPACEdurchPrivateTmp,ProtectSystemusw., Issue #4). Gegenmittel ist das Drop-insystemd/lxc-compat.confnach/etc/systemd/system/pdf-ocr-hotfolder@.service.d/; der Installer erkennt Container viasystemd-detect-virt --containerund bietet es an,update.shzieht ein vorhandenes Drop-in nach. -
Das Paket wird nicht pip-installiert, sondern nach
/opt/pdf-ocr-hotfolderkopiert. Gestartet wird perpython -m pdf_ocr_hotfolder, gefunden wird das Modul nur über das Arbeitsverzeichnis —WorkingDirectory=/opt/pdf-ocr-hotfolderin der Unit ist daher Pflicht, nicht Kosmetik (Issue #5). Auchupdate.shruft--check-configdeshalb mitcd "$INSTALL_DIR"auf. -
Relative Pfade in der Config waren still falsch. Sie wurden gegen
WorkingDirectory=/opt/pdf-ocr-hotfolderaufgelöst, nicht gegen das Verzeichnis der Config. Seit 0.7.0ConfigError+ 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. Deshalbchmod 640undchown root:<service-gruppe>, und/etc/pdf-ocr-hotfolderselbst750 root:pdfocr. Das Update-Backup enthält diese Configs und ist deshalb0600 root:rootin einem700-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, undtarlöscht nichts, was neu hinzugekommen ist. Grenzen des Rollbacks: docs/UPDATE.md.
🛠️ 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ürprocessorunduploaders— 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()undupload_sftp()sind ungetestet (nurupload_folder()).install.sh/update.sh/lib/common.shhaben keine automatisierten Tests — dieLIB_ONLY-Schnittstelle inupdate.shist dafür vorbereitet, aber ungenutzt;lib/common.shwä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.shstatt 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, nichtgit: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)