Ampel / ampel (push) Failing after 48s
WAS: Der Ablauf ist aus tasks.py heraus (ablauf.py, ohne Celery), die
LocalQueue wird bedient, ein Laeufer arbeitet Auftraege im selben Prozess
ab, und Rippy meldet sich mit gemessenen Faehigkeiten selbst als Arbeiter.
WARUM: "Rippy fuer Windows soll standalone funktionieren" (Commander). Bis
hierher konnte die Windows-App alles ANZEIGEN und nichts TUN — ein Rip waere
eingereiht worden und fuer immer liegengeblieben, weil niemand ihn holt.
DIE TRENNUNG: rip_disc hing an GENAU DREI Celery-Stellen in 234 Zeilen —
self.update_state, _transcode_queue, transcode_files.apply_async. Alle drei
sind Fragen der ZUSTELLUNG, nicht des Ablaufs. Sie sind jetzt Rueckrufe:
tasks.py reicht die Celery-Fassung herein, standalone.py die lokale. OHNE
Rueckruf komprimiert derselbe Prozess weiter — genau das, was ein
Ein-Prozess-Rippy braucht. Der Ablauf selbst ist Zeile fuer Zeile derselbe;
der Docker-Betrieb merkt vom Umbau nichts (Task-Namen, Argumente, Queues
unveraendert).
EINE ZUSTELL-STELLE statt drei: celery_client.abschicken() bedient alle
Auftragsarten. Vorher rief jede Stelle send_task selbst auf — der
Standalone-Betrieb haette an drei Stellen umgebogen werden muessen, beim
naechsten Auftragstyp an einer vierten.
WEITERER BLOCKER GEFUNDEN: ablauf.py holte detect_disc_type fest aus dem
LINUX-Treiber, in einem try/except. Unter Windows waere es damit IMMER None
gewesen und Rippen "hart verriegelt" — Rippy haette alles angezeigt und
nichts gerippt, ohne dass irgendwo ein Fehler stuende. Jetzt fragt es den
Treiber-Port.
WERKZEUGE: ripping.py und caps.py suchten nur im PATH. Auf dem Commander-PC
gemessen, vorher/nachher:
vorher check_makemkv_installed() -> False (obwohl installiert)
erkenne_encoder() -> nur CPU
nachher MakeMKV 1.18.4 C:\Program Files (x86)\MakeMKV\makemkvcon64.exe
HandBrake 1.11.2 ueber die API geholt, in 2,7 s
Encoder cpu-x264, cpu-x265, cpu-av1, VCE, VCE-AV1
107 Presets, Ryzen 7 9700X, 16 Kerne, avx512f
VCE ist die Hardwarebeschleunigung der Radeon — die hat Rippy auf diesem
Rechner vorher nie gesehen, weil es HandBrake gar nicht fand.
NEUE ROUTEN: GET /system/werkzeuge (was liegt wo, in welcher Fassung, gibt
es Neueres) und POST /system/werkzeuge/{name}/holen. HandBrake kommt
vollautomatisch von GitHub. MakeMKV wird NICHT mitgeliefert — Rippy laedt
die offizielle Datei und startet sie (Black-Box-Trennung, KONZEPT.md § 6).
makemkv.com antwortete beim Bauen mit HTTP 525; das wird im Klartext
gemeldet, und eine selbst geholte Datei bleibt moeglich.
HERZSCHLAG: /capabilities las die workers-Tabelle, die bisher nur der
Celery-Herzschlag fuellte. Im Standalone-Betrieb stand dort "0 Worker" und
die Encoder-Auswahl im UI blieb LEER — auf einem Rechner, der alles kann.
Jetzt meldet sich der Prozess selbst, mit dem, was caps.py MISST.
GEMESSEN, aus der fertigen EXE (29,0 MB):
bereit nach 1 s, keine Fehler im Log
Werkzeuge: beide gefunden, mit Version und Pfad
Worker: 1 (TobisNicerPC), 5 Encoder, 107 Presets
Die ganze Kette ist als Test festgehalten (test_kette.py): zustellen ->
einreihen -> Laeufer -> ablauf -> Job endet in einem EHRLICHEN Zustand.
Ohne Laufwerk geprueft, und das ist der wichtigere Fall: Ein Rip auf ein
totes Geraet muss zuegig scheitern, nicht auf "pending" haengenbleiben.
GEMESSEN: ruff sauber, 512 Tests gruen + 15 uebersprungen (vorher 489).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""Dünner Celery-Client: die API SCHICKT Tasks an den Worker, führt sie nie aus.
|
|
|
|
Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste —
|
|
kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
|
|
|
|
## Warum Celery hier erst bei Bedarf entsteht (V2-4, 28.08.2026)
|
|
|
|
Hier stand `celery_client = Celery(...)` auf Modulebene. Damit brauchte JEDER
|
|
Import von `main.py` ein funktionierendes Celery — auch dann, wenn nie ein Rip
|
|
angestoßen wird. Im Windows-Paket ist Celery bewusst NICHT enthalten (der
|
|
Standalone-Betrieb hat keinen Broker), und die fertige EXE starb sofort beim
|
|
Start:
|
|
|
|
File "celery_client.py", ...
|
|
celery_client = Celery("rippy_api", broker=REDIS_URL, ...)
|
|
ModuleNotFoundError: No module named 'celery.fixups'
|
|
|
|
Dasselbe Muster wie beim Store, der seine Engine beim Import baute: Was erst
|
|
bei der ersten Benutzung gebraucht wird, soll auch erst dann entstehen.
|
|
|
|
**Was das für den Windows-Betrieb bedeutet — ehrlich gesagt:** Die Oberfläche,
|
|
die Laufwerks-Erkennung und alles Lesende laufen dort. Ein RIP anzustoßen geht
|
|
noch nicht, weil die Zustellung weiterhin über Celery läuft; die Umstellung auf
|
|
die LocalQueue steht in Etappe V2-5. Bis dahin sagt `start_rip()` das
|
|
ausdrücklich, statt mit einem Importfehler zu sterben oder still nichts zu tun.
|
|
"""
|
|
|
|
import os
|
|
|
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
|
|
|
_client = None
|
|
|
|
# Wer Auftraege zustellt. None = Celery (verteilter Betrieb).
|
|
# Der Standalone-Betrieb setzt hier seine eigene Zustellung ein — siehe
|
|
# `zusteller_setzen()`.
|
|
_zusteller = None
|
|
|
|
|
|
class KeinBroker(RuntimeError):
|
|
"""Es gibt hier kein Celery — mit Ansage statt mit Importfehler."""
|
|
|
|
|
|
def hole_client():
|
|
"""Der Celery-Client, beim ERSTEN Zugriff gebaut."""
|
|
global _client
|
|
if _client is None:
|
|
try:
|
|
from celery import Celery
|
|
except ImportError as e:
|
|
raise KeinBroker(
|
|
"Celery ist in dieser Installation nicht enthalten. Rippy läuft "
|
|
"hier im Standalone-Betrieb; das Anstoßen von Rips über einen "
|
|
"Broker ist damit nicht möglich (Umstellung auf die lokale "
|
|
"Auftrags-Queue: Etappe V2-5)."
|
|
) from e
|
|
_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
|
|
return _client
|
|
|
|
|
|
def __getattr__(name):
|
|
"""`celery_client` von außen — baut den Client bei Bedarf.
|
|
|
|
Damit bleiben die bestehenden `from celery_client import celery_client`
|
|
unverändert gültig, ohne dass der Import schon einen Broker verlangt.
|
|
"""
|
|
if name == "celery_client":
|
|
return hole_client()
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
|
|
|
|
def zusteller_setzen(funktion) -> None:
|
|
"""Legt fest, WER Auftraege bekommt.
|
|
|
|
Ohne Aufruf geht alles an Celery — das ist der Docker-Betrieb, unveraendert.
|
|
Der Standalone-Betrieb (Windows-App, Headless-Linux) setzt hier seine
|
|
lokale Auftrags-Queue ein; dann laeuft die Arbeit im selben Prozess.
|
|
|
|
Die Unterschrift ist die von `abschicken`:
|
|
|
|
funktion(task_name, args, queue=None) -> irgendetwas
|
|
|
|
Das ist bewusst dieselbe Form, die Celery hat. So muss keine Aufrufstelle
|
|
wissen, in welchem Betrieb sie gerade laeuft.
|
|
"""
|
|
global _zusteller
|
|
_zusteller = funktion
|
|
|
|
|
|
def abschicken(task_name: str, args: list, queue: str = None):
|
|
"""Einen Auftrag zustellen — an Celery oder an die lokale Queue.
|
|
|
|
EINE Stelle fuer alle drei Auftragsarten (rip_disc, transcode_files,
|
|
scan_tracks). Vorher rief jede Aufrufstelle `send_task` selbst auf; damit
|
|
haette der Standalone-Betrieb an drei Stellen umgebogen werden muessen —
|
|
und beim naechsten Auftragstyp an einer vierten.
|
|
"""
|
|
if _zusteller is not None:
|
|
return _zusteller(task_name, args, queue)
|
|
kwargs = {"args": args}
|
|
if queue:
|
|
kwargs["queue"] = queue
|
|
return hole_client().send_task(task_name, **kwargs)
|
|
|
|
|
|
def transcode_queue(node: str = None):
|
|
"""Ziel-Queue für die Kompression: der GEWÄHLTE Worker (worker_direct)
|
|
wenn er gerade online ist, sonst die geteilte transcode-Queue.
|
|
|
|
So kann ein Job gezielt einen Encoder ansprechen — fällt der Worker aber
|
|
weg, bleibt die Kompression nicht in einer toten Queue hängen, sondern
|
|
landet bei irgendeinem freien Worker.
|
|
"""
|
|
if not node:
|
|
return "transcode"
|
|
try:
|
|
from celery.utils import worker_direct
|
|
|
|
antworten = hole_client().control.ping(timeout=1.0) or []
|
|
online = {k for antwort in antworten for k in antwort.keys()}
|
|
if node in online:
|
|
return worker_direct(node)
|
|
except Exception:
|
|
pass
|
|
return "transcode"
|
|
|
|
|
|
def start_rip(device_path: str, job_id: str, target_dir: str = None):
|
|
"""Schickt den Rip-Auftrag los (Task-Name aus worker/tasks.py)."""
|
|
return abschicken("worker.tasks.rip_disc", [device_path, job_id, target_dir])
|