feat(windows): Rippy arbeitet eigenstaendig — Rippen, Werkzeuge, Fähigkeiten
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>
This commit is contained in:
Hitonabi
2026-08-28 11:27:54 +02:00
co-authored by Claude Opus 5
parent 288f9eeb8d
commit f4a8d77598
19 changed files with 2082 additions and 981 deletions
+41 -4
View File
@@ -31,6 +31,11 @@ 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."""
@@ -64,6 +69,40 @@ def __getattr__(name):
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.
@@ -87,7 +126,5 @@ def transcode_queue(node: str = None):
def start_rip(device_path: str, job_id: str, target_dir: str = None):
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
return hole_client().send_task(
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
)
"""Schickt den Rip-Auftrag los (Task-Name aus worker/tasks.py)."""
return abschicken("worker.tasks.rip_disc", [device_path, job_id, target_dir])
+88 -7
View File
@@ -1214,8 +1214,7 @@ async def scan_tracks_starten(name: str):
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
await asyncio.to_thread(db.save_settings, {"status": "running"}, f"tracks:{device_path}")
celery_anbindung.hole_client().send_task(
"worker.tasks.scan_tracks", args=[device_path])
celery_anbindung.abschicken("worker.tasks.scan_tracks", [device_path])
return {"status": "scanning"}
@@ -1273,11 +1272,9 @@ async def retry_transcode(job_id: str):
except ValueError:
meta = {}
from celery_client import transcode_queue
celery_anbindung.hole_client().send_task(
"worker.tasks.transcode_files",
args=[job_id, raw_dir, final_dir],
queue=transcode_queue(meta.get("transcode_node")),
)
celery_anbindung.abschicken(
"worker.tasks.transcode_files", [job_id, raw_dir, final_dir],
queue=transcode_queue(meta.get("transcode_node")))
await asyncio.to_thread(db.update_job, job_id, status="transcoding", progress=0, error=None)
await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id}: Kompression neu eingereiht")
return {"id": job_id, "status": "transcoding"}
@@ -1833,6 +1830,90 @@ async def browse_mkdir(request: MkdirRequest):
return {"path": ziel}
# ═══════════════════════════════════════════════════════════════════════
# Werkzeuge: Bestand, Update-Stand, Beschaffung — Etappe V2-4
# ═══════════════════════════════════════════════════════════════════════
#
# WOFUER: Damit Rippy unter Windows eigenstaendig arbeiten kann, muss es
# seine Werkzeuge nicht nur FINDEN, sondern auch HOLEN und aktuell halten
# koennen. Im Container ist das anders — dort stecken sie im Image, und ein
# Update ist ein Rebuild (siehe /system/updates, das bleibt unveraendert).
#
# WARUM EIGENE ROUTEN statt /system/updates zu erweitern: /system/updates
# beantwortet "welche Version gibt es draussen". Diese hier beantworten
# "was liegt auf DIESER Maschine, wo, und was kann ich dagegen tun". Zwei
# verschiedene Fragen; eine Route, die beides taete, koennte keine davon
# ehrlich beantworten.
@app.get("/system/werkzeuge")
async def system_werkzeuge():
"""Was ist installiert, wo, in welcher Fassung — und gibt es Neueres?
`neueste` ist leer, wenn die Quelle nicht antwortet. Dann steht dort
ausdruecklich NICHT "Update verfuegbar": Eine Nichtauskunft ist keine
Aussage ueber die Welt.
"""
from rippy.tools import beschaffen as werkzeug_beschaffung
einstellungen = await asyncio.to_thread(db.get_settings)
eingestellt = (einstellungen or {}).get("werkzeugPfade") or {}
lage = await asyncio.to_thread(werkzeug_beschaffung.lage, eingestellt)
return {
"werkzeuge": lage,
# Wohin Rippy selbst installiert — im UI sichtbar, damit niemand
# raten muss, wo die geholten Dateien landen.
"ordner": werkzeug_beschaffung.katalog.werkzeug_ordner(),
"windows": os.name == "nt",
}
@app.post("/system/werkzeuge/{name}/holen")
async def system_werkzeug_holen(name: str):
"""Holt ein Werkzeug und installiert es.
HandBrake wird vollstaendig automatisch geholt (GitHub-Release, ein ZIP
mit einer .exe — kein Installer, keine Administratorrechte).
MakeMKV wird NICHT mitgeliefert: Rippy laedt die offizielle Datei vom
Hersteller und startet sie. Das ist dieselbe Black-Box-Trennung, die
KONZEPT.md § 6 fuer den Container festhaelt — MakeMKV bleibt ein fremdes
Programm, Rippy nimmt nur die Handgriffe ab.
"""
from rippy.tools import beschaffen as werkzeug_beschaffung
if name not in werkzeug_beschaffung.katalog.WERKZEUGE:
raise HTTPException(
status_code=404,
detail=f"Unbekanntes Werkzeug '{name}'. Bekannt: "
+ ", ".join(sorted(werkzeug_beschaffung.katalog.WERKZEUGE)))
meldungen = []
def fortschritt(text, anteil=None):
if text:
meldungen.append(text)
try:
if name == "handbrake":
pfad = await asyncio.to_thread(
werkzeug_beschaffung.handbrake_holen, None, fortschritt)
else:
pfad = await asyncio.to_thread(
werkzeug_beschaffung.makemkv_holen, "", None, fortschritt, False)
except werkzeug_beschaffung.BeschaffungsFehler as e:
# Klartext statt Traceback: Der haeufigste Grund ist eine nicht
# erreichbare Quelle, und das ist nichts, was der Nutzer im Code
# suchen sollte.
await asyncio.to_thread(db.add_log, "error", "werkzeuge", str(e))
raise HTTPException(status_code=502, detail=str(e)) from e
await asyncio.to_thread(
db.add_log, "info", "werkzeuge",
f"{werkzeug_beschaffung.katalog.WERKZEUGE[name]['titel']} beschafft: {pfad}")
return {"name": name, "pfad": pfad, "meldungen": meldungen}
@app.get("/system/updates")
async def system_updates():
"""Update-Check für die Kern-Werkzeuge (Einstellungen → System).
+5 -1
View File
@@ -145,7 +145,11 @@ def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
# den Containern, deshalb per Pfad laden statt importieren.
worker_tasks = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
# gruen, ohne etwas zu beweisen.
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "ablauf.py"
)
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
quelltext = open(worker_tasks, encoding="utf-8").read()
+5 -1
View File
@@ -55,7 +55,11 @@ def test_der_schluessel_heisst_im_worker_genauso():
import re
quelle = (
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py"
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
# gruen, ohne etwas zu beweisen.
pathlib.Path(__file__).resolve().parents[1] / "worker" / "ablauf.py"
).read_text(encoding="utf-8")
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
# ankommen.