Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8394de6926 | |||
| 574354131c | |||
| ef0a574a70 | |||
| 883c1c290b | |||
| e0cb7b3ddc |
@@ -66,4 +66,21 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
|
||||
- ✅ **Etappe 18 (v3.11):** 4K-UHD gelöst — `makemkvcon` holt Schlüssel
|
||||
unter Linux nie, unter Windows schon; Schlüsselspeicher übernehmbar.
|
||||
Akira-UHD geht auf der VM auf (`TCOUNT:5`, bewiesen)
|
||||
- ✅ **Etappe 19 (v3.14):** Durchsicht Frontend/Backend — vier Placebos weg
|
||||
(Fortschritt log, Auswurf tat nichts, „Alle Tracks" konnte nichts, Encoder
|
||||
wurden behauptet statt gemessen), Zombie-Erkennung gebaut, Pfad-Prüfung
|
||||
gehärtet, und der Platten-Schutz aus `c065967` als **unwirksam** entlarvt
|
||||
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
|
||||
|
||||
## Was diese Sitzungen wiederholt gekostet hat
|
||||
|
||||
**Nicht aus einem Zustandswert auf einen Mechanismus schließen.** Vorgefallen:
|
||||
aus „kein Schlüssel da" → „Server abgeschaltet" (falsch), aus Status
|
||||
`transcoding` → „Celery hat neu zugestellt" (falsch), aus `progress=99` →
|
||||
„Altwert aus dem Absturz" (falsch — ein Bug), aus gleichem `st_dev` →
|
||||
„`os.rename` funktioniert" (falsch — der Kernel vergleicht den Mount).
|
||||
Jedes Mal hätte eine Messung von unter einer Minute gereicht.
|
||||
|
||||
**Und die Umkehrung gilt genauso:** gleiches `st_dev` heißt NICHT gleicher
|
||||
Mount. Wo eine Eigenschaft ausprobierbar ist, probiere sie aus, statt sie
|
||||
vorherzusagen.
|
||||
|
||||
+194
-1
@@ -1,6 +1,199 @@
|
||||
# SAVEPOINT — Rippy
|
||||
|
||||
## Aktueller Stand: v3.13 — ÜBERGABE (25.07.2026, 18:05)
|
||||
## Aktueller Stand: v3.14 — Durchsicht: vier Placebos und ein unwirksamer Schutz (25.07.2026, 18:40)
|
||||
|
||||
> 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
|
||||
|
||||
`_original_aufheben()` entschied per `os.stat().st_dev`, ob umgehängt oder
|
||||
kopiert werden muss. Auf der VM gemessen — **beides gleichzeitig wahr**:
|
||||
|
||||
```
|
||||
st_dev /app/temp = 2050
|
||||
st_dev /app/media = 2050 → identisch
|
||||
os.rename(...) → EXDEV, "Invalid cross-device link"
|
||||
```
|
||||
|
||||
Der Kernel vergleicht bei `rename()` den **Mount**, nicht das Gerät.
|
||||
`/app/temp` (Docker-Volume) und `/app/media` (Bind-Mount) sind zwei Mounts
|
||||
DERSELBEN ext4-Partition. Die Prüfung sah deshalb „gleiches Dateisystem",
|
||||
**übersprang die Platzprüfung**, und `shutil.move` kopierte doch — 75 GB bei
|
||||
37 GB frei. Der Schutz hätte genau den Schaden zugelassen, gegen den er
|
||||
gebaut wurde.
|
||||
|
||||
**Behoben:** `os.rename` wird jetzt VERSUCHT statt vorhergesagt. Klappt es,
|
||||
ist es umgehängt. Kommt EXDEV, steht die Kopie fest — und erst dann wird der
|
||||
Platz geprüft. Vier Tests dazu (`test_original_aufheben.py`), inklusive des
|
||||
Falls, der die Platte füllte.
|
||||
|
||||
### GEMESSEN, Stand 18:30 (alles per Befehl auf der VM geprüft)
|
||||
|
||||
- **Container:** alle 5 `Up`, api/postgres/redis `healthy`. api/ui/worker seit
|
||||
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-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/<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
|
||||
→ **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:
|
||||
CPU-Typ von VM 106 in Proxmox auf `host` stellen (braucht VM-Neustart).
|
||||
- **HandBrake im Worker-Image kann:** `svt_av1`, `x264`, `x265` (je 10/12-bit),
|
||||
`mpeg4/2`, `VP8/9`, `theora` — und **keinen einzigen Hardware-Encoder**.
|
||||
- **Ampel grün** für `b526a0a` und `8bb075c` (Gitea-API abgefragt).
|
||||
|
||||
### GEBAUT — vier Placebos entfernt bzw. echt gemacht
|
||||
|
||||
1. **Fortschritt log statt Wahrheit.** `get_progress_from_line` matchte jede
|
||||
Zahl vor einem `%` — also auch HandBrakes **Scan-Durchlauf**, der VOR dem
|
||||
Encodieren bis 100 % hochläuft. Dazu warf `if progress > 0` alle echten
|
||||
Werte unter 1,00 % weg. Ergebnis: Anzeige klebte stundenlang auf 99 %.
|
||||
Jetzt wird nur die `Encoding:`-Zeile gelesen, `task N of M` mitgerechnet,
|
||||
und **-1 heißt „keine Angabe"** (Muster von `get_progress_from_prgv`).
|
||||
Formatstrings aus dem Binary gelesen, nicht geraten.
|
||||
**⚠️ Richtigstellung zu v3.13:** Dort steht, `progress=99` sei ein „Altwert
|
||||
aus dem Absturz". Falsch — beide Startpfade setzen auf 0, der Wert war
|
||||
frisch vom Scan-Durchlauf geschrieben. Es war ein Bug, kein Überrest.
|
||||
2. **„Automatischer Auswurf" tat nichts.** Die Einstellung (Standard: ein)
|
||||
wurde von niemandem gelesen: DVD/Blu-ray warfen **nie** aus, Audio-CDs
|
||||
**immer**, weil abcde `-x` fest verdrahtet bekam. Jetzt entscheidet die
|
||||
Einstellung beides (`wirf_disc_aus()` per CDROMEJECT-ioctl, Linux-guarded).
|
||||
3. **„Alle Tracks rippen" konnte nichts bewirken** — abcde bekommt keine
|
||||
Track-Auswahl und es gibt keine Oberfläche dafür. Schalter entfernt, an
|
||||
seiner Stelle steht jetzt die Wahrheit („wird immer vollständig gerippt").
|
||||
4. **Encoder-Auslese behauptete statt zu messen.** `cpu-x264`/`cpu-x265`
|
||||
standen fest verdrahtet drin („immer dabei") — ein Rip-Worker **ohne**
|
||||
HandBrake behauptete damit, komprimieren zu können. Und `vaapi` wurde
|
||||
allein wegen `/dev/dri` gemeldet, ohne zu prüfen, ob HandBrake das kann
|
||||
(dieses Image kann es nicht). Jetzt aus `HandBrakeCLI --help` geparst, plus
|
||||
**CPU-Modell, Kernzahl und Vektorbefehlsstufe** je Worker — mit sichtbarer
|
||||
Warnung im UI, wenn AVX2 fehlt. Genau die Angabe, deren Fehlen den
|
||||
55-Stunden-Encode unsichtbar machte.
|
||||
|
||||
### GEBAUT — Zombie-Erkennung (v3.13 Punkt 2 abgearbeitet)
|
||||
|
||||
`zombies.py`: Beim Worker-Start werden Jobs, die auf `ripping`/`transcoding`/
|
||||
`canceling` stehen, gegen Celerys `active`/`reserved`/`scheduled` gehalten und
|
||||
ehrlich auf `failed` gesetzt, wenn niemand daran arbeitet. Drei Sicherungen,
|
||||
weil ein falsch getöteter Job teurer ist als eine stehende Leiche:
|
||||
|
||||
- **nur beim Start** (da ist „es lief nichts" eindeutig),
|
||||
- **120 s Gnadenfrist** (Celery stellt unbestätigte Aufgaben erneut zu),
|
||||
- **Vollzähligkeit**: antworten weniger Knoten als laut Herzschlag online
|
||||
sind, wird NICHTS gewertet — sonst wäre der laufende Job eines
|
||||
beschäftigten Remote-Workers eine falsche Leiche.
|
||||
|
||||
13 Tests, unter anderem: „laufender Job wird nicht angetastet" und
|
||||
„schweigender Worker verhindert jedes Urteil".
|
||||
|
||||
### GEBAUT — Pfad-Prüfung gehärtet
|
||||
|
||||
Elf Stellen prüften mit nacktem `startswith(MEDIA_ROOT)`. `/app/media-boese/x`
|
||||
beginnt mit `/app/media`, liegt aber außerhalb — betroffen waren auch `/browse`
|
||||
und `/browse/mkdir`, wo der Pfad vom Nutzer kommt. Neuer Zwillings-Helfer
|
||||
`unter_wurzel()` in `api/main.py` und `worker/tasks.py`, alle elf Stellen
|
||||
umgestellt, Tests in beiden.
|
||||
|
||||
Nebenbefund dabei: `_zielbasis()` benutzte `os.path.normpath` — unter Windows
|
||||
werden daraus Backslashes und die Prüfung greift nicht mehr; das gewählte Ziel
|
||||
wäre still auf den Standard zurückgefallen. Genau die Falle, die
|
||||
`_arbeitsverzeichnis()` drei Zeilen weiter dokumentiert und mit `posixpath`
|
||||
vermeidet. Live war es nie (nur aus `rip_disc`, das auf Windows verriegelt
|
||||
ist), jetzt konsistent.
|
||||
|
||||
### GEFUNDEN, NICHT ANGETASTET — vier tote Endpunkte
|
||||
|
||||
Nirgends im UI referenziert (mechanisch gegengeprüft: alle `api.*`-Aufrufe
|
||||
gegen alle Routen):
|
||||
|
||||
| Endpunkt | Lage |
|
||||
|---|---|
|
||||
| `POST /jellyfin/format` | ersetzt durch `medien.py` im Worker; zieht `nfo_generator.py` + `image_downloader.py` in der API mit, die sonst niemand nutzt |
|
||||
| `POST /prescan` | ohne Aufrufer (die `PreScan`-Klasse selbst wird woanders sehr wohl gebraucht) |
|
||||
| `GET /stream/jobs` | niemand konsumiert ihn; der Kommentar behauptet einen „Fix 23.07.", der ihn funktionsfähig machte — gebraucht wird er trotzdem nicht |
|
||||
| `GET /worker-setup/windows-gui` | seit der `.exe` (v3.9) unreferenziert |
|
||||
|
||||
Bewusst nicht entfernt: `test_api_smoke.py` prüft `/prescan` als verdrahtete
|
||||
Route, und Entfernen ist eine Entscheidung, keine Reparatur. **Der Commander
|
||||
entscheidet.**
|
||||
|
||||
### NOCH OFFEN / EHRLICH UNGEKLÄRT
|
||||
|
||||
- **Was die Platte am 25.07. mittags füllte, ist nicht belegt.** Es gibt keine
|
||||
Kopier-Reste, kein `original`-Verzeichnis und **keinen einzigen Log-Eintrag
|
||||
von `_original_aufheben`** — das loggt in beiden Zweigen. Zwischen 10:21:50
|
||||
und 17:49 steht überhaupt nichts im Log. Der EXDEV-Mechanismus ist jetzt
|
||||
belegt (siehe oben), dass er DAMALS zuschlug, ist es nicht.
|
||||
- **`tracks:/dev/sr0` meldet `{"status":"done","tracks":[]}`** — null Titel für
|
||||
eine Disc, die MakeMKV mit `TCOUNT:5` öffnet. Nicht weiter verfolgt.
|
||||
- **Der Discord-Webhook und der MakeMKV-Key liegen im Klartext** in der
|
||||
settings-Tabelle. Für Heimnetz erwartbar, aber der Webhook ist eine echte
|
||||
Zugangsberechtigung.
|
||||
- **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
|
||||
|
||||
**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".
|
||||
|
||||
```
|
||||
ssh arcane@192.168.178.162 'cd ~/projects/rippy && git pull --ff-only && docker compose up -d --build'
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Vorheriger Stand: v3.13 — ÜBERGABE (25.07.2026, 18:05)
|
||||
|
||||
> Dieser Block ist eine Übergabe an die nächste Sitzung. Er trennt bewusst
|
||||
> **gemessen** von **vermutet** — in dieser Sitzung wurden zwei Behauptungen
|
||||
|
||||
+23
-9
@@ -155,7 +155,7 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
||||
else:
|
||||
unterordner = einstellungen.get("movieDir") or "movies"
|
||||
ziel = os.path.normpath(os.path.join(basis, unterordner))
|
||||
if not ziel.startswith(MEDIA_ROOT):
|
||||
if not unter_wurzel(ziel, MEDIA_ROOT):
|
||||
ziel = None
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
@@ -308,7 +308,7 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
||||
return False
|
||||
if os.path.isdir(os.path.join("/app/temp/raw", job["id"])):
|
||||
return True
|
||||
return work_dir.startswith(MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
|
||||
return unter_wurzel(work_dir, MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
|
||||
|
||||
|
||||
@app.get("/jobs", response_model=List[Job])
|
||||
@@ -373,7 +373,7 @@ def _validiere_ziel(target_dir: Optional[str]) -> Optional[str]:
|
||||
if not target_dir:
|
||||
return None
|
||||
normalisiert = os.path.normpath(target_dir)
|
||||
if not normalisiert.startswith(MEDIA_ROOT):
|
||||
if not unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)",
|
||||
@@ -462,10 +462,24 @@ def _sicherer_dateiname(name: str) -> bool:
|
||||
return bool(name) and "/" not in name and "\\" not in name and not name.startswith(".")
|
||||
|
||||
|
||||
def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
||||
"""Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)?
|
||||
|
||||
Pure Funktion, testbar. Ein nacktes `startswith()` genügt hier nicht:
|
||||
„/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb
|
||||
(Befund 25.07.2026 bei der Durchsicht). Deshalb Gleichheit ODER Wurzel
|
||||
samt Trennzeichen. Erwartet werden normalisierte Container-Pfade mit „/".
|
||||
"""
|
||||
if not pfad or not wurzel:
|
||||
return False
|
||||
sauber = wurzel.rstrip("/") or "/"
|
||||
return pfad == sauber or pfad.startswith(sauber + "/")
|
||||
|
||||
|
||||
def _job_ausgabeordner(job: dict) -> str:
|
||||
"""Validierter Ausgabeordner eines Jobs — strikt unter /app/media."""
|
||||
ausgabe = os.path.normpath(job.get("output_path") or "")
|
||||
if not ausgabe.startswith(MEDIA_ROOT):
|
||||
if not unter_wurzel(ausgabe, MEDIA_ROOT):
|
||||
raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media")
|
||||
return ausgabe
|
||||
|
||||
@@ -522,7 +536,7 @@ async def download_job_file(job_id: str, dateiname: str):
|
||||
pfad = os.path.join(ausgabe, dateiname)
|
||||
|
||||
def pruefe():
|
||||
return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MEDIA_ROOT)
|
||||
return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MEDIA_ROOT)
|
||||
|
||||
if not await asyncio.to_thread(pruefe):
|
||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||
@@ -633,7 +647,7 @@ async def retry_transcode(job_id: str):
|
||||
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
|
||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
||||
raw_basis = work_dir if work_dir.startswith(MEDIA_ROOT) else "/app/temp/raw"
|
||||
raw_basis = work_dir if unter_wurzel(work_dir, MEDIA_ROOT) else "/app/temp/raw"
|
||||
raw_dir = f"{raw_basis}/{job_id}"
|
||||
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
|
||||
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
|
||||
@@ -988,7 +1002,7 @@ async def delete_storage_mount(name: str):
|
||||
async def browse(path: str = MEDIA_ROOT):
|
||||
"""Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media)."""
|
||||
normalisiert = os.path.normpath(path)
|
||||
if not normalisiert.startswith(MEDIA_ROOT):
|
||||
if not unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
||||
|
||||
def liste():
|
||||
@@ -1029,7 +1043,7 @@ class MkdirRequest(BaseModel):
|
||||
async def browse_mkdir(request: MkdirRequest):
|
||||
"""Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung)."""
|
||||
basis = os.path.normpath(request.path)
|
||||
if not basis.startswith(MEDIA_ROOT):
|
||||
if not unter_wurzel(basis, MEDIA_ROOT):
|
||||
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
||||
name = request.name.strip()
|
||||
if not name or "/" in name or "\\" in name or name.startswith("."):
|
||||
@@ -1388,7 +1402,7 @@ async def download_aacs_dump(dateiname: str):
|
||||
pfad = os.path.join(MAKEMKV_DATA_ROOT, dateiname)
|
||||
|
||||
def pruefe():
|
||||
return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MAKEMKV_DATA_ROOT)
|
||||
return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MAKEMKV_DATA_ROOT)
|
||||
|
||||
if not await asyncio.to_thread(pruefe):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -66,3 +66,31 @@ def test_remount_blockiert_den_api_start_nicht():
|
||||
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
||||
assert "await asyncio.to_thread(remount)" not in quelle, \
|
||||
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
|
||||
|
||||
|
||||
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||
"""Befund 25.07.2026: In main.py prueften neun Stellen mit nacktem
|
||||
startswith(MEDIA_ROOT) — darunter /browse und /browse/mkdir, wo der Pfad
|
||||
vom Nutzer kommt. „/app/media-boese/x" beginnt mit „/app/media", liegt
|
||||
aber ausserhalb. Zwilling von tasks.unter_wurzel im Worker."""
|
||||
from main import unter_wurzel
|
||||
|
||||
assert unter_wurzel("/app/media", "/app/media") is True
|
||||
assert unter_wurzel("/app/media/movies", "/app/media") is True
|
||||
assert unter_wurzel("/app/media-boese/x", "/app/media") is False
|
||||
assert unter_wurzel("/app/mediaX", "/app/media") is False
|
||||
assert unter_wurzel("/etc/passwd", "/app/media") is False
|
||||
assert unter_wurzel("", "/app/media") is False
|
||||
assert unter_wurzel("/app/media", "") is False
|
||||
assert unter_wurzel("/app/media/movies", "/app/media/") is True
|
||||
|
||||
|
||||
def test_sicherer_dateiname_blockt_pfad_tricks():
|
||||
from main import _sicherer_dateiname
|
||||
|
||||
assert _sicherer_dateiname("film.mkv") is True
|
||||
assert _sicherer_dateiname("../../etc/passwd") is False
|
||||
assert _sicherer_dateiname("a/b.mkv") is False
|
||||
assert _sicherer_dateiname("a\b.mkv") is False
|
||||
assert _sicherer_dateiname(".versteckt") is False
|
||||
assert _sicherer_dateiname("") is False
|
||||
|
||||
@@ -14,6 +14,7 @@ interface WorkerInfo {
|
||||
const ENCODER_LABELS: Record<string, string> = {
|
||||
'cpu-x264': 'CPU · H.264 (x264)',
|
||||
'cpu-x265': 'CPU · H.265 (x265)',
|
||||
'cpu-av1': 'CPU · AV1 (SVT-AV1)',
|
||||
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
|
||||
'nvenc': 'Hardware · NVENC (NVIDIA)',
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ interface WorkerWahl {
|
||||
}
|
||||
|
||||
const ENCODER_KURZ: Record<string, string> = {
|
||||
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
|
||||
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1',
|
||||
'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
|
||||
}
|
||||
|
||||
interface TitelInfo {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { simdWarnung } from '../lib/encoder'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
|
||||
import { Button } from './ui/Button'
|
||||
@@ -16,16 +17,22 @@ interface WorkerInfo {
|
||||
ip?: string
|
||||
makemkv?: string
|
||||
handbrake?: string
|
||||
cpu_modell?: string
|
||||
cpu_kerne?: string
|
||||
cpu_simd?: string
|
||||
handbrake_encoder?: string
|
||||
}
|
||||
}
|
||||
|
||||
const ENCODER_LABELS: Record<string, string> = {
|
||||
'cpu-x264': 'CPU · H.264',
|
||||
'cpu-x265': 'CPU · H.265',
|
||||
'cpu-av1': 'CPU · AV1 (SVT)',
|
||||
'vaapi': 'GPU · VAAPI (AMD/Intel)',
|
||||
'nvenc': 'GPU · NVENC (NVIDIA)',
|
||||
}
|
||||
|
||||
|
||||
function relativeZeit(iso?: string): string {
|
||||
if (!iso) return 'nie'
|
||||
const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
@@ -134,6 +141,18 @@ export default function WorkerVerwaltung() {
|
||||
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
||||
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
||||
</p>
|
||||
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
|
||||
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
|
||||
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
|
||||
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{simdWarnung(w.info?.cpu_simd) && (
|
||||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400 max-w-xl">
|
||||
{simdWarnung(w.info?.cpu_simd)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -80,6 +80,7 @@ export const LOG_LEVEL_STYLES = {
|
||||
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
|
||||
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
|
||||
'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
|
||||
'cpu-av1': { label: 'AV1 (CPU)', highlight: false },
|
||||
'vaapi': { label: 'VAAPI ⚡', highlight: true },
|
||||
'nvenc': { label: 'NVENC ⚡', highlight: true },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Encoder-Fähigkeiten eines Workers in Klartext übersetzen.
|
||||
//
|
||||
// Befund 25.07.2026: Die Rippy-VM lief auf dem generischen QEMU-CPU-Modell
|
||||
// („QEMU Virtual CPU version 2.5+") und hatte deshalb kein AVX2, nur sse4_2.
|
||||
// x265 lebt von diesen Vektorbefehlen — ein 4K-Encode brauchte dort gemessene
|
||||
// 28-55 Stunden, und nirgends im UI war das zu sehen. Der Worker meldet die
|
||||
// Angaben seit dieser Runde selbst (worker/caps.py), hier werden sie gedeutet.
|
||||
|
||||
// Stufen, mit denen Software-Encoding brauchbar schnell ist.
|
||||
export const SIMD_SCHNELL = ['avx512f', 'avx2']
|
||||
|
||||
/**
|
||||
* Warnt, wenn die CPU dieses Workers zu schwach für Software-Encoding ist.
|
||||
* Gibt null zurück, wenn alles in Ordnung ist ODER die Stufe unbekannt ist —
|
||||
* lieber nichts sagen als etwas Falsches behaupten.
|
||||
*/
|
||||
export function simdWarnung(simd?: string): string | null {
|
||||
if (!simd || simd === 'unbekannt') return null
|
||||
if (SIMD_SCHNELL.includes(simd)) return null
|
||||
return `Diese CPU kann nur ${simd} (kein AVX2) — Software-Encoding ist hier `
|
||||
+ 'sehr langsam. Bei einer virtuellen Maschine hilft es meist, den '
|
||||
+ 'CPU-Typ auf "host" zu stellen; sonst besser einen Worker mit '
|
||||
+ 'Hardware-Encoder wählen.'
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Button } from '../components/ui/Button'
|
||||
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||
import { ENCODER_BADGES } from '../lib/design'
|
||||
import { simdWarnung } from '../lib/encoder'
|
||||
|
||||
interface SettingsState {
|
||||
tmdbApiKey: string
|
||||
@@ -19,7 +20,6 @@ interface SettingsState {
|
||||
movieDir: string
|
||||
seriesDir: string
|
||||
musicDir: string
|
||||
ripAllTracks: boolean
|
||||
mainFeatureOnly: boolean
|
||||
autoEject: boolean
|
||||
autoRipStart: boolean
|
||||
@@ -48,7 +48,6 @@ const defaultSettings: SettingsState = {
|
||||
movieDir: 'movies',
|
||||
seriesDir: 'series',
|
||||
musicDir: 'music',
|
||||
ripAllTracks: true,
|
||||
mainFeatureOnly: false,
|
||||
autoEject: true,
|
||||
autoRipStart: false,
|
||||
@@ -74,7 +73,15 @@ interface WorkerInfo {
|
||||
// keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg
|
||||
// in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich
|
||||
// rippt, zaehlt, deshalb steht die Angabe pro Worker und nicht global.
|
||||
info?: { makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string }
|
||||
// cpu_kerne/cpu_simd: Rechenleistung fuers Software-Encodieren. Ohne diese
|
||||
// Angaben war am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf der
|
||||
// Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne).
|
||||
// handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst.
|
||||
info?: {
|
||||
makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string,
|
||||
cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string,
|
||||
handbrake_encoder?: string,
|
||||
}
|
||||
}
|
||||
|
||||
// Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert).
|
||||
@@ -427,14 +434,16 @@ export default function SettingsPage() {
|
||||
🎵 Audio-CD
|
||||
</p>
|
||||
|
||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
<Toggle
|
||||
label="Alle Tracks rippen"
|
||||
description="Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)."
|
||||
checked={settings.ripAllTracks}
|
||||
onChange={(v) => handleChange('ripAllTracks', v)}
|
||||
/>
|
||||
</div>
|
||||
{/* Der Schalter „Alle Tracks rippen" ist am 25.07.2026 entfallen.
|
||||
Er war ein Placebo: abcde bekommt keine Track-Auswahl (siehe
|
||||
build_abcde_cmd) und es gibt auch keine Oberflaeche, um eine
|
||||
Teilmenge zu waehlen — eine Audio-CD wurde also immer
|
||||
vollstaendig gerippt, egal wie der Schalter stand. Statt einer
|
||||
Wahl, die nichts bewirkt, steht hier jetzt die Wahrheit. */}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
💿 Eine Audio-CD wird immer vollständig gerippt — alle Tracks als
|
||||
FLAC, mit MusicBrainz-Tags.
|
||||
</p>
|
||||
|
||||
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
|
||||
Allgemein
|
||||
@@ -555,6 +564,24 @@ export default function SettingsPage() {
|
||||
{ENCODER_BADGES[e]?.label || e}
|
||||
</span>
|
||||
))}
|
||||
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
|
||||
{w.info?.cpu_modell ? `${w.info.cpu_modell} · ` : ''}
|
||||
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
|
||||
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
|
||||
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{simdWarnung(w.info?.cpu_simd) && (
|
||||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
{simdWarnung(w.info?.cpu_simd)}
|
||||
</p>
|
||||
)}
|
||||
{w.info?.handbrake_encoder && (
|
||||
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500 font-mono break-all">
|
||||
HandBrake meldet: {w.info.handbrake_encoder}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
+128
-9
@@ -6,26 +6,133 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def erkenne_encoder() -> list:
|
||||
"""Liste der verfügbaren Encoder-Backends auf dieser Maschine."""
|
||||
gefunden = ["cpu-x264", "cpu-x265"] # HandBrake-Software-Encoder, immer dabei
|
||||
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
|
||||
|
||||
# VAAPI: AMD (VCN) und Intel (QuickSync) melden sich über /dev/dri
|
||||
if os.path.exists("/dev/dri/renderD128"):
|
||||
# Reihenfolge = Anzeige-Reihenfolge im UI.
|
||||
SIMD_STUFEN = ("avx512f", "avx2", "avx", "sse4_2")
|
||||
|
||||
|
||||
def parse_encoder_liste(hilfe_text: str) -> list:
|
||||
"""Video-Encoder, die DIESES HandBrake wirklich anbietet.
|
||||
|
||||
Quelle: `HandBrakeCLI --help`, Abschnitt `-e, --encoder <string>`
|
||||
(AGENTS Regel D — im Worker-Image am 25.07.2026 gemessen, nicht geraten):
|
||||
|
||||
-e, --encoder <string> Select video encoder:
|
||||
svt_av1
|
||||
x264
|
||||
x265
|
||||
...
|
||||
--encoder-preset <string>
|
||||
|
||||
Der Block endet an der nächsten Option — also an der ersten Zeile, deren
|
||||
erstes nicht-leeres Zeichen ein Bindestrich ist.
|
||||
"""
|
||||
encoder = []
|
||||
im_block = False
|
||||
for zeile in (hilfe_text or "").splitlines():
|
||||
inhalt = zeile.strip()
|
||||
if not im_block:
|
||||
if HB_ENCODER_KOPF.match(inhalt):
|
||||
im_block = True
|
||||
continue
|
||||
if not inhalt:
|
||||
continue
|
||||
if inhalt.startswith("-"):
|
||||
break
|
||||
encoder.append(inhalt.split()[0])
|
||||
return encoder
|
||||
|
||||
|
||||
def leite_backends_ab(hb_encoder, dri_da: bool, nvidia_da: bool) -> list:
|
||||
"""Übersetzt die HandBrake-Encoder-Namen in die Backends, die das UI zeigt.
|
||||
|
||||
Zwei Ehrlichkeits-Regeln, die vorher fehlten (Befund 25.07.2026):
|
||||
* Ohne HandBrake gibt es KEINE Encoder. Vorher standen `cpu-x264` und
|
||||
`cpu-x265` fest verdrahtet in der Liste („immer dabei") — ein reiner
|
||||
Rip-Worker ohne HandBrake behauptete damit, komprimieren zu können.
|
||||
* Hardware zählt nur, wenn das Gerät da ist UND HandBrake den Encoder
|
||||
überhaupt kennt. Vorher genügte `/dev/dri` — das Worker-Image kann
|
||||
laut eigener Auskunft aber gar keinen Hardware-Encoder.
|
||||
"""
|
||||
namen = {e.lower() for e in (hb_encoder or [])}
|
||||
gefunden = []
|
||||
if any(n.startswith("x264") for n in namen):
|
||||
gefunden.append("cpu-x264")
|
||||
if any(n.startswith("x265") for n in namen):
|
||||
gefunden.append("cpu-x265")
|
||||
if any(n.startswith("svt_av1") for n in namen):
|
||||
gefunden.append("cpu-av1")
|
||||
if dri_da and any(("qsv" in n) or ("vce" in n) or ("vaapi" in n) for n in namen):
|
||||
gefunden.append("vaapi")
|
||||
|
||||
# NVENC: NVIDIA-Treiber im Container sichtbar
|
||||
if shutil.which("nvidia-smi") or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1"):
|
||||
if nvidia_da and any("nvenc" in n for n in namen):
|
||||
gefunden.append("nvenc")
|
||||
|
||||
return gefunden
|
||||
|
||||
|
||||
def beste_simd(flags_text: str) -> str:
|
||||
"""Höchste Vektor-Befehlsstufe der CPU — entscheidend fürs Software-Encodieren.
|
||||
|
||||
Befund 25.07.2026: Die Rippy-VM läuft auf dem generischen QEMU-CPU-Modell
|
||||
(„QEMU Virtual CPU version 2.5+") und hat deshalb KEIN AVX2, nur sse4_2.
|
||||
x265 lebt von diesen Befehlen; ein 4K-Encode brauchte dort gemessene
|
||||
28-55 Stunden. Sichtbar war das nirgends — genau diese Lücke schließt die
|
||||
Anzeige. Abhilfe bei Proxmox/KVM: CPU-Typ der VM auf `host` stellen.
|
||||
"""
|
||||
vorhanden = set((flags_text or "").split())
|
||||
for stufe in SIMD_STUFEN:
|
||||
if stufe in vorhanden:
|
||||
return stufe
|
||||
return "unbekannt"
|
||||
|
||||
|
||||
def _lies_cpuinfo() -> tuple:
|
||||
"""(Modellname, Flags-Text, Kernzahl) aus /proc/cpuinfo — leer auf Windows."""
|
||||
modell, flags, kerne = "", "", 0
|
||||
try:
|
||||
with open("/proc/cpuinfo", encoding="utf-8", errors="replace") as f:
|
||||
for zeile in f:
|
||||
if zeile.startswith("processor"):
|
||||
kerne += 1
|
||||
elif not modell and zeile.startswith("model name"):
|
||||
modell = zeile.split(":", 1)[-1].strip()
|
||||
elif not flags and zeile.startswith("flags"):
|
||||
flags = zeile.split(":", 1)[-1].strip()
|
||||
except OSError:
|
||||
pass
|
||||
return modell, flags, kerne
|
||||
|
||||
|
||||
def hole_handbrake_hilfe() -> str:
|
||||
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
|
||||
if not shutil.which("HandBrakeCLI"):
|
||||
return ""
|
||||
try:
|
||||
aus = subprocess.run(
|
||||
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
return (aus.stdout or "") + (aus.stderr or "")
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
def erkenne_encoder() -> list:
|
||||
"""Encoder-Backends, die auf DIESER Maschine wirklich nutzbar sind."""
|
||||
hb_encoder = parse_encoder_liste(hole_handbrake_hilfe())
|
||||
dri_da = os.path.exists("/dev/dri/renderD128")
|
||||
nvidia_da = bool(
|
||||
shutil.which("nvidia-smi")
|
||||
or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1")
|
||||
)
|
||||
return leite_backends_ab(hb_encoder, dri_da, nvidia_da)
|
||||
|
||||
|
||||
def erkenne_ip() -> str:
|
||||
"""Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic).
|
||||
|
||||
@@ -56,11 +163,23 @@ def werkzeug_versionen() -> dict:
|
||||
"""
|
||||
import socket
|
||||
|
||||
modell, flags, kerne = _lies_cpuinfo()
|
||||
info = {
|
||||
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
|
||||
"hostname": socket.gethostname(),
|
||||
"ip": erkenne_ip(),
|
||||
# Rechenleistung fürs Software-Encodieren. Ohne diese drei Angaben war
|
||||
# am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf dieser Maschine
|
||||
# Tage statt Stunden braucht (kein AVX2, 4 Kerne).
|
||||
"cpu_modell": modell or (platform.processor() or "unbekannt"),
|
||||
"cpu_kerne": str(kerne or os.cpu_count() or 0),
|
||||
"cpu_simd": beste_simd(flags),
|
||||
}
|
||||
hb_encoder = parse_encoder_liste(hole_handbrake_hilfe())
|
||||
if hb_encoder:
|
||||
# Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist,
|
||||
# woher die Backend-Liste kommt.
|
||||
info["handbrake_encoder"] = ",".join(hb_encoder)
|
||||
if shutil.which("makemkvcon"):
|
||||
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
||||
if shutil.which("HandBrakeCLI"):
|
||||
|
||||
@@ -33,6 +33,30 @@ celery_app.conf.update(
|
||||
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
|
||||
import caps # noqa: E402
|
||||
import db # noqa: E402
|
||||
import zombies # noqa: E402
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def raeume_job_leichen_auf(**kwargs):
|
||||
"""Nach der Gnadenfrist: Jobs, an denen niemand arbeitet, ehrlich auf
|
||||
'failed' setzen (Details und Sicherungen in zombies.py).
|
||||
|
||||
Läuft im Hintergrund-Thread — der Worker soll sofort Aufgaben annehmen und
|
||||
nicht zwei Minuten auf die Aufräumung warten.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
def spaeter():
|
||||
time.sleep(zombies.GNADENFRIST_SEKUNDEN)
|
||||
try:
|
||||
db.init_db()
|
||||
bericht = zombies.raeume_zombies_auf(celery_app, db)
|
||||
print(f"Zombie-Erkennung: {bericht}")
|
||||
except Exception as e: # darf den Worker nie mitnehmen
|
||||
print(f"Zombie-Erkennung fehlgeschlagen: {e}")
|
||||
|
||||
threading.Thread(target=spaeter, daemon=True, name="zombie-erkennung").start()
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
|
||||
@@ -181,6 +181,42 @@ def get_job_status(job_id: str) -> str:
|
||||
return zeile[0] if zeile else ""
|
||||
|
||||
|
||||
def list_jobs_mit_status(stati) -> list:
|
||||
"""Alle Jobs in einem der genannten Zustände (id/status/title/created_at).
|
||||
|
||||
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
|
||||
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile — die
|
||||
Erkennung braucht nichts weiter.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
with engine.connect() as conn:
|
||||
zeilen = conn.execute(
|
||||
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
|
||||
.where(jobs.c.status.in_(list(stati)))
|
||||
).mappings().all()
|
||||
return [dict(z) for z in zeilen]
|
||||
|
||||
|
||||
def zaehle_online_worker(sekunden: int = 120) -> int:
|
||||
"""Wie viele Worker gelten laut Herzschlag gerade als online?
|
||||
|
||||
Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten:
|
||||
melden sich weniger Worker als bekannt sind, ist die Auskunft
|
||||
unvollstaendig — dann wird NICHTS als Leiche gewertet.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
grenze = utcnow() - timedelta(seconds=sekunden)
|
||||
with engine.connect() as conn:
|
||||
anzahl = conn.execute(
|
||||
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
|
||||
).scalar()
|
||||
return int(anzahl or 0)
|
||||
|
||||
|
||||
def update_job(job_id: str, **fields) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
||||
|
||||
+125
-36
@@ -25,6 +25,36 @@ class RipAbbruch(Exception):
|
||||
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
|
||||
|
||||
|
||||
# include/uapi/linux/cdrom.h — dasselbe ioctl wie in api/devices.py
|
||||
CDROMEJECT = 0x5309
|
||||
|
||||
|
||||
def wirf_disc_aus(device_path: str) -> bool:
|
||||
"""Wirft die Disc aus; gibt zurück, ob es geklappt hat. Wirft NIE.
|
||||
|
||||
Befund 25.07.2026: Die Einstellung „Automatischer Auswurf" (Standard: ein,
|
||||
Beschreibung „Disc nach erfolgreichem Ripping automatisch auswerfen") wurde
|
||||
von niemandem gelesen — bei DVD/Blu-ray warf Rippy nie aus.
|
||||
|
||||
Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser
|
||||
Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux — der native
|
||||
Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie.
|
||||
"""
|
||||
try:
|
||||
from fcntl import ioctl
|
||||
except ImportError: # Windows
|
||||
return False
|
||||
try:
|
||||
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
try:
|
||||
ioctl(fd, CDROMEJECT, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def check_makemkv_installed() -> bool:
|
||||
"""Prüft, ob makemkvcon installiert ist."""
|
||||
return shutil.which("makemkvcon") is not None
|
||||
@@ -271,20 +301,57 @@ def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT
|
||||
]
|
||||
|
||||
|
||||
HB_ENCODE_RE = re.compile(
|
||||
r'^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%'
|
||||
)
|
||||
|
||||
|
||||
def get_progress_from_line(line: str) -> int:
|
||||
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
|
||||
"""Extrahiert den ENCODE-Fortschritt in Prozent aus HandBrake-Ausgabe.
|
||||
|
||||
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
|
||||
dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe.
|
||||
Gibt **-1** zurück, wenn die Zeile kein Encode-Fortschritt ist — genau wie
|
||||
get_progress_from_prgv. Sonst wäre ein echtes „0 %" nicht von „keine
|
||||
Angabe" zu unterscheiden, und der Anfang des Encodes bliebe unsichtbar.
|
||||
|
||||
Formatstrings aus dem HandBrakeCLI-Binary im Worker-Image gelesen
|
||||
(AGENTS Regel D — nicht aus dem Kopf):
|
||||
Scanning title %d of %d, preview %d, %.2f %% → Scan, NICHT Encode
|
||||
Encoding: task %d of %d, %.2f %% (%.2f fps, avg → Encode
|
||||
Encoding: task %d of %d, Searching for start time, %.2f → Vorlauf, NICHT Encode
|
||||
|
||||
Befund 25.07.2026 (Akira-UHD, live gemessen): Die alte Regex nahm JEDE
|
||||
Zahl vor einem Prozentzeichen — also auch den Scan-Durchlauf, der VOR dem
|
||||
Encodieren einmal bis 100 % hochläuft. Die Anzeige stand deshalb auf 99 %,
|
||||
während der eigentliche Encode bei 1 % war. Zusammen mit dem alten Filter
|
||||
`progress > 0` im Aufrufer blieb sie dort stundenlang kleben, weil
|
||||
int(0.87) == 0 verworfen wurde.
|
||||
|
||||
`task N of M` wird mitgerechnet: Presets mit zwei Durchläufen zählen die
|
||||
Prozente je Durchlauf neu, sonst sprang die Anzeige mitten in der Datei
|
||||
zurück auf 0.
|
||||
"""
|
||||
match = re.search(r'(\d+\.\d+)\s*%', line)
|
||||
if match:
|
||||
return int(float(match.group(1)))
|
||||
return 0
|
||||
match = HB_ENCODE_RE.match(line.strip())
|
||||
if not match:
|
||||
return -1
|
||||
aktuell, gesamt, prozent = int(match.group(1)), int(match.group(2)), float(match.group(3))
|
||||
if gesamt < 1 or aktuell < 1:
|
||||
return -1
|
||||
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"}
|
||||
|
||||
@@ -296,44 +363,64 @@ 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)
|
||||
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 build_abcde_cmd(device_path: str, config_path: str) -> list:
|
||||
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).
|
||||
|
||||
-o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende,
|
||||
-c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben.
|
||||
|
||||
Befund 25.07.2026: `-x` stand fest verdrahtet drin. Damit warf eine
|
||||
Audio-CD IMMER aus, während eine DVD/Blu-ray NIE auswarf — und die
|
||||
Einstellung „Automatischer Auswurf" regelte keines von beidem, weil sie
|
||||
nirgends gelesen wurde. Jetzt entscheidet sie hier mit.
|
||||
"""
|
||||
return [
|
||||
cmd = [
|
||||
"abcde",
|
||||
"-d", device_path,
|
||||
"-o", "flac",
|
||||
"-N",
|
||||
"-x",
|
||||
"-c", config_path
|
||||
]
|
||||
if auswerfen:
|
||||
cmd.append("-x")
|
||||
cmd += ["-c", config_path]
|
||||
return cmd
|
||||
|
||||
|
||||
def write_abcde_config(output_dir: str) -> str:
|
||||
@@ -467,8 +554,10 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
||||
log_cb=log_cb)
|
||||
|
||||
|
||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict:
|
||||
"""Rippt eine CD mit abcde (FLAC)."""
|
||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
||||
auswerfen: bool = True) -> dict:
|
||||
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||
Auswurf" (abcde macht das selbst per -x)."""
|
||||
if not check_abcde_installed():
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -492,7 +581,7 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
build_abcde_cmd(device_path, config_path),
|
||||
build_abcde_cmd(device_path, config_path, auswerfen=auswerfen),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
|
||||
+143
-35
@@ -12,11 +12,13 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
|
||||
/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
|
||||
"""
|
||||
|
||||
import errno
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
@@ -44,6 +46,7 @@ from ripping import (
|
||||
rip_cd,
|
||||
rip_video,
|
||||
run_handbrake,
|
||||
wirf_disc_aus,
|
||||
)
|
||||
|
||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
@@ -90,14 +93,44 @@ 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)?
|
||||
|
||||
Pure Funktion, testbar. Ein nacktes `startswith()` genügt nicht:
|
||||
„/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb
|
||||
(Befund 25.07.2026 bei der Durchsicht). Immer „/" als Trenner — das sind
|
||||
Container-Pfade, auch wenn ein nativer Windows-Worker das Modul lädt.
|
||||
|
||||
Gleichlautend in docker/api/main.py; es gibt kein geteiltes Paket zwischen
|
||||
den Containern.
|
||||
"""
|
||||
if not pfad or not wurzel:
|
||||
return False
|
||||
sauber = wurzel.rstrip("/") or "/"
|
||||
return pfad == sauber or pfad.startswith(sauber + "/")
|
||||
|
||||
|
||||
def _zielbasis(target_dir, disc_type: str) -> str:
|
||||
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard."""
|
||||
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
||||
|
||||
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
||||
das sind IMMER Container-Pfade. os.path.normpath macht unter Windows
|
||||
Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr, das
|
||||
gewählte Ziel fiele still auf den Standard zurück. Aufgefallen 25.07.2026,
|
||||
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
||||
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
||||
"""
|
||||
if target_dir:
|
||||
normalisiert = os.path.normpath(target_dir)
|
||||
if normalisiert.startswith(MEDIA_ROOT):
|
||||
normalisiert = posixpath.normpath(target_dir)
|
||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
return normalisiert
|
||||
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
||||
return posixpath.join(RIP_OUTPUT_DIR, disc_type)
|
||||
|
||||
|
||||
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
||||
@@ -121,7 +154,7 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
||||
wert = (kandidat or "").strip()
|
||||
if wert:
|
||||
normalisiert = posixpath.normpath(wert)
|
||||
if normalisiert.startswith(MEDIA_ROOT):
|
||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
||||
return normalisiert
|
||||
return RAW_DIR
|
||||
|
||||
@@ -199,36 +232,65 @@ def _original_aufheben(job_id: str, raw_dir: str, final_dir: str) -> None:
|
||||
"""Roh-Rip zusätzlich aufheben — darf den Job NIEMALS scheitern lassen.
|
||||
|
||||
Befund 25.07.2026 (Akira-UHD, echter Schaden): Hier stand ein nacktes
|
||||
shutil.move(). Arbeitsverzeichnis (/app/temp, Docker-Volume) und Ziel
|
||||
(/app/media, Bind-Mount) sind VERSCHIEDENE Dateisysteme — os.rename
|
||||
scheitert dort mit EXDEV, und shutil.move fällt auf Kopieren zurück. Damit
|
||||
lief eine 74-GB-Vollkopie auf dieselbe Platte an, bis sie mit ENOSPC voll
|
||||
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr —
|
||||
und der Job wurde als FEHLGESCHLAGEN markiert, obwohl die komprimierte
|
||||
Datei längst fertig und in Ordnung war. Der Nutzer sah nur „nichts da".
|
||||
shutil.move(). Zwischen Arbeitsverzeichnis (/app/temp) und Ziel
|
||||
(/app/media) scheitert os.rename mit EXDEV, shutil.move fällt auf Kopieren
|
||||
zurück — eine 74-GB-Vollkopie auf dieselbe Platte, bis sie mit ENOSPC voll
|
||||
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr, und
|
||||
der Job galt als FEHLGESCHLAGEN, obwohl die komprimierte Datei längst
|
||||
fertig war. Der Nutzer sah nur „nichts da".
|
||||
|
||||
Deshalb dreifach abgesichert: Platz vorher prüfen, Fehler abfangen, und im
|
||||
Zweifel die Rohdatei einfach liegen lassen, wo sie ist.
|
||||
## Warum hier NICHT vorhergesagt, sondern versucht wird
|
||||
|
||||
Die erste Fassung dieses Schutzes verglich `os.stat(...).st_dev` und
|
||||
schloss aus gleichen Werten auf „reines Umhängen, kein Platz nötig". Am
|
||||
25.07.2026 im Worker-Container nachgemessen — beides zugleich wahr:
|
||||
|
||||
st_dev /app/temp = 2050
|
||||
st_dev /app/media = 2050 → also identisch
|
||||
os.rename(...) → EXDEV, „Invalid cross-device link"
|
||||
|
||||
Der Kernel vergleicht bei rename() den **Mount**, nicht das Gerät. /app/temp
|
||||
(Docker-Volume) und /app/media (Bind-Mount) sind zwei Mounts DERSELBEN
|
||||
ext4-Partition. Die st_dev-Prüfung war deshalb wirkungslos: sie sah
|
||||
„gleiches Dateisystem", übersprang die Platzprüfung, und shutil.move kopierte
|
||||
doch. Der Schutz hätte genau den Schaden zugelassen, gegen den er gebaut war.
|
||||
|
||||
Also: erst rename VERSUCHEN. Klappt es, ist es umgehängt und fertig.
|
||||
Kommt EXDEV, steht fest, dass kopiert werden müsste — und erst dann wird
|
||||
der Platz geprüft. Das ist keine Vermutung mehr, sondern die Antwort des
|
||||
Kernels.
|
||||
"""
|
||||
ziel_original = os.path.join(final_dir, "original")
|
||||
try:
|
||||
# Liegt beides auf demselben Dateisystem, ist es ein reines Umhängen —
|
||||
# sofort fertig, kein zusätzlicher Platz nötig.
|
||||
gleiches_dateisystem = os.stat(raw_dir).st_dev == os.stat(final_dir).st_dev
|
||||
if not gleiches_dateisystem:
|
||||
benoetigt = _ordner_groesse(raw_dir)
|
||||
frei = _frei_bytes(final_dir)
|
||||
if frei < benoetigt * 1.05:
|
||||
db.add_log(
|
||||
"warning", "worker",
|
||||
f"Job {job_id}: Original NICHT aufgehoben — dafür wären "
|
||||
f"{benoetigt / 1024**3:.1f} GB nötig, frei sind nur "
|
||||
f"{frei / 1024**3:.1f} GB. Die Roh-Datei bleibt unter "
|
||||
f"{raw_dir} liegen. Abhilfe: Arbeitsverzeichnis "
|
||||
"(Einstellungen → Verarbeitung) auf dieselbe Freigabe legen "
|
||||
"wie das Ziel — dann wird nur umgehängt statt kopiert."
|
||||
)
|
||||
return
|
||||
# Der billige Weg zuerst — und er ist gleichzeitig der einzige
|
||||
# verlässliche Test, ob überhaupt umgehängt werden kann.
|
||||
try:
|
||||
os.rename(raw_dir, ziel_original)
|
||||
db.add_log(
|
||||
"info", "worker",
|
||||
f"Job {job_id}: Original behalten unter {ziel_original} (umgehängt, "
|
||||
"kein zusätzlicher Platz nötig)",
|
||||
)
|
||||
return
|
||||
except OSError as e:
|
||||
if e.errno != errno.EXDEV:
|
||||
raise # etwas anderes ist schiefgelaufen → unten ehrlich melden
|
||||
|
||||
# Ab hier ist eine echte Kopie unvermeidlich. Jetzt lohnt die Platzfrage.
|
||||
benoetigt = _ordner_groesse(raw_dir)
|
||||
frei = _frei_bytes(final_dir)
|
||||
if frei < benoetigt * 1.05:
|
||||
db.add_log(
|
||||
"warning", "worker",
|
||||
f"Job {job_id}: Original NICHT aufgehoben — Ziel liegt auf einem "
|
||||
f"anderen Mount, es müsste kopiert werden. Dafür wären "
|
||||
f"{benoetigt / 1024**3:.1f} GB nötig, frei sind nur "
|
||||
f"{frei / 1024**3:.1f} GB. Die Roh-Datei bleibt unter "
|
||||
f"{raw_dir} liegen. Abhilfe: Arbeitsverzeichnis "
|
||||
"(Einstellungen → Verarbeitung) auf dieselbe Freigabe legen "
|
||||
"wie das Ziel — dann wird nur umgehängt statt kopiert."
|
||||
)
|
||||
return
|
||||
shutil.move(raw_dir, ziel_original)
|
||||
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
||||
except OSError as e:
|
||||
@@ -500,7 +562,10 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
return ergebnis
|
||||
|
||||
if disc_type == "cd":
|
||||
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt, output_dir=final_dir)
|
||||
ergebnis = rip_cd(
|
||||
device_path, job_id, progress_cb=fortschritt, output_dir=final_dir,
|
||||
auswerfen=bool(einstellungen.get("autoEject", True)),
|
||||
)
|
||||
elif transcode_an:
|
||||
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
|
||||
ergebnis = rip_video(
|
||||
@@ -562,6 +627,23 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
"Normale BD/DVD gehen weiterhin."
|
||||
)
|
||||
|
||||
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
||||
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
||||
#
|
||||
# Befund 25.07.2026: Die Einstellung („Disc nach erfolgreichem Ripping
|
||||
# automatisch auswerfen", Standard ein) wurde von NIEMANDEM gelesen. Bei
|
||||
# DVD/Blu-ray warf Rippy deshalb nie aus, bei Audio-CD dagegen immer, weil
|
||||
# abcde `-x` fest verdrahtet bekam. Jetzt entscheidet die Einstellung beides.
|
||||
if ergebnis.get("status") == "success" and ist_video and einstellungen.get("autoEject", True):
|
||||
if wirf_disc_aus(device_path):
|
||||
db.add_log("info", "worker", f"Job {job_id}: Disc ausgeworfen")
|
||||
else:
|
||||
db.add_log(
|
||||
"warning", "worker",
|
||||
f"Job {job_id}: Disc konnte nicht ausgeworfen werden ({device_path}) — "
|
||||
"der Rip ist davon unberührt.",
|
||||
)
|
||||
|
||||
if ergebnis.get("status") == "success" and transcode_an:
|
||||
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
|
||||
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein
|
||||
@@ -652,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))
|
||||
|
||||
@@ -660,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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests der Fähigkeiten-Erkennung — reine Funktionen, keine Infrastruktur.
|
||||
|
||||
Die Testdaten sind ECHTE Ausgaben, am 25.07.2026 im Worker-Image der Rippy-VM
|
||||
abgenommen (AGENTS Regel D). Erfundene Formate hätten hier keinen Wert: genau
|
||||
daran ist die Erkennung vorher gescheitert.
|
||||
"""
|
||||
|
||||
import caps
|
||||
|
||||
# Wörtlich aus `docker exec rippy-worker-1 HandBrakeCLI --help` (HandBrake im
|
||||
# Worker-Image, 25.07.2026). Bemerkenswert: KEIN Hardware-Encoder dabei.
|
||||
HB_HILFE_ECHT = """
|
||||
-Z, --preset <string> Select preset by name
|
||||
-e, --encoder <string> Select video encoder:
|
||||
svt_av1
|
||||
svt_av1_10bit
|
||||
x264
|
||||
x264_10bit
|
||||
x265
|
||||
x265_10bit
|
||||
x265_12bit
|
||||
mpeg4
|
||||
mpeg2
|
||||
VP8
|
||||
VP9
|
||||
VP9_10bit
|
||||
theora
|
||||
--encoder-preset <string>
|
||||
Adjust video encoding settings
|
||||
"""
|
||||
|
||||
# Wie dieselbe Stelle auf einer Maschine MIT Hardware-Encodern aussieht
|
||||
# (HandBrake-Windows-Build; Struktur identisch, nur mehr Namen).
|
||||
HB_HILFE_MIT_HARDWARE = """
|
||||
-e, --encoder <string> Select video encoder:
|
||||
x264
|
||||
x265
|
||||
nvenc_h264
|
||||
nvenc_h265
|
||||
qsv_h264
|
||||
qsv_h265
|
||||
--encoder-preset <string>
|
||||
"""
|
||||
|
||||
|
||||
def test_encoder_liste_wird_aus_echter_hilfe_gelesen():
|
||||
encoder = caps.parse_encoder_liste(HB_HILFE_ECHT)
|
||||
assert encoder == [
|
||||
"svt_av1", "svt_av1_10bit",
|
||||
"x264", "x264_10bit",
|
||||
"x265", "x265_10bit", "x265_12bit",
|
||||
"mpeg4", "mpeg2", "VP8", "VP9", "VP9_10bit", "theora",
|
||||
]
|
||||
# Der Block endet an der nächsten Option — sie darf nicht mitgelesen werden
|
||||
assert not any("encoder-preset" in e for e in encoder)
|
||||
assert not any(e.startswith("-") for e in encoder)
|
||||
|
||||
|
||||
def test_encoder_liste_ohne_handbrake_ist_leer():
|
||||
assert caps.parse_encoder_liste("") == []
|
||||
assert caps.parse_encoder_liste(None) == []
|
||||
assert caps.parse_encoder_liste("irgendein Text ohne Encoder-Abschnitt") == []
|
||||
|
||||
|
||||
def test_ohne_handbrake_keine_encoder():
|
||||
"""Der wichtigste Fall: vorher standen cpu-x264/cpu-x265 fest verdrahtet in
|
||||
der Liste. Ein Rip-Worker ohne HandBrake behauptete damit, komprimieren zu
|
||||
können — und jeder Transcode dort endete sofort mit 'nicht installiert'."""
|
||||
assert caps.leite_backends_ab([], dri_da=True, nvidia_da=True) == []
|
||||
|
||||
|
||||
def test_backends_aus_echter_liste():
|
||||
encoder = caps.parse_encoder_liste(HB_HILFE_ECHT)
|
||||
# Kein /dev/dri, kein NVIDIA — und HandBrake kann hier ohnehin keine Hardware
|
||||
assert caps.leite_backends_ab(encoder, dri_da=False, nvidia_da=False) == [
|
||||
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||
]
|
||||
|
||||
|
||||
def test_hardware_nur_wenn_geraet_UND_handbrake_es_kann():
|
||||
"""Befund 25.07.2026: Vorher genügte /dev/dri, um 'vaapi' zu melden. Das
|
||||
Worker-Image kann laut eigener Auskunft aber gar keinen Hardware-Encoder —
|
||||
Rippy hätte VAAPI versprochen und wäre beim Encodieren gescheitert."""
|
||||
ohne_hw = caps.parse_encoder_liste(HB_HILFE_ECHT)
|
||||
assert "vaapi" not in caps.leite_backends_ab(ohne_hw, dri_da=True, nvidia_da=True)
|
||||
assert "nvenc" not in caps.leite_backends_ab(ohne_hw, dri_da=True, nvidia_da=True)
|
||||
|
||||
mit_hw = caps.parse_encoder_liste(HB_HILFE_MIT_HARDWARE)
|
||||
# Gerät fehlt → trotz Können kein Hardware-Backend
|
||||
assert caps.leite_backends_ab(mit_hw, dri_da=False, nvidia_da=False) == [
|
||||
"cpu-x264", "cpu-x265",
|
||||
]
|
||||
# Beides da → beides gemeldet
|
||||
beides = caps.leite_backends_ab(mit_hw, dri_da=True, nvidia_da=True)
|
||||
assert "vaapi" in beides # qsv_* vorhanden
|
||||
assert "nvenc" in beides
|
||||
|
||||
|
||||
def test_simd_stufe_erkennt_die_hoechste():
|
||||
assert caps.beste_simd("fpu vme de sse4_2 avx avx2") == "avx2"
|
||||
assert caps.beste_simd("sse4_2 avx avx2 avx512f") == "avx512f"
|
||||
assert caps.beste_simd("fpu vme de pse tsc sse4_2") == "sse4_2"
|
||||
assert caps.beste_simd("") == "unbekannt"
|
||||
assert caps.beste_simd(None) == "unbekannt"
|
||||
|
||||
|
||||
def test_simd_stufe_der_rippy_vm_ist_sse4_2():
|
||||
"""Echt gemessen auf der Rippy-VM (25.07.2026): generisches QEMU-CPU-Modell,
|
||||
`grep -c avx2 /proc/cpuinfo` = 0. Genau das macht Software-4K dort so lahm."""
|
||||
flags_vm = (
|
||||
"fpu de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 "
|
||||
"clflush mmx fxsr sse sse2 syscall nx lm rep_good nopl cpuid "
|
||||
"pni cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm"
|
||||
)
|
||||
assert caps.beste_simd(flags_vm) == "sse4_2"
|
||||
@@ -114,63 +114,11 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
||||
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
|
||||
|
||||
|
||||
def test_original_aufheben_laesst_job_nie_scheitern(tmp_path, monkeypatch):
|
||||
"""Der Schritt "Original behalten" darf einen fertigen Job NICHT killen.
|
||||
|
||||
Schaden vom 25.07.2026 (Akira-UHD): shutil.move() ueber Dateisystemgrenzen
|
||||
kopierte 74 GB, lief in ENOSPC, und der Job galt als fehlgeschlagen —
|
||||
obwohl die komprimierte Datei laengst fertig war. Der Nutzer sah nur eine
|
||||
leere Queue.
|
||||
"""
|
||||
import tasks
|
||||
|
||||
meldungen = []
|
||||
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
|
||||
|
||||
raw = tmp_path / "raw"
|
||||
raw.mkdir()
|
||||
(raw / "title_t00.mkv").write_bytes(b"x" * 1024)
|
||||
final = tmp_path / "final"
|
||||
final.mkdir()
|
||||
|
||||
def platzt(*_a, **_k):
|
||||
raise OSError(28, "No space left on device")
|
||||
|
||||
monkeypatch.setattr(tasks.shutil, "move", platzt)
|
||||
# Darf NICHT werfen
|
||||
tasks._original_aufheben("job-1", str(raw), str(final))
|
||||
assert meldungen and meldungen[-1][0] == "warning"
|
||||
assert "Roh-Datei bleibt" in meldungen[-1][2]
|
||||
|
||||
|
||||
def test_original_aufheben_warnt_statt_platte_vollzuschreiben(tmp_path, monkeypatch):
|
||||
"""Zu wenig Platz auf einem ANDEREN Dateisystem -> gar nicht erst anfangen."""
|
||||
import tasks
|
||||
|
||||
meldungen = []
|
||||
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
|
||||
bewegt = []
|
||||
monkeypatch.setattr(tasks.shutil, "move", lambda *a: bewegt.append(a))
|
||||
|
||||
raw = tmp_path / "raw"
|
||||
raw.mkdir()
|
||||
(raw / "gross.mkv").write_bytes(b"x" * 4096)
|
||||
final = tmp_path / "final"
|
||||
final.mkdir()
|
||||
|
||||
# Verschiedene Dateisysteme vortaeuschen und den freien Platz kleinrechnen.
|
||||
# _ordner_groesse wird mitgefaelscht, weil ein selbstgebautes os.stat sonst
|
||||
# os.path.getsize mitreisst (das liest st_size an derselben Stelle).
|
||||
class Stat:
|
||||
def __init__(self, dev): self.st_dev = dev
|
||||
monkeypatch.setattr(tasks.os, "stat", lambda p: Stat(1 if "raw" in str(p) else 2))
|
||||
monkeypatch.setattr(tasks, "_ordner_groesse", lambda _p: 4096)
|
||||
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 100)
|
||||
|
||||
tasks._original_aufheben("job-2", str(raw), str(final))
|
||||
assert bewegt == [], "es darf NICHT kopiert werden, wenn der Platz nicht reicht"
|
||||
assert meldungen[-1][0] == "warning"
|
||||
assert "NICHT aufgehoben" in meldungen[-1][2]
|
||||
# Die beiden frueheren Tests zu _original_aufheben sind nach
|
||||
# test_original_aufheben.py gewandert und dort erweitert. Sie pruefen jetzt das
|
||||
# richtige Kriterium: Vorher taeuschten sie per gefaelschtem os.stat
|
||||
# „verschiedene Dateisysteme" vor — genau die Annahme, an der der Schutz in der
|
||||
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
||||
|
||||
|
||||
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||
@@ -191,3 +139,37 @@ def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
||||
# Leere Wahl faellt sauber auf die Einstellung zurueck
|
||||
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
||||
|
||||
|
||||
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||
"""Befund 25.07.2026: Elf Stellen prueften mit nacktem startswith().
|
||||
„/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb."""
|
||||
import tasks
|
||||
|
||||
assert tasks.unter_wurzel("/app/media", "/app/media") is True
|
||||
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
|
||||
assert tasks.unter_wurzel("/app/media/movies/Film (2020)", "/app/media") is True
|
||||
# Der Fall, den startswith() durchgelassen haette
|
||||
assert tasks.unter_wurzel("/app/media-boese/x", "/app/media") is False
|
||||
assert tasks.unter_wurzel("/app/mediaX", "/app/media") is False
|
||||
assert tasks.unter_wurzel("/etc/passwd", "/app/media") is False
|
||||
assert tasks.unter_wurzel("", "/app/media") is False
|
||||
assert tasks.unter_wurzel("/app/media", "") is False
|
||||
# Wurzel mit Schrägstrich am Ende darf nichts aendern
|
||||
assert tasks.unter_wurzel("/app/media/movies", "/app/media/") is True
|
||||
|
||||
|
||||
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
||||
import tasks
|
||||
|
||||
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
|
||||
# Ausbruch per Praefix-Namen faellt auf den Standard zurueck
|
||||
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
|
||||
assert tasks._zielbasis("/etc", "bluray") != "/etc"
|
||||
|
||||
|
||||
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
||||
import tasks
|
||||
|
||||
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
|
||||
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests fuer _original_aufheben — der Pfad, der am 25.07.2026 die Platte fuellte.
|
||||
|
||||
Der Kern: Es wird NICHT vorhergesagt, ob umgehaengt werden kann, sondern
|
||||
os.rename versucht. Die alte Fassung verglich st_dev und lag damit falsch —
|
||||
auf der Rippy-VM sind st_dev von /app/temp und /app/media identisch (2050),
|
||||
os.rename scheitert zwischen ihnen trotzdem mit EXDEV, weil der Kernel den
|
||||
Mount vergleicht und nicht das Geraet. Die Platzpruefung wurde deshalb
|
||||
uebersprungen und shutil.move kopierte 75 GB bei 37 GB frei.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import tasks
|
||||
|
||||
|
||||
class FakeDb:
|
||||
"""Faengt nur die Log-Zeilen ab — mehr braucht _original_aufheben nicht."""
|
||||
|
||||
def __init__(self):
|
||||
self.logs = []
|
||||
|
||||
def add_log(self, level, source, message):
|
||||
self.logs.append((level, message))
|
||||
|
||||
def meldungen(self):
|
||||
return " ".join(m for _, m in self.logs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_db(monkeypatch):
|
||||
ersatz = FakeDb()
|
||||
monkeypatch.setattr(tasks, "db", ersatz)
|
||||
return ersatz
|
||||
|
||||
|
||||
def _lege_rohdaten_an(tmp_path, groesse=2048):
|
||||
raw = tmp_path / "raw" / "job-1"
|
||||
raw.mkdir(parents=True)
|
||||
(raw / "title_t00.mkv").write_bytes(b"x" * groesse)
|
||||
final = tmp_path / "media" / "Film (2020)"
|
||||
final.mkdir(parents=True)
|
||||
return str(raw), str(final)
|
||||
|
||||
|
||||
def test_umhaengen_wenn_derselbe_mount(tmp_path, fake_db):
|
||||
"""Der gute Fall: rename klappt, nichts wird kopiert, kein Platz noetig."""
|
||||
raw, final = _lege_rohdaten_an(tmp_path)
|
||||
|
||||
tasks._original_aufheben("job-1", raw, final)
|
||||
|
||||
assert os.path.isdir(os.path.join(final, "original"))
|
||||
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
|
||||
assert not os.path.exists(raw)
|
||||
assert "umgehängt" in fake_db.meldungen()
|
||||
|
||||
|
||||
def test_bei_exdev_und_zu_wenig_platz_wird_nur_gewarnt(tmp_path, fake_db, monkeypatch):
|
||||
"""Der Fall, der die Platte fuellte: rename geht nicht, Platz reicht nicht.
|
||||
|
||||
Vorher lief hier eine Vollkopie an, weil die st_dev-Pruefung „gleiches
|
||||
Dateisystem" meldete und die Platzpruefung deshalb ausblieb.
|
||||
"""
|
||||
raw, final = _lege_rohdaten_an(tmp_path)
|
||||
|
||||
def kein_rename(*_a, **_k):
|
||||
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
||||
|
||||
monkeypatch.setattr(tasks.os, "rename", kein_rename)
|
||||
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 1024) # weniger als die Rohdaten
|
||||
|
||||
tasks._original_aufheben("job-1", raw, final)
|
||||
|
||||
# Rohdaten bleiben unangetastet liegen, es wurde NICHTS kopiert
|
||||
assert os.path.isfile(os.path.join(raw, "title_t00.mkv"))
|
||||
assert not os.path.exists(os.path.join(final, "original"))
|
||||
meldungen = fake_db.meldungen()
|
||||
assert "NICHT aufgehoben" in meldungen
|
||||
assert "anderen Mount" in meldungen
|
||||
assert "Arbeitsverzeichnis" in meldungen # nennt die Abhilfe
|
||||
|
||||
|
||||
def test_bei_exdev_und_genug_platz_wird_kopiert(tmp_path, fake_db, monkeypatch):
|
||||
raw, final = _lege_rohdaten_an(tmp_path)
|
||||
|
||||
echtes_rename = os.rename
|
||||
aufrufe = {"n": 0}
|
||||
|
||||
def rename_erst_exdev(*args, **kwargs):
|
||||
# Nur der Versuch von _original_aufheben scheitert; shutil.move darf
|
||||
# intern weiter umbenennen (es kopiert selbst und benennt Teile um).
|
||||
aufrufe["n"] += 1
|
||||
if aufrufe["n"] == 1:
|
||||
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
||||
return echtes_rename(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(tasks.os, "rename", rename_erst_exdev)
|
||||
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 10 * 1024**3)
|
||||
|
||||
tasks._original_aufheben("job-1", raw, final)
|
||||
|
||||
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
|
||||
assert "Original behalten" in fake_db.meldungen()
|
||||
|
||||
|
||||
def test_anderer_fehler_wird_ehrlich_gemeldet_und_reisst_job_nicht_mit(
|
||||
tmp_path, fake_db, monkeypatch
|
||||
):
|
||||
"""Ein Fehler beim Aufheben darf den Job NIE scheitern lassen — die
|
||||
komprimierte Datei ist zu diesem Zeitpunkt fertig und in Ordnung."""
|
||||
raw, final = _lege_rohdaten_an(tmp_path)
|
||||
|
||||
def zugriff_verweigert(*_a, **_k):
|
||||
raise OSError(errno.EACCES, "Permission denied")
|
||||
|
||||
monkeypatch.setattr(tasks.os, "rename", zugriff_verweigert)
|
||||
|
||||
tasks._original_aufheben("job-1", raw, final) # darf nicht werfen
|
||||
|
||||
meldungen = fake_db.meldungen()
|
||||
assert "konnte nicht aufgehoben werden" in meldungen
|
||||
assert raw in meldungen # sagt, WO die Rohdatei liegt
|
||||
@@ -43,10 +43,43 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
||||
|
||||
|
||||
def test_handbrake_progress_parsing():
|
||||
# Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen
|
||||
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
||||
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
|
||||
assert get_progress_from_line("kein Fortschritt hier") == 0
|
||||
# Echte Zeile mit fps-Anhang, wie sie im Binary steht
|
||||
assert get_progress_from_line(
|
||||
"Encoding: task 1 of 1, 12.34 % (5.67 fps, avg 4.32 fps, ETA 00h12m34s)"
|
||||
) == 12
|
||||
# Ein echtes 0 % ist eine ANGABE, keine Leermeldung
|
||||
assert get_progress_from_line("Encoding: task 1 of 1, 0.00 %") == 0
|
||||
|
||||
|
||||
def test_handbrake_progress_ignoriert_scan_durchlauf():
|
||||
"""Befund 25.07.2026 (Akira-UHD, live gemessen): HandBrake läuft VOR dem
|
||||
Encodieren einen Scan-Durchlauf, der ebenfalls Prozente ausgibt und dabei
|
||||
bis 100 % steigt. Die alte Regex nahm jede Zahl vor einem Prozentzeichen
|
||||
und meldete deshalb 99 %, während der Encode bei 1 % stand.
|
||||
|
||||
-1 heißt „keine Encode-Fortschrittszeile" — dasselbe Muster wie bei
|
||||
get_progress_from_prgv. Fremde Zeilen dürfen NIE als 0 % gelten.
|
||||
"""
|
||||
assert get_progress_from_line("Scanning title 1 of 1, preview 3, 30.00 %") == -1
|
||||
assert get_progress_from_line("Scanning title 1 of 1, preview 10, 100.00 %") == -1
|
||||
# Vorlauf-Phase: Prozente beziehen sich auf die Suche, nicht auf den Encode
|
||||
assert get_progress_from_line(
|
||||
"Encoding: task 1 of 1, Searching for start time, 42.00 %"
|
||||
) == -1
|
||||
assert get_progress_from_line("kein Fortschritt hier") == -1
|
||||
assert get_progress_from_line("Muxing: this may take awhile...") == -1
|
||||
assert get_progress_from_line("") == -1
|
||||
|
||||
|
||||
def test_handbrake_progress_rechnet_zwei_durchlaeufe_zusammen():
|
||||
"""Presets mit zwei Durchläufen zählen die Prozente je Durchlauf neu.
|
||||
Ohne Verrechnung sprang die Anzeige mitten in der Datei zurück auf 0."""
|
||||
assert get_progress_from_line("Encoding: task 1 of 2, 50.00 %") == 25
|
||||
assert get_progress_from_line("Encoding: task 2 of 2, 0.00 %") == 50
|
||||
assert get_progress_from_line("Encoding: task 2 of 2, 100.00 %") == 100
|
||||
|
||||
|
||||
def test_prgv_parsing():
|
||||
@@ -129,6 +162,20 @@ def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
||||
assert "-N" in cmd # nicht-interaktiv, sonst hängt der Worker
|
||||
|
||||
|
||||
def test_abcde_auswurf_folgt_der_einstellung():
|
||||
"""Befund 25.07.2026: `-x` (Auswurf) stand fest verdrahtet drin. Eine
|
||||
Audio-CD warf damit IMMER aus, eine DVD/Blu-ray NIE — und die Einstellung
|
||||
„Automatischer Auswurf" regelte keines von beidem, weil sie nirgends
|
||||
gelesen wurde."""
|
||||
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=True)
|
||||
assert "-x" not in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
|
||||
# Standard bleibt „auswerfen" — so war das Verhalten bisher
|
||||
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf")
|
||||
# Die Config darf durch das weggefallene -x nicht verrutschen
|
||||
ohne = build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
|
||||
assert ohne[ohne.index("-c") + 1] == "/tmp/c.conf"
|
||||
|
||||
|
||||
def test_abcde_config_enthaelt_zielverzeichnis():
|
||||
pfad = write_abcde_config("/app/media/cd/test123")
|
||||
try:
|
||||
@@ -178,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
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Tests der Zombie-Erkennung — ohne Postgres und ohne Redis.
|
||||
|
||||
Der Schwerpunkt liegt bewusst auf dem, was WEHTUT: ein laufender Job darf
|
||||
niemals als Leiche gelten. Genau das wäre am 25.07.2026 passiert, wenn die
|
||||
Erkennung nach dem Alter des Jobs geurteilt hätte — der Akira-Job war seit
|
||||
neun Stunden offen und lief trotzdem.
|
||||
"""
|
||||
|
||||
import zombies
|
||||
|
||||
|
||||
# --- reine Funktionen -------------------------------------------------------
|
||||
|
||||
def test_belegte_job_ids_findet_id_an_beliebiger_stelle():
|
||||
"""job_id steht bei rip_disc an zweiter, bei transcode_files an erster
|
||||
Stelle — die Erkennung darf sich auf keine Position verlassen."""
|
||||
aktiv = {
|
||||
"celery@node1": [
|
||||
{"name": "worker.tasks.transcode_files", "args": ["job-eins", "/raw", "/final"]},
|
||||
{"name": "worker.tasks.rip_disc", "args": ["/dev/sr0", "job-zwei"]},
|
||||
]
|
||||
}
|
||||
belegt = zombies.belegte_job_ids([aktiv], ["job-eins", "job-zwei", "job-drei"])
|
||||
assert belegt == {"job-eins", "job-zwei"}
|
||||
|
||||
|
||||
def test_belegte_job_ids_versteht_args_als_text():
|
||||
"""Celery liefert args je nach Version als Liste ODER als Text-Repräsentation."""
|
||||
aktiv = {"celery@node1": [{"name": "x", "args": "('job-eins', '/raw', '/final')"}]}
|
||||
assert zombies.belegte_job_ids([aktiv], ["job-eins", "job-zwei"]) == {"job-eins"}
|
||||
|
||||
|
||||
def test_belegte_job_ids_ohne_auskunft_ist_leer():
|
||||
assert zombies.belegte_job_ids(None, ["a"]) == set()
|
||||
assert zombies.belegte_job_ids([None, None, None], ["a"]) == set()
|
||||
assert zombies.belegte_job_ids([{}, None], ["a"]) == set()
|
||||
|
||||
|
||||
def test_antwortende_knoten_sammelt_ueber_alle_abfragen():
|
||||
aktiv = {"celery@a": []}
|
||||
vorgemerkt = {"celery@b": []}
|
||||
assert zombies.antwortende_knoten([aktiv, None, vorgemerkt]) == {"celery@a", "celery@b"}
|
||||
assert zombies.antwortende_knoten([None, None]) == set()
|
||||
|
||||
|
||||
def test_auskunft_nur_vollstaendig_wenn_alle_bekannten_antworten():
|
||||
assert zombies.auskunft_vollstaendig(1, 1) is True
|
||||
assert zombies.auskunft_vollstaendig(2, 1) is True
|
||||
# Ein bekannter Worker schweigt → NICHT urteilen
|
||||
assert zombies.auskunft_vollstaendig(1, 2) is False
|
||||
# Niemand antwortet → wir wissen nichts
|
||||
assert zombies.auskunft_vollstaendig(0, 0) is False
|
||||
|
||||
|
||||
def test_finde_zombies_verschont_belegte_jobs():
|
||||
offene = [{"id": "a", "status": "transcoding"}, {"id": "b", "status": "ripping"}]
|
||||
assert zombies.finde_zombies(offene, {"a"}) == [{"id": "b", "status": "ripping"}]
|
||||
assert zombies.finde_zombies(offene, {"a", "b"}) == []
|
||||
|
||||
|
||||
def test_fehlertext_nennt_zustand_und_den_weg_zurueck():
|
||||
text = zombies.fehlertext({"status": "transcoding"})
|
||||
assert "transcoding" in text
|
||||
assert "Neu komprimieren" in text
|
||||
assert "NICHT gelöscht" in text
|
||||
|
||||
|
||||
# --- Attrappen für den Gesamtdurchlauf --------------------------------------
|
||||
|
||||
class FakeInspektor:
|
||||
def __init__(self, aktiv=None, vorgemerkt=None, geplant=None):
|
||||
self._aktiv, self._vorgemerkt, self._geplant = aktiv, vorgemerkt, geplant
|
||||
|
||||
def active(self):
|
||||
return self._aktiv
|
||||
|
||||
def reserved(self):
|
||||
return self._vorgemerkt
|
||||
|
||||
def scheduled(self):
|
||||
return self._geplant
|
||||
|
||||
|
||||
class FakeCelery:
|
||||
def __init__(self, inspektor):
|
||||
self.control = self
|
||||
self._inspektor = inspektor
|
||||
|
||||
def inspect(self, timeout=None):
|
||||
return self._inspektor
|
||||
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, offene, online=1):
|
||||
self._offene, self._online = offene, online
|
||||
self.aktualisierungen = []
|
||||
self.logs = []
|
||||
|
||||
def list_jobs_mit_status(self, stati):
|
||||
return [j for j in self._offene if j["status"] in stati]
|
||||
|
||||
def zaehle_online_worker(self, sekunden=120):
|
||||
return self._online
|
||||
|
||||
def update_job(self, job_id, **felder):
|
||||
self.aktualisierungen.append((job_id, felder))
|
||||
|
||||
def add_log(self, level, source, message):
|
||||
self.logs.append((level, message))
|
||||
|
||||
def utcnow(self):
|
||||
return "jetzt"
|
||||
|
||||
|
||||
# --- Gesamtdurchlauf --------------------------------------------------------
|
||||
|
||||
def test_laufender_job_wird_nicht_angetastet():
|
||||
"""Der Fall, der wehtut: Job läuft seit Stunden und IST aktiv."""
|
||||
db = FakeDb([{"id": "lebt", "status": "transcoding", "title": "Akira"}])
|
||||
aktiv = {"celery@a": [{"name": "worker.tasks.transcode_files", "args": ["lebt"]}]}
|
||||
celery = FakeCelery(FakeInspektor(aktiv=aktiv, vorgemerkt={}, geplant={}))
|
||||
|
||||
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||
|
||||
assert bericht["aufgeraeumt"] == []
|
||||
assert db.aktualisierungen == []
|
||||
|
||||
|
||||
def test_echte_leiche_wird_auf_failed_gesetzt():
|
||||
db = FakeDb([{"id": "leiche", "status": "transcoding", "title": "Akira"}])
|
||||
# Der Knoten antwortet — er arbeitet nur an nichts.
|
||||
celery = FakeCelery(FakeInspektor(aktiv={"celery@a": []}, vorgemerkt={}, geplant={}))
|
||||
|
||||
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||
|
||||
assert bericht["aufgeraeumt"] == ["leiche"]
|
||||
job_id, felder = db.aktualisierungen[0]
|
||||
assert job_id == "leiche"
|
||||
assert felder["status"] == "failed"
|
||||
assert "Neu komprimieren" in felder["error"]
|
||||
assert felder["finished_at"] == "jetzt"
|
||||
|
||||
|
||||
def test_schweigender_worker_verhindert_jedes_urteil():
|
||||
"""Zwei Worker gelten als online, nur einer antwortet — der andere könnte
|
||||
genau diesen Job bearbeiten. Also: Finger weg."""
|
||||
db = FakeDb([{"id": "unklar", "status": "transcoding", "title": "X"}], online=2)
|
||||
celery = FakeCelery(FakeInspektor(aktiv={"celery@a": []}, vorgemerkt={}, geplant={}))
|
||||
|
||||
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||
|
||||
assert bericht["aufgeraeumt"] == []
|
||||
assert db.aktualisierungen == []
|
||||
assert "unvollständige Auskunft" in bericht["uebersprungen"]
|
||||
|
||||
|
||||
def test_gar_keine_antwort_fuehrt_zu_keinem_urteil():
|
||||
db = FakeDb([{"id": "unklar", "status": "ripping", "title": "X"}], online=0)
|
||||
celery = FakeCelery(FakeInspektor(aktiv=None, vorgemerkt=None, geplant=None))
|
||||
|
||||
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||
|
||||
assert bericht["aufgeraeumt"] == []
|
||||
assert db.aktualisierungen == []
|
||||
|
||||
|
||||
def test_fertige_jobs_werden_gar_nicht_betrachtet():
|
||||
db = FakeDb([
|
||||
{"id": "fertig", "status": "completed", "title": "A"},
|
||||
{"id": "kaputt", "status": "failed", "title": "B"},
|
||||
{"id": "wartet", "status": "pending", "title": "C"},
|
||||
])
|
||||
celery = FakeCelery(FakeInspektor(aktiv={"celery@a": []}, vorgemerkt={}, geplant={}))
|
||||
|
||||
bericht = zombies.raeume_zombies_auf(celery, db)
|
||||
|
||||
# pending bleibt bewusst unberührt: die Aufgabe kann noch in der
|
||||
# Warteschlange liegen und wird von selbst abgeholt.
|
||||
assert bericht["geprueft"] == 0
|
||||
assert db.aktualisierungen == []
|
||||
|
||||
|
||||
def test_fehler_reisst_den_worker_start_nicht_mit():
|
||||
class KaputteDb(FakeDb):
|
||||
def list_jobs_mit_status(self, stati):
|
||||
raise RuntimeError("Postgres weg")
|
||||
|
||||
db = KaputteDb([])
|
||||
bericht = zombies.raeume_zombies_auf(FakeCelery(FakeInspektor()), db)
|
||||
assert "Postgres weg" in bericht["uebersprungen"]
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Erkennt Job-Leichen: Jobs, die behaupten zu laufen, an denen aber niemand arbeitet.
|
||||
|
||||
Befund 25.07.2026 (Akira-UHD): Nach einem Absturz stand ein Job auf
|
||||
`transcoding` bei 96 %, obwohl weder ein Prozess lief noch etwas in den
|
||||
Celery-Queues stand. Folge für den Nutzer: kein Fortschritt, kein Download —
|
||||
und der Knopf „Neu komprimieren" fehlte, weil `_kann_neu_komprimieren`
|
||||
(api/main.py) `status == "failed"` verlangt. Der Job war damit unerreichbar,
|
||||
obwohl die Rohdateien vollständig dalagen.
|
||||
|
||||
## Die Leitregel: ohne vollständige Auskunft wird NICHTS angetastet
|
||||
|
||||
Ein falsch als Leiche markierter Job ist teurer als eine stehengebliebene
|
||||
Leiche. Deshalb drei Sicherungen:
|
||||
|
||||
1. **Nur beim Worker-Start.** Da ist die Aussage eindeutig: „als ich hochkam,
|
||||
lief nichts". Ein periodischer Lauf könnte einen Job erwischen, der legitim
|
||||
in der Warteschlange wartet, weil alle Arbeitsplätze belegt sind — der ist
|
||||
für `active()`/`reserved()` unsichtbar. Bewusst nicht gebaut.
|
||||
2. **Gnadenfrist.** Nach einem Neustart stellt Celery unbestätigte Aufgaben
|
||||
erneut zu. Erst abwarten, dann urteilen.
|
||||
3. **Vollzähligkeit.** Es wird nur geurteilt, wenn mindestens so viele
|
||||
Celery-Knoten antworten, wie laut Herzschlag online sind. Antwortet ein
|
||||
beschäftigter Remote-Worker nicht rechtzeitig, wäre sein laufender Job
|
||||
sonst eine falsche Leiche.
|
||||
|
||||
Die Kernfunktionen sind bewusst rein (kein Postgres, kein Redis), damit die
|
||||
Ampel sie ohne Infrastruktur prüfen kann.
|
||||
"""
|
||||
|
||||
# Zustände, die behaupten: hier arbeitet gerade jemand.
|
||||
ARBEITS_STATI = ("ripping", "transcoding", "canceling")
|
||||
|
||||
# Wartezeit nach dem Worker-Start, bevor geurteilt wird. Deckt die
|
||||
# Wiederzustellung unbestätigter Aufgaben durch Celery ab.
|
||||
GNADENFRIST_SEKUNDEN = 120
|
||||
|
||||
# Wie lange auf Antworten der Celery-Knoten gewartet wird. Großzügig, weil ein
|
||||
# Worker mitten in einem 4K-Encode träge antwortet.
|
||||
INSPEKT_TIMEOUT_SEKUNDEN = 10
|
||||
|
||||
|
||||
def belegte_job_ids(inspektionen, kandidaten) -> set:
|
||||
"""Welche der `kandidaten`-IDs kommen in irgendeiner Celery-Aufgabe vor?
|
||||
|
||||
Bewusst als Textsuche über die ganze Inspektions-Antwort: `job_id` steht
|
||||
bei `rip_disc` an ZWEITER, bei `transcode_files` an ERSTER Stelle, und
|
||||
Celery liefert `args` je nach Version als Liste oder als Text. Eine
|
||||
Positions-Auswertung wäre an beidem zerbrechlich. Eine Job-ID ist eine
|
||||
36-stellige UUID — Fehltreffer sind praktisch ausgeschlossen.
|
||||
|
||||
`inspektionen` ist die Liste der Antworten (active/reserved/scheduled);
|
||||
`None`-Einträge werden übersprungen.
|
||||
"""
|
||||
vorhandene = [i for i in (inspektionen or []) if i]
|
||||
if not vorhandene:
|
||||
return set()
|
||||
text = repr(vorhandene)
|
||||
return {jid for jid in kandidaten if jid and jid in text}
|
||||
|
||||
|
||||
def antwortende_knoten(inspektionen) -> set:
|
||||
"""Namen aller Celery-Knoten, die auf die Inspektion geantwortet haben."""
|
||||
knoten = set()
|
||||
for antwort in (inspektionen or []):
|
||||
if antwort:
|
||||
knoten.update(antwort.keys())
|
||||
return knoten
|
||||
|
||||
|
||||
def auskunft_vollstaendig(anzahl_antworten: int, anzahl_bekannt: int) -> bool:
|
||||
"""Darf aus dieser Auskunft überhaupt ein Urteil folgen?
|
||||
|
||||
Nein, wenn niemand geantwortet hat (dann wissen wir nichts), und nein, wenn
|
||||
weniger Knoten antworten als laut Herzschlag online sind (dann fehlt uns
|
||||
genau der Knoten, der den Job vielleicht gerade bearbeitet).
|
||||
"""
|
||||
if anzahl_antworten < 1:
|
||||
return False
|
||||
return anzahl_antworten >= anzahl_bekannt
|
||||
|
||||
|
||||
def finde_zombies(offene_jobs, belegte) -> list:
|
||||
"""Jobs aus `offene_jobs`, zu denen keine Celery-Aufgabe existiert."""
|
||||
return [job for job in offene_jobs if job.get("id") not in belegte]
|
||||
|
||||
|
||||
def fehlertext(job) -> str:
|
||||
"""Ehrlicher Klartext für die Job-Karte — was war, und was jetzt hilft."""
|
||||
zuletzt = job.get("status") or "unbekannt"
|
||||
return (
|
||||
f"Abgebrochen: Der Vorgang lief nicht mehr (zuletzt gemeldet: {zuletzt}). "
|
||||
"Beim Start des Workers war dazu weder ein Prozess noch eine Aufgabe in "
|
||||
"der Warteschlange zu finden — meistens ein Absturz oder ein Neustart "
|
||||
"mitten im Lauf. Die Rohdateien wurden NICHT gelöscht: mit "
|
||||
"'Neu komprimieren' läuft die Kompression erneut, ohne die Disc noch "
|
||||
"einmal zu rippen."
|
||||
)
|
||||
|
||||
|
||||
def raeume_zombies_auf(celery_app, db) -> dict:
|
||||
"""Sucht Leichen und setzt sie ehrlich auf `failed`. Wirft nie.
|
||||
|
||||
Rückgabe (auch für den Log): was geprüft und was getan wurde.
|
||||
"""
|
||||
bericht = {"geprueft": 0, "aufgeraeumt": [], "uebersprungen": ""}
|
||||
try:
|
||||
offene = db.list_jobs_mit_status(ARBEITS_STATI)
|
||||
bericht["geprueft"] = len(offene)
|
||||
if not offene:
|
||||
return bericht
|
||||
|
||||
inspektor = celery_app.control.inspect(timeout=INSPEKT_TIMEOUT_SEKUNDEN)
|
||||
inspektionen = [inspektor.active(), inspektor.reserved(), inspektor.scheduled()]
|
||||
|
||||
knoten = antwortende_knoten(inspektionen)
|
||||
bekannt = db.zaehle_online_worker()
|
||||
if not auskunft_vollstaendig(len(knoten), bekannt):
|
||||
bericht["uebersprungen"] = (
|
||||
f"unvollständige Auskunft ({len(knoten)} von {bekannt} Worker "
|
||||
"haben geantwortet) — es wird nichts als Leiche gewertet"
|
||||
)
|
||||
db.add_log(
|
||||
"info", "worker",
|
||||
f"Zombie-Erkennung übersprungen: {bericht['uebersprungen']}",
|
||||
)
|
||||
return bericht
|
||||
|
||||
belegte = belegte_job_ids(inspektionen, [j.get("id") for j in offene])
|
||||
for job in finde_zombies(offene, belegte):
|
||||
db.update_job(
|
||||
job["id"],
|
||||
status="failed",
|
||||
error=fehlertext(job),
|
||||
finished_at=db.utcnow(),
|
||||
)
|
||||
db.add_log(
|
||||
"warning", "worker",
|
||||
f"Job {job['id']} ({job.get('title') or 'ohne Titel'}) stand auf "
|
||||
f"'{job.get('status')}', es arbeitet aber niemand daran — "
|
||||
"ehrlich auf 'failed' gesetzt. Rohdateien bleiben liegen.",
|
||||
)
|
||||
bericht["aufgeraeumt"].append(job["id"])
|
||||
except Exception as e: # Erkennung darf den Worker-Start NIEMALS verhindern
|
||||
bericht["uebersprungen"] = f"Fehler: {e}"
|
||||
try:
|
||||
db.add_log("warning", "worker", f"Zombie-Erkennung fehlgeschlagen: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
return bericht
|
||||
Reference in New Issue
Block a user