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
+56 -24
View File
@@ -2,9 +2,10 @@
## Aktueller Stand: v3.14 — Durchsicht: vier Placebos und ein unwirksamer Schutz (25.07.2026, 18:40) ## 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 > Alles unten ist committet, die Ampel ist grün, **nichts ist deployt**.
> schießt ihn ab). Alles unten ist committet und die Ampel ist grün. > Der Akira-Encode wurde parallel zu dieser Sitzung um 18:31 abgebrochen
> **Zuerst lesen: „SOFORT ENTSCHEIDEN" ganz unten.** > (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 ### 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. 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 - **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. (Evangelion) + ~25 G System. **Keine Kopier-Reste**, kein `original`-Ordner.
- **Akira-Encode läuft:** HandBrake PID 40 seit 18:01:37, Preset - **Akira-Job:** `failed`, „Abgebrochen durch Nutzer" (Abbruch 18:30:17
`H.265 MKV 2160p60 4K`, **3,84 von 4 Kernen**. Leseposition im Quellstrom angefordert, Worker bestätigt 18:33:38 → **3,4 Minuten Verzug**, siehe unten).
**1.145.940.149 von 79.604.951.639 Bytes = 1,44 %**. Es läuft **kein** HandBrake mehr (per `/proc` geprüft, Stand 19:06).
- **Restlaufzeit ≈ 2855 h.** Zwei Messfenster ergaben 395 und 784 KB/s — das - **Der Encode war bei 1,44 %**, gemessen an der Leseposition im Quellstrom
Tempo schwankt mit der Bildkomplexität, deshalb eine Spanne und keine Zahl. (`/proc/<pid>/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
**2855 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 - **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 QEMU-CPU-Modell (`QEMU Virtual CPU version 2.5+`), `grep -c avx2
/proc/cpuinfo` = **0**, nur bis `sse4_2`. x265 lebt von AVX2. Abhilfe: /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` - **Zwei neue API-Tests laufen nur in der Ampel** — `test_api_smoke.py`
überspringt sich unter Windows selbst (`main.py` braucht `fcntl`). ü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 ### SOFORT ENTSCHEIDEN
**Der laufende Job wird beim Fertigwerden mit dem DEPLOYTEN (alten) Code **1. Deployen — es läuft nichts, der Weg ist frei.** Nichts blockiert mehr, und
versuchen, 75 GB in 37 GB zu kopieren.** `keepOriginal` steht auf `true`, und solange nicht deployt ist, gilt weiterhin der alte Code: unwirksamer
den Wert hat die Aufgabe beim Start um 18:01:37 gelesen — ihn jetzt umzustellen Platten-Schutz, Anzeige auf 99 %, Auswurf ohne Wirkung, träges „Abbrechen".
ä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.
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 **2. Die UHD-Strategie ist noch offen** (aus dem Nachtrag, unverändert gültig).
bisherigen ~30 Minuten (der Lauf ist bei 1,44 % — praktisch nichts) und Preset-je-Disc-Typ war richtig, reicht aber nicht: Software-HEVC in 4K ist auf
bringt sofort alle Fixes live, inklusive des Platten-Schutzes und der dieser CPU keine Option. Drei Wege, keiner davon gebaut:
ehrlichen Fortschrittsanzeige. Wer ohnehin über den CPU-Typ nachdenkt, - **UHD gar nicht komprimieren** — Roh-MKV behalten. Ehrlichste Variante,
erledigt das im selben Fenster. kostet Platz (75100 GB je Film, gehört dann auf die NAS).
2. **Laufen lassen und in 12 Tagen deployen**, vorher die 75 GB von Hand - **Hardware-Encoder** — Remote-Worker mit GPU (`nvenc`/`vaapi`). Rippy kann
wegräumen oder auf die NAS schieben, damit die Kopie nicht anlaufen kann. das schon routen (bewiesen v3.7); es fehlt die Maschine. Achtung: Das
3. **Laufen lassen und den vollen Plattenlauf in Kauf nehmen.** 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 24. Aus 2855 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.
--- ---
+46 -25
View File
@@ -339,8 +339,19 @@ def get_progress_from_line(line: str) -> int:
return int(((aktuell - 1) * 100 + prozent) / gesamt) 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: def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.""" 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(): if not check_handbrake_installed():
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"} 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, text=True,
bufsize=1 bufsize=1
) )
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
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,
}
except Exception as e: except Exception as e:
return {"status": "error", "error": str(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: def build_abcde_cmd(device_path: str, config_path: str, auswerfen: bool = True) -> list:
"""Baut das abcde-Kommando (pure Funktion, testbar). """Baut das abcde-Kommando (pure Funktion, testbar).
+35 -3
View File
@@ -18,6 +18,7 @@ import json
import os import os
import posixpath import posixpath
import shutil import shutil
import time
import requests import requests
@@ -92,6 +93,11 @@ def pfad_lokal(pfad: str, mapping: str = None) -> str:
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw") RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
MEDIA_ROOT = "/app/media" 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: def unter_wurzel(pfad: str, wurzel: str) -> bool:
"""Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)? """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) anzahl = len(quellen)
letzter = [-1] 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): for index, quelle in enumerate(quellen):
ziel = os.path.join(final_dir, os.path.basename(quelle)) 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]: if gesamt == letzter[0]:
return return
letzter[0] = gesamt letzter[0] = gesamt
if _abbruch_angefordert(job_id):
raise RipAbbruch()
db.update_job(job_id, progress=min(99, gesamt)) 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": if hb.get("status") == "cancelled":
_job_abschliessen(job_id, hb) _job_abschliessen(job_id, hb)
return 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(None, {}) == DEFAULT_HB_PRESET assert preset_fuer(None, {}) == DEFAULT_HB_PRESET
assert preset_fuer("cd", {"transcodePresetUhd": "egal"}) == 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