fix(transcode): "Abbrechen" wirkt sofort statt erst beim naechsten Prozent
Ampel / ampel (push) Failing after 28s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-25 21:10:03 +02:00
parent 574354131c
commit 8394de6926
4 changed files with 184 additions and 52 deletions
+46 -25
View File
@@ -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).
+35 -3
View File
@@ -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
+47
View File
@@ -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