From 8394de69264ae05a196fc164b6ef580155544cb0 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 25 Jul 2026 21:10:03 +0200 Subject: [PATCH] fix(transcode): "Abbrechen" wirkt sofort statt erst beim naechsten Prozent Fund des Commanders am laufenden Akira-Job (Nachtrag im vorigen Savepoint): Job stand auf 'canceling', HandBrake lief weiter. Gemessen 3,4 Minuten zwischen Anforderung (18:30:17) und Bestaetigung (18:33:38) - bei langsamerem Fortschritt entsprechend mehr. Ursache genau wie dort beschrieben: Der Abbruch wurde nur in datei_fortschritt geprueft, und diese Closure stieg oben sofort wieder aus, wenn sich die Prozentzahl nicht geaendert hatte ("if gesamt == letzter[0]: return"). Bei einem Prozent je halber Stunde hing der Abbruch also an einem Ereignis, das eine halbe Stunde lang nicht eintrat. run_handbrake bekommt jetzt einen eigenen abbruch_cb neben progress_cb - dasselbe Muster, das run_makemkv schon fuer log_cb benutzt, und aus demselben Grund: der Fortschritts-Kanal verwirft Aufrufe. Er wird bei JEDER Ausgabezeile aufgerufen und im Worker auf 5 s gedrosselt. Nebeneffekt, der genauso wichtig ist: Er greift auch waehrend des Scan-Durchlaufs, der ueberhaupt keine Encode-Prozente liefert. Dort war ein Abbruch vorher grundsaetzlich unmoeglich - und seit die Prozent-Regex den Scan korrekt ignoriert, waere das sonst sogar schlimmer geworden. Die Leseschleife ist als _handbrake_schleife() herausgezogen, damit die Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake pruefbar ist. Der Test belegt: zwei Scan-Zeilen genuegen fuer den Abbruch, es muss NICHT auf eine Encode-Zeile gewartet werden. Savepoint nachgezogen: Der Encode laeuft nicht mehr, ein Deploy ist gefahrlos moeglich, und die alte "SOFORT ENTSCHEIDEN"-Passage (Platte laeuft voll, wenn der Job fertig wird) ist damit gegenstandslos. Neu darin: die drei Wege fuer UHD, und die Warnung, dass eine /proc-Suche nach "HandBrake" die eigene Shell mittrifft - zwei Fehlalarme in dieser Sitzung. Co-Authored-By: Claude Opus 5 --- SAVEPOINT.md | 80 +++++++++++++++++++-------- docker/worker/ripping.py | 71 +++++++++++++++--------- docker/worker/tasks.py | 38 ++++++++++++- docker/worker/test_ripping_helpers.py | 47 ++++++++++++++++ 4 files changed, 184 insertions(+), 52 deletions(-) diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 12545fb..a1bd9de 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -2,9 +2,10 @@ ## Aktueller Stand: v3.14 — Durchsicht: vier Placebos und ein unwirksamer Schutz (25.07.2026, 18:40) -> Diese Sitzung hat nichts deployt (der Akira-Encode läuft — ein Rebuild -> schießt ihn ab). Alles unten ist committet und die Ampel ist grün. -> **Zuerst lesen: „SOFORT ENTSCHEIDEN" ganz unten.** +> Alles unten ist committet, die Ampel ist grün, **nichts ist deployt**. +> Der Akira-Encode wurde parallel zu dieser Sitzung um 18:31 abgebrochen +> (siehe Nachtrag im Block darunter) — **es läuft jetzt nichts mehr, ein +> Deploy ist gefahrlos möglich.** Zuerst lesen: „SOFORT ENTSCHEIDEN" unten. ### DER WICHTIGSTE FUND: der Platten-Schutz aus `c065967` greift nicht @@ -35,11 +36,16 @@ Falls, der die Platte füllte. 18:00:39 (Deploy von `8bb075c`), postgres/redis älter. - **Platte:** 148 G, 105 G belegt, 37 G frei = 75 G Rohschnitt + 4,9 G Medien (Evangelion) + ~25 G System. **Keine Kopier-Reste**, kein `original`-Ordner. -- **Akira-Encode läuft:** HandBrake PID 40 seit 18:01:37, Preset - `H.265 MKV 2160p60 4K`, **3,84 von 4 Kernen**. Leseposition im Quellstrom - **1.145.940.149 von 79.604.951.639 Bytes = 1,44 %**. -- **Restlaufzeit ≈ 28–55 h.** Zwei Messfenster ergaben 395 und 784 KB/s — das - Tempo schwankt mit der Bildkomplexität, deshalb eine Spanne und keine Zahl. +- **Akira-Job:** `failed`, „Abgebrochen durch Nutzer" (Abbruch 18:30:17 + angefordert, Worker bestätigt 18:33:38 → **3,4 Minuten Verzug**, siehe unten). + Es läuft **kein** HandBrake mehr (per `/proc` geprüft, Stand 19:06). +- **Der Encode war bei 1,44 %**, gemessen an der Leseposition im Quellstrom + (`/proc//fdinfo/3`: 1.145.940.149 von 79.604.951.639 Bytes) — exakter + als jede Fortschrittsanzeige. Zwei Tempo-Fenster ergaben 395 und 784 KB/s + → **28–55 h** für den Film, bei 3,84 von 4 gesättigten Kernen. +- **Achtung bei Prozess-Suchen per `/proc`:** Ein `case "$c" in *HandBrake*)` + trifft die eigene Shell mit, weil deren Kommandozeile das Wort enthält. Zwei + Fehlalarme in dieser Sitzung. Immer die PID gegenprüfen. - **Die Ursache dafür ist neu und behebbar:** Die VM läuft auf dem generischen QEMU-CPU-Modell (`QEMU Virtual CPU version 2.5+`), `grep -c avx2 /proc/cpuinfo` = **0**, nur bis `sse4_2`. x265 lebt von AVX2. Abhilfe: @@ -138,26 +144,52 @@ entscheidet.** - **Zwei neue API-Tests laufen nur in der Ampel** — `test_api_smoke.py` überspringt sich unter Windows selbst (`main.py` braucht `fcntl`). +### GEBAUT — „Abbrechen" wirkt jetzt sofort + +Der Fund aus dem Nachtrag (Commander, am laufenden Job beobachtet) ist behoben. +Ursache war genau wie dort beschrieben: Der Abbruch wurde nur in +`datei_fortschritt` geprüft, und diese Closure stieg oben sofort wieder aus, +wenn sich die Prozentzahl nicht geändert hatte. Bei einem Prozent je halber +Stunde sah „Abbrechen" entsprechend lange wirkungslos aus. + +Jetzt gibt es in `run_handbrake` einen **eigenen Abbruch-Kanal** neben dem +Fortschritts-Callback — dasselbe Muster, das `run_makemkv` schon für `log_cb` +benutzt, und aus demselben Grund. Er wird bei JEDER Ausgabezeile aufgerufen +und ist im Worker auf 5 Sekunden gedrosselt (`ABBRUCH_INTERVALL_SEKUNDEN`). +Nebeneffekt: Er greift auch während des Scan-Durchlaufs, der überhaupt keine +Encode-Prozente liefert — dort war ein Abbruch vorher grundsätzlich unmöglich. +Die Leseschleife ist als `_handbrake_schleife()` herausgezogen, damit die +Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist. + ### SOFORT ENTSCHEIDEN -**Der laufende Job wird beim Fertigwerden mit dem DEPLOYTEN (alten) Code -versuchen, 75 GB in 37 GB zu kopieren.** `keepOriginal` steht auf `true`, und -den Wert hat die Aufgabe beim Start um 18:01:37 gelesen — ihn jetzt umzustellen -ändert für diesen Lauf nichts mehr. Der Ablauf wäre: Platte läuft auf 100 %, -dann ENOSPC, dann räumt der `except`-Zweig die Teilkopie weg. Selbstheilend — -aber währenddessen ist die Systemplatte voll, und genau daran hat sich heute -Mittag der Worker-Container verschluckt. +**1. Deployen — es läuft nichts, der Weg ist frei.** Nichts blockiert mehr, und +solange nicht deployt ist, gilt weiterhin der alte Code: unwirksamer +Platten-Schutz, Anzeige auf 99 %, Auswurf ohne Wirkung, träges „Abbrechen". -Die Optionen, in der Reihenfolge, die ich empfehle: +``` +ssh arcane@192.168.178.162 'cd ~/projects/rippy && git pull --ff-only && docker compose up -d --build' +``` -1. **Encode abbrechen, deployen, Kompression neu anstoßen.** Kostet die - bisherigen ~30 Minuten (der Lauf ist bei 1,44 % — praktisch nichts) und - bringt sofort alle Fixes live, inklusive des Platten-Schutzes und der - ehrlichen Fortschrittsanzeige. Wer ohnehin über den CPU-Typ nachdenkt, - erledigt das im selben Fenster. -2. **Laufen lassen und in 1–2 Tagen deployen**, vorher die 75 GB von Hand - wegräumen oder auf die NAS schieben, damit die Kopie nicht anlaufen kann. -3. **Laufen lassen und den vollen Plattenlauf in Kauf nehmen.** +**2. Die UHD-Strategie ist noch offen** (aus dem Nachtrag, unverändert gültig). +Preset-je-Disc-Typ war richtig, reicht aber nicht: Software-HEVC in 4K ist auf +dieser CPU keine Option. Drei Wege, keiner davon gebaut: +- **UHD gar nicht komprimieren** — Roh-MKV behalten. Ehrlichste Variante, + kostet Platz (75–100 GB je Film, gehört dann auf die NAS). +- **Hardware-Encoder** — Remote-Worker mit GPU (`nvenc`/`vaapi`). Rippy kann + das schon routen (bewiesen v3.7); es fehlt die Maschine. Achtung: Das + Worker-Image kann selbst keinen Hardware-Encoder, ein GPU-Worker braucht ein + HandBrake-Build mit `nvenc_*`/`qsv_*` — die neue Anzeige sagt das jetzt. +- **CPU-Typ der VM auf `host`** — schaltet AVX2 frei, bringt bei x265 typisch + Faktor 2–4. Aus 28–55 h werden damit aber immer noch Stunden bis Tage; das + allein löst 4K nicht, hilft aber jedem 1080p-Encode. + +**3. Vier tote Endpunkte** — entfernen oder behalten (Tabelle oben). + +**4. Im Akira-Ordner liegt ein unbrauchbares 4K-Fragment** (die 1080p-Fassung +wurde beim Start auf 0 Bytes gekürzt). Der 75-GB-Rohschnitt ist unversehrt, +also ist nichts verloren — aber die Datei gehört weg, bevor jemand sie für ein +Ergebnis hält. --- diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 8e20658..95dfd43 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -339,8 +339,19 @@ def get_progress_from_line(line: str) -> int: return int(((aktuell - 1) * 100 + prozent) / gesamt) -def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict: - """Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.""" +def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, + progress_cb=None, abbruch_cb=None) -> dict: + """Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt. + + `abbruch_cb` wird bei JEDER Ausgabezeile aufgerufen und soll RipAbbruch + werfen, wenn der Nutzer abgebrochen hat. Bewusst ein eigener Kanal neben + `progress_cb` — aus demselben Grund wie `log_cb` bei run_makemkv: der + Fortschritts-Callback verwirft Aufrufe mit unveränderter Prozentzahl, und + genau daran hing der Abbruch früher. Bei einem 4K-Encode mit einem Prozent + je halber Stunde sah „Abbrechen" deshalb minutenlang wirkungslos aus + (Befund 25.07.2026). Zusätzlich greift es damit auch während des + Scan-Durchlaufs, der gar keine Encode-Prozente ausgibt. + """ if not check_handbrake_installed(): return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"} @@ -352,33 +363,43 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR text=True, bufsize=1 ) - - try: - for line in process.stdout: - progress = get_progress_from_line(line) - # >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte - # Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei - # einem 4K-Encode sind das Stunden ohne sichtbare Bewegung. - if progress >= 0 and progress_cb: - progress_cb(progress) - except RipAbbruch: - process.kill() - process.wait() - return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"} - - process.wait() - - if process.returncode == 0 and os.path.exists(output_path): - return {"status": "success", "output_path": output_path} - return { - "status": "error", - "error": f"HandBrake endete mit Code {process.returncode}", - "return_code": process.returncode, - } + return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb) except Exception as e: return {"status": "error", "error": str(e)} +def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict: + """Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die + Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist.""" + try: + for line in process.stdout: + # Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt + # einen Fortschritt enthält. Sonst haengt „Abbrechen" daran, dass + # sich die Prozentzahl bewegt (Befund 25.07.2026). + if abbruch_cb: + abbruch_cb() + progress = get_progress_from_line(line) + # >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte + # Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei + # einem 4K-Encode sind das Stunden ohne sichtbare Bewegung. + if progress >= 0 and progress_cb: + progress_cb(progress) + except RipAbbruch: + process.kill() + process.wait() + return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"} + + process.wait() + + if process.returncode == 0 and os.path.exists(output_path): + return {"status": "success", "output_path": output_path} + return { + "status": "error", + "error": f"HandBrake endete mit Code {process.returncode}", + "return_code": process.returncode, + } + + def build_abcde_cmd(device_path: str, config_path: str, auswerfen: bool = True) -> list: """Baut das abcde-Kommando (pure Funktion, testbar). diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index c2ef0d9..cf32b2b 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -18,6 +18,7 @@ import json import os import posixpath import shutil +import time import requests @@ -92,6 +93,11 @@ def pfad_lokal(pfad: str, mapping: str = None) -> str: RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw") MEDIA_ROOT = "/app/media" +# Wie oft während einer Kompression nachgesehen wird, ob der Nutzer abgebrochen +# hat. Eine DB-Abfrage alle paar Sekunden ist nichts gegen einen Encode, der +# Stunden läuft — und „Abbrechen" fühlt sich damit sofort an. +ABBRUCH_INTERVALL_SEKUNDEN = 5 + def unter_wurzel(pfad: str, wurzel: str) -> bool: """Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)? @@ -728,6 +734,30 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str): anzahl = len(quellen) letzter = [-1] + letzte_abbruchpruefung = [0.0] + + def abbruch_pruefen(): + """Zeitgesteuert prüfen, ob der Nutzer abgebrochen hat. Wirft RipAbbruch. + + Befund 25.07.2026 (Commander, am laufenden Akira-Job beobachtet): Der + Abbruch wurde NUR in `datei_fortschritt` geprüft — und diese Closure + stieg oben sofort wieder aus, wenn sich die Prozentzahl nicht geändert + hatte. Bei einem 4K-Encode, der pro Prozent eine halbe Stunde braucht, + sah „Abbrechen" entsprechend lange wirkungslos aus (gemessen: 3,4 min + zwischen Anforderung 18:30:17 und Bestätigung 18:33:38 — bei noch + langsamerem Fortschritt entsprechend mehr). + + Deshalb hängt die Prüfung jetzt an der Zeit statt am Fortschritt und + läuft bei JEDER Ausgabezeile von HandBrake — auch während des + Scan-Durchlaufs, der überhaupt keine Encode-Prozente liefert. + """ + jetzt = time.monotonic() + if jetzt - letzte_abbruchpruefung[0] < ABBRUCH_INTERVALL_SEKUNDEN: + return + letzte_abbruchpruefung[0] = jetzt + if _abbruch_angefordert(job_id): + raise RipAbbruch() + for index, quelle in enumerate(quellen): ziel = os.path.join(final_dir, os.path.basename(quelle)) @@ -736,11 +766,13 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str): if gesamt == letzter[0]: return letzter[0] = gesamt - if _abbruch_angefordert(job_id): - raise RipAbbruch() db.update_job(job_id, progress=min(99, gesamt)) - hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt) + hb = run_handbrake( + quelle, ziel, preset=preset, + progress_cb=datei_fortschritt, + abbruch_cb=abbruch_pruefen, + ) if hb.get("status") == "cancelled": _job_abschliessen(job_id, hb) return hb diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index f785e11..64722c7 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -225,3 +225,50 @@ def test_preset_fuer_ohne_einstellungen_nimmt_den_eingebauten_standard(): assert preset_fuer("", None) == DEFAULT_HB_PRESET assert preset_fuer(None, {}) == DEFAULT_HB_PRESET assert preset_fuer("cd", {"transcodePresetUhd": "egal"}) == DEFAULT_HB_PRESET + + +def test_handbrake_prueft_abbruch_bei_jeder_zeile_nicht_nur_bei_fortschritt(): + """Befund 25.07.2026 (am laufenden Akira-Job beobachtet): Der Abbruch hing + am Fortschritts-Callback, und der stieg bei unveraenderter Prozentzahl + sofort aus. Bei einem 4K-Encode mit einem Prozent je halber Stunde sah + „Abbrechen" minutenlang wirkungslos aus (gemessen: 3,4 min). + + Der Abbruch-Kanal muss deshalb JEDE Ausgabezeile sehen — auch die des + Scan-Durchlaufs, der gar keine Encode-Prozente liefert. + """ + import ripping + + zeilen = [ + "Scanning title 1 of 1, preview 1, 10.00 %\n", + "Scanning title 1 of 1, preview 2, 20.00 %\n", + "Encoding: task 1 of 1, 0.00 %\n", + "Encoding: task 1 of 1, 0.00 %\n", + ] + gesehen = [] + + class FakeProcess: + def __init__(self): + self.stdout = iter(zeilen) + self.returncode = 0 + self.getoetet = False + + def kill(self): + self.getoetet = True + + def wait(self): + return 0 + + prozess = FakeProcess() + + def abbruch_cb(): + gesehen.append(1) + if len(gesehen) == 2: # beim zweiten Mal abbrechen + raise ripping.RipAbbruch() + + ergebnis = ripping._handbrake_schleife(prozess, "/x.mkv", abbruch_cb, None) + + assert ergebnis["status"] == "cancelled" + assert prozess.getoetet is True + # Zwei Scan-Zeilen genuegten — es musste NICHT auf eine Encode-Zeile gewartet + # werden. Genau das war der Fehler. + assert len(gesehen) == 2