# Installation Erstinstallation und Anlage von Hotfolder-Instanzen mit `install.sh`. Verwandte Dokumente: [README](../README.md) · [Update](UPDATE.md) · [Debian-Major-Upgrade](OS-UPGRADE.md) --- ## Voraussetzungen | Punkt | Anforderung | |-------|-------------| | Betriebssystem | Debian 12 (bookworm) oder Debian 13 — systemd wird vorausgesetzt | | Python | 3.11+ (wegen `tomllib` aus der stdlib); kommt aus der Distribution | | Arbeitsspeicher | **mindestens 2 GB** für den produktiven Betrieb — siehe [Systemanforderungen](#systemanforderungen) | | Dateisystem | **ext4, xfs oder zfs**; `incoming/` **lokal**, kein CIFS/NFS — siehe [Dateisystem](#dateisystem-ext4-xfs-oder-zfs) | | Rechte | `root` (`sudo ./install.sh`) | | Netz | apt-Zugriff für die System-Pakete, PyPI-Zugriff für die venv | | Repo | muss dauerhaft liegen bleiben — `update.sh` kopiert daraus (s. [UPDATE.md](UPDATE.md)) | Die System-Pakete installiert der Installer selbst. Die Liste steht als einzige Quelle in `lib/common.sh` (Funktion `pdf_ocr_apt_packages()`, zwischen den Marken `# --- BEGIN apt-packages` / `# --- END apt-packages`); `install.sh` sourct die Datei, und `update.sh` schneidet den Block zusätzlich noch einmal aus der **Repo**-Fassung heraus, damit beim Update die neue Liste gilt und nicht die eventuell ältere Kopie unter `/opt/pdf-ocr-hotfolder/lib/`: ``` python3 python3-venv python3-pip tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng ghostscript qpdf unpaper pngquant icc-profiles-free ca-certificates curl ``` Weitere Tesseract-Sprachpakete installiert der Installer bei Bedarf pro Instanz nach (siehe [OCR-Sprachen](#ocr-sprachen)). Die Python-Abhängigkeiten stehen **fest gepinnt** in `requirements.txt` (ocrmypdf, watchdog, requests, paramiko). Warum das so ist und wie man die Pins anhebt: [OS-UPGRADE.md](OS-UPGRADE.md#pins-in-requirementstxt). --- ## Systemanforderungen CPU und Platte sind unkritisch — **der Arbeitsspeicher ist es nicht.** OCR rastert jede Seite in voller Auflösung ins RAM; der Spitzenbedarf hängt an der Seitengröße, nicht an der Dateigröße der PDF. **Empfehlung: mindestens 2 GB RAM.** Für Mehr-Instanz-Betrieb oder `max_workers > 2` entsprechend mehr. ### Dateisystem: ext4, xfs oder zfs Der Dienst wird **ausschließlich auf ext4, xfs oder zfs** betrieben. Andere Dateisysteme sind nicht vorgesehen und werden nicht getestet. **`incoming/` gehört auf ein lokales, inotify-fähiges Dateisystem — kein CIFS/NFS-Mount.** Der Hotfolder hängt vollständig an inotify (`watchdog` meldet `created`, `moved`, `closed`). inotify ist ein Mechanismus des *lokalen* Kernels: er sieht nur Änderungen, die dieser Kernel selbst ausführt. Schreibt ein anderer Rechner über SMB oder NFS in ein gemountetes Verzeichnis, geht das am lokalen VFS vorbei und **es entsteht gar kein Event** — nicht verzögert, nicht unzuverlässig, sondern grundsätzlich keines. Die Folge ist heimtückisch, weil nichts kaputt aussieht: Der Dienst startet, meldet `active (running)`, arbeitet den Bestand beim Start-Scan sauber ab — und bemerkt danach **keine einzige neue Datei mehr**. Es gibt keinen Fehler, keine Meldung, keine Mail. Erst wenn jemand die Instanz neu startet, wird der inzwischen angesammelte Stapel auf einmal verarbeitet. Richtiger Aufbau: der Scanner schreibt über SMB/NFS auf **den Rechner, auf dem der Dienst läuft**, und `incoming/` liegt dort auf der lokalen Platte. Der Netz-Export zeigt auf dieses lokale Verzeichnis, nicht umgekehrt. Für die **Ausgabe** gilt die Einschränkung nicht — `outgoing/` und `[upload.folder].target` dürfen auf einem Netz-Share liegen, dorthin wird nur geschrieben. ### Was 2 GB tatsächlich tragen Gemessen auf einem LXC-Container mit **2 GB RAM**, Debian 13 — eine **A4-Seite in 300 dpi** mit `deskew = true`, `oversample = 300`, `jobs = 4`: | Messwert | Ergebnis | |----------|----------| | Laufzeit | **19 s** | | `MemoryPeak` des Dienstes (`systemctl show -p MemoryPeak`) | **380 MB** | | `memory.peak` des ganzen Containers | **503 MB** | | `oom_kill` in `/sys/fs/cgroup/memory.events` | **0** | **Dieselbe Seite auf derselben Maschine mit 512 MB war genau der OOM-Kill unten.** Der Spitzenbedarf liegt also bei rund einem halben Gigabyte für *eine* Seite bei *einem* Worker — 2 GB lassen damit Luft für den Default `max_workers = 2`, für das Betriebssystem und für einen zweiten Hotfolder, sind aber keine üppige Reserve. ### Warum 512 MB nachweislich nicht reichen Gemessen auf einem LXC-Container mit **512 MB RAM + 512 MB Swap**, Debian 13, Ghostscript 10.05.1: | Vorgang | Ergebnis | |---------|----------| | eine einzelne **A4-Seite in 300 dpi**, `deskew = true` | cgroup-Limit gerissen, Dienst vom **OOM-Killer** beendet | | dieselbe Verarbeitung mit einer kleineren Seite (850 × 1100 px) | läuft sauber durch, ca. 19 s | Beleg aus dem `dmesg` des LXC-Hosts: ``` oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/lxc/201, task=python total-vm:1168764kB, anon-rss:489676kB ``` Eine Seite, ein Worker — und schon knapp 500 MB anonymer Speicher. 512 MB sind damit für 300-dpi-Scans keine knappe, sondern eine unzureichende Dimensionierung. ### RAM ↔ `max_workers` × `jobs` × Auflösung Der Spitzenbedarf multipliziert sich über drei Config-Werte aus [`[ocr]`](#ocr): | Key | Default | Wirkung auf den Speicher | |-----|---------|--------------------------| | `max_workers` | `2` | **so viele PDFs gleichzeitig** — jede mit eigenem Seitenpuffer. Der direkte Multiplikator | | `jobs` | `4` | Threads **innerhalb** einer PDF; mehrere Seiten gleichzeitig im Speicher | | `oversample` | `300` | Auflösung gerasterter Seiten — der Bedarf wächst quadratisch mit der dpi | Der Default `max_workers = 2` erlaubt also, dass **zwei** solcher Seiten parallel verarbeitet werden. Wer knapp dimensioniert, zieht zuerst `max_workers` auf `1` herunter, danach `jobs`. `oversample` unter 300 zu drücken, spart zwar Speicher, kostet aber Erkennungsqualität — das ist der letzte Hebel, nicht der erste. ### Wie sich ein OOM-Kill äußert Von außen sieht ein OOM-Kill wie ein Anwendungsfehler aus — er ist keiner: - Der Dienst ist **weg** bzw. wurde von systemd neu gestartet (`Restart=on-failure`); `systemctl show -p NRestarts` steigt. - Im journal bricht die Verarbeitung **mitten in der Datei** ab, ohne Python-Traceback und ohne `ERROR`-Zeile aus dem Tool. - Die betroffene PDF bleibt in `working/` liegen. ### Wie man ihn nachweist **Im Container:** ```bash cat /sys/fs/cgroup/memory.events # oom_kill 1 <- alles über 0 ist ein Treffer ``` **Auf dem LXC-Host** (im Container zeigt `dmesg` diese Zeilen nicht): ```bash dmesg -T | grep -i oom-kill ``` Diese Prüfung gehört an den **Anfang** der Fehlersuche, wenn Dateien unerklärlich in `working/` liegen bleiben: ohne sie sucht man den Fehler in ocrmypdf, Tesseract oder der Config, wo keiner ist. ### Datenverlust ist abgefangen, der OOM bleibt Seit **v0.6.0** greift der Dienst beim nächsten Start auf, was in `working/` liegen geblieben ist (siehe [UPDATE.md](UPDATE.md#wiederaufnahme-aus-working)). Eine vom OOM-Killer unterbrochene Datei geht also nicht verloren. Behoben ist damit aber nur die Folge: bei unveränderter Dimensionierung läuft dieselbe Datei nach dem Neustart erneut in denselben OOM — bis `max_workers` sinkt oder das System mehr RAM bekommt. --- ## Installation ```bash git clone gitea@gitea.sonith.de:sonith_ug/pdf-ocr-hotfolder.git cd pdf-ocr-hotfolder sudo ./install.sh ``` `install.sh` ist **Installer und Instanz-Manager in einem** und idempotent — jeder weitere Aufruf überspringt, was schon steht. ### Basis-Install vs. Instanz-Anlage Der Installer unterscheidet zwei Ebenen: | Ebene | Wann | Was passiert | |-------|------|--------------| | **Basis-Install** | einmalig; erkannt an `venv` + Template-Unit | System-Pakete, Ghostscript-Check, Container-Erkennung (inkl. [journald-Prüfung](#debian-13-in-lxc-auf-proxmox-journald-scheitert-243credentials)), Default-User `pdfocr`, Code **und `lib/`** nach `/opt/pdf-ocr-hotfolder/`, venv, systemd-Template-Unit | | **Instanz-Anlage** | bei jedem Lauf, beliebig oft | Abfragen pro Instanz, Datenverzeichnisse, `.toml`, optionales User-Drop-in, `enable --now` | Ist die Basis-Installation vorhanden, aber die venv passt nicht mehr zum System-Python (typisch nach einem Distributions-Upgrade), läuft der Basis-Install **zur Reparatur erneut**: die alte venv wird nach `venv.old-` weggesichert und neu gebaut. Für den geplanten Weg über ein Debian-Major-Upgrade ist aber `update.sh --rebuild-venv` gedacht, siehe [OS-UPGRADE.md](OS-UPGRADE.md). Beim Erstlauf ist mindestens **eine** Instanz Pflicht. Danach fragt der Installer in der Schleife `Weitere Instanz anlegen? [j/N]:`. --- ## Die Abfragen pro Instanz `create_instance()` stellt fünf Fragen, in der Reihenfolge der folgenden Abschnitte: Instanz-Name, Basis-Pfad, Service-User, OCR-Sprachen, Original-Behandlung. Alle Antworten gelten **nur für diese Instanz** — nichts davon ist global. ### Instanz-Name ``` Instanz-Name (nur a-z, 0-9, -): ``` Muster `^[a-z0-9][a-z0-9-]*$`. Der Name wird zum Unit-Suffix (`pdf-ocr-hotfolder@.service`) und zum Config-Dateinamen (`/etc/pdf-ocr-hotfolder/.toml`). Existiert die Config schon, bricht die Anlage ab — ein versehentliches Überschreiben gibt es nicht. ### Basis-Pfad für die Daten ``` Basis-Pfad für Daten [/var/lib/pdf-ocr-hotfolder/]: ``` Darunter entstehen `incoming/`, `outgoing/`, `working/`, `error/` und werden auf den Service-User gechownt. ### Service-User ``` Service-User [pdfocr]: ``` - **Existiert der User** (lokal oder als **AD-User via SSSD/Winbind**), wird er übernommen; die primäre Gruppe ermittelt der Installer per `id -gn`. - **Existiert er nicht**, bietet der Installer an, ihn lokal als System-User anzulegen. Wird das abgelehnt, bricht die Instanz-Anlage ab — der User muss dann erst über AD/SSSD bereitstehen. - Ist der gewählte User **nicht** `pdfocr`, legt der Installer das Drop-in `/etc/systemd/system/pdf-ocr-hotfolder@.service.d/user.conf` mit `User=`/`Group=` an. Bei AD-Usern mit lokaler UID werden die Datei-Berechtigungen über die UID gesetzt — das läuft transparent. ### OCR-Sprachen ``` Tesseract-Sprachen [deu+eng]: ``` Die Sprachen werden bewusst **je Instanz** abgefragt: ein Hotfolder `buchhaltung` sieht nur deutsche Belege, ein Hotfolder `export` internationale Korrespondenz. ```toml # /etc/pdf-ocr-hotfolder/buchhaltung.toml languages = "deu" # /etc/pdf-ocr-hotfolder/export.toml languages = "deu+eng+fra" ``` **Die Liste so eng wie möglich halten.** Jede zusätzliche Sprache kostet Laufzeit *und* Erkennungsqualität: Tesseract wägt mehr Modelle gegeneinander ab und verwechselt dabei Wörter, die in einer Sprache eindeutig wären. `deu+eng+fra` auf reinen Deutsch-Scans ist kein Sicherheitsnetz, sondern ein Rückschritt. Was der Installer damit macht: 1. **Format prüfen** — Sprachcodes mit `+` verbunden (`^[a-z]{3}(_[A-Za-z]+)?(\+…)*$`), also `deu`, `deu+eng`, `chi_sim+eng`. Bei Unsinn wird erneut gefragt, nicht abgebrochen. 2. **Jeden Code gegen `tesseract --list-langs` prüfen.** Fehlt eine Sprachdatei, bietet er das passende apt-Paket an: `tesseract-ocr-`, Unterstrich wird zum Bindestrich (`chi_sim` → `tesseract-ocr-chi-sim`). 3. **Lehnt man ab oder scheitert die Installation**, warnt er, dass OCR mit dieser Sprache **bei jeder Datei** scheitern würde, und fragt die Sprachen erneut ab — die fehlende Sprache kann man dann einfach weglassen. 4. Ist `tesseract` gar nicht aufrufbar, wird die Prüfung übersprungen und die Eingabe unverändert übernommen. ### Original archivieren? ``` Original nach erfolgreichem OCR archivieren? [j/N]: ``` - **Nein** (Default) → `original_on_success = "delete"`, das Original wird nach erfolgreichem OCR gelöscht. - **Ja** → `original_on_success = "archive"`, danach: ``` Archiv-Verzeichnis [/archive]: ``` Der Pfad muss **absolut** sein und darf **nicht** `incoming/`, `outgoing/`, `working/` oder `error/` der Instanz sein — im Eingang würde das Original sonst endlos neu aufgegriffen, in den übrigen kollidiert es mit der Verarbeitung. Das Verzeichnis wird angelegt und auf den Service-User gechownt; innerhalb des Basis-Pfads erledigt das bestehende `chown -R` das mit, ein Archiv **außerhalb** bekommt ein eigenes. ### Was danach passiert Die Instanz-Config entsteht per `sed` aus `config.example.toml`. Substituiert werden die vier `[paths]`-Zeilen sowie `[ocr].languages`, `[output].original_on_success` und `[output].archive_dir`. Anschließend liest der Installer diese drei Keys aus der erzeugten Datei zurück und vergleicht sie mit der Eingabe; erst wenn das passt, nennt die Zusammenfassung Sprachen und Archiv-Verzeichnis. Sonst gibt es eine Warnung mit der Bitte, die Config von Hand nachzuziehen. Die Config bekommt `chmod 640` und `chown root:`, das Verzeichnis `/etc/pdf-ocr-hotfolder` selbst `750 root:pdfocr` — in den Instanz-Configs stehen **Klartext-Passwörter** für SMTP, Nextcloud und SFTP. Zum Schluss: `daemon-reload` und `systemctl enable --now pdf-ocr-hotfolder@.service`. ### Test ```bash cp irgendein-scan.pdf /var/lib/pdf-ocr-hotfolder//incoming/ journalctl -u pdf-ocr-hotfolder@ -f ``` Nach wenigen Sekunden liegt das OCR-PDF im `outgoing/` der Instanz. --- ## Multi-Instanz-Betrieb Das Tool arbeitet komplett instanzbasiert über die systemd-Template-Unit `pdf-ocr-hotfolder@.service`. Jede Instanz hat eigene Config, eigene Datenverzeichnisse, eigene Unit, optional eigenen Service-User — und eigene OCR-Sprachen und Original-Behandlung. ```bash sudo ./install.sh # legt z.B. kunde-a, kunde-b, buchhaltung an systemctl status 'pdf-ocr-hotfolder@*' journalctl -u pdf-ocr-hotfolder@kunde-a -f ``` Der Code unter `/opt/pdf-ocr-hotfolder/` (inkl. venv) ist für **alle** Instanzen gemeinsam. Ein Update trifft damit immer alle Instanzen auf einmal — siehe [UPDATE.md](UPDATE.md). Das vollständige Verzeichnis-Layout steht im [README](../README.md#verzeichnisse). --- ## LXC/Container: Error 226/NAMESPACE In LXC-Containern schlagen die systemd-Hardening-Optionen der Unit (`PrivateTmp`, `ProtectSystem`, `ProtectKernelTunables`, …) fehl; systemd quittiert das mit `Error 226/NAMESPACE`. Der Installer erkennt Container über `systemd-detect-virt --container` und bietet das Drop-in automatisch an. Manuell: ```bash sudo mkdir -p /etc/systemd/system/pdf-ocr-hotfolder@.service.d/ sudo cp /opt/pdf-ocr-hotfolder/systemd/lxc-compat.conf \ /etc/systemd/system/pdf-ocr-hotfolder@.service.d/ sudo systemctl daemon-reload sudo systemctl restart 'pdf-ocr-hotfolder@*' ``` Das Drop-in setzt alle betroffenen Hardening-Schalter auf `false`. Es liegt auf Template-Ebene (`pdf-ocr-hotfolder@.service.d/`) und gilt damit für alle Instanzen. Ist es installiert, zieht `update.sh` es bei jedem Update aus dem Repo nach, damit ein neu ergänzter Hardening-Schalter nicht alle Container-Instanzen reißt. --- ## Ghostscript-Bug auf Debian 12 Ghostscript 10.0.0 bis einschließlich 10.02.0 — der **Debian-12-Default** — enthält Regressionen, die PDFs mit vorhandenem Text beschädigen. ocrmypdf verweigert deshalb den Dienst, statt ein kaputtes Ergebnis zu liefern. ### Wann genau ocrmypdf abbricht Die Bedingung steht in `ocrmypdf/builtin_plugins/ghostscript.py` (`check_options()`) und hängt an **zwei** Dingen — an der Config *und* an der ocrmypdf-Version: | ocrmypdf | Die Prüfung greift bei | Heißt für uns | |----------|------------------------|---------------| | **≤ 16.x** | `skip_text` oder `redo_ocr` — **unabhängig vom `output_type`** | Auch ohne PDF/A scheitert **jede** Datei, denn `skip_text = true` ist unser Default | | **≥ 17.0** | dasselbe, aber nur innerhalb von `if options.output_type.startswith('pdfa')` | Ohne PDF/A wird Ghostscript gar nicht angefasst — unkritisch | > ⚠️ **`pdfa_level = ""` allein ist damit kein Schutz.** Die Entwarnung gilt nur > zusammen mit **ocrmypdf ≥ 17**. Das war der Fehler in 0.6.0: der Pin stand auf > `ocrmypdf==16.13.0`, und auf Debian 12 landete daraufhin jede PDF in `error/` — > bei grünem `systemctl status` und „Preflight ok". > `requirements.txt` pinnt deshalb 17.x. ### Was das Tool dagegen tut - `pdfa_level = ""` ist der Default (kein PDF/A-Output). - `requirements.txt` pinnt **ocrmypdf 17.x**. Ein Downgrade auf 16.x macht jede Debian-12-Instanz unbrauchbar; `update.sh` weist Versionssprünge der gepinnten Pakete deshalb ausdrücklich aus. - Der Preflight bricht beim **Dienststart** mit **Exit 2** ab, wenn die Ghostscript-Version betroffen ist **und** die Kombination aus `skip_text`, `pdfa_level` und installierter ocrmypdf-Version tatsächlich zum Abbruch führen würde. Der Dienst startet dann gar nicht erst, statt jede Datei einzeln scheitern zu lassen. - `--check-config` meldet denselben Zustand als **Fehler (Exit 2)** und zeigt ocrmypdf- und Ghostscript-Version an (siehe [UPDATE.md](UPDATE.md#config-prüfung-per---check-config)). - Der [Rauchtest](UPDATE.md#rauchtest) in `update.sh` schiebt nach dem Update eine Test-PDF durch die echte Pipeline — er hätte den Ausfall sofort gezeigt. ### Abhilfe **Weg 1 — Ghostscript anheben** (empfohlen). Der Installer erkennt betroffene Versionen und bietet auf Debian 12 bookworm-backports an. Manuell: ```bash echo 'deb http://deb.debian.org/debian bookworm-backports main' | \ sudo tee /etc/apt/sources.list.d/bookworm-backports.list sudo apt update && sudo apt install -t bookworm-backports ghostscript ``` Ab Ghostscript 10.02.1 ist alles in Ordnung; PDF/A kann dann eingeschaltet werden. **Weg 2 — `skip_text = false` setzen.** Dann wird vorhandener Text neu erkannt statt übersprungen, und die Bedingung greift nicht mehr. Das kostet Laufzeit bei PDFs, die bereits eine Textebene haben. --- ## Instanz manuell löschen Der Installer legt Instanzen an, löscht aber keine. Von Hand: ```bash sudo systemctl disable --now pdf-ocr-hotfolder@ sudo rm /etc/pdf-ocr-hotfolder/.toml sudo rm -rf /etc/systemd/system/pdf-ocr-hotfolder@.service.d sudo systemctl daemon-reload # Datenverzeichnis /var/lib/pdf-ocr-hotfolder/ manuell aufräumen ``` Das Datenverzeichnis bleibt **bewusst** liegen: dort können noch unverarbeitete PDFs in `incoming/`, Fehlerfälle in `error/` oder Originale im Archiv liegen. Erst hineinsehen, dann löschen. Solange die Config unter `/etc/pdf-ocr-hotfolder/` liegt, zählt `update.sh` die Instanz weiter mit — auch wenn sie gestoppt ist. --- ## Konfigurationsreferenz Vollständiges, kommentiertes Beispiel: [`config.example.toml`](../config.example.toml). Jede Instanz hat ihre eigene Kopie unter `/etc/pdf-ocr-hotfolder/.toml`. Unbekannte Keys werden beim Laden ignoriert, aber **gemeldet** — beim Dienststart im Log und von `--check-config`. Ein Tippfehler wie `[ocr].langauges` fällt damit auf. ### `[paths]` — Pflicht | Key | Bedeutung | |-----|-----------| | `incoming` | Eingang, hier schreibt der Scanner hinein | | `outgoing` | Ausgang, fertige OCR-PDFs | | `working` | Arbeitsverzeichnis während der Verarbeitung | | `error` | fehlgeschlagene PDFs | Fehlt die Sektion oder einer der vier Einträge, gibt es eine deutsche `ConfigError`-Meldung mit Datei- und Key-Nennung und **Exit 2** — der Dienst startet nicht. **Alle vier Pfade müssen absolut sein.** Ein relativer Pfad wird gegen das Arbeitsverzeichnis des *Prozesses* aufgelöst, bei der Unit also gegen `WorkingDirectory=/opt/pdf-ocr-hotfolder` — **nicht** gegen das Verzeichnis, in dem die Config liegt. `incoming = "in"` legte damit still `/opt/pdf-ocr-hotfolder/in` an: der Scanner schreibt woanders hin als der Dienst schaut, und niemand sieht einen Fehler. Seit 0.7.0 ist das ein Config-Fehler mit **Exit 2**. Dieselbe Regel gilt für [`[output].archive_dir`](#output) und [`[upload.folder].target`](#uploadfolder--uploadnextcloud--uploadsftp); `install.sh` erzeugt ohnehin nur absolute Pfade. `incoming` muss außerdem auf einem **lokalen** Dateisystem liegen — siehe [Dateisystem](#dateisystem-ext4-xfs-oder-zfs). ### `[ocr]` | Key | Default | Bedeutung | |-----|---------|-----------| | `languages` | `"deu+eng"` | Tesseract-Sprachen; der Installer fragt sie pro Instanz ab | | `jobs` | `4` | Threads, die ocrmypdf innerhalb **einer** PDF nutzt | | `skip_text` | `true` | Seiten, die schon Text haben, nicht neu OCRen | | `oversample` | `300` | Auflösung für gerasterte Seiten | | `pdfa_level` | `""` | `"1"`, `"2"`, `"3"` oder leer für reines PDF — leer wegen des [Ghostscript-Bugs](#ghostscript-bug-auf-debian-12). Achtung: leer allein schützt nur zusammen mit ocrmypdf ≥ 17 | | `deskew` | `true` | schiefe Scans begradigen | | `clean` | `false` | Hintergrund säubern (unpaper) | | `max_workers` | `2` | wie viele PDFs **parallel** verarbeitet werden | | `timeout` | `300` | max. Sekunden, die Tesseract **pro Seite** laufen darf; `0` = kein eigenes Limit | **`timeout` ist ein Seiten-Timeout, kein Gesamt-Timeout.** Der Wert geht als `tesseract_timeout` an ocrmypdf; ein Dokument-Timeout kennt ocrmypdf nicht. Wer noch den alten Default `1800` aus einer Config vor 0.4.0 stehen hat, gibt Tesseract 30 Minuten **je Seite** — Richtwert ist 300. Läuft eine Seite in den Timeout, landet sie ohne Textebene im Ergebnis, die übrigen Seiten laufen weiter. Ein durchgereichtes `0` würde ocrmypdf dazu bringen, OCR **still zu überspringen**, deshalb wird `0` (oder negativ) gar nicht erst übergeben und der ocrmypdf-Default greift. Siehe auch [Config-Drift](UPDATE.md#config-drift-nach-einem-update). ### `[output]` | Key | Default | Bedeutung | |-----|---------|-----------| | `name_mode` | `"prefix"` | `prefix` → `OCR_scan.pdf`, `suffix` → `scan_OCR.pdf` (vor der Extension), `none` → unverändert | | `name_tag` | `"OCR_"` | verbatim eingefügter String; leer wirkt wie `none` | | `original_on_success` | `"delete"` | `delete` oder `archive` — Installer fragt das ab | | `archive_dir` | `""` | absoluter Pfad (relativ wird abgelehnt), **Pflicht** bei `archive`; Namenskollision → Zeitstempel-Suffix | Ein Tippfehler in `name_mode` oder `original_on_success` führt beim Start zum Abbruch mit **Exit 2**, nicht erst bei der ersten Datei. **Kollisionen überschreiben nichts.** Liegt im Ziel bereits eine Datei desselben Namens, wird die neue mit Zeitstempel danebengelegt (`scan.pdf` → `scan_20260923-081500.pdf`; bei zwei Dateien innerhalb derselben Sekunde zusätzlich mit Zähler), und es gibt eine Warnung im Journal. Das gilt seit 0.7.0 einheitlich für **`outgoing/`, das Archiv, `error/` und den Ordner-Upload** — vorher ersetzte der zweite Durchlauf das Ergebnis des ersten kommentarlos. Die E-Mail-Benachrichtigung und die Upload-Ziele nennen den tatsächlich geschriebenen Namen. **Scheitert das Entsorgen des Originals** (Platte voll, Verzeichnis read-only), gilt der Durchlauf trotzdem als Erfolg: das fertige PDF liegt bereits in `outgoing/` und wird normal ausgeliefert. Es gibt aber eine `ERROR`-Zeile im Journal, und die Mail geht als **„OK mit Warnung"** raus — auch bei `[notify.email].on = "errors"`. Das Original bleibt dann in `working/` liegen und wird beim nächsten Start **erneut** durch das OCR geschickt; es gehört von Hand aufgeräumt und die Ursache behoben. ### `[verapdf]` | Key | Default | Bedeutung | |-----|---------|-----------| | `enabled` | `false` | PDF/A-Validierung per veraPDF-CLI | | `binary` | `/opt/verapdf/verapdf` | Pfad zum veraPDF-Binary, oder ein nackter Name, der im `PATH` gesucht wird | | `flavour` | `"1b"` | PDF/A-Flavour | veraPDF startet eine JVM und ist entsprechend teuer — nur einschalten, wenn die Validierung wirklich gebraucht wird. Bei FAIL wandert das OCR-Ergebnis nach `error/`; das Original folgt `original_on_success` (bei `archive` bleibt es also erhalten). **Mit `enabled = true` wird `binary` im Preflight geprüft** (seit 0.7.0). Zeigt der Pfad nicht auf ein vorhandenes, ausführbares Programm, startet der Dienst gar nicht erst (**Exit 2**), und `--check-config` meldet denselben Fehler. `--check-config` zeigt Binary und Flavour außerdem in der Übersicht an. > ⚠️ **Warum das eine harte Sperre ist.** Bis 0.6.3 war ein Tippfehler in > `binary` der gefährlichste Fehler des ganzen Dienstes: `run_verapdf()` fand > das Programm für **jede** Datei nicht, wertete das als „nicht konform", > schob das OCR-Ergebnis nach `error/` — und entsorgte das Original laut > `original_on_success`, beim Default `delete` also die Vorlage. Scan für Scan > verschwanden so die Originale, während die Unit als `active (running)` > dastand. **Störung ist kein FAIL.** Lässt sich veraPDF im laufenden Betrieb nicht mehr befragen — Programm verschwunden, JVM startet nicht, Timeout (300 s pro Datei), oder die Ausgabe enthält weder `PASS` noch `FAIL` —, ist das **kein Urteil über die PDF**. In diesem Fall wandern **Original und OCR-Ergebnis** nach `error/`, und das Original wird **weder gelöscht noch archiviert**, unabhängig von `original_on_success`. Im Journal steht die Ursache samt Hinweis auf `--check-config`. ### `[upload.folder]` / `[upload.nextcloud]` / `[upload.sftp]` Beliebig viele Ziele gleichzeitig aktivierbar. Sind alle aus, bleibt das fertige PDF einfach in `outgoing/` liegen. | Sektion | Keys | |---------|------| | `[upload.folder]` | `enabled`, `target` — leer heißt `[paths].outgoing`, dann No-op; sonst **absoluter** Pfad (relativ wird abgelehnt, Exit 2) | | `[upload.nextcloud]` | `enabled`, `url`, `username`, `password`, `remote_path`, `verify_ssl` | | `[upload.sftp]` | `enabled`, `host`, `port`, `username`, `key_file`, `password`, `remote_path` | Schlägt mindestens ein Ziel fehl, zählt das als Fehler und die Mail geht als FEHLER raus — das PDF bleibt aber **bewusst in `outgoing/`** liegen, das OCR selbst war ja erfolgreich. Liegt im `target` von `[upload.folder]` schon eine gleichnamige Datei, wird sie seit 0.7.0 **nicht mehr ersetzt**, sondern die Kopie mit Zeitstempel danebengelegt (samt Warnung) — dieselbe Regel wie in [`[output]`](#output). ### `[notify.email]` | Key | Default | Bedeutung | |-----|---------|-----------| | `enabled` | `false` | E-Mail-Benachrichtigung an/aus | | `smtp_host`, `smtp_port`, `smtp_user`, `smtp_password`, `use_starttls` | — | SMTP-Zugang | | `from_addr` | — | Absender | | `to_addrs` | `[]` | Empfängerliste | | `on` | `"errors"` | `always` \| `errors` \| `never` | ### `[logging]` | Key | Default | Bedeutung | |-----|---------|-----------| | `level` | `"INFO"` | `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` | Der Dienst schreibt **kein eigenes Logfile** — alles geht nach stdout und damit ins journal. --- ## Exit-Codes Der Dienst und die CLI benutzen vier Codes. `systemctl status` und `journalctl` zeigen sie als `status=`. | Exit | Bedeutung | Startet systemd neu? | Was zu tun ist | |------|-----------|----------------------|----------------| | **0** | regulärer Stopp (SIGTERM/SIGINT); bei `--once`: alles verarbeitet, auch „nichts da"; bei `--check-config`: Config sauber | — | nichts | | **1** | nur im Einmal-Betrieb: mindestens eine PDF ist fehlgeschlagen. Bei `--check-config`: Config nutzbar, aber mit **Warnungen** | — | `error/` ansehen bzw. Warnungen nachziehen | | **2** | **Config- oder Preflight-Fehler** — kaputtes/unlesbares TOML, fehlender Pflicht-Key, relativer Pfad, ungültige `[output]`-Werte, fehlendes `tesseract`/`gs`, betroffene Ghostscript-Version, nicht aufrufbares veraPDF | **nein** — `RestartPreventExitStatus=2` in der Unit | Config korrigieren, mit `--check-config` gegenprüfen, dann `systemctl start` | | **3** | **Der Verzeichnis-Watch ist gestorben** — es würden keine neuen Dateien mehr erkannt | **ja**, und genau darum geht es | meist nichts; häuft es sich, `fs.inotify.max_user_watches` und den Mount von `incoming/` prüfen | **Zu Exit 2:** Ein Neustart heilt einen Config-Fehler nicht. Ohne `RestartPreventExitStatus=2` startete `Restart=on-failure` die Instanz endlos im 5-Sekunden-Takt neu (das Start-Rate-Limit greift bei `RestartSec=5` nie). Seit 0.7.0 bleibt sie stattdessen sichtbar `failed` stehen — das ist gewollt und soll beim Nachsehen auffallen. **Zu Exit 3:** Stirbt der watchdog-Observer im Betrieb (erschöpftes `fs.inotify.max_user_watches`, ersetztes oder neu gemountetes Verzeichnis), blieb die Unit früher `active (running)` und verarbeitete stumm nichts mehr — für einen Hotfolder der schlechteste denkbare Zustand. Der Dienst prüft den Observer jetzt sekündlich mit, loggt eine `ERROR`-Zeile mit den möglichen Ursachen und beendet sich mit 3, damit systemd ihn neu startet und der Watch neu aufgesetzt wird. Ein einzelnes Vorkommnis ist damit selbstheilend; ein steigendes `systemctl show -p NRestarts` ist der Hinweis, dass man nachsehen sollte. --- ## Troubleshooting ### Tesseract findet die Sprache nicht ```bash sudo apt install tesseract-ocr-deu tesseract-ocr-eng ``` Danach `[ocr].languages` prüfen. Der Installer nimmt einem das beim Anlegen einer Instanz ab, ein nachträglich in die Config geschriebener Sprachcode wird aber nicht geprüft — `--check-config` zeigt die eingestellten Sprachen an. ### "PriorOcrFoundError" ocrmypdf erkennt bereits vorhandenen OCR-Text. `skip_text = true` in der Config setzen (Default). ### Berechtigungsprobleme bei AD-User Der Service-User braucht **rw** auf alle vier Verzeichnisse der Instanz (und auf das Archiv, falls konfiguriert): ```bash sudo chown -R DOMAIN\\scanuser:DOMAIN\\scangroup /var/lib/pdf-ocr-hotfolder/ ``` ### veraPDF: Dienst startet nicht / Dateien landen in `error/` Seit 0.7.0 prüft der Preflight `[verapdf].binary`. Startet die Instanz mit **Exit 2** nicht mehr, nachdem vorher „alles lief", ist das die gute Nachricht: der Pfad war schon vorher falsch, nur hat es bisher niemand gemerkt. Vorher wurde jede PDF als ungültig gewertet und das Original laut `original_on_success` entsorgt. ```bash ls -l /opt/verapdf/verapdf # vorhanden? ausführbar (chmod +x)? ``` Korrigieren — oder, wenn die Validierung nicht zwingend gebraucht wird, `[verapdf].enabled = false` setzen. Landen **Original und OCR-Ergebnis gemeinsam** in `error/`, war veraPDF im laufenden Betrieb nicht mehr ansprechbar; das Original ist dann unangetastet, siehe [`[verapdf]`](#verapdf). ### Dienst startet nicht (Exit 2) Exit 2 heißt immer: Config oder Preflight — siehe [Exit-Codes](#exit-codes). Die Instanz bleibt bewusst `failed` stehen und wird **nicht** neu gestartet. Die Ursache steht im journal und ausführlicher in: ```bash cd /opt/pdf-ocr-hotfolder && sudo ./venv/bin/python -m pdf_ocr_hotfolder \ --check-config --config /etc/pdf-ocr-hotfolder/.toml ``` Typische Fälle: kaputtes TOML (die Meldung nennt Zeile und Spalte, sofern der Interpreter sie liefert), ein relativer Pfad in `[paths]`, `[output].archive_dir` oder `[upload.folder].target`, ein leeres `archive_dir` bei `original_on_success = "archive"`, ein nicht aufrufbares veraPDF oder eine betroffene Ghostscript-Version. ### Dienst läuft, verarbeitet aber nichts mehr `systemctl status` sagt `active (running)`, in `incoming/` stapeln sich die PDFs, im Journal passiert nichts. Drei Ursachen, in dieser Reihenfolge prüfen: 1. **`incoming/` liegt auf einem CIFS/NFS-Mount.** Dann liefert inotify grundsätzlich keine Events — der Dienst verarbeitet nur noch beim Start. `findmnt -T /var/lib/pdf-ocr-hotfolder//incoming` zeigt den Typ; Hintergrund und richtiger Aufbau unter [Dateisystem](#dateisystem-ext4-xfs-oder-zfs). 2. **Der Verzeichnis-Watch ist gestorben.** Seit 0.7.0 fällt das auf: der Dienst beendet sich mit [Exit 3](#exit-codes) und systemd startet ihn neu. Im Journal steht die `ERROR`-Zeile, `systemctl show -p NRestarts` steigt. Häuft sich das, ist meist das inotify-Limit erschöpft: ```bash cat /proc/sys/fs/inotify/max_user_watches ``` 3. **Es sind gar keine PDFs.** Dateien ohne `.pdf`-Endung werden ignoriert. Beim Start-Scan meldet der Dienst sie seit 0.7.0 als Sammelzeile mit Anzahl und bis zu drei Beispielnamen: ```bash journalctl -u pdf-ocr-hotfolder@ | grep 'ohne .pdf-Endung' ``` ### Dienst startet nicht (203/EXEC) Der Interpreter der venv ist weg — fast immer nach einem Distributions-Upgrade. Siehe [OS-UPGRADE.md](OS-UPGRADE.md). ### Keine Logs: "No journal files were found" **Symptom:** `journalctl -u pdf-ocr-hotfolder@` bleibt leer oder meldet `No journal files were found.` — auch dann, wenn der Dienst nachweislich läuft und Dateien verarbeitet. **Erste Prüfung:** ```bash systemctl status systemd-journald ``` Ist `systemd-journald.service` selbst `failed` (beobachtet mit `status=243/CREDENTIALS`), gibt es schlicht kein journal, in das geschrieben werden könnte. **Einordnung.** Der Dienst loggt seit v0.4.1 **ausschließlich** nach journald — es gibt bewusst kein eigenes Logfile und kein Logverzeichnis. Ein kaputtes journald ist damit ein blinder Fleck: jede Fehlersuche läuft ins Leere, und der Ausfall sieht aus wie ein stummer Dienst. **Deshalb vor jeder Fehlersuche zuerst journald prüfen**, nicht erst, wenn nichts anderes mehr passt. Die häufigste Ursache ist **kein** Schaden an dieser einen Maschine, sondern systematisch: **Debian 13 in einer LXC auf Proxmox** — siehe den nächsten Abschnitt. Auf Debian 12 tritt sie nicht auf. `install.sh` warnt beim Erstinstall in einem Container von sich aus, wenn `systemd-journald` nicht läuft, nennt den Drop-in-Befehl und fragt, ob fortgefahren werden soll. **Notbehelf, solange journald nicht zu retten ist:** die Instanz einmal im Vordergrund laufen lassen — dann geht die Ausgabe direkt ins Terminal, am journal vorbei. ```bash sudo systemctl stop pdf-ocr-hotfolder@ cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfolder \ --config /etc/pdf-ocr-hotfolder/.toml ``` Das `cd` ist zwingend: das Paket wird nicht pip-installiert, sondern nach `/opt/pdf-ocr-hotfolder` kopiert und nur über das Arbeitsverzeichnis gefunden (ohne `cd` gibt es `No module named pdf_ocr_hotfolder`, v0.6.2). Beenden mit `Strg+C`, danach `sudo systemctl start pdf-ocr-hotfolder@`. Wer nur den Bestand abarbeiten und dann aussteigen will, hängt `--once` an (siehe [Manueller Lauf](#manueller-lauf-one-shot)). ### Debian 13 in LXC auf Proxmox: journald scheitert (243/CREDENTIALS) Verifizierter Befund. Er betrifft **jede** Debian-13-LXC auf Proxmox 8.4, nicht nur eine einzelne Maschine — und er trifft nicht nur dieses Tool, sondern alles, was auf dem Container-journal aufsetzt. **Symptom im Container:** ```bash systemctl status systemd-journald # ● systemd-journald.service - Journal Service # Active: failed # Process: ... (code=exited, status=243/CREDENTIALS) # Main PID exited, status=243/CREDENTIALS journalctl -u pdf-ocr-hotfolder@ # No journal files were found. ``` **Ursache.** Ab systemd 255 (Debian 13 liefert **257**) setzt die journald-Unit `ImportCredential=journal.*`. Zum Einlesen dieser Credentials startet systemd den Hilfsprozess `(sd-mkdcreds)`, und der **mountet** dafür. Genau diesen Mount verbietet das AppArmor-Profil des Proxmox-Hosts. Im Log des **Hosts** steht dazu: ``` apparmor="DENIED" operation="mount" profile="lxc-_" name="/dev/" comm="(sd-mkdcreds)" ``` Debian 12 hat systemd 252, kennt `ImportCredential` in dieser Unit nicht und ist deshalb **nicht** betroffen. Das Upgrade 12 → 13 ist damit der Auslöser, nicht der Container an sich. **Dieselbe Ursache legt weitere Units lahm** — beobachtet bei `systemd-logind`, `systemd-networkd`, `console-getty` und `systemd-tmpfiles-setup`. Wer nur journald repariert, hat die übrigen noch vor sich; ein Blick auf `systemctl --failed` lohnt sich. **Abhilfe im Container** (reboot-fest verifiziert) — `ImportCredential` wird per Drop-in auf leer gesetzt und damit abgeschaltet: ```bash sudo mkdir -p /etc/systemd/system/systemd-journald.service.d printf '[Service]\nImportCredential=\n' | \ sudo tee /etc/systemd/system/systemd-journald.service.d/no-credentials.conf sudo systemctl daemon-reload sudo systemctl restart systemd-journald sudo journalctl --flush ``` Danach `systemctl status systemd-journald` (muss `active (running)` sein) und `journalctl -u pdf-ocr-hotfolder@` gegenprüfen. Für die anderen betroffenen Units gilt dasselbe Muster mit deren Unit-Namen. > **Der saubere Weg liegt host-seitig.** Das Drop-in kuriert das Symptom im > Container. Richtig behoben wird es auf dem Proxmox-Host: Update von > `pve-container`/`lxc-pve` auf eine Fassung mit passenden AppArmor-Regeln — > oder, als grobes Mittel, `lxc.apparmor.profile: unconfined` in der > Container-Config, was die AppArmor-Isolation dieses Containers allerdings > komplett aufgibt. ### Dienst bricht mitten in der Verarbeitung weg Datei bleibt in `working/`, kein Traceback, `NRestarts` steigt: das ist fast immer der OOM-Killer, kein Anwendungsfehler. Nachweis und Dimensionierung unter [Systemanforderungen](#wie-sich-ein-oom-kill-äußert). --- ## Manueller Lauf (One-Shot) Bestehende PDFs einer Instanz einmalig verarbeiten und beenden — greift auch Dateien auf, die in `working/` liegen geblieben sind: ```bash cd /opt/pdf-ocr-hotfolder && sudo -u pdfocr ./venv/bin/python -m pdf_ocr_hotfolder \ --config /etc/pdf-ocr-hotfolder/kunde-a.toml --once ``` Exit-Code: `0` = alles verarbeitet (auch "nichts da"), `1` = mindestens eine Datei fehlgeschlagen, `2` = Config- oder Preflight-Fehler. Exit 3 gibt es hier nicht — der gehört zum Dauerbetrieb, siehe [Exit-Codes](#exit-codes).