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