# KONZEPT v2 — Rippy > Architektur- und Implementierungskonzept für Rippy v2. > Ergänzt `KONZEPT.md` (v1), ersetzt es nicht: Alle Muss-Features aus v1 bleiben > Muss-Features. Was sich ändert, ist die **Verdrahtung** — nicht der Zweck. --- ## 0. Leseanleitung Dieses Dokument beantwortet fünf Fragen in dieser Reihenfolge: | § | Frage | |---|-------| | 1–2 | Wie ist v2 aufgebaut, und warum genau so? | | 3 | Wie kann dieselbe Anwendung mit SQLite allein UND mit Postgres+Redis+Remote-Workern laufen? | | 4–5 | Wie sieht das auf Windows, auf einem nackten Linux-Server und in Docker konkret aus? | | 6 | Welche Endpunkte und welche Live-Ereignisse gibt es? | | 7–10 | Wie kommen wir von v1 dorthin, ohne unterwegs kaputtzugehen? | **§ 10 ist die einzige Stelle, an der eine Entscheidung vom Commander gebraucht wird.** Alles davor ist Vorschlag mit Begründung. Alle Befunde aus v1, auf die sich dieses Dokument beruft, sind in `KONZEPT.md` § 10, `AGENTS.md` („Was diese Sitzungen wiederholt gekostet hat") und `SAVEPOINT.md` belegt und im Code kommentiert. Dieses Dokument erfindet keine neuen Messungen. --- ## 1. Der Kerngedanke > **Rippy v2 ist EINE Anwendung mit DREI Verdrahtungen — nicht drei Produkte.** Das ist die zentrale Entscheidung, und alles andere folgt daraus. v1 hat den umgekehrten Weg genommen: Der Linux-Docker-Worker und der Windows-Worker sind heute zwei getrennte Codestände, die sich Module per Kopie teilen (`detection.py`, `makemkv_daten.py`, `notify.py` liegen zweimal im Repo, byte-identisch). Jede Änderung muss zweimal gemacht werden, und `db.py` sagt das sogar selbst im Kopfkommentar: *„Wer die Struktur ändert, ändert BEIDE Dateien."* Genau daran stirbt ein Projekt langsam. v2 dreht das um. Es gibt **einen** Kern, und er weiß nicht, wo er läuft: ``` ┌──────────────────────────────┐ │ rippy.core (Domänenkern) │ │ kennt weder DB noch Broker │ │ noch Betriebssystem │ └──────────────┬───────────────┘ │ spricht nur über 4 Ports ┌──────────────┬───────────┼───────────┬──────────────┐ ▼ ▼ ▼ ▼ ▼ Store Queue Bus Drives Storage (Zustand) (Aufträge) (Ereignisse) (Hardware) (Ablage) ``` Ein Betriebsmodus ist nichts weiter als die Auswahl der Treiber hinter diesen Ports: | Port | Standalone (Windows / Linux headless / Docker-AiO) | Verteilt (Docker-Cluster) | |------|---------------------------------------------------|---------------------------| | **Store** | SQLite (WAL) | PostgreSQL | | **Queue** | LocalQueue (Tabelle + Prozess-Pool) | Celery über Redis | | **Bus** | In-Process (asyncio) | Redis Pub/Sub | | **Drives** | `LinuxDrives` / `WindowsDrives` | `LinuxDrives` je Knoten | | **Storage** | Host-Pfade direkt | Host-Mounts, in Container gebunden | Derselbe Rip-Code, dieselben Tests, dasselbe UI. Wer den Modus wechselt, ändert eine Zeile in der Konfiguration — nicht das Programm. --- ## 2. Systemarchitektur ### 2.1 Schichtenmodell Sechs Fachschichten, klar getrennt nach dem, was sie *wissen* müssen: | Schicht | Verantwortung | Weiß NICHTS über | |---------|---------------|------------------| | **`core`** | Job-Modell, Zustandsmaschine, Phasen, Regeln („darf dieser Job jetzt starten?") | DB, Broker, OS, HTTP | | **`drives`** (DAL) | Laufwerke finden, Disc-Status, Typ, Verriegeln, Auswerfen, Einwurf-Ereignisse | Jobs, Metadaten | | **`metadata`** | TMDb/TVDb/OMDb/MusicBrainz/AcoustID/Jikan, Matching, Confidence | Ripping, Ablage | | **`rip`** | MakeMKV, abcde/cdparanoia — Kommandobau, Fortschritts-Parsing | Warum gerippt wird | | **`transcode`** | HandBrake, FFmpeg, Encoder-Erkennung, Preset-Auswahl | Discs | | **`library`** | Zielstruktur, Benennung, NFO, Poster, Medienserver-Refresh | Wie es gerippt wurde | Darunter die vier **Ports** (§ 2.3), darüber drei dünne **Oberflächen**: `api` (FastAPI-Router), `cli` (Typer), `tray` (Windows/Desktop). **Regel, die das trägt:** Eine Fachschicht importiert nie eine andere Fachschicht direkt. Die Verkettung („scan → Metadaten → rip → transcode → ablegen") lebt ausschließlich in `core.pipeline`. Das ist der Unterschied zu v1, wo `tasks.rip_disc` 236 Zeilen lang ist und alles gleichzeitig macht. ### 2.2 Komponenten-Diagramm ```mermaid flowchart TB subgraph OB["Oberflächen"] UI["Web-UI (React)
SSE-Client"] CLI["rippy CLI"] TRAY["Tray / WebView2
(nur Windows)"] end subgraph APP["rippyd — eine Anwendung, drei Profile"] API["api — FastAPI-Router
(dünn, keine Fachlogik)"] CORE["core — Pipeline & Zustandsmaschine"] subgraph FACH["Fachschichten"] DAL["drives (DAL)"] META["metadata"] RIP["rip"] ENC["transcode"] LIB["library"] end end subgraph PORTS["Ports — austauschbare Treiber"] STORE["Store"] QUEUE["Queue"] BUS["Bus"] STOR["Storage"] end subgraph TREIBER["Treiber"] SQLITE["SQLite WAL"] PG["PostgreSQL"] LOCALQ["LocalQueue
(Tabelle + Pool)"] CELERY["Celery / Redis"] MEMBUS["asyncio-Bus"] REDISBUS["Redis Pub/Sub"] end subgraph HW["Außenwelt"] DRIVE["Optische Laufwerke
/dev/sr* · \\\\.\\D:"] TOOLS["makemkvcon · HandBrakeCLI
abcde · ffmpeg"] NET["TMDb · TVDb · MusicBrainz
Jellyfin/Emby/Plex"] FS["Medien-Ablage
lokal · SMB · NFS"] end UI -.SSE.-> API UI --> API CLI --> CORE TRAY --> API API --> CORE CORE --> DAL & META & RIP & ENC & LIB CORE --> STORE & QUEUE & BUS STORE --> SQLITE & PG QUEUE --> LOCALQ & CELERY BUS --> MEMBUS & REDISBUS DAL --> DRIVE RIP --> TOOLS ENC --> TOOLS META --> NET LIB --> NET LIB --> STOR STOR --> FS ``` **Was das Diagramm zeigt und was in v1 fehlt:** 1. `api` hat **keinen** Pfeil auf `rip` oder `drives`. In v1 hat sie den: `main.py` importiert `devices.py` und ruft `eject()` direkt auf. Deshalb braucht der API-Container heute einen `devices:`-Eintrag in der Compose-Datei und `SYS_ADMIN`. 2. Die Ports sitzen zwischen Kern und Treibern — nicht daneben. Ein Aufruf von `core` nach `SQLite` gibt es nicht, nur `core → Store → SQLite`. 3. Die Außenwelt hängt an genau drei Stellen: DAL (Hardware), Werkzeug-Adapter (CLI-Programme), Storage (Dateisystem). Alles Blockierende ist damit an drei Stellen eingesperrt statt über 2 254 Zeilen verteilt. ### 2.3 Die vier Ports Ports sind Python-`Protocol`s — keine Basisklassen, keine Vererbung, testbar ohne Mocking-Bibliothek. ```python # rippy/ports.py class Store(Protocol): """Zustand: Jobs, Logs, Einstellungen, Worker, Mounts.""" def job_anlegen(self, job: Job) -> None: ... def job_holen(self, job_id: str) -> Job | None: ... def jobs_listen(self, filter: JobFilter) -> list[Job]: ... def job_aendern(self, job_id: str, **felder) -> Job: ... def log_anhaengen(self, eintrag: LogEintrag) -> None: ... def einstellungen_holen(self) -> Einstellungen: ... class Queue(Protocol): """Aufträge. Die WAHRHEIT liegt im Store — siehe § 3.1.""" def einreihen(self, auftrag: Auftrag) -> None: ... def uebernehmen(self, faehigkeiten: set[str], knoten: str) -> Auftrag | None: ... def lebenszeichen(self, auftrag_id: str) -> None: ... def abschliessen(self, auftrag_id: str, ergebnis: Ergebnis) -> None: ... def abbrechen(self, auftrag_id: str) -> None: ... class Bus(Protocol): """Ereignisse. Feuer-und-vergiss — nie ein Rückkanal für Zustand.""" async def senden(self, ereignis: Ereignis) -> None: ... async def abonnieren(self, ab_seq: int | None) -> AsyncIterator[Ereignis]: ... class Drives(Protocol): """Hardware. Der EINZIGE Ort mit ioctl/Win32 — siehe § 5.""" def laufwerke(self) -> list[Laufwerk]: ... def zustand(self, id: str) -> Laufwerkszustand: ... def verriegeln(self, id: str, an: bool) -> None: ... def auswerfen(self, id: str) -> None: ... # prüft nach, wirft sonst async def ereignisse(self) -> AsyncIterator[DiscEreignis]: ... ``` `Drives.auswerfen` trägt die v1-Lehre in der Signatur: Es gibt keinen Rückgabewert, den man fälschlich für Erfolg halten könnte. Entweder die Disc ist draußen, oder es fliegt eine Ausnahme. (v1-Befund 26.07.2026: `CDROMEJECT` quittiert auf einem verriegelten Laufwerk Erfolg und tut nichts — `devices.py:22`.) ### 2.4 Paketstruktur Ein Monorepo, ein installierbares Paket, drei Extras: ``` rippy/ ├── pyproject.toml # [project.optional-dependencies] │ # server = fastapi, uvicorn │ # distributed = celery, redis, psycopg │ # windows = pywin32, pystray, pywebview ├── src/rippy/ │ ├── core/ # Job, Phase, Zustandsmaschine, pipeline.py │ ├── ports.py # die vier Protocols │ ├── drives/ # base.py · linux.py · windows.py · darwin.py │ ├── metadata/ # clients/ (tmdb, tvdb, omdb, musicbrainz, jikan) │ ├── rip/ # makemkv.py · abcde.py · parser.py │ ├── transcode/ # handbrake.py · ffmpeg.py · caps.py · presets.py │ ├── library/ # struktur.py · nfo.py · mediaserver.py │ ├── storage/ # pfade.py · mounts.py · pfadmap.py │ ├── store/ # sqlite.py · postgres.py · schema.py · migrations/ │ ├── queue/ # lokal.py · celery.py │ ├── bus/ # memory.py · redis.py · schema.py │ ├── api/ # routers/ (jobs, drives, storage, system, …) │ ├── cli/ # Typer-Kommandos │ ├── platform/ # win_service.py · systemd.py · tray.py · winlauf.py │ └── config.py # TOML + ENV + CLI, eine Präzedenz ├── ui/ # React (aus docker/ui/ übernommen) ├── packaging/ │ ├── windows/ # Inno-Setup-Skript, WinSW-XML, Nuitka-Spec │ ├── linux/ # systemd-Units, .deb/.rpm/AppImage │ └── docker/ # Dockerfile (ein Image), compose-Varianten └── tests/ # wandern 1:1 aus v1 mit (siehe § 8.2) ``` **Die Extras sind der Punkt.** `pip install rippy` gibt einen lauffähigen Headless-Daemon mit SQLite. `pip install rippy[distributed]` zieht Celery, Redis-Client und psycopg nach. Ein Windows-Nutzer bekommt nie eine Postgres-Abhängigkeit zu sehen — heute schleppt `docker/api/requirements.txt` alles für alle mit. --- ## 3. Daten- & Queue-Strategie ### 3.1 Der Grundsatz > **Die Datenbank ist die Wahrheit über den Job-Zustand. > Der Broker ist nur der Wecker.** Das klingt nach einem Detail und ist die wichtigste Entscheidung in § 3. In v1 ist es umgekehrt gedacht: Celery hält den Auftrag, die DB spiegelt ihn nach. Deshalb gibt es `zombies.py` (206 Zeilen) plus `test_zombies.py` (263 Zeilen) — ein nachträglicher Reparaturmechanismus für Jobs, die in der DB laufen, während in Celery niemand mehr an ihnen arbeitet. Der Kommentar in `celery_app.py` beschreibt es genau: nach einer Gnadenfrist werden sie „ehrlich auf `failed` gesetzt". Wenn die DB die Wahrheit ist, verschwindet das Problem, statt repariert zu werden: - Ein Auftrag ist eine Zeile mit `status`, `claimed_by`, `lease_until`. - Ein Knoten übernimmt ihn per bedingtem `UPDATE` (atomar, in beiden DBs). - Er hält ihn per **Lease** am Leben: alle 15 s `lease_until = jetzt + 60 s`. - Läuft die Lease ab, ist der Auftrag frei — egal ob der Knoten abgestürzt ist, das Netz weg war oder jemand den Stecker gezogen hat. Das ist derselbe Mechanismus in *beiden* Betriebsmodi. Und es macht die Queue-Treiber austauschbar, weil der Broker keinen Zustand mehr besitzt: - **LocalQueue** pollt die Tabelle (1 s) — kein Broker nötig. - **CeleryQueue** benutzt Redis nur als Wecker („da ist Arbeit"); der Task holt sich die Details aus der DB. Geht die Nachricht verloren, findet der nächste Poll-Durchlauf sie trotzdem. ### 3.2 Store-Port: zwei Treiber, ein Schema v1 nutzt **SQLAlchemy Core** (nicht das ORM) — `db.py` arbeitet mit `Table()`, `select()`, `insert()`. Das ist ein Glücksfall: Core läuft auf SQLite und Postgres mit demselben Code. Der Store-Port ist deshalb kein Neubau, sondern ein Umzug. Was wirklich angefasst werden muss: | Thema | v1 | v2 | |-------|-----|-----| | Migrationen | `ALTER TABLE … ADD COLUMN IF NOT EXISTS` von Hand in `init_db()` — **Postgres-only, bricht auf SQLite** | dialektneutral: erst `inspect()` fragen, dann nur fehlende Spalten anlegen — **kein Alembic**, siehe Kasten unten | | Nebenläufigkeit | Postgres regelt es | SQLite: `PRAGMA journal_mode=WAL`, `busy_timeout=5000`, **ein** Schreiber-Kontext | | Zeitstempel | `DateTime(timezone=True)` | unverändert; SQLite speichert ISO-8601 UTC | | JSON-Felder | `Text` + `json.dumps` von Hand | unverändert (portabel), Serialisierung in den Store gezogen | | Duplikat | `api/db.py` **und** `worker/db.py` | eine Datei | > **Abweichung vom Entwurf (beim Bauen entschieden, 28.08.2026):** Hier stand > ursprünglich Alembic. Zwei Dinge sprachen beim Umsetzen dagegen. Erstens > **gibt es die Datenbank auf der VM schon** — Alembic müsste sie erst > „stempeln" (`alembic stamp head`), sonst hält es sie für leer und versucht > vorhandene Tabellen anzulegen; das ist ein Handgriff auf einer laufenden > Installation und damit genau die Sorte Schritt, die beim nächsten Deploy > jemand vergisst. Zweitens **gibt es hier nichts zu versionieren**: Die > gesamte Migrationslast des Projekts sind drei nachgetragene Spalten. > Stattdessen fragt `store.migrieren()` per `inspect()` nach, welche Spalten > existieren, und legt nur die fehlenden an — das läuft auf beiden Dialekten. > Sollte das Schema je wirklich wandern (Spalten umbenennen, Daten > umschichten), ist Alembic die richtige Antwort, dann aber mit einer > bewussten Stempel-Runde. **SQLite-Grenzen, ehrlich benannt:** WAL erlaubt viele Leser und *einen* Schreiber. Für Rippy reicht das mit großem Abstand — ein Rip schreibt etwa alle 2 s einen Fortschrittswert. Wer mehr als ~4 gleichzeitige Rip-Knoten fährt, nimmt Postgres; das ist genau die Grenze, an der der verteilte Modus ohnehin sinnvoll wird. ### 3.3 Schema (v2) Aufbauend auf v1 (`jobs`, `logs`, `settings`, `workers`, `storage_mounts`), mit vier Ergänzungen: ```sql -- NEU: Aufträge getrennt von Jobs. Ein Job („Akira rippen") kann mehrere -- Aufträge haben (scan → rip → transcode → ablegen). v1 hatte das implizit -- in tasks.py verdrahtet und konnte deshalb nicht gezielt neu starten. CREATE TABLE auftraege ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, art TEXT NOT NULL, -- scan | rip | transcode | ablegen status TEXT NOT NULL, -- wartend | laufend | fertig | fehler | abgebrochen faehigkeiten TEXT NOT NULL, -- JSON: {"drive":"sr0"} / {"encoder":"nvenc"} prioritaet INTEGER DEFAULT 0, claimed_by TEXT, -- Knotenname lease_until TIMESTAMP, -- § 3.1 versuche INTEGER DEFAULT 0, payload TEXT, -- JSON created_at TIMESTAMP NOT NULL ); CREATE INDEX idx_auftraege_frei ON auftraege(status, prioritaet DESC, created_at); -- NEU: Laufwerke als erstklassige Objekte (Multi-Drive, § 7.1) CREATE TABLE laufwerke ( id TEXT PRIMARY KEY, -- stabil: WWID/Serial, NICHT sr0 knoten TEXT NOT NULL, pfad TEXT NOT NULL, -- /dev/sr0 bzw. D: anzeigename TEXT, profil TEXT, -- JSON: Zero-Click je Disc-Typ (§ 7.2) last_seen TIMESTAMP ); -- NEU: Ereignis-Ringpuffer für SSE-Wiederaufnahme (§ 6.3) CREATE TABLE ereignisse ( seq INTEGER PRIMARY KEY AUTOINCREMENT, ts TIMESTAMP NOT NULL, typ TEXT NOT NULL, entitaet TEXT, entitaet_id TEXT, daten TEXT -- JSON ); -- GEÄNDERT: jobs.device → jobs.laufwerk_id (stabile Kennung statt sr0) -- GEÄNDERT: storage_mounts.passwort → Verweis auf OS-Schlüsselspeicher (§ 9) ``` `jobs.device` auf eine stabile Kennung umzustellen, behebt nebenbei ein v1-Ärgernis, das in `.env.example` dokumentiert ist: *„nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern → Container neu starten."* ### 3.4 Queue-Port: die Entscheidung Der Auftrag nennt vier Kandidaten (Taskiq, Celery-lite, ARQ, AsyncIO-Queue). **Empfehlung: keiner davon als Bibliothek — stattdessen zwei eigene, sehr kleine Treiber hinter dem Port.** Begründung, kurz: 1. **Der teure Teil ist kein Task, sondern ein Subprozess.** Ein Rip ist ein 30–90-Minuten-`makemkvcon`-Aufruf, dessen stdout zeilenweise geparst wird (`ripping.get_progress_from_prgv`). Was Taskiq/ARQ liefern — Serialisierung, Retry, Broker-Anbindung — löst davon nichts. Was gebraucht wird — Fortschritts-Streaming, Abbruch mitten im Lauf, Lease — muss man ohnehin selbst bauen. 2. **Zwei Bibliotheken heißen zwei Programmiermodelle.** Der verteilte Modus soll Celery behalten (funktioniert, Remote-Windows-Worker ist bewiesen). Eine zweite Queue-Bibliothek daneben bedeutet zwei Fehlerbilder, zwei Retry-Semantiken, zwei Sorten Doku. 3. **Mit § 3.1 ist der Treiber trivial.** LocalQueue ist ein `SELECT … WHERE status='wartend' … LIMIT 1` plus bedingtes `UPDATE` plus ein `ProcessPoolExecutor`. Das sind etwa 150 Zeilen, vollständig testbar, ohne laufenden Broker. ```python # rippy/queue/lokal.py — Kern (gekürzt) def uebernehmen(self, faehigkeiten: set[str], knoten: str) -> Auftrag | None: jetzt = utcnow() with self.store.begin() as tx: kandidat = tx.execute( select(auftraege) .where(auftraege.c.status == "wartend") .where(or_(auftraege.c.lease_until.is_(None), auftraege.c.lease_until < jetzt)) .order_by(auftraege.c.prioritaet.desc(), auftraege.c.created_at) .limit(1) ).mappings().first() if not kandidat or not self._passt(kandidat, faehigkeiten): return None # Bedingtes UPDATE: gewinnt genau EIN Knoten, auch bei Gleichstand. betroffen = tx.execute( auftraege.update() .where(auftraege.c.id == kandidat["id"]) .where(auftraege.c.claimed_by.is_(None)) # <- die Bedingung .values(status="laufend", claimed_by=knoten, lease_until=jetzt + LEASE) ).rowcount return Auftrag(**kandidat) if betroffen == 1 else None ``` `CeleryQueue` implementiert denselben Port und behält v1s `worker_direct=True`-Trick, mit dem die API die Kompression gezielt an einen gewählten Encoder-Knoten schickt. ### 3.5 Event-Bus Zwei Treiber, ein Schema (§ 6.3): - **`MemoryBus`** — `asyncio.Queue` je Abonnent, dazu ein Ringpuffer der letzten 512 Ereignisse für Wiederaufnahme nach Verbindungsabriss. - **`RedisBus`** — Redis Pub/Sub für die Verteilung zwischen Knoten; der Ringpuffer liegt in der Tabelle `ereignisse` (§ 3.3), damit ein UI, das an Knoten A hängt, auch Ereignisse von Knoten B lückenlos nachholt. **Regel:** Der Bus überträgt nur *Nachrichten über Änderungen*, nie den Zustand selbst. Wer den Zustand will, fragt den Store. Das verhindert die v1-Klasse von Fehlern, bei denen ein verpasstes Signal als Aussage über die Welt gelesen wird (`AGENTS.md`: *„Ein verpasster Abruf ist keine Nachricht über die Welt"*). --- ## 4. Plattform-Spezifikationen ### 4.1 Modus 1 — Native Windows-Installation #### Laufzeit-Architektur ``` ┌─────────────────────────────────────────────────────────┐ │ RippyService (Windows-Dienst, LocalSystem) │ │ ├─ uvicorn → 127.0.0.1:7788 (API + UI-Assets) │ │ ├─ LocalQueue → 1–N Rip-/Encode-Prozesse │ │ ├─ DriveWatcher → WM_DEVICECHANGE (Ereignis, kein Poll)│ │ └─ Store → %ProgramData%\Rippy\rippy.db (SQLite)│ └───────────────────────┬─────────────────────────────────┘ │ HTTP + SSE, nur loopback ┌───────────────────────▼─────────────────────────────────┐ │ RippyTray (Benutzersitzung, optional) │ │ ├─ Tray-Icon + Kontextmenü (Status, Öffnen, Beenden) │ │ └─ WebView2-Fenster → http://127.0.0.1:7788 │ └─────────────────────────────────────────────────────────┘ ``` Dienst und Tray sind **getrennte Prozesse**. Grund: Ein Dienst hat keine Benutzersitzung und kann kein Fenster zeigen; ein Tray-Programm stirbt beim Abmelden. Wer beides in einen Prozess legt, bekommt entweder keinen Autostart vor der Anmeldung oder kein Icon. v1s `gui.py` (24 KB Flet) vermischt das heute. **Alternative ohne Dienst** (für Nutzer ohne Administratorrechte): Autostart per Aufgabenplanung „Bei Anmeldung", Tray startet den Kern als Kindprozess. Der Installer bietet beides an; Standard ist der Dienst. #### Bündelung und Installer | Baustein | Wahl | Warum | |----------|------|-------| | Python-Bündelung | **Nuitka** (`--standalone`), Fallback PyInstaller one-dir | Kein Embedded-Python-Gefummel; ein Ordner, ein `rippyd.exe`. One-**dir** statt one-file: ein Onefile-Paket entpackt sich bei jedem Start neu nach `%TEMP%` — bei 90 MB spürbar, und Virenscanner mögen es nicht. | | Installer | **Inno Setup** | Frei, ein Skript, kann Dienste registrieren, Deinstallation sauber, keine MSI-Werkzeugkette | | Dienst-Wrapper | **WinSW** (XML-konfiguriert) | NSSM wird per Kommandozeile konfiguriert — nicht reproduzierbar. WinSW legt eine XML neben die EXE, die im Repo versionierbar ist. | | Ansicht | **WebView2** (Edge-Runtime) | Auf Win 10/11 vorinstalliert bzw. per Evergreen-Bootstrapper nachrüstbar; kein CEF-Ballast | Der Installer fragt **fünf** Dinge und leitet den Rest ab: Zielordner, Autostart (Dienst/Anmeldung/keiner), Medien-Ablage, MakeMKV-Pfad (vorbelegt aus der Erkennung), Port. #### Werkzeug-Erkennung ```python # rippy/platform/win_tools.py MAKEMKV_KANDIDATEN = [ winreg_wert(HKLM, r"SOFTWARE\MakeMKV", "InstallDir"), r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe", r"C:\Program Files\MakeMKV\makemkvcon64.exe", shutil.which("makemkvcon64") or shutil.which("makemkvcon"), ] ``` HandBrakeCLI analog, plus mitgelieferte Kopie im Installationsordner als Rückfallebene (HandBrake ist GPL-2 — Weitergabe erlaubt, Quellverweis gehört in den Über-Dialog; siehe die GPL-Checkliste in `KONZEPT.md` § 4). **MakeMKV wird NICHT mitgeliefert.** Grund steht in `KONZEPT.md` § 6: Black-Box-Trennung. Der Installer erkennt eine vorhandene Installation und verlinkt sonst auf makemkv.com. #### Win32-Laufwerkszugriff (Kurzfassung, Details § 5) Alles über `CreateFileW(r"\\.\D:", …)` plus `DeviceIoControl`: | Zweck | IOCTL | |-------|-------| | Laufwerke finden | `GetLogicalDrives` + `GetDriveTypeW == DRIVE_CDROM` | | Medium eingelegt? | `IOCTL_STORAGE_CHECK_VERIFY2` | | Verriegeln / Entriegeln | `IOCTL_STORAGE_MEDIA_REMOVAL` (`PreventMediaRemoval`) | | Auswerfen | `IOCTL_STORAGE_EJECT_MEDIA` | | Exklusivzugriff | `FSCTL_LOCK_VOLUME` | | Disc-Typ (grob) | `IOCTL_CDROM_DISK_TYPE` (Audio/Daten) | | Disc-Typ (genau) | MMC `GET CONFIGURATION` via `IOCTL_SCSI_PASS_THROUGH_DIRECT` → *Current Profile*: `0x08` CD, `0x10` DVD, `0x40` BD | | Einwurf-Ereignis | `WM_DEVICECHANGE` / `DBT_DEVICEARRIVAL`; im Dienst `RegisterServiceCtrlHandlerEx` + `SERVICE_CONTROL_DEVICEEVENT` | Die v1-Lehre gilt hier genauso: **nach dem Auswurf den Zustand fragen**, nicht dem Rückgabewert glauben (`CHECK_VERIFY2` muss `ERROR_NOT_READY` liefern). Zwei Windows-Eigenheiten, die v1 schon kennt und die übernommen werden: - `winlauf.OHNE_FENSTER` (`CREATE_NO_WINDOW`) — sonst blitzt bei jedem `HandBrakeCLI --version` ein Konsolenfenster auf. v1-Befund 26.07.2026. - Standby verhindern: `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` beim ersten laufenden Auftrag, zurücksetzen beim letzten. **Ohne `ES_DISPLAY_REQUIRED`** — der Bildschirm darf ausgehen. #### Der 4K-UHD-Gewinn Der wichtigste strukturelle Vorteil des Windows-Modus: `makemkvcon` ruft dort Disc-Schlüssel online ab, unter Linux nie (v1-Messung auf beiden Maschinen, 25.07.2026, `KONZEPT.md` § 8). In v1 war das ein manueller Datei-Transfer. In v2 ist der Windows-Knoten damit automatisch die **Schlüssel-Quelle** für den Rest der Installation: ``` Windows-Knoten rippt UHD → MakeMKV legt Schlüssel in %APPDATA%\MakeMKV ab → Rippy spiegelt _private_data.tar in den Store Linux-Knoten braucht Schlüssel → holt den Speicher über die API ``` Das ist dasselbe, was der Nutzer heute von Hand macht — nur ohne Handarbeit. In der Schlüsselkette aus § 7.5 ist das **Stufe 1**: der eigene Bestand, der vor jedem Netzabruf gefragt wird. ### 4.2 Modus 2 — Headless Linux Application #### Konfiguration: eine Präzedenz, überall gleich ``` CLI-Flag > Umgebungsvariable (RIPPY_*) > Konfigurationsdatei > Vorgabe ``` ```toml # /etc/rippy/rippy.toml [server] host = "0.0.0.0" port = 7788 ui = true # statische Assets mitausliefern (kein nginx) [store] treiber = "sqlite" # sqlite | postgres pfad = "/var/lib/rippy/rippy.db" # url = "postgresql://rippy:…@db:5432/rippy" # bei treiber = "postgres" [queue] treiber = "lokal" # lokal | celery rip_slots = 1 # gleichzeitige Rips (§ 7.1) encode_slots = 2 [drives] erkennung = "udev" # udev | poll poll_sekunden = 3 # nur bei erkennung = "poll" auto_auswurf = true [[drives.profil]] # Zero-Click je Disc-Typ (§ 7.2) typ = "cd" modus = "zero-click" [[drives.profil]] typ = "uhd" modus = "interaktiv" [storage] medien = "/srv/medien" temp = "/var/lib/rippy/temp" pfad_timeout_sekunden = 5 # § 4.2, nicht-blockierende Prüfung [metadata] tmdb_key = "" # oder RIPPY_METADATA__TMDB_KEY sprachen = ["de", "en"] [transcode] encoder = "auto" # auto | x265 | nvenc | qsv | vaapi | none [transcode.preset] uhd = "keine" # verlustfrei durchreichen bluray = "HQ 1080p30 Surround" dvd = "H.264 MKV 576p25" [mediaserver] typ = "jellyfin" url = "http://192.168.178.20:8096" api_key = "" ``` Umgebungsvariablen bilden die Verschachtelung mit doppeltem Unterstrich ab: `RIPPY_STORE__PFAD`, `RIPPY_TRANSCODE__ENCODER`. Das ist der Docker-Weg und funktioniert dort ohne Konfigurationsdatei. #### CLI ```bash rippyd --config /etc/rippy/rippy.toml # Daemon (Vordergrund, systemd-tauglich) rippy status # Knoten, Queue, laufende Jobs rippy drives # Laufwerke + Disc-Zustand rippy drives eject sr0 rippy scan sr0 # TOC lesen, Metadaten vorschlagen rippy rip sr0 --titel 3,5 --ziel /srv/medien/Filme --warten rippy queue # Aufträge, Leases, Knoten rippy queue retry --ab transcode # gezielt neu ab einer Phase rippy logs --follow --job # SSE im Terminal rippy config check # Konfiguration + Werkzeuge + Pfade rippy doctor # Vollprüfung (siehe unten) ``` `rippy doctor` ist die CLI-Fassung dessen, was `install.sh` heute in seiner Prüfphase macht — und es übernimmt deren wichtigste Eigenschaft: **erst alles prüfen, dann berichten, nichts ändern** (`install.sh` Zeile 5 ff.). #### systemd ```ini # /etc/systemd/system/rippy.service [Unit] Description=Rippy — automatisches Ripping After=network-online.target Wants=network-online.target [Service] Type=exec User=rippy Group=rippy SupplementaryGroups=cdrom # Zugriff auf /dev/sr* ohne root ExecStart=/usr/bin/rippyd --config /etc/rippy/rippy.toml Restart=on-failure RestartSec=5 # Härtung — der Ersatz für das, was v1 über Container-Isolation löste ProtectSystem=strict ProtectHome=true PrivateTmp=true NoNewPrivileges=true ReadWritePaths=/var/lib/rippy /srv/medien DeviceAllow=block-sr rw DeviceAllow=char-sg rw [Install] WantedBy=multi-user.target ``` `DeviceAllow` für **beide** Knotenklassen ist Pflicht, nicht Kosmetik: MakeMKV spricht Laufwerke über SCSI-generic an und findet ohne `sg`-Knoten „keine usable optical drives" (`docker-compose.yml`, Kommentar bei `devices:`). Für `sysvinit`/OpenRC liegt ein Init-Skript in `packaging/linux/` — dieselbe Binärdatei, dieselben Flags. #### Disc-Erkennung: udev statt Polling Auf blankem Linux geht das, was im Container prinzipbedingt nicht ging (`KONZEPT.md` § 10, 23./24.07.2026): ``` # /etc/udev/rules.d/99-rippy.rules ACTION=="change", KERNEL=="sr[0-9]*", ENV{ID_CDROM_MEDIA}=="1", \ RUN+="/usr/bin/rippy internal disc-event --device /dev/%k --aktion eingelegt" ACTION=="change", KERNEL=="sr[0-9]*", ENV{ID_CDROM_MEDIA}=="", \ RUN+="/usr/bin/rippy internal disc-event --device /dev/%k --aktion entfernt" ``` `erkennung = "poll"` bleibt als Rückfallebene (v1s ioctl-Poller, 3 s) — für Container und für Systeme ohne udev. #### UI ohne nginx Die kompilierten Vite-Assets werden ins Python-Paket gelegt und über `StaticFiles` ausgeliefert. Damit fällt der nginx weg — und mit ihm ein v1-Fehler: Hinter dem Proxy war `request.client.host` immer der Proxy, weshalb alle Clients einen Rate-Limit-Eimer teilten (`ratelimit.py`). Ohne Proxy ist die Peer-Adresse wieder echt. Die `X-Real-IP`-Auswertung bleibt für Nutzer, die selbst einen Proxy davorstellen. ### 4.3 Modus 3 — Docker #### Ein Image, drei Kommandos ```dockerfile # packaging/docker/Dockerfile — multi-stage, multi-arch FROM node:20-alpine AS ui WORKDIR /ui COPY ui/package*.json ./ RUN npm ci COPY ui/ ./ RUN npm run build FROM python:3.12-slim AS runtime RUN apt-get update && apt-get install -y --no-install-recommends \ handbrake-cli ffmpeg abcde cdparanoia flac eyed3 \ libchromaprint-tools ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=ui /ui/dist /opt/rippy/ui COPY dist/rippy-*.whl /tmp/ RUN pip install --no-cache-dir /tmp/rippy-*.whl[server,distributed] ENTRYPOINT ["rippyd"] ``` | Variante | Kommando | Store | Queue | |----------|----------|-------|-------| | **All-in-One** | `rippyd --profil standalone` | SQLite | LocalQueue | | **API-Knoten** | `rippyd --profil api` | Postgres | Celery (Producer) | | **Rip/Encode-Knoten** | `rippyd --profil node` | Postgres | Celery (Consumer) | `cifs-utils` fehlt in der Liste — mit Absicht, siehe unten. #### All-in-One-Compose ```yaml services: rippy: image: ghcr.io/…/rippy:2 command: ["--profil", "standalone"] ports: ["7788:7788"] environment: RIPPY_STORE__TREIBER: sqlite RIPPY_STORE__PFAD: /data/rippy.db RIPPY_STORAGE__MEDIEN: /medien volumes: - rippy-data:/data - /srv/medien:/medien # Host mountet, nicht der Container (!) - /srv/rippy/makemkv:/root/.MakeMKV devices: - /dev/sr0:/dev/sr0 - /dev/sg1:/dev/sg1 - /dev/dri:/dev/dri # Intel QSV / AMD VAAPI group_add: ["cdrom", "video", "render"] restart: unless-stopped volumes: rippy-data: ``` **Die wichtigste Änderung gegenüber v1** steht in der Zeile mit dem Ausrufezeichen. v1 mountet SMB/NFS *im* API-Container (`mounts.py`, `CAP_SYS_ADMIN`, `DAC_READ_SEARCH`, `apparmor:unconfined`, `propagation: rshared`). Der Befund aus Etappe 22 steht in `AGENTS.md`: *„Die CIFS-Verbindung lebt in der Netz-Namespace des api-Containers und stirbt mit ihm."* Dagegen wurde eine Wache gebaut, die das selbst heilt — 8 s Wiederanbindung statt 202 s. v2 löst die Ursache statt des Symptoms: **Mounten ist Aufgabe des Hosts.** - Einfachster Weg: `/etc/fstab` bzw. eine `.mount`-Unit, dann Bind-Mount. - Bequemer Weg für Compose-Nutzer: der eingebaute Volume-Treiber — ```yaml volumes: nas-medien: driver: local driver_opts: type: cifs device: "//192.168.178.5/medien" o: "username=rippy,password=…,uid=1000,gid=1000,vers=3.0" ``` Damit verschwinden `SYS_ADMIN`, `DAC_READ_SEARCH`, `apparmor:unconfined`, `propagation: rshared` und die Mount-Wache. Der Container läuft wieder als normaler Container. **Was aus `mounts.py` bleibt:** die Prüf-Logik. `ist_erreichbar` mit `timeout N ls -d` im Kindprozess, die Unterscheidung „konnte nicht nachsehen" vs. „ist nicht da", die SMB-Klartext-Fehlermeldungen (`uebersetze_smb_fehler`) und der Pfad-Map-Vorschlag. Nur das *Ausführen* des `mount` fällt weg — stattdessen zeigt das UI eine kopierbare Anleitung für Host oder Compose. #### Verteilter Modus ```yaml services: api: image: ghcr.io/…/rippy:2 command: ["--profil", "api"] ports: ["7788:7788"] environment: RIPPY_STORE__TREIBER: postgres RIPPY_STORE__URL: postgresql://rippy:${PG_PASS}@postgres/rippy RIPPY_QUEUE__TREIBER: celery RIPPY_QUEUE__BROKER: redis://redis:6379/0 depends_on: {postgres: {condition: service_healthy}, redis: {condition: service_healthy}} ripper: # am Laufwerk image: ghcr.io/…/rippy:2 command: ["--profil", "node", "--faehigkeiten", "rip"] devices: ["/dev/sr0:/dev/sr0", "/dev/sg1:/dev/sg1"] group_add: ["cdrom"] # … Store/Queue wie oben encoder: # GPU-Maschine, ohne Laufwerk image: ghcr.io/…/rippy:2 command: ["--profil", "node", "--faehigkeiten", "transcode"] deploy: resources: reservations: devices: [{driver: nvidia, count: all, capabilities: [gpu, video]}] environment: NVIDIA_DRIVER_CAPABILITIES: video,compute,utility ``` Der `--faehigkeiten`-Schalter ist der Ersatz für v1s implizite Queue-Namen. Er landet direkt in der `faehigkeiten`-Spalte der Auftrags-Tabelle (§ 3.3) und funktioniert deshalb in *beiden* Queue-Treibern gleich. #### Hardware-Beschleunigung | Backend | Durchreichung | Prüfung beim Start | |---------|---------------|--------------------| | Intel QSV | `devices: /dev/dri`, `group_add: [render]` | `caps.erkenne_encoder()` | | AMD VAAPI | dito | dito | | Nvidia NVENC | `deploy.resources` + `NVIDIA_DRIVER_CAPABILITIES` | dito | | Apple VideoToolbox | nur nativ (kein Docker auf macOS mit GPU) | dito | `caps.py` aus v1 macht das schon richtig: Es **misst** die Encoder über `HandBrakeCLI --help`, statt sie zu behaupten. Der Kommentar dazu stammt aus Etappe 19 („Encoder wurden behauptet statt gemessen"). Das Modul wandert unverändert nach `rippy/transcode/caps.py`. Multi-Arch (`linux/amd64`, `linux/arm64`) über `docker buildx`. Einschränkung ehrlich benannt: MakeMKV liefert offiziell **kein** ARM64-Binary. Ein ARM64-Image ist damit ein reiner Encode-/API-Knoten, kein Rip-Knoten. Das gehört in die Image-Beschreibung, nicht in eine Fußnote. --- ## 5. Drive Abstraction Layer (DAL) Ein Protocol, drei Treiber. Die gesamte plattformabhängige Hardware-Logik von Rippy lebt in `rippy/drives/` und **nirgendwo sonst**. ```python # rippy/drives/base.py @dataclass(frozen=True) class Laufwerk: id: str # stabil (WWID/Serial), NICHT sr0 oder D: pfad: str # /dev/sr0 · \\.\D: anzeigename: str # "ASUS BW-16D1HT" knoten: str faehigkeiten: frozenset[str] # {"cd","dvd","bd","uhd"} class Laufwerkszustand(StrEnum): LEER = "leer"; OFFEN = "offen"; BEREIT = "bereit" LIEST = "liest"; UNBEKANNT = "unbekannt" # <- ehrlich, nicht "leer" ``` `UNBEKANNT` ist kein Schönheitsfehler, sondern die direkte Umsetzung einer v1-Lehre: *„,konnte nicht nachsehen' ist etwas anderes als ,ist nicht da'"* (`AGENTS.md`). Ein `ioctl`, das `EBUSY` liefert, darf nie als „leer" ins UI durchschlagen. | Operation | Linux | Windows | macOS *(später)* | |-----------|-------|---------|------------------| | Finden | `glob /dev/sr*` + `/sys/class/block/*/device/{vendor,model,wwid}` | `GetLogicalDrives` + `GetDriveTypeW` | `DiskArbitration` | | Zustand | `CDROM_DRIVE_STATUS` (0x5326) | `IOCTL_STORAGE_CHECK_VERIFY2` | `DADiskCopyDescription` | | Typ | `CDROM_DISC_STATUS` + `BLKGETSIZE64` → `detection.classify` | `IOCTL_CDROM_DISK_TYPE` + MMC `GET CONFIGURATION` | `drutil status` | | Verriegeln | `CDROM_LOCKDOOR` (0x5329) | `IOCTL_STORAGE_MEDIA_REMOVAL` | — | | Auswerfen | `CDROMEJECT` (0x5309) | `IOCTL_STORAGE_EJECT_MEDIA` | `DADiskEject` | | Ereignisse | udev / ioctl-Poll | `WM_DEVICECHANGE` | `DARegisterDiskAppearedCallback` | **Zwei Regeln, die für alle Treiber gelten:** 1. **Auswerfen heißt: entriegeln, auswerfen, nachsehen.** In dieser Reihenfolge, immer, auf jeder Plattform. v1 hat das für Linux nach einer Live-Messung gelernt (`devices.py:22`); der Windows-Treiber bekommt es von Anfang an. 2. **Jede Hardware-Operation läuft mit Zeitgrenze in einem Ausführer**, nie direkt im Ereignis-Loop. Ein hängendes `ioctl` auf einem defekten Laufwerk darf nicht die API blockieren — dieselbe Klasse von Fehler wie die CIFS-Blockade in v1. --- ## 6. API- & Event-Design ### 6.1 REST — von 56 flachen Routen zu 8 Routern v1s `main.py` hat 56 Routen in einer 2 254-Zeilen-Datei. v2 gruppiert sie: | Router | Präfix | Kern-Endpunkte | |--------|--------|----------------| | `jobs` | `/api/v2/jobs` | `GET /` · `POST /` · `GET /{id}` · `DELETE /{id}` · `POST /{id}/cancel` · `POST /{id}/retry` (mit `?ab=rip\|transcode`) · `GET /{id}/files` | | `drives` | `/api/v2/drives` | `GET /` · `GET /{id}` · `POST /{id}/eject` · `POST /{id}/scan` · `GET /{id}/tracks` · `PUT /{id}/profil` | | `metadata` | `/api/v2/metadata` | `GET /search` · `POST /jobs/{id}/override` · `GET /tv/{id}/season/{n}` · `GET /status` | | `storage` | `/api/v2/storage` | `GET /targets` · `GET /mounts` · `GET /browse` · `POST /mkdir` · `GET /freigaben` | | `nodes` | `/api/v2/nodes` | `GET /` · `DELETE /{name}` · `GET /{name}/capabilities` | | `system` | `/api/v2/system` | `GET /info` · `GET /version` · `GET /updates` · `GET|POST /settings` · `GET /health` | | `keys` | `/api/v2/keys` | `GET|POST|DELETE /keydb` · `GET|POST /keystore` · `GET /aacs-dumps` | | `events` | `/api/v2/events` | `GET /` (SSE, § 6.3) | Regeln: - **Ein Router ist eine Datei und ruft genau eine Kern-Funktion pro Endpunkt auf.** Keine Fachlogik in der Route. Das ist die mechanische Bremse gegen ein zweites 94-KB-`main.py`. - **`/api/v1/*` bleibt als Weiterleitungs-Schicht bestehen**, solange ein v1-Windows-Worker im Netz sein kann. Ein Umzug, bei dem der installierte Worker eines Nutzers stirbt, ist kein Umzug. - Fehler immer als `{"fehler": "…", "code": "…", "details": {…}}` — v1 mischt heute FastAPI-Standard und eigene Formen. ### 6.2 Warum SSE und nicht WebSocket | Kriterium | SSE | WebSocket | |-----------|-----|-----------| | Richtung | Server → Client | beide | | Wiederaufnahme | eingebaut (`Last-Event-ID`) | selbst bauen | | Proxy/Reverse-Proxy | normales HTTP | Upgrade nötig | | Browser-Reconnect | automatisch | selbst bauen | | Aufwand im UI | `new EventSource(...)` | Bibliothek | Rippy braucht **einen** Rückkanal: Zustand vom Server zum Browser. Kommandos gehen weiter per REST (idempotent, protokollierbar, mit CLI teilbar). Damit ist SSE die passende Wahl; WebSocket wäre Aufwand ohne Gegenwert. Sollte später etwas Bidirektionales dazukommen (Terminal-Durchreichung o. ä.), lässt sich ein WS-Endpunkt daneben stellen, ohne SSE anzufassen. ### 6.3 Event-Schema ``` GET /api/v2/events?Last-Event-ID=1042 (oder als Header) id: 1043 event: job.progress data: {"seq":1043,"ts":"2026-08-28T10:14:22Z","typ":"job.progress", "entitaet":"job","id":"a4f…","daten":{"phase":"rip","prozent":37, "eta_sekunden":1820,"laufwerk":"sr0"}} ``` Ereignistypen: | Typ | Auslöser | `daten` | |-----|----------|---------| | `snapshot` | **immer beim Verbinden** | vollständiger Zustand: Jobs, Laufwerke, Knoten, Mounts | | `drive.changed` | DAL | `{id, zustand, disc_typ}` | | `disc.inserted` / `disc.removed` | DAL | `{laufwerk_id, disc_typ, label}` | | `job.created` / `job.finished` | Kern | `{job}` | | `job.phase` | Kern | `{phase, von, nach}` | | `job.progress` | Rip/Transcode (gedrosselt, max. 1/s) | `{phase, prozent, eta_sekunden}` | | `log.line` | Log-Brücke | `{level, quelle, text, job_id?}` | | `node.seen` / `node.lost` | Lease-Wächter | `{name, encoder, last_seen}` | | `mount.changed` | Storage-Wache | `{name, erreichbar, grund}` | | `system.notice` | überall | `{level, text}` — Rate-Limit-Treffer, Plattenplatz, … | **Drei Eigenschaften, die aus v1-Schmerzen stammen:** 1. **`snapshot` zuerst, dann nur Deltas.** Ein Client, der verbindet, bekommt nie ein halbes Bild. 2. **`seq` ist monoton und lückenlos.** Ein Reconnect mit `Last-Event-ID` liefert alles Verpasste nach (Ringpuffer, § 3.5). Ist die Lücke zu groß, schickt der Server statt Deltas einen neuen `snapshot` — ausdrücklich, nicht stillschweigend. 3. **Ein Verbindungsabriss ist keine Aussage.** Das UI behält den letzten Stand und zeigt ein Banner. Es setzt **nie** eine Liste auf leer. Das ist genau der Fehler, den der Commander als *„wird oft neu geladen"* gemeldet hat und der in `AGENTS.md` als `catch(() => [])` dokumentiert ist — jetzt im Protokoll verankert statt in fünf Komponenten einzeln. ### 6.4 Was das an Last spart | | v1 (Polling) | v2 (SSE) | |---|---|---| | Dashboard | 5 Endpunkte / 4 s = 75/min | 1 offene Verbindung | | Log-Kasten | 2 / 5 s = 24/min | — (im Stream) | | Laufwerke | 1 / 5 s = 12/min | — (im Stream) | | Worker-Liste | 1 / 15 s = 4/min | — (im Stream) | | Log-Seite | 1 / 10 s = 6/min | — (im Stream) | | Windows-Tray | 1 / 5 s = 12/min | 1 offene Verbindung | | **Summe, ein Tab + ein Worker** | **≈ 133/min** | **≈ 0/min** | Das Rate-Limit (600/min) bleibt als Amok-Bremse bestehen. Aber die Rechnung im Kommentar von `ratelimit.py` muss mitgezogen werden — sonst steht dort in einem Jahr eine Begründung, die nicht mehr stimmt. **Wer die Zahl anfasst, rechnet die eigene Grundlast neu vor.** Das ist in v1 durch `test_grenze_deckt_die_eigene_last_ab` mechanisch abgesichert; der Test wandert mit und bekommt die neuen Zahlen. --- ## 7. Die neuen Features — Einordnung Kurz, weil § 1–6 die Grundlage legen. Was hier steht, ist die *Konsequenz* der Architektur, nicht eine zweite Wunschliste. ### 7.1 Multi-Drive Parallel-Ripping Fällt fast von selbst an, sobald Laufwerke erstklassige Objekte sind (§ 3.3) und Aufträge Fähigkeiten haben (§ 3.4): Ein Rip-Auftrag verlangt `{"drive":""}`, der Slot-Zähler steht in `queue.rip_slots`. **Eine Einschränkung, die gemessen gehört, bevor sie geglaubt wird:** Mehrere `makemkvcon`-Prozesse teilen sich ein Datenverzeichnis (`_private_data.tar`, `settings.conf`). Parallele Schreibzugriffe darauf sind ein Konflikt. Der Entwurf sieht deshalb vor: **Rip-Phase parallel, Schlüssel-Phase serialisiert** (ein prozessübergreifendes Schloss auf dem Datenverzeichnis). Das ist eine Annahme aus der Aktenlage — sie ist an zwei Laufwerken zu prüfen, bevor die Grenze festgeschrieben wird. ### 7.2 Zero-Click vs. Interaktiv Pro Laufwerk **und** pro Disc-Typ in `laufwerke.profil` (§ 3.3), Vorgabe aus der Konfiguration (§ 4.2). Der Ablauf ist derselbe; nur ob nach `scan` ein `bestaetigung`-Zustand eingelegt wird, unterscheidet sich. Die Zustandsmaschine bekommt genau einen zusätzlichen Zustand — keine zweite Pipeline. ### 7.3 Auto-Presets nach gemessener Hardware `caps.erkenne_encoder()` liefert bereits die Backend-Liste. v2 verheiratet sie mit dem Disc-Typ zu einem Vorschlag (UHD → verlustfrei durchreichen oder NVENC 10-bit; BD → QSV HQ; DVD → x264 mit Deinterlacing). **Vorschlag, nicht Zwang** — v1s Entscheidung „Kompression je Disc-Typ abwählbar" (Etappe 20) bleibt. ### 7.4 Audio-/Untertitel-Regeln v1 kann Sprachwahl vor dem Rip (Etappe 23). v2 macht daraus ein Regelwerk: Originalton immer, Wunschsprachen nach Liste, erzwungene Untertitel behalten, Kommentarspuren verwerfen, HD-Tonspuren durchreichen statt downmixen. Der Ort dafür existiert schon: `ripping.parse_stream_info` und `ripping.sprachen_zusammenfassen`. ### 7.5 KeyDB / AACS — Schlüsselkette in drei Stufen **Commander-Entscheid 28.08.2026:** automatischer Abruf, mit den bisherigen Wegen als Rückfallebene. Das verschiebt die Grenze aus `KONZEPT.md` § 10 (25.07.2026) bewusst — dort stand: *„Rippy liefert, lädt und verteilt KEINE Disc-Schlüssel."* Die Entscheidung ist getroffen und wird hier dokumentiert, nicht still vollzogen (`AGENTS.md` Regel B). Vor einem UHD-Rip arbeitet Rippy drei Stufen der Reihe nach ab und **hält an, sobald eine trägt**: | Stufe | Quelle | Wann | |-------|--------|------| | **1** | **Eigener Bestand** — Schlüsselspeicher im Store | immer zuerst. Gefüllt vom Windows-Knoten (§ 4.1, MakeMKV holt dort selbst) und von jedem Import | | **2** | **Automatischer Abruf** — konfigurierte Quelle | wenn Stufe 1 die Disc nicht kennt | | **3** | **Import von Hand** — `_private_data.tar`, `KEYDB.cfg` über das UI | wenn Stufe 2 nichts liefert. Unverändert aus v1 | Stufe 1 zuerst ist keine Höflichkeit gegenüber der alten Grenze, sondern das schnellere Verfahren: Was der eigene Windows-Rechner schon geholt hat, ist da — ein Netzabruf dafür wäre reine Wartezeit. **Fünf Regeln für Stufe 2**, alle aus v1-Lehren abgeleitet: 1. **Die Adresse steht in der Konfiguration, nicht im Quelltext.** `keys.quelle_url` ist leer vorbelegt; ohne Eintrag ist Stufe 2 schlicht übersprungen. Grund steht in `.env.example`: *„eine Adresse, die nicht liefert, lässt die Konfiguration gesund aussehen und den Bau später scheitern."* Eine vorbelegte Adresse, die irgendwann tot ist, wäre genau dieselbe Falle. 2. **Ein funktionierender Bestand wird nie still überschrieben.** Neue Datei kommt daneben, wird geprüft (Größe, Format, parsebar), und erst dann getauscht. Der vorherige Stand bleibt eine Version lang liegen. 3. **Ein Fehlschlag ist laut.** Kein `except: pass`. Ergebnis, Zeitpunkt und Quelle landen im Log und als `system.notice`-Ereignis im UI (`AGENTS.md`: *„Ein Hintergrund-Prozess, der still scheitert, ist schlimmer als einer, der laut scheitert"*). 4. **Das UI zeigt Herkunft und Alter jedes Eintrags.** Woher, wann, wie viele Discs — dieselbe Ehrlichkeit, die v1 für den Schlüsselspeicher schon hat. 5. **Rippy bringt selbst nichts mit.** Weder Installer noch Docker-Image enthalten Schlüssel oder eine vorbelegte Bezugsadresse. Was abgerufen wird, bestimmt der Betreiber der Installation. Die Weitergabe **innerhalb der eigenen Installation** (Windows-Knoten → Linux-Knoten) bleibt wie in § 4.1 beschrieben und ist von Stufe 2 unabhängig — sie funktioniert auch, wenn `keys.quelle_url` leer bleibt. ### 7.6 Serien-Intelligenz & Anime v1 kann Laufzeitabgleich gegen TMDb (`medien.matche_episoden`) und hat einen Jikan-Client (`clients/jikan.py`). v2 zieht beides in `metadata.matching` zusammen und ergänzt AniList als zweite Anime-Quelle. Die v1-Regel bleibt: **umbenannt wird nur bei eindeutiger Zuordnung** (`KONZEPT.md` § 10). ### 7.7 Medienserver-Push `medien.bibliothek_refresh` existiert für Jellyfin/Emby/Plex. v2 macht daraus einen Auftrag mit Wiederholung statt eines Aufrufs am Ende der Pipeline — heute schlägt ein Refresh still fehl, wenn Jellyfin gerade neu startet. ### 7.8 FFmpeg-Direktpfad Als zweiter Transcode-Adapter neben HandBrake, für Remux ohne Neukodierung (Container wechseln, Spuren filtern — Sekunden statt Stunden). Auswahl über `transcode.engine = "handbrake" | "ffmpeg"`, Vorgabe bleibt HandBrake. --- ## 8. Migrationsplan ### 8.1 Grundregel > **Jede Etappe endet mit grüner Ampel und einem lauffähigen System.** > Kein „großer Umbau", nach dem monatelang nichts geht. Der Weg ist so geschnitten, dass v1 bis Etappe 5 **produktiv weiterläuft**. Das ist nicht Vorsicht, sondern Notwendigkeit: Auf der VM liegen echte Medien, und `AGENTS.md` Regel A gilt unverändert. ### 8.2 Was aus v1 wiederverwendet wird Die gute Nachricht zuerst: **Der Fachkern ist bereits weitgehend reine Logik mit Tests.** Von etwa 6 600 Zeilen Python im Repo wandert der größte Teil unverändert. **Unverändert übernehmen** (reine Funktionen, Tests wandern mit): | v1 | → v2 | Tests | |----|------|-------| | `ripping.py` — Parser & Kommandobau (`parse_tinfo_dauern`, `parse_titel_info`, `parse_stream_info`, `get_progress_from_prgv`, `get_progress_from_line`, `build_*_cmd`, `laengster_titel`, `episoden_titel`) | `rip/parser.py`, `rip/makemkv.py`, `rip/abcde.py` | `test_ripping_helpers.py` (24 KB) | | `caps.py` | `transcode/caps.py` | `test_caps.py` | | `presets.py` | `transcode/presets.py` | `test_presets.py` | | `medien.py` | `library/struktur.py`, `library/nfo.py`, `library/mediaserver.py` | `test_medien.py` | | `prescan/prescan.py` | `metadata/prescan.py` | `test_prescan_helpers.py` | | `clients/*` (tmdb, tvdb, omdb, musicbrainz, jikan) | `metadata/clients/*` | `test_tmdb_helpers.py`, `test_jikan_helpers.py` | | `eta.py`, `phasen.py`, `verwaltung.py` | `core/eta.py`, `core/phasen.py`, `core/verwaltung.py` | `test_eta.py`, `test_phasen.py`, `test_verwaltung.py` | | `winlauf.py` | `platform/winlauf.py` | — | | `ratelimit.py` | `api/ratelimit.py` | `test_ratelimit.py` | **Zusammenführen** (heute doppelt im Repo): | Duplikat | → v2 | |----------|------| | `api/detection.py` + `worker/detection.py` (byte-identisch) | `drives/detection.py` | | `api/makemkv_daten.py` + `worker/makemkv_daten.py` (byte-identisch) | `rip/makemkv_daten.py` | | `api/notify.py` + `worker/notify.py` (byte-identisch) | `core/notify.py` | | `api/db.py` + `worker/db.py` (überlappend) | `store/schema.py` + `store/sqlite.py` / `store/postgres.py` | **Umbauen:** | v1 | Umfang | → v2 | |----|--------|------| | `main.py` (2 254 Z, 56 Routen) | groß | 8 Router + `core/pipeline.py`; die Fachlogik wandert *aus* den Routen heraus | | `tasks.py` (993 Z) | mittel | `core/pipeline.py` (Ablauf) + `queue/*` (Zustellung); die Celery-Dekoratoren fallen weg | | `mounts.py` (20 KB) | mittel | `storage/mounts.py` — **Prüf-Logik bleibt, `mount`-Ausführung fällt weg** (§ 4.3) | | `devices.py` | klein | `drives/linux.py` | | `zombies.py` (+ 263 Z Tests) | ersetzt | Lease-Mechanik (§ 3.1) — die Tests werden zu Lease-Tests | | `logbruecke.py` | klein | `bus/` — aus Log-Weiterleitung wird ein Ereignistyp | | `gui.py` (24 KB Flet) | ersetzt | `platform/tray.py` (pystray) + WebView2 auf dieselbe Web-UI | | UI: `Settings.tsx` (1 477 Z), `Dashboard.tsx` (920 Z) | groß | Aufteilen; Polling-Haken → ein `useEventStream` | **Neu:** `drives/windows.py` · `drives/darwin.py` · `store/migrations/` (Alembic) · `queue/lokal.py` · `bus/*` · `config.py` · `cli/*` · `api/routers/events.py` · `packaging/*` ### 8.3 Etappen | # | Titel | Inhalt | Fertig, wenn | |---|-------|--------|--------------| | **V2-0** | **Monorepo** | `src/rippy/` anlegen, Duplikate zusammenführen, Tests mitziehen. **Verhalten unverändert** — Container importieren aus dem neuen Paket. | Ampel grün, `docker compose up` verhält sich wie vorher, kein Modul mehr doppelt | | **V2-1** | **Ports einziehen** | `Store`/`Queue`/`Bus`/`Drives` als Protocol; v1-Verhalten läuft über Postgres-/Celery-/Redis-/Linux-Treiber. Kein Funktionsgewinn — reine Verdrahtung. | Ampel grün; ein Rip auf der VM läuft durch | | **V2-2** | **Standalone** | SQLite-Treiber, Alembic, LocalQueue, Lease. `rippyd --profil standalone` auf blankem Linux. | Ein DVD-Rip komplett ohne Postgres/Redis/Docker | | **V2-3** | **Echtzeit** | Bus-Treiber, `/api/v2/events`, UI auf `useEventStream`. Polling raus. | Grundlast ≈ 0/min gemessen; Verbindungsabriss leert keine Liste | | **V2-4** | **Windows** | `drives/windows.py`, Dienst + Tray + WebView2, Nuitka-Bau, Inno-Setup. | Frischer Win-11-Rechner: Installer → Disc rein → MKV raus. **Und: UHD-Schlüssel automatisch** | | **V2-5** | **Docker neu** | Ein Image, drei Profile, GPU-Durchreichung, **Host-Mounts statt Container-Mounts**. | All-in-One und verteilt laufen; `SYS_ADMIN`/`apparmor:unconfined` sind weg | | **V2-6** | **CLI & Pakete** | `rippy status/drives/rip/queue/doctor`, systemd-Units, `.deb`/AppImage. | Headless-Server ohne Browser bedienbar | | **V2-7** | **Neue Features** | § 7.1–7.8, darin die **Schlüsselkette in drei Stufen** (§ 7.5, Entscheid 2) | je Feature ein Nachweis | Etappen 0–1 ändern **kein** beobachtbares Verhalten. Das ist der Preis dafür, dass 2–7 klein bleiben, und er ist es wert: Nach V2-1 ist jeder weitere Modus eine Treiber-Datei, kein Umbau. ### 8.4 Was in v1 bleibt und nicht wandert - **`install.sh`** — bleibt für Modus 3 (Docker), verliert die Mount-Schritte. - **`deploy.sh`** und die Gitea-CI — unverändert; die Ampel prüft ab V2-0 das neue Paket mit. - **Die alte Compose-Datei** — bleibt bis V2-5 als lauffähiger Rückweg liegen. --- ## 9. Risiken & offene Punkte | Risiko | Bewertung | Behandlung | |--------|-----------|------------| | **Umbau bricht die laufende VM** | hoch × mittel | V2-0/V2-1 ändern kein Verhalten; alte Compose bleibt bis V2-5 | | **SQLite unter Last** | mittel × niedrig | WAL + `busy_timeout`; ein Schreiber-Kontext. Grenze (~4 Rip-Knoten) dokumentiert, darüber Postgres | | **Nuitka/PyInstaller + Virenscanner** | mittel | One-dir statt one-file; signierte EXE erwägen; Ausnahme-Anleitung in der Doku | | **Win32-DAL auf fremder Hardware** | mittel | Nur die IOCTLs benutzen, die im MS-Storage-Stack dokumentiert sind; `UNBEKANNT` statt Raten; auf ≥ 2 Laufwerken prüfen | | **Parallel-Rip vs. MakeMKV-Datenverzeichnis** | mittel | § 7.1 — **messen, bevor die Grenze festgeschrieben wird** | | **MakeMKV hat kein ARM64-Binary** | niedrig × sicher | ARM64-Image = Encode/API-Knoten, in der Image-Beschreibung benannt | | **SSE hinter fremdem Reverse-Proxy** | niedrig | `X-Accel-Buffering: no`, Heartbeat-Kommentar alle 15 s, Doku-Abschnitt | | **Zwei Queue-Treiber divergieren** | mittel | Ein gemeinsamer Vertragstest läuft gegen **beide** Treiber — die Testsuite ist die Bremse | | **Mount-Passwörter im Klartext** (v1: `storage_mounts.passwort`) | mittel | v2: Verweis auf OS-Schlüsselspeicher (DPAPI / libsecret / Datei mit 0600). Heimnetz-Kompromiss aus v1 wird damit aufgelöst | | **KeyDB-Quelle wird irgendwann tot sein** | mittel × sicher | § 7.5 Regel 1–3: Adresse in der Konfiguration, Bestand wird nie still überschrieben, Fehlschlag ist laut. Stufe 1 und 3 tragen weiter | | **Verantwortung für die Bezugsquelle** | Betreiber-Sache | § 7.5 Regel 5: Rippy bringt weder Schlüssel noch eine vorbelegte Adresse mit. Was abgerufen wird, trägt die Installation ein | --- ## 10. Getroffene Entscheidungen Alle drei offenen Fragen sind am **28.08.2026 vom Commander entschieden**. ### Entscheid 1 — Reihenfolge: Echtzeit vor Windows Der Plan bleibt wie in § 8.3: V2-3 (Polling raus, SSE rein) kommt **vor** V2-4 (Windows). Begründung, die dafür gesprochen hat: Der Windows-Modus braucht LocalQueue und SQLite ohnehin; in dieser Reihenfolge wird er eine Treiber-Datei statt eines zweiten Sonderwegs. Praktische Folge für den Alltag: Das ständige Neuladen im UI verschwindet zuerst. **Keine Änderung am Plan.** ### Entscheid 2 — KeyDB: automatischer Abruf mit Rückfallebene Der Commander hat sich für den automatischen Abruf entschieden, **mit den bisherigen Wegen als Rückfallebene**. Damit wird die Grenze aus `KONZEPT.md` § 10 (25.07.2026) — *„Rippy liefert, lädt und verteilt KEINE Disc-Schlüssel"* — bewusst verschoben. Umgesetzt als Kette in drei Stufen (§ 7.5): eigener Bestand → automatischer Abruf → Import von Hand. Die fünf Regeln dort sind Teil des Entscheids, nicht Beiwerk — insbesondere: **die Bezugsadresse steht in der Konfiguration und ist leer vorbelegt**, und Rippy bringt weder im Installer noch im Docker-Image Schlüssel oder eine vorbelegte Adresse mit. **Folge für den Plan:** Etappe V2-7 bekommt „Schlüsselkette in drei Stufen" als eigenen Punkt. `KONZEPT.md` § 10 braucht eine Fortschreibung mit diesem Datum — sonst widersprechen sich die beiden Dokumente. ### Entscheid 3 — NAS-Ordner: Zeile zum Kopieren v2 nimmt der Anwendung das Mounten aus der Hand (§ 4.3): Der Host mountet, Rippy prüft und meldet. Statt des Knopfes im Browser zeigt das UI eine **fertige, kopierbare Zeile** — für `/etc/fstab`, für eine `.mount`-Unit oder als `volumes:`-Block für die `compose.yml`, passend zum erkannten Betriebsmodus. **Folge für den Plan:** Die Prüf- und Übersetzungs-Logik aus `mounts.py` bleibt vollständig erhalten (Erreichbarkeit mit Zeitgrenze, SMB-Klartextfehler, Pfad-Map-Vorschlag) und bekommt einen neuen Nachbarn: einen Generator, der aus den eingegebenen Daten die passende Zeile baut. Das UI behält also seine Eingabemaske — nur der Knopf „Verbinden" wird zu „Zeile kopieren". **Konsequenz, die dazugehört:** `SYS_ADMIN`, `DAC_READ_SEARCH`, `apparmor:unconfined`, `propagation: rshared` und die Mount-Wache fallen in V2-5 ersatzlos weg. ### Entscheid 4 — Echtes Fenster statt Browser-Tab, aber ohne Electron Nachgefragt vom Commander am 28.08.2026: > „Warum nutzen wir für Windows weiterhin einen Browser? Warum nutzen wir kein > Electron oder sowas und machen daraus einen echten Client?" Die erste Hälfte trifft zu und wird umgesetzt: Ein Browser-Tab ist kein Client. Er hat eine Adresszeile, die niemand braucht, liegt zwischen fremden Tabs, hat kein eigenes Symbol in der Taskleiste, und wer den Browser schließt, glaubt, er habe Rippy beendet. Die zweite Hälfte wird **abgelehnt**, und zwar gemessen statt geschätzt: | Weg | Zusatz zum Paket | Was mitkommt | |-----|------------------|--------------| | **pywebview + WebView2** | **8,0 MB** | nur die Anbindung; der Renderer liegt schon auf dem System | | Electron | ~150–210 MB | ein komplettes zweites Chromium **und** eine zweite Laufzeitumgebung neben Python | Windows 11 liefert die WebView2-Laufzeit mit. Auf dem Rechner des Commanders am 28.08.2026 nachgesehen: **151.0.4129.107**. Und nachgemessen, was sie wirklich rendert — nicht angenommen: ``` navigator.userAgent …Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0 fetch ja EventSource ja CSS Grid ja Pfeilfunktionen ja ``` Das ist dasselbe Chromium, das auch in Electron steckt. Rippy bekommt also denselben Renderer, nur ohne ihn ein zweites Mal mitzuschleppen. Damit bleibt auch § 4.1 unverändert gültig — dort stand WebView2 von Anfang an. **Drei Dinge gehören zum Entscheid, nicht als Beiwerk:** 1. **Kein stiller Rückfall auf MSHTML.** `pywebview` kann unter Windows auch den alten IE-Renderer nehmen; der stellt die React-Oberfläche nicht dar. Fehlt die WebView2-Laufzeit, öffnet Rippy **den Browser** und sagt warum — ein leeres Fenster wäre schlimmer als ein Tab. 2. **Fenster und Tray sind getrennte Prozesse.** `pystray` belegt unter Windows den Haupt-Thread, das WebView2-Fenster braucht ihn genauso; zwei Nachrichtenschleifen passen nicht in einen Thread. `--dienst` hält Server und Tray, `--oeffnen` ist der Client davor. Beide heißen im Taskmanager `Rippy.exe`, und das Fenster lässt sich schließen, ohne den Dienst mitzureißen. 3. **Die eigene Konsole wird versteckt, eine geerbte nie.** `Rippy.exe` ist ein Konsolenprogramm, weil der Installer seine Meldungen zeigen muss. Beim Doppelklick auf das Desktop-Symbol blitzte damit erst eine schwarze Box auf. `GetConsoleProcessList` unterscheidet beides: genau ein Prozess an der Konsole heißt, sie gehört uns. **Folge für den Plan:** Kein neuer Etappen-Punkt — das ist Teil von V2-4 und dort erledigt. `src/rippy/fenster.py` trägt die vollständige Begründung. ### Entscheid 5 — Die Werkzeuge gehören ins Setup, nicht in eine Fehlermeldung Der Commander am 28.08.2026: > „handbrake und makemkv MÜSSEN mitgeliefert werden oder während des Setups > separat installiert werden! Ohne das ist das tool NICHT einsatzfähig" Er hat recht, und die Lücke war real: `katalog.py` konnte die Werkzeuge finden, `beschaffen.py` konnte sie holen — **das Setup rief beides nie auf.** Wer Rippy auf einem frischen Rechner installierte, bekam eine Oberfläche, die ihm mitteilte, was fehlt, und keinen Weg, es zu ändern. Die beiden gehen unterschiedliche Wege, und der Grund ist die **Lizenz**, nicht die Bequemlichkeit: | | Weg | Warum | |---|---|---| | **HandBrakeCLI** | **mitgeliefert** (+35 MB) | GPL-2 erlaubt die Weitergabe ausdrücklich, solange Lizenztext und Quellverweis dabei sind. `LIZENZ-HandBrake.txt` liegt daneben. Damit komprimiert Rippy auch ohne Internet. | | **MakeMKV** | **beim Einrichten geholt** | Proprietär — die Lizenz erlaubt Dritten keine Weitergabe. Rippy lädt die offizielle Datei vom Hersteller und startet dessen Installer. Der Nutzer bezieht sie also weiterhin von MakeMKV; Rippy nimmt ihm nur die Handgriffe ab. | Das deckt sich mit § 4.1 („MakeMKV wird NICHT mitgeliefert") und mit der Black-Box-Trennung aus `KONZEPT.md` § 6. Beides bleibt gültig. **Drei Regeln, die zum Entscheid gehören:** 1. **Ein Setup meldet nie Erfolg, während ein Pflichtwerkzeug fehlt.** `einrichten.sicherstellen()` liest den Bestand VOR und NACH dem Versuch und gibt zurück, was danach wirklich da ist — nicht, dass es versucht wurde. 2. **Ein nicht erreichbarer Download bricht die Installation nicht ab.** Am 28.08.2026 antwortete makemkv.com mit HTTP 525. Rippy ist dann trotzdem installiert und sagt im Klartext, was fehlt und wie man es beschafft. 3. **Ohne HandBrake ist Rippy einsatzbereit, ohne MakeMKV nicht.** Ein Rip läuft ohne Kompression durch — das ist ein vorgesehener Betriebsfall. Ohne MakeMKV ist Rippy ein Anzeigeprogramm. Nur `pflicht`-Werkzeuge entscheiden über „einsatzbereit". **Nicht im Repo:** Die 70,9 MB HandBrakeCLI holt der **Bau** nach `dist/windows/vendor/` (nicht versioniert) — dasselbe Muster wie der `vendor/`-Ordner für MakeMKV auf der VM. Ein Binärblob in git wäre bei jedem Klon dabei und bei jedem Update ein zweites Mal. ### Entscheid 6 — Die Oberfläche kennt ihren Betrieb, und das Setup fragt Zwei Befunde des Commanders am 28.08.2026, beide am selben Punkt: > „Du hast ja quasi nur rippy genommen und die docker installation für Windows > gebaut. […] Das gilt für die ganze standalone version für Windows, auch für > die settings und die Anleitung usw." > „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — > wo will ich das hinspeichern, nen pre requirement check usw. Da kommt > garnichts Rippy geht einfach auf." #### 6a — Fähigkeiten statt Modus-Name Im Windows-Fenster stand `Worker erreichbar: 0 von 1`, `Container-Platte: unbekannt` und `Prüfen: docker compose ps`. Kein Satz davon ergibt dort einen Sinn. Die naheliegende Reparatur wäre ein `if (windows)` an dreißig Stellen gewesen — dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung. Es fehlte etwas anderes: **Das UI hat nie erfahren, worauf es läuft.** `GET /betrieb` meldet deshalb **Fähigkeiten**, keinen Namen: externe_worker Gibt es andere Maschinen, die Jobs übernehmen? freigaben_einhaengen Kann Rippy Netzwerk-Freigaben selbst einhängen? container_pfade Sind Pfade wie /app/media überhaupt gemeint? werkzeuge_verwalten Kann Rippy MakeMKV/HandBrake selbst beschaffen? Ein Modus-Name würde das UI zwingen, aus einem Namen auf Verhalten zu schließen — und das bricht beim nächsten Betriebsfall: Ein Docker-All-in-One hat Container-Pfade, aber keinen zweiten Worker. #### 6b — Ein Setup, das prüft und fragt Acht Voraussetzungs-Prüfungen, zwei Ordner-Wahlen (Programm **und** Ablage), Port, drei Schalter. Drei Regeln: 1. **Nur ein Fehler blockiert.** Eine Warnung, die den Knopf sperrt, ist eine Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Kein optisches Laufwerk ist eine Warnung — eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall. 2. **Jeder Befund sagt, was zu tun ist.** Ein Test hält das für jede Prüfung fest. 3. **Die Prüfungen kennen kein Fenster.** Sie stehen in `einrichtung.py` und sind vollständig ohne WebView2 prüfbar; im Fenstermodul steht nur Aufbau und Brücke. WebView2 statt Win32-Dialog: Es ist wegen Entscheid 4 ohnehin da — der Assistent kostet **null zusätzliche Bytes**. Die Seite lädt nichts aus dem Netz; sie muss auf einem Rechner ohne Internet aufgehen. #### 5a — Ausweichquellen für MakeMKV (28.08.2026, nachgetragen) Der Commander: *„bitte baue für MakeMKV fallback seiten ein."* Anlass war ein echter Ausfall. Gemessen: https://www.makemkv.com/download/ HTTP 525 (dauerhaft) https://forum.makemkv.com/ Zeitablauf / 522 / OK (wechselnd) web.archive.org/web/2025id_/…exe HTTP 200, 16.432.607 Bytes 525/522 heißen: Cloudflare erreicht den Ursprungsserver nicht. Eine Störung beim Hersteller, nichts, was Rippy reparieren könnte. **Die Kette, in dieser Reihenfolge:** | | Version | Datei | |---|---|---| | eigene Quelle (`basis`) | — | zuerst, wer sie einträgt hat sich etwas dabei gedacht | | Hersteller-Seite | **maßgeblich** — wer sie erreicht, sucht nicht weiter | ja | | Hersteller-Forum | ja (Ankündigungs-Bereich) | — | | Internet Archive | ja (Rückfall) | ja | Zwei Feinheiten, beide aus Messungen: * **Die höchste Nummer gewinnt, nicht die erste.** Antwortet das Forum nicht, meldet das Archiv einen älteren Stand (1.18.2 statt 1.18.4) — „neueste Fassung" wäre dann eine falsche Aussage. * **Ein zweiter Anlauf, mit knapper Zeitgrenze.** Drei Abrufe am Forum: Zeitablauf, HTTP 522, dann Erfolg. Ohne Wiederholung fiel die Quelle in zwei von drei Fällen aus; mit 20-Sekunden-Grenzen dauerte der schlimmste Fall 46 Sekunden, in denen das Setup eingefroren aussah. Jetzt: acht Sekunden je Versuch, schlimmstenfalls gut zwanzig insgesamt. **Und warum das trotzdem sicher ist.** Der naheliegende Schutz geht nicht: MakeMKV **signiert seinen Installer nicht** (`Get-AuthenticodeSignature` → `NotSigned`, an der echten Datei gemessen). Eine Signaturprüfung wäre eine, die immer fehlschlägt. Geprüft wird stattdessen die Versions-Ressource: CompanyName GuinpinSoft inc FileDescription MakeMKV installer FileVersion v1.18.4 Das ersetzt keine Signatur — wer die Datei fälscht, fälscht auch die Ressource. Es fängt aber zuverlässig ab, was hier wirklich droht: eine Fehlerseite mit `.exe`-Namen, ein abgebrochener Download, eine falsche Fassung. Und Rippy nennt hinterher die Quelle, aus der die Datei kam. **Die Grenze aus `KONZEPT.md` § 6 bleibt unangetastet:** Rippy liefert MakeMKV weiterhin NICHT mit. Es holt die Datei des Herstellers — im Rückfall aus einem Archiv, das genau diese Datei aufbewahrt. --- ### Was jetzt noch fehlt, bevor gebaut wird - **`KONZEPT.md` § 10 fortschreiben** — Entscheid 2 widerspricht dem Eintrag vom 25.07.2026. Solange beide Dokumente nebeneinander stehen, ist unklar, welches gilt. - **`ROADMAP.md`** — Etappen V2-0 bis V2-7 eintragen. - **`SAVEPOINT.md`** — Stand festhalten, damit die nächste Sitzung nicht bei null anfängt. --- ## Anhang A — Fortschreibung von `KONZEPT.md` Dieses Dokument **streicht kein Muss-Feature** aus `KONZEPT.md` § 4. Es ändert die Umsetzung an vier Stellen; alle vier sind oben begründet: | `KONZEPT.md` sagt | v2 macht | § | |-------------------|----------|---| | „React-UI (statisch via Nginx)" | statisch via FastAPI `StaticFiles`; nginx optional | 4.2 | | „Celery-Queue + Redis mit AOF-Persistence" | im **verteilten** Modus unverändert; standalone LocalQueue | 3.4 | | „PostgreSQL für Job-Logs" | im **verteilten** Modus unverändert; standalone SQLite/WAL | 3.2 | | „Disc-Erkennung via udev-Event" (in v1 zu ioctl-Poll geändert) | udev **wieder** möglich (nativ), Poll als Rückfallebene | 4.2 | Neu hinzu kommen die drei Betriebsmodi (§ 4), der DAL (§ 5) und das Ereignis-Protokoll (§ 6) — alles Ergänzungen, keine Ersetzungen.