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