# 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.
---
### 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.