feat(windows): V2-4 fertig — RippySetup.exe, Taskmanager-Eintrag, Deinstallation
Ampel / ampel (push) Failing after 46s

WAS: Eine 28,6-MB-Datei, drei Betriebsarten. Doppelklick installiert,
`--dienst` laeuft im Hintergrund, `--deinstallieren` raeumt sich weg.
Dazu packaging/windows/build.py, das sie baut.

DIE ZWEI COMMANDER-WUENSCHE, GEMESSEN:

1. EINTRAG IM TASKMANAGER
     ProcessName   Rippy
     Beschreibung  Rippy — automatisches Ripping
     Produkt       Rippy
     Version       2.0.0
   Der Prozessname kommt vom Dateinamen (bei der Installation kopiert sich
   die Datei als Rippy.exe), die Beschreibung aus einer eingebetteten
   Versions-Ressource. Ohne die steht dort nichts, und Windows SmartScreen
   sieht eine EXE ohne Herausgeberangabe genauso misstrauisch wie ein Mensch.

2. EINTRAG IN PROGRAMME UND FEATURES
   Aus der Registry zurueckgelesen, nicht behauptet:
     DisplayName      Rippy
     DisplayVersion   2.0.0
     Publisher        Rippy
     InstallLocation  <Zielordner>
     UninstallString  "<pfad>\Rippy.exe" --deinstallieren
     EstimatedSize    29241  (KiB)
     NoModify/NoRepair 1
   HKCU statt HKLM: Der Eintrag erscheint genauso in "Apps & Features", die
   Installation laeuft aber ohne UAC-Abfrage durch.

VOLLER KREISLAUF GEMESSEN: installieren -> Rippy.exe + rippy.ico liegen da,
Registry-Eintrag da, Autostart da; Dienst starten -> nach 1 s bereit, alle
sieben Endpunkte HTTP 200, Oberflaeche kommt; deinstallieren -> nach ~4 s
sind Datei, Ordner, Registry-Eintrag, Autostart UND das Aufraeum-Skript weg.

VIER FEHLER, DIE NUR DIE FERTIGE EXE ZEIGT:

1. rippy/store baute die Engine BEIM IMPORT, mit PostgreSQL als Vorgabe.
   Im Windows-Paket gibt es psycopg2 bewusst nicht -> die EXE starb sofort.
   Die Engine entsteht jetzt beim ersten Zugriff. Und der Fehler war
   grundsaetzlicher als der fehlende Treiber: Ein Modul, das beim Import
   schon eine Verbindung aufbaut, laesst sich gar nicht mehr umstellen —
   store.verbinden() kaeme immer zu spaet.

2. celery_client baute den Broker-Client beim Import. Dasselbe Muster, und
   `from celery_client import celery_client` loeste es sogar dann aus, wenn
   nie ein Rip angestossen wird. Jetzt traege, mit KeinBroker als klarer
   Ansage statt eines Importfehlers.

3. Die Selbstloeschung beim Deinstallieren ging als EIN Argument an cmd.
   Python maskiert dabei die inneren Anfuehrungszeichen — cmd suchte einen
   Dateinamen mit Backslashes davor, fand nichts und meldete nichts (>nul).
   Registry und Autostart waren weg, die 28-MB-Datei blieb liegen: ein still
   scheiternder Hintergrundprozess, wie ihn AGENTS.md beschreibt. Jetzt ein
   Aufraeum-Skript, das 15-mal versucht (die Onefile-EXE laeuft als ZWEI
   Prozesse; der Starter haelt die Datei nach dem Ende noch offen).

4. deinstallieren() raeumte den STANDARD-Ordner auf statt des tatsaechlichen.
   Wer nach --ziel D:\Rippy installiert hatte, dessen Dateien waeren
   geblieben, waehrend ein fremder Ordner angefasst worden waere. Der Pfad
   steht in der Registry (InstallLocation) und wird jetzt dort gelesen.

WAS NOCH NICHT GEHT, ausdruecklich: Ein RIP laesst sich unter Windows noch
nicht anstossen — die Zustellung laeuft weiter ueber Celery. Die Umstellung
auf die LocalQueue ist V2-5. Oberflaeche, Laufwerks-Erkennung, Auswurf und
alles Lesende laufen.

GEMESSEN: ruff sauber, 456 Tests gruen + 15 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 11:00:39 +02:00
co-authored by Claude Opus 5
parent e5254c23f0
commit 059183c651
17 changed files with 1251 additions and 46 deletions
+58 -6
View File
@@ -2,16 +2,66 @@
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
from celery import Celery
from celery.utils import worker_direct
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
_client = 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 transcode_queue(node: str = None):
@@ -25,7 +75,9 @@ def transcode_queue(node: str = None):
if not node:
return "transcode"
try:
antworten = celery_client.control.ping(timeout=1.0) or []
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)
@@ -36,6 +88,6 @@ 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 celery_client.send_task(
return hole_client().send_task(
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
)
+6 -4
View File
@@ -24,7 +24,8 @@ from rippy.bus.waechter import Waechter
import phasen
import presets as preset_auswahl
import rohdaten
from celery_client import celery_client, start_rip
import celery_client as celery_anbindung
from celery_client import start_rip
from rippy.drives.cdrom import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN
from config_validation import validate_config, ConfigValidationError
@@ -1213,7 +1214,8 @@ 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_client.send_task("worker.tasks.scan_tracks", args=[device_path])
celery_anbindung.hole_client().send_task(
"worker.tasks.scan_tracks", args=[device_path])
return {"status": "scanning"}
@@ -1271,7 +1273,7 @@ async def retry_transcode(job_id: str):
except ValueError:
meta = {}
from celery_client import transcode_queue
celery_client.send_task(
celery_anbindung.hole_client().send_task(
"worker.tasks.transcode_files",
args=[job_id, raw_dir, final_dir],
queue=transcode_queue(meta.get("transcode_node")),
@@ -1401,7 +1403,7 @@ PING_ALTER_MAX_SEKUNDEN = 30
def _ping_jetzt() -> list:
"""Pingt sofort (blockiert ~1 s) und füllt den Vorrat."""
try:
antworten = celery_client.control.ping(timeout=1.0) or []
antworten = celery_anbindung.hole_client().control.ping(timeout=1.0) or []
knoten = [k for antwort in antworten for k in antwort.keys()]
except Exception:
knoten = []
+1 -1
View File
@@ -11,7 +11,7 @@ import re
import shutil
import subprocess
from winlauf import OHNE_FENSTER
from rippy.platform.winlauf import OHNE_FENSTER
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
+1 -1
View File
@@ -36,7 +36,7 @@ from rippy.drives.linux import ( # noqa: F401
_auswurf_geglueckt,
)
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
from winlauf import OHNE_FENSTER
from rippy.platform.winlauf import OHNE_FENSTER
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
+1 -1
View File
@@ -45,7 +45,7 @@ import re
import subprocess
import urllib.request
from winlauf import OHNE_FENSTER
from rippy.platform.winlauf import OHNE_FENSTER
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
-32
View File
@@ -1,32 +0,0 @@
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
## Der Befund, der das nötig gemacht hat (26.07.2026)
Commander: *„Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
recht — es war kein Geist, sondern der Herzschlag des Workers.
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an — und die ist sichtbar.
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
kein Fenster.
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
Schüben — genau das „alle paar Sekunden".
## Warum eine eigene Datei
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
`creationflags=0` eine Nulloperation — im Worker-Container gegengeprüft, damit
derselbe Code auf beiden Seiten läuft.
"""
import subprocess
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)