diff --git a/KONZEPT-V2.md b/KONZEPT-V2.md
new file mode 100644
index 0000000..fd95685
--- /dev/null
+++ b/KONZEPT-V2.md
@@ -0,0 +1,1280 @@
+# 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** | 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
+ 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.
diff --git a/KONZEPT.md b/KONZEPT.md
index 1a0726f..e67f3b6 100644
--- a/KONZEPT.md
+++ b/KONZEPT.md
@@ -220,3 +220,52 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
EINDEUTIGER Zuordnung wird umbenannt).
+- **28.08.2026 — DISC-SCHLÜSSEL: die Grenze vom 25.07. ist verschoben
+ (Commander-Entscheid).** Der Eintrag vom 25.07.2026 oben sagt: *„Rippy
+ liefert und verteilt KEINE Disc-Schlüssel und lädt auch keine herunter."*
+ **Das gilt ab jetzt nicht mehr unverändert.** Auf ausdrückliche Entscheidung
+ des Commanders bekommt Rippy v2 einen automatischen Abruf — mit den
+ bisherigen Wegen als Rückfallebene. Umgesetzt als Kette in DREI Stufen, die
+ der Reihe nach abgearbeitet wird und anhält, sobald eine trägt:
+
+ 1. **Eigener Bestand** (Schlüsselspeicher der Installation) — immer zuerst.
+ Gefüllt vom Windows-Knoten, der die Schlüssel über die eigene
+ MakeMKV-Lizenz selbst abruft (unter Linux tut `makemkvcon` das nie,
+ Messung 25.07.2026), und von jedem Import.
+ 2. **Automatischer Abruf** von der konfigurierten Quelle — wenn Stufe 1
+ die Disc nicht kennt.
+ 3. **Import von Hand** (`_private_data.tar`, `KEYDB.cfg` über das UI) —
+ unverändert aus v1.
+
+ Fünf Regeln gehören zum Entscheid dazu: (a) die Bezugsadresse steht in der
+ KONFIGURATION und ist LEER vorbelegt — ohne Eintrag ist Stufe 2
+ übersprungen; eine vorbelegte Adresse, die irgendwann tot ist, wäre genau
+ die Falle aus `.env.example` („lässt die Konfiguration gesund aussehen und
+ den Bau später scheitern"). (b) Ein funktionierender Bestand wird NIE still
+ überschrieben — neue Datei daneben, prüfen, dann tauschen. (c) Ein
+ Fehlschlag ist LAUT (kein `except: pass`, Meldung im Log und im UI).
+ (d) Das UI zeigt Herkunft und Alter jedes Eintrags. (e) **Rippy bringt
+ selbst nichts mit** — weder Installer noch Docker-Image enthalten Schlüssel
+ oder eine vorbelegte Bezugsadresse; was abgerufen wird, trägt der Betreiber
+ der Installation ein. Ausführlich in `KONZEPT-V2.md` § 7.5 und § 10.
+- **28.08.2026 — SPEICHERZIELE: der Host mountet, Rippy prüft
+ (Commander-Entscheid).** Das Muss-Feature „NFS/Bind-Mount für Medien-Store"
+ (§ 6) bleibt; was wegfällt, ist das Mounten DURCH Rippy. Begründung ist der
+ Befund vom 26.07.2026: Die CIFS-Verbindung lebt in der Netz-Namespace des
+ api-Containers und stirbt mit ihm — dagegen läuft heute eine Mount-Wache.
+ In v2 hängt der Host ein (fstab, `.mount`-Unit oder Compose-Volume-Treiber),
+ und Rippy erzeugt dafür die fertige, kopierbare Zeile. Die Eingabemaske im
+ UI bleibt; der Knopf „Verbinden" wird zu „Zeile kopieren". Die gesamte
+ PRÜF-Logik aus `mounts.py` bleibt erhalten (Erreichbarkeit mit Zeitgrenze
+ im Kindprozess, SMB-Klartextfehler, Pfad-Map-Vorschlag). Folge:
+ `CAP_SYS_ADMIN`, `DAC_READ_SEARCH`, `apparmor:unconfined`,
+ `propagation: rshared` und die Mount-Wache fallen ersatzlos weg.
+- **28.08.2026 — RIPPY v2: drei Betriebsmodi statt eines
+ (Commander-Auftrag).** Die Multi-Container-Architektur aus § 6 bleibt als
+ EINER von drei Modi bestehen. Dazu kommen eine native Windows-Installation
+ ohne Docker und eine Headless-Linux-Anwendung — beide mit demselben
+ Webinterface. Technisch tragen alle drei denselben Kern; ein Modus ist nur
+ die Auswahl der Treiber hinter vier Ports (Store, Queue, Bus, Drives).
+ Damit sind Postgres und Redis für Einzelinstallationen keine Pflicht mehr
+ (SQLite + lokale Queue), bleiben im verteilten Modus aber unverändert.
+ Vollständige Spezifikation: **`KONZEPT-V2.md`**, Etappen in `ROADMAP.md`.
diff --git a/ROADMAP.md b/ROADMAP.md
index 3a73866..b8f1256 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -570,8 +570,105 @@ heruntergerechnet und der Rohschnitt gelöscht worden (`keepOriginal: False`).
---
+## Rippy v2 — Etappen V2-0 bis V2-7 (Plan vom 28.08.2026)
+
+**Vollständige Spezifikation: `KONZEPT-V2.md`.** Hier stehen nur die Etappen
+und ihre Abnahmekriterien.
+
+**Ziel:** drei Betriebsmodi statt eines — Docker (wie heute, aufgeräumt), eine
+native Windows-App ohne Docker, eine Headless-Linux-Anwendung. Alle drei mit
+demselben Webinterface, alle drei aus EINEM Kern.
+
+**Grundregel für den ganzen Weg:** Jede Etappe endet mit grüner Ampel und einem
+lauffähigen System. v1 läuft bis V2-5 produktiv weiter — auf der VM liegen echte
+Medien.
+
+### V2-0: Monorepo — ein Paket statt Zwillingen
+- [ ] `src/rippy/` als gemeinsames Paket anlegen (api UND worker importieren daraus)
+- [ ] Die byte-identischen Zwillinge zusammenführen: `detection.py`,
+ `makemkv_daten.py`, `notify.py` — je zweimal im Repo
+- [ ] Tests wandern mit; `test_zwillinge_sind_byteweise_identisch` wird
+ gegenstandslos und weicht einem Test, der die EINE Quelle prüft
+- [ ] Beide Dockerfiles kopieren `src/rippy`, `PYTHONPATH` gesetzt
+- **Verhalten unverändert.** Kein Funktionsgewinn, reine Struktur.
+- **Fertig, wenn:** Ampel grün, `docker compose up` verhält sich wie vorher,
+ kein Modul mehr doppelt im Repo.
+
+### V2-1: Ports einziehen
+- [ ] `Store`, `Queue`, `Bus`, `Drives` als Python-`Protocol`
+- [ ] v1-Verhalten läuft über die Treiber Postgres / Celery / Redis / Linux
+- [ ] Kein Funktionsgewinn — reine Verdrahtung
+- **Fertig, wenn:** Ampel grün und ein Rip auf der VM durchläuft.
+
+### V2-2: Standalone (SQLite + lokale Queue)
+- [ ] SQLite-Treiber mit WAL, `busy_timeout`, ein Schreiber-Kontext
+- [ ] Alembic statt handgeschriebener `ALTER TABLE … IF NOT EXISTS`
+ (das ist Postgres-only und bricht auf SQLite)
+- [ ] LocalQueue: Auftrags-Tabelle + Lease + Prozesspool, kein Broker
+- [ ] Lease ersetzt `zombies.py` — abgelaufene Lease = Auftrag ist frei
+- [ ] `rippyd --profil standalone`
+- **Fertig, wenn:** ein DVD-Rip komplett ohne Postgres, Redis und Docker läuft.
+
+### V2-3: Echtzeit — Polling raus
+- [ ] Bus-Treiber (asyncio in-process / Redis Pub/Sub)
+- [ ] `GET /api/v2/events` als SSE-Strom, `snapshot` beim Verbinden,
+ lückenlose `seq` für Wiederaufnahme
+- [ ] UI auf einen `useEventStream`-Haken; die neun `setInterval` fliegen raus
+- [ ] `test_grenze_deckt_die_eigene_last_ab` auf die neuen Zahlen ziehen
+- **Fertig, wenn:** Grundlast ≈ 0/min gemessen (heute ≈ 133/min bei einem Tab
+ plus Worker) UND ein Verbindungsabriss keine Liste leert.
+
+### V2-4: Windows nativ
+- [ ] `drives/windows.py` — Win32 statt ioctl (`IOCTL_STORAGE_CHECK_VERIFY2`,
+ `IOCTL_STORAGE_MEDIA_REMOVAL`, `IOCTL_STORAGE_EJECT_MEDIA`,
+ MMC `GET CONFIGURATION` für den Disc-Typ)
+- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling
+- [ ] Dienst (WinSW) + Tray als GETRENNTE Prozesse; WebView2-Fenster
+- [ ] Nuitka `--standalone` (one-dir, nicht one-file) + Inno Setup
+- [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED`
+- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
+ Disc rein → MKV raus. Und der UHD-Schlüssel kommt automatisch.
+
+### V2-5: Docker neu
+- [ ] EIN Image, drei Profile (`standalone`, `api`, `node`)
+- [ ] GPU-Durchreichung: `/dev/dri` für QSV/VAAPI, `runtime: nvidia` für NVENC
+- [ ] **Host-Mounts statt Container-Mounts** (Entscheid 28.08.2026)
+- [ ] Multi-Arch; ARM64 ist Encode-/API-Knoten (MakeMKV hat kein ARM64-Binary)
+- **Fertig, wenn:** All-in-One und verteilt laufen und `SYS_ADMIN`,
+ `DAC_READ_SEARCH`, `apparmor:unconfined` weg sind.
+
+### V2-6: CLI & Pakete
+- [ ] `rippy status / drives / scan / rip / queue / logs --follow / doctor`
+- [ ] `rippy doctor` = die Prüfphase aus `install.sh` als Befehl (erst alles
+ prüfen, dann berichten, nichts ändern)
+- [ ] systemd-Unit mit `SupplementaryGroups=cdrom` und `DeviceAllow` für
+ block-sr UND char-sg (ohne sg findet MakeMKV kein Laufwerk)
+- [ ] udev-Regel für echte Disc-Ereignisse; ioctl-Poll bleibt Rückfallebene
+- [ ] `.deb` / AppImage
+- **Fertig, wenn:** ein Headless-Server ohne Browser bedienbar ist.
+
+### V2-7: Neue Features
+- [ ] Multi-Drive Parallel-Ripping (Rip parallel, **Schlüssel-Phase
+ serialisiert** — vorher an zwei Laufwerken MESSEN, nicht annehmen)
+- [ ] Zero-Click gegen Interaktiv, je Laufwerk und Disc-Typ
+- [ ] Auto-Presets nach gemessener Hardware (Vorschlag, kein Zwang)
+- [ ] Ton-/Untertitel-Regelwerk (Originalton, Wunschsprachen, erzwungene
+ Untertitel behalten, Kommentarspuren verwerfen, HD-Ton durchreichen)
+- [ ] **Schlüsselkette in drei Stufen** (Entscheid 28.08.2026, `KONZEPT.md` § 10)
+- [ ] Anime über AniList zusätzlich zu Jikan
+- [ ] Medienserver-Refresh als Auftrag MIT Wiederholung (heute still scheiternd)
+- [ ] FFmpeg-Direktpfad für reines Remuxen
+- **Fertig, wenn:** je Feature ein Nachweis vorliegt.
+
+---
+
## Ideen-Katalog (Rest) — bewusst offen
+> **Stand 28.08.2026:** Punkt 3 (Kodi-Refresh) und Punkt 4 (Windows-Worker als
+> Dienst) sind in den v2-Plan aufgegangen — Punkt 4 ist V2-4, Punkt 3 steckt in
+> V2-7 („Medienserver-Refresh"). Punkt 2 (AI-Box als Transcode-Worker) wird von
+> V2-5 abgedeckt, sobald der Host-Mount-Weg steht.
+
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
Briefing: `docs/DESIGN-2.0-BRIEFING.md`. Arbeitsbranch: `design-2.0`
(deployt bewusst NICHT, nur `main` wird befördert). Kern: 428