Files
rippy/KONZEPT-V2.md
T
HitonabiandClaude Opus 5 20675a98fa docs: Konzept fuer Rippy v2 + drei Commander-Entscheide (28.08.2026)
WAS: KONZEPT-V2.md neu — Systemarchitektur, Daten-/Queue-Strategie, die
drei Betriebsmodi, API-/Event-Design, Migrationsplan V2-0 bis V2-7.
KONZEPT.md §10 und ROADMAP.md ziehen nach.

WARUM: Rippy soll drei Betriebsarten bekommen statt einer — Docker,
native Windows-App ohne Docker, Headless-Linux-Dienst; alle mit
demselben Webinterface. Das traegt technisch nur, wenn es EINEN Kern
gibt, dessen Betriebsmodus nur die Auswahl der Treiber hinter vier
Ports ist (Store, Queue, Bus, Drives).

Drei Entscheide des Commanders sind eingearbeitet:

- Reihenfolge: Echtzeit (SSE statt Polling) VOR der Windows-App. Die
  Windows-App braucht SQLite und die lokale Queue ohnehin.
- Disc-Schluessel: automatischer Abruf MIT Rueckfallebene, als Kette in
  drei Stufen (eigener Bestand -> Abruf -> Import von Hand). Das
  verschiebt die Grenze aus KONZEPT.md §10 vom 25.07.2026 bewusst —
  deshalb steht sie dort jetzt ausdruecklich fortgeschrieben statt
  still ersetzt (AGENTS Regel B). Bezugsadresse leer vorbelegt,
  Fehlschlag laut, Bestand wird nie still ueberschrieben.
- Speicherziele: der Host mountet, Rippy prueft und erzeugt die
  kopierbare Zeile. Raeumt die URSACHE der CIFS-Ausfaelle ab (Befund
  26.07.2026: die Verbindung lebt in der Netz-Namespace des
  api-Containers und stirbt mit ihm) statt weiter das Symptom zu
  heilen. SYS_ADMIN, DAC_READ_SEARCH, apparmor:unconfined und die
  Mount-Wache fallen damit weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:03 +02:00

1281 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
|---|-------|
| 12 | Wie ist v2 aufgebaut, und warum genau so? |
| 3 | Wie kann dieselbe Anwendung mit SQLite allein UND mit Postgres+Redis+Remote-Workern laufen? |
| 45 | Wie sieht das auf Windows, auf einem nackten Linux-Server und in Docker konkret aus? |
| 6 | Welche Endpunkte und welche Live-Ereignisse gibt es? |
| 710 | 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)<br/>SSE-Client"]
CLI["rippy CLI"]
TRAY["Tray / WebView2<br/>(nur Windows)"]
end
subgraph APP["rippyd — eine Anwendung, drei Profile"]
API["api — FastAPI-Router<br/>(dünn, keine Fachlogik)"]
CORE["core — Pipeline &amp; 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<br/>(Tabelle + Pool)"]
CELERY["Celery / Redis"]
MEMBUS["asyncio-Bus"]
REDISBUS["Redis Pub/Sub"]
end
subgraph HW["Außenwelt"]
DRIVE["Optische Laufwerke<br/>/dev/sr* · \\\\.\\D:"]
TOOLS["makemkvcon · HandBrakeCLI<br/>abcde · ffmpeg"]
NET["TMDb · TVDb · MusicBrainz<br/>Jellyfin/Emby/Plex"]
FS["Medien-Ablage<br/>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** | Alembic, ein Verzeichnis, beide Dialekte |
| 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 |
**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
3090-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 → 1N 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 <job-id> --ab transcode # gezielt neu ab einer Phase
rippy logs --follow --job <job-id> # 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 § 16 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":"<laufwerk-id>"}`, 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.17.8, darin die **Schlüsselkette in drei Stufen** (§ 7.5, Entscheid 2) | je Feature ein Nachweis |
Etappen 01 ändern **kein** beobachtbares Verhalten. Das ist der Preis dafür,
dass 27 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 13: 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.