feat: Wiederaufnahme aus working/, --check-config, feldtauglicher Updater (v0.6.0)

Datenverlust behoben:
- Nach hartem Stopp blieb das Original in working/ liegen und wurde nie
  wieder angefasst (_scan_existing sah nur incoming/). Es wird jetzt beim
  Start an Ort und Stelle wieder aufgegriffen, mit Kollisionsschutz gegen
  gleichnamige neue Scans; angefangene __ocr_-Fragmente werden geloescht.
- TimeoutStopSec 30 -> 300, damit laufendes OCR zu Ende laufen darf.

Config-Drift sichtbar gemacht:
- Neues --check-config (Exit 0 sauber / 1 Warnungen / 2 Fehler), das
  update.sh vor dem Neustart ueber alle Instanz-Configs laufen laesst.
- Warnungen fuer [ocr].timeout >= 900 (seit 0.4.0 pro SEITE) und gesetztes
  pdfa_level, beim Dienststart wie im Check.
- Unbekannte Config-Keys werden nicht mehr still verworfen, sondern genannt.

Updater feldtauglich:
- venv-Health-Check erkennt toten Symlink UND Versions-Drift gegen das
  System-Python; --rebuild-venv als ausdruecklicher Weg nach einem Debian-
  Major-Upgrade. Neubau ist ganz-oder-gar-nicht mit Rollback.
- apt-Pakete werden auch beim Update synchronisiert (Quelle: install.sh).
- Instanz-Erfassung inkl. activating/failed, Verifikation prueft is-failed
  und NRestarts statt sleep 1 + is-active.
- Backup enthaelt Configs, Unit, Drop-ins und pip-freeze.txt, liegt auf
  0600 und rotiert auf 5; schlaegt es fehl, bricht das Update vorher ab.
- ERR-Trap faehrt die vorher laufenden Instanzen wieder hoch.
- lxc-compat.conf wird beim Update nachgezogen.
- requirements.txt gepinnt (ocrmypdf 16.13.0, geprueft fuer Python 3.11+3.13).

Doku in Installation / Update / OS-Upgrade aufgeteilt (docs/).
135 Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:04:04 +02:00
parent 2062476252
commit 3e24aa2ecd
20 changed files with 3095 additions and 347 deletions
+33 -5
View File
@@ -14,6 +14,10 @@ log = logging.getLogger(__name__)
# Erlaubte Werte für [output].name_mode — wird auch vom Preflight geprüft
VALID_NAME_MODES = ("prefix", "suffix", "none")
# Präfix der Zwischendatei, in die ocrmypdf schreibt. Bleibt sie nach einem
# harten Stopp in working/ liegen, ist sie ein unvollständiges Fragment.
OCR_TEMP_PREFIX = "__ocr_"
def build_output_name(src_name: str, mode: str, tag: str) -> str:
"""Erzeugt den Ziel-Dateinamen für ein OCR-PDF.
@@ -114,13 +118,29 @@ def process_pdf(
"""Verarbeitet eine einzelne PDF: move→OCR→validate→outgoing/error."""
out_name = build_output_name(src.name, output_cfg.name_mode, output_cfg.name_tag)
work_src = working_dir / src.name
work_out = working_dir / f"__ocr_{out_name}" # Temp-Name, damit er != src.name ist
work_out = working_dir / f"{OCR_TEMP_PREFIX}{out_name}" # Temp-Name, damit er != src.name ist
final_out = outgoing_dir / out_name
try:
shutil.move(str(src), str(work_src))
except OSError as e:
return ProcessResult(src, final_out, False, f"move to working failed: {e}")
if _is_same_file(src, work_src):
# Wiederaufnahme: die Datei liegt bereits in working/, weil ein
# früherer Lauf hart abgebrochen wurde. Kein zweiter Move — der würde
# die Datei bestenfalls auf sich selbst schieben.
log.warning("Wiederaufnahme aus %s: %s wird erneut per OCR verarbeitet",
working_dir, src.name)
elif work_src.exists():
# Gleicher Dateiname, andere Datei: ein Move würde den laufenden bzw.
# wiederaufgenommenen Vorgang in working/ stillschweigend überschreiben.
return ProcessResult(
src, final_out, False,
f"in {working_dir} liegt bereits eine andere Datei namens "
f"{src.name} — Original bleibt in {src.parent} liegen und wird "
"beim nächsten Lauf erneut versucht",
)
else:
try:
shutil.move(str(src), str(work_src))
except OSError as e:
return ProcessResult(src, final_out, False, f"move to working failed: {e}")
try:
run_ocr(work_src, work_out, ocr_cfg)
@@ -153,6 +173,14 @@ def process_pdf(
return ProcessResult(src, final_out, True, verapdf_passed=vera_ok)
def _is_same_file(a: Path, b: Path) -> bool:
"""Zeigen beide Pfade auf dieselbe Datei? (verträgt fehlende Dateien)"""
try:
return a.resolve() == b.resolve()
except OSError:
return False
def _dispose_original(work_src: Path, original_name: str, cfg: OutputConfig) -> None:
"""Entsorgt das Original laut [output].original_on_success — löschen oder archivieren.