Ampel / ampel (push) Failing after 54s
Beide Befunde des Commanders vom 28.08.2026 festgehalten, mitsamt der Begruendung, warum ein 'if (windows)' an dreissig Stellen die falsche Reparatur gewesen waere: Die Oberflaeche haette weiterhin nichts ueber ihren Betrieb gewusst, nur eine zweite Sorte Vermutung gehabt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1444 lines
66 KiB
Markdown
1444 lines
66 KiB
Markdown
# 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)<br/>SSE-Client"]
|
||
CLI["rippy CLI"]
|
||
TRAY["Tray / WebView2<br/>(nur Windows)"]
|
||
end
|
||
|
||
subgraph APP["rippyd — eine Anwendung, drei Profile"]
|
||
API["api — FastAPI-Router<br/>(dünn, keine Fachlogik)"]
|
||
CORE["core — Pipeline & Zustandsmaschine"]
|
||
|
||
subgraph FACH["Fachschichten"]
|
||
DAL["drives (DAL)"]
|
||
META["metadata"]
|
||
RIP["rip"]
|
||
ENC["transcode"]
|
||
LIB["library"]
|
||
end
|
||
end
|
||
|
||
subgraph PORTS["Ports — austauschbare Treiber"]
|
||
STORE["Store"]
|
||
QUEUE["Queue"]
|
||
BUS["Bus"]
|
||
STOR["Storage"]
|
||
end
|
||
|
||
subgraph TREIBER["Treiber"]
|
||
SQLITE["SQLite WAL"]
|
||
PG["PostgreSQL"]
|
||
LOCALQ["LocalQueue<br/>(Tabelle + Pool)"]
|
||
CELERY["Celery / Redis"]
|
||
MEMBUS["asyncio-Bus"]
|
||
REDISBUS["Redis Pub/Sub"]
|
||
end
|
||
|
||
subgraph HW["Außenwelt"]
|
||
DRIVE["Optische Laufwerke<br/>/dev/sr* · \\\\.\\D:"]
|
||
TOOLS["makemkvcon · HandBrakeCLI<br/>abcde · ffmpeg"]
|
||
NET["TMDb · TVDb · MusicBrainz<br/>Jellyfin/Emby/Plex"]
|
||
FS["Medien-Ablage<br/>lokal · SMB · NFS"]
|
||
end
|
||
|
||
UI -.SSE.-> API
|
||
UI --> API
|
||
CLI --> CORE
|
||
TRAY --> API
|
||
|
||
API --> CORE
|
||
CORE --> DAL & META & RIP & ENC & LIB
|
||
CORE --> STORE & QUEUE & BUS
|
||
|
||
STORE --> SQLITE & PG
|
||
QUEUE --> LOCALQ & CELERY
|
||
BUS --> MEMBUS & REDISBUS
|
||
|
||
DAL --> DRIVE
|
||
RIP --> TOOLS
|
||
ENC --> TOOLS
|
||
META --> NET
|
||
LIB --> NET
|
||
LIB --> STOR
|
||
STOR --> FS
|
||
```
|
||
|
||
**Was das Diagramm zeigt und was in v1 fehlt:**
|
||
|
||
1. `api` hat **keinen** Pfeil auf `rip` oder `drives`. In v1 hat sie den:
|
||
`main.py` importiert `devices.py` und ruft `eject()` direkt auf. Deshalb
|
||
braucht der API-Container heute einen `devices:`-Eintrag in der Compose-Datei
|
||
und `SYS_ADMIN`.
|
||
2. Die Ports sitzen zwischen Kern und Treibern — nicht daneben. Ein Aufruf von
|
||
`core` nach `SQLite` gibt es nicht, nur `core → Store → SQLite`.
|
||
3. Die Außenwelt hängt an genau drei Stellen: DAL (Hardware), Werkzeug-Adapter
|
||
(CLI-Programme), Storage (Dateisystem). Alles Blockierende ist damit an drei
|
||
Stellen eingesperrt statt über 2 254 Zeilen verteilt.
|
||
|
||
### 2.3 Die vier Ports
|
||
|
||
Ports sind Python-`Protocol`s — keine Basisklassen, keine Vererbung, testbar
|
||
ohne Mocking-Bibliothek.
|
||
|
||
```python
|
||
# rippy/ports.py
|
||
|
||
class Store(Protocol):
|
||
"""Zustand: Jobs, Logs, Einstellungen, Worker, Mounts."""
|
||
def job_anlegen(self, job: Job) -> None: ...
|
||
def job_holen(self, job_id: str) -> Job | None: ...
|
||
def jobs_listen(self, filter: JobFilter) -> list[Job]: ...
|
||
def job_aendern(self, job_id: str, **felder) -> Job: ...
|
||
def log_anhaengen(self, eintrag: LogEintrag) -> None: ...
|
||
def einstellungen_holen(self) -> Einstellungen: ...
|
||
|
||
class Queue(Protocol):
|
||
"""Aufträge. Die WAHRHEIT liegt im Store — siehe § 3.1."""
|
||
def einreihen(self, auftrag: Auftrag) -> None: ...
|
||
def uebernehmen(self, faehigkeiten: set[str], knoten: str) -> Auftrag | None: ...
|
||
def lebenszeichen(self, auftrag_id: str) -> None: ...
|
||
def abschliessen(self, auftrag_id: str, ergebnis: Ergebnis) -> None: ...
|
||
def abbrechen(self, auftrag_id: str) -> None: ...
|
||
|
||
class Bus(Protocol):
|
||
"""Ereignisse. Feuer-und-vergiss — nie ein Rückkanal für Zustand."""
|
||
async def senden(self, ereignis: Ereignis) -> None: ...
|
||
async def abonnieren(self, ab_seq: int | None) -> AsyncIterator[Ereignis]: ...
|
||
|
||
class Drives(Protocol):
|
||
"""Hardware. Der EINZIGE Ort mit ioctl/Win32 — siehe § 5."""
|
||
def laufwerke(self) -> list[Laufwerk]: ...
|
||
def zustand(self, id: str) -> Laufwerkszustand: ...
|
||
def verriegeln(self, id: str, an: bool) -> None: ...
|
||
def auswerfen(self, id: str) -> None: ... # prüft nach, wirft sonst
|
||
async def ereignisse(self) -> AsyncIterator[DiscEreignis]: ...
|
||
```
|
||
|
||
`Drives.auswerfen` trägt die v1-Lehre in der Signatur: Es gibt keinen
|
||
Rückgabewert, den man fälschlich für Erfolg halten könnte. Entweder die Disc ist
|
||
draußen, oder es fliegt eine Ausnahme. (v1-Befund 26.07.2026: `CDROMEJECT`
|
||
quittiert auf einem verriegelten Laufwerk Erfolg und tut nichts —
|
||
`devices.py:22`.)
|
||
|
||
### 2.4 Paketstruktur
|
||
|
||
Ein Monorepo, ein installierbares Paket, drei Extras:
|
||
|
||
```
|
||
rippy/
|
||
├── pyproject.toml # [project.optional-dependencies]
|
||
│ # server = fastapi, uvicorn
|
||
│ # distributed = celery, redis, psycopg
|
||
│ # windows = pywin32, pystray, pywebview
|
||
├── src/rippy/
|
||
│ ├── core/ # Job, Phase, Zustandsmaschine, pipeline.py
|
||
│ ├── ports.py # die vier Protocols
|
||
│ ├── drives/ # base.py · linux.py · windows.py · darwin.py
|
||
│ ├── metadata/ # clients/ (tmdb, tvdb, omdb, musicbrainz, jikan)
|
||
│ ├── rip/ # makemkv.py · abcde.py · parser.py
|
||
│ ├── transcode/ # handbrake.py · ffmpeg.py · caps.py · presets.py
|
||
│ ├── library/ # struktur.py · nfo.py · mediaserver.py
|
||
│ ├── storage/ # pfade.py · mounts.py · pfadmap.py
|
||
│ ├── store/ # sqlite.py · postgres.py · schema.py · migrations/
|
||
│ ├── queue/ # lokal.py · celery.py
|
||
│ ├── bus/ # memory.py · redis.py · schema.py
|
||
│ ├── api/ # routers/ (jobs, drives, storage, system, …)
|
||
│ ├── cli/ # Typer-Kommandos
|
||
│ ├── platform/ # win_service.py · systemd.py · tray.py · winlauf.py
|
||
│ └── config.py # TOML + ENV + CLI, eine Präzedenz
|
||
├── ui/ # React (aus docker/ui/ übernommen)
|
||
├── packaging/
|
||
│ ├── windows/ # Inno-Setup-Skript, WinSW-XML, Nuitka-Spec
|
||
│ ├── linux/ # systemd-Units, .deb/.rpm/AppImage
|
||
│ └── docker/ # Dockerfile (ein Image), compose-Varianten
|
||
└── tests/ # wandern 1:1 aus v1 mit (siehe § 8.2)
|
||
```
|
||
|
||
**Die Extras sind der Punkt.** `pip install rippy` gibt einen lauffähigen
|
||
Headless-Daemon mit SQLite. `pip install rippy[distributed]` zieht Celery,
|
||
Redis-Client und psycopg nach. Ein Windows-Nutzer bekommt nie eine
|
||
Postgres-Abhängigkeit zu sehen — heute schleppt `docker/api/requirements.txt`
|
||
alles für alle mit.
|
||
|
||
---
|
||
|
||
## 3. Daten- & Queue-Strategie
|
||
|
||
### 3.1 Der Grundsatz
|
||
|
||
> **Die Datenbank ist die Wahrheit über den Job-Zustand.
|
||
> Der Broker ist nur der Wecker.**
|
||
|
||
Das klingt nach einem Detail und ist die wichtigste Entscheidung in § 3.
|
||
|
||
In v1 ist es umgekehrt gedacht: Celery hält den Auftrag, die DB spiegelt ihn
|
||
nach. Deshalb gibt es `zombies.py` (206 Zeilen) plus `test_zombies.py`
|
||
(263 Zeilen) — ein nachträglicher Reparaturmechanismus für Jobs, die in der DB
|
||
laufen, während in Celery niemand mehr an ihnen arbeitet. Der Kommentar in
|
||
`celery_app.py` beschreibt es genau: nach einer Gnadenfrist werden sie „ehrlich
|
||
auf `failed` gesetzt".
|
||
|
||
Wenn die DB die Wahrheit ist, verschwindet das Problem, statt repariert zu
|
||
werden:
|
||
|
||
- Ein Auftrag ist eine Zeile mit `status`, `claimed_by`, `lease_until`.
|
||
- Ein Knoten übernimmt ihn per bedingtem `UPDATE` (atomar, in beiden DBs).
|
||
- Er hält ihn per **Lease** am Leben: alle 15 s `lease_until = jetzt + 60 s`.
|
||
- Läuft die Lease ab, ist der Auftrag frei — egal ob der Knoten abgestürzt ist,
|
||
das Netz weg war oder jemand den Stecker gezogen hat.
|
||
|
||
Das ist derselbe Mechanismus in *beiden* Betriebsmodi. Und es macht die
|
||
Queue-Treiber austauschbar, weil der Broker keinen Zustand mehr besitzt:
|
||
|
||
- **LocalQueue** pollt die Tabelle (1 s) — kein Broker nötig.
|
||
- **CeleryQueue** benutzt Redis nur als Wecker („da ist Arbeit"); der Task holt
|
||
sich die Details aus der DB. Geht die Nachricht verloren, findet der nächste
|
||
Poll-Durchlauf sie trotzdem.
|
||
|
||
### 3.2 Store-Port: zwei Treiber, ein Schema
|
||
|
||
v1 nutzt **SQLAlchemy Core** (nicht das ORM) — `db.py` arbeitet mit `Table()`,
|
||
`select()`, `insert()`. Das ist ein Glücksfall: Core läuft auf SQLite und
|
||
Postgres mit demselben Code. Der Store-Port ist deshalb kein Neubau, sondern ein
|
||
Umzug.
|
||
|
||
Was wirklich angefasst werden muss:
|
||
|
||
| Thema | v1 | v2 |
|
||
|-------|-----|-----|
|
||
| Migrationen | `ALTER TABLE … ADD COLUMN IF NOT EXISTS` von Hand in `init_db()` — **Postgres-only, bricht auf SQLite** | dialektneutral: erst `inspect()` fragen, dann nur fehlende Spalten anlegen — **kein Alembic**, siehe Kasten unten |
|
||
| Nebenläufigkeit | Postgres regelt es | SQLite: `PRAGMA journal_mode=WAL`, `busy_timeout=5000`, **ein** Schreiber-Kontext |
|
||
| Zeitstempel | `DateTime(timezone=True)` | unverändert; SQLite speichert ISO-8601 UTC |
|
||
| JSON-Felder | `Text` + `json.dumps` von Hand | unverändert (portabel), Serialisierung in den Store gezogen |
|
||
| Duplikat | `api/db.py` **und** `worker/db.py` | eine Datei |
|
||
|
||
> **Abweichung vom Entwurf (beim Bauen entschieden, 28.08.2026):** Hier stand
|
||
> ursprünglich Alembic. Zwei Dinge sprachen beim Umsetzen dagegen. Erstens
|
||
> **gibt es die Datenbank auf der VM schon** — Alembic müsste sie erst
|
||
> „stempeln" (`alembic stamp head`), sonst hält es sie für leer und versucht
|
||
> vorhandene Tabellen anzulegen; das ist ein Handgriff auf einer laufenden
|
||
> Installation und damit genau die Sorte Schritt, die beim nächsten Deploy
|
||
> jemand vergisst. Zweitens **gibt es hier nichts zu versionieren**: Die
|
||
> gesamte Migrationslast des Projekts sind drei nachgetragene Spalten.
|
||
> Stattdessen fragt `store.migrieren()` per `inspect()` nach, welche Spalten
|
||
> existieren, und legt nur die fehlenden an — das läuft auf beiden Dialekten.
|
||
> Sollte das Schema je wirklich wandern (Spalten umbenennen, Daten
|
||
> umschichten), ist Alembic die richtige Antwort, dann aber mit einer
|
||
> bewussten Stempel-Runde.
|
||
|
||
**SQLite-Grenzen, ehrlich benannt:** WAL erlaubt viele Leser und *einen*
|
||
Schreiber. Für Rippy reicht das mit großem Abstand — ein Rip schreibt etwa alle
|
||
2 s einen Fortschrittswert. Wer mehr als ~4 gleichzeitige Rip-Knoten fährt,
|
||
nimmt Postgres; das ist genau die Grenze, an der der verteilte Modus ohnehin
|
||
sinnvoll wird.
|
||
|
||
### 3.3 Schema (v2)
|
||
|
||
Aufbauend auf v1 (`jobs`, `logs`, `settings`, `workers`, `storage_mounts`), mit
|
||
vier Ergänzungen:
|
||
|
||
```sql
|
||
-- NEU: Aufträge getrennt von Jobs. Ein Job („Akira rippen") kann mehrere
|
||
-- Aufträge haben (scan → rip → transcode → ablegen). v1 hatte das implizit
|
||
-- in tasks.py verdrahtet und konnte deshalb nicht gezielt neu starten.
|
||
CREATE TABLE auftraege (
|
||
id TEXT PRIMARY KEY,
|
||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||
art TEXT NOT NULL, -- scan | rip | transcode | ablegen
|
||
status TEXT NOT NULL, -- wartend | laufend | fertig | fehler | abgebrochen
|
||
faehigkeiten TEXT NOT NULL, -- JSON: {"drive":"sr0"} / {"encoder":"nvenc"}
|
||
prioritaet INTEGER DEFAULT 0,
|
||
claimed_by TEXT, -- Knotenname
|
||
lease_until TIMESTAMP, -- § 3.1
|
||
versuche INTEGER DEFAULT 0,
|
||
payload TEXT, -- JSON
|
||
created_at TIMESTAMP NOT NULL
|
||
);
|
||
CREATE INDEX idx_auftraege_frei ON auftraege(status, prioritaet DESC, created_at);
|
||
|
||
-- NEU: Laufwerke als erstklassige Objekte (Multi-Drive, § 7.1)
|
||
CREATE TABLE laufwerke (
|
||
id TEXT PRIMARY KEY, -- stabil: WWID/Serial, NICHT sr0
|
||
knoten TEXT NOT NULL,
|
||
pfad TEXT NOT NULL, -- /dev/sr0 bzw. D:
|
||
anzeigename TEXT,
|
||
profil TEXT, -- JSON: Zero-Click je Disc-Typ (§ 7.2)
|
||
last_seen TIMESTAMP
|
||
);
|
||
|
||
-- NEU: Ereignis-Ringpuffer für SSE-Wiederaufnahme (§ 6.3)
|
||
CREATE TABLE ereignisse (
|
||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ts TIMESTAMP NOT NULL,
|
||
typ TEXT NOT NULL,
|
||
entitaet TEXT,
|
||
entitaet_id TEXT,
|
||
daten TEXT -- JSON
|
||
);
|
||
|
||
-- GEÄNDERT: jobs.device → jobs.laufwerk_id (stabile Kennung statt sr0)
|
||
-- GEÄNDERT: storage_mounts.passwort → Verweis auf OS-Schlüsselspeicher (§ 9)
|
||
```
|
||
|
||
`jobs.device` auf eine stabile Kennung umzustellen, behebt nebenbei ein
|
||
v1-Ärgernis, das in `.env.example` dokumentiert ist: *„nach USB-Reconnect zur
|
||
Laufzeit kann die sg-Nummer wandern → Container neu starten."*
|
||
|
||
### 3.4 Queue-Port: die Entscheidung
|
||
|
||
Der Auftrag nennt vier Kandidaten (Taskiq, Celery-lite, ARQ, AsyncIO-Queue).
|
||
**Empfehlung: keiner davon als Bibliothek — stattdessen zwei eigene, sehr kleine
|
||
Treiber hinter dem Port.**
|
||
|
||
Begründung, kurz:
|
||
|
||
1. **Der teure Teil ist kein Task, sondern ein Subprozess.** Ein Rip ist ein
|
||
30–90-Minuten-`makemkvcon`-Aufruf, dessen stdout zeilenweise geparst wird
|
||
(`ripping.get_progress_from_prgv`). Was Taskiq/ARQ liefern — Serialisierung,
|
||
Retry, Broker-Anbindung — löst davon nichts. Was gebraucht wird —
|
||
Fortschritts-Streaming, Abbruch mitten im Lauf, Lease — muss man ohnehin
|
||
selbst bauen.
|
||
2. **Zwei Bibliotheken heißen zwei Programmiermodelle.** Der verteilte Modus
|
||
soll Celery behalten (funktioniert, Remote-Windows-Worker ist bewiesen).
|
||
Eine zweite Queue-Bibliothek daneben bedeutet zwei Fehlerbilder, zwei
|
||
Retry-Semantiken, zwei Sorten Doku.
|
||
3. **Mit § 3.1 ist der Treiber trivial.** LocalQueue ist ein `SELECT … WHERE
|
||
status='wartend' … LIMIT 1` plus bedingtes `UPDATE` plus ein
|
||
`ProcessPoolExecutor`. Das sind etwa 150 Zeilen, vollständig testbar, ohne
|
||
laufenden Broker.
|
||
|
||
```python
|
||
# rippy/queue/lokal.py — Kern (gekürzt)
|
||
def uebernehmen(self, faehigkeiten: set[str], knoten: str) -> Auftrag | None:
|
||
jetzt = utcnow()
|
||
with self.store.begin() as tx:
|
||
kandidat = tx.execute(
|
||
select(auftraege)
|
||
.where(auftraege.c.status == "wartend")
|
||
.where(or_(auftraege.c.lease_until.is_(None),
|
||
auftraege.c.lease_until < jetzt))
|
||
.order_by(auftraege.c.prioritaet.desc(), auftraege.c.created_at)
|
||
.limit(1)
|
||
).mappings().first()
|
||
if not kandidat or not self._passt(kandidat, faehigkeiten):
|
||
return None
|
||
# Bedingtes UPDATE: gewinnt genau EIN Knoten, auch bei Gleichstand.
|
||
betroffen = tx.execute(
|
||
auftraege.update()
|
||
.where(auftraege.c.id == kandidat["id"])
|
||
.where(auftraege.c.claimed_by.is_(None)) # <- die Bedingung
|
||
.values(status="laufend", claimed_by=knoten,
|
||
lease_until=jetzt + LEASE)
|
||
).rowcount
|
||
return Auftrag(**kandidat) if betroffen == 1 else None
|
||
```
|
||
|
||
`CeleryQueue` implementiert denselben Port und behält v1s
|
||
`worker_direct=True`-Trick, mit dem die API die Kompression gezielt an einen
|
||
gewählten Encoder-Knoten schickt.
|
||
|
||
### 3.5 Event-Bus
|
||
|
||
Zwei Treiber, ein Schema (§ 6.3):
|
||
|
||
- **`MemoryBus`** — `asyncio.Queue` je Abonnent, dazu ein Ringpuffer der letzten
|
||
512 Ereignisse für Wiederaufnahme nach Verbindungsabriss.
|
||
- **`RedisBus`** — Redis Pub/Sub für die Verteilung zwischen Knoten; der
|
||
Ringpuffer liegt in der Tabelle `ereignisse` (§ 3.3), damit ein UI, das an
|
||
Knoten A hängt, auch Ereignisse von Knoten B lückenlos nachholt.
|
||
|
||
**Regel:** Der Bus überträgt nur *Nachrichten über Änderungen*, nie den Zustand
|
||
selbst. Wer den Zustand will, fragt den Store. Das verhindert die v1-Klasse von
|
||
Fehlern, bei denen ein verpasstes Signal als Aussage über die Welt gelesen wird
|
||
(`AGENTS.md`: *„Ein verpasster Abruf ist keine Nachricht über die Welt"*).
|
||
|
||
---
|
||
|
||
## 4. Plattform-Spezifikationen
|
||
|
||
### 4.1 Modus 1 — Native Windows-Installation
|
||
|
||
#### Laufzeit-Architektur
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ RippyService (Windows-Dienst, LocalSystem) │
|
||
│ ├─ uvicorn → 127.0.0.1:7788 (API + UI-Assets) │
|
||
│ ├─ LocalQueue → 1–N Rip-/Encode-Prozesse │
|
||
│ ├─ DriveWatcher → WM_DEVICECHANGE (Ereignis, kein Poll)│
|
||
│ └─ Store → %ProgramData%\Rippy\rippy.db (SQLite)│
|
||
└───────────────────────┬─────────────────────────────────┘
|
||
│ HTTP + SSE, nur loopback
|
||
┌───────────────────────▼─────────────────────────────────┐
|
||
│ RippyTray (Benutzersitzung, optional) │
|
||
│ ├─ Tray-Icon + Kontextmenü (Status, Öffnen, Beenden) │
|
||
│ └─ WebView2-Fenster → http://127.0.0.1:7788 │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
Dienst und Tray sind **getrennte Prozesse**. Grund: Ein Dienst hat keine
|
||
Benutzersitzung und kann kein Fenster zeigen; ein Tray-Programm stirbt beim
|
||
Abmelden. Wer beides in einen Prozess legt, bekommt entweder keinen Autostart
|
||
vor der Anmeldung oder kein Icon. v1s `gui.py` (24 KB Flet) vermischt das heute.
|
||
|
||
**Alternative ohne Dienst** (für Nutzer ohne Administratorrechte): Autostart per
|
||
Aufgabenplanung „Bei Anmeldung", Tray startet den Kern als Kindprozess. Der
|
||
Installer bietet beides an; Standard ist der Dienst.
|
||
|
||
#### Bündelung und Installer
|
||
|
||
| Baustein | Wahl | Warum |
|
||
|----------|------|-------|
|
||
| Python-Bündelung | **Nuitka** (`--standalone`), Fallback PyInstaller one-dir | Kein Embedded-Python-Gefummel; ein Ordner, ein `rippyd.exe`. One-**dir** statt one-file: ein Onefile-Paket entpackt sich bei jedem Start neu nach `%TEMP%` — bei 90 MB spürbar, und Virenscanner mögen es nicht. |
|
||
| Installer | **Inno Setup** | Frei, ein Skript, kann Dienste registrieren, Deinstallation sauber, keine MSI-Werkzeugkette |
|
||
| Dienst-Wrapper | **WinSW** (XML-konfiguriert) | NSSM wird per Kommandozeile konfiguriert — nicht reproduzierbar. WinSW legt eine XML neben die EXE, die im Repo versionierbar ist. |
|
||
| Ansicht | **WebView2** (Edge-Runtime) | Auf Win 10/11 vorinstalliert bzw. per Evergreen-Bootstrapper nachrüstbar; kein CEF-Ballast |
|
||
|
||
Der Installer fragt **fünf** Dinge und leitet den Rest ab: Zielordner, Autostart
|
||
(Dienst/Anmeldung/keiner), Medien-Ablage, MakeMKV-Pfad (vorbelegt aus der
|
||
Erkennung), Port.
|
||
|
||
#### Werkzeug-Erkennung
|
||
|
||
```python
|
||
# rippy/platform/win_tools.py
|
||
MAKEMKV_KANDIDATEN = [
|
||
winreg_wert(HKLM, r"SOFTWARE\MakeMKV", "InstallDir"),
|
||
r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe",
|
||
r"C:\Program Files\MakeMKV\makemkvcon64.exe",
|
||
shutil.which("makemkvcon64") or shutil.which("makemkvcon"),
|
||
]
|
||
```
|
||
|
||
HandBrakeCLI analog, plus mitgelieferte Kopie im Installationsordner als
|
||
Rückfallebene (HandBrake ist GPL-2 — Weitergabe erlaubt, Quellverweis gehört in
|
||
den Über-Dialog; siehe die GPL-Checkliste in `KONZEPT.md` § 4).
|
||
|
||
**MakeMKV wird NICHT mitgeliefert.** Grund steht in `KONZEPT.md` § 6:
|
||
Black-Box-Trennung. Der Installer erkennt eine vorhandene Installation und
|
||
verlinkt sonst auf makemkv.com.
|
||
|
||
#### Win32-Laufwerkszugriff (Kurzfassung, Details § 5)
|
||
|
||
Alles über `CreateFileW(r"\\.\D:", …)` plus `DeviceIoControl`:
|
||
|
||
| Zweck | IOCTL |
|
||
|-------|-------|
|
||
| Laufwerke finden | `GetLogicalDrives` + `GetDriveTypeW == DRIVE_CDROM` |
|
||
| Medium eingelegt? | `IOCTL_STORAGE_CHECK_VERIFY2` |
|
||
| Verriegeln / Entriegeln | `IOCTL_STORAGE_MEDIA_REMOVAL` (`PreventMediaRemoval`) |
|
||
| Auswerfen | `IOCTL_STORAGE_EJECT_MEDIA` |
|
||
| Exklusivzugriff | `FSCTL_LOCK_VOLUME` |
|
||
| Disc-Typ (grob) | `IOCTL_CDROM_DISK_TYPE` (Audio/Daten) |
|
||
| Disc-Typ (genau) | MMC `GET CONFIGURATION` via `IOCTL_SCSI_PASS_THROUGH_DIRECT` → *Current Profile*: `0x08` CD, `0x10` DVD, `0x40` BD |
|
||
| Einwurf-Ereignis | `WM_DEVICECHANGE` / `DBT_DEVICEARRIVAL`; im Dienst `RegisterServiceCtrlHandlerEx` + `SERVICE_CONTROL_DEVICEEVENT` |
|
||
|
||
Die v1-Lehre gilt hier genauso: **nach dem Auswurf den Zustand fragen**, nicht
|
||
dem Rückgabewert glauben (`CHECK_VERIFY2` muss `ERROR_NOT_READY` liefern).
|
||
|
||
Zwei Windows-Eigenheiten, die v1 schon kennt und die übernommen werden:
|
||
|
||
- `winlauf.OHNE_FENSTER` (`CREATE_NO_WINDOW`) — sonst blitzt bei jedem
|
||
`HandBrakeCLI --version` ein Konsolenfenster auf. v1-Befund 26.07.2026.
|
||
- Standby verhindern: `SetThreadExecutionState(ES_CONTINUOUS |
|
||
ES_SYSTEM_REQUIRED)` beim ersten laufenden Auftrag, zurücksetzen beim letzten.
|
||
**Ohne `ES_DISPLAY_REQUIRED`** — der Bildschirm darf ausgehen.
|
||
|
||
#### Der 4K-UHD-Gewinn
|
||
|
||
Der wichtigste strukturelle Vorteil des Windows-Modus: `makemkvcon` ruft dort
|
||
Disc-Schlüssel online ab, unter Linux nie (v1-Messung auf beiden Maschinen,
|
||
25.07.2026, `KONZEPT.md` § 8). In v1 war das ein manueller Datei-Transfer.
|
||
|
||
In v2 ist der Windows-Knoten damit automatisch die **Schlüssel-Quelle** für den
|
||
Rest der Installation:
|
||
|
||
```
|
||
Windows-Knoten rippt UHD → MakeMKV legt Schlüssel in %APPDATA%\MakeMKV ab
|
||
→ Rippy spiegelt _private_data.tar in den Store
|
||
Linux-Knoten braucht Schlüssel → holt den Speicher über die API
|
||
```
|
||
|
||
Das ist dasselbe, was der Nutzer heute von Hand macht — nur ohne Handarbeit. In
|
||
der Schlüsselkette aus § 7.5 ist das **Stufe 1**: der eigene Bestand, der vor
|
||
jedem Netzabruf gefragt wird.
|
||
|
||
### 4.2 Modus 2 — Headless Linux Application
|
||
|
||
#### Konfiguration: eine Präzedenz, überall gleich
|
||
|
||
```
|
||
CLI-Flag > Umgebungsvariable (RIPPY_*) > Konfigurationsdatei > Vorgabe
|
||
```
|
||
|
||
```toml
|
||
# /etc/rippy/rippy.toml
|
||
[server]
|
||
host = "0.0.0.0"
|
||
port = 7788
|
||
ui = true # statische Assets mitausliefern (kein nginx)
|
||
|
||
[store]
|
||
treiber = "sqlite" # sqlite | postgres
|
||
pfad = "/var/lib/rippy/rippy.db"
|
||
# url = "postgresql://rippy:…@db:5432/rippy" # bei treiber = "postgres"
|
||
|
||
[queue]
|
||
treiber = "lokal" # lokal | celery
|
||
rip_slots = 1 # gleichzeitige Rips (§ 7.1)
|
||
encode_slots = 2
|
||
|
||
[drives]
|
||
erkennung = "udev" # udev | poll
|
||
poll_sekunden = 3 # nur bei erkennung = "poll"
|
||
auto_auswurf = true
|
||
|
||
[[drives.profil]] # Zero-Click je Disc-Typ (§ 7.2)
|
||
typ = "cd"
|
||
modus = "zero-click"
|
||
[[drives.profil]]
|
||
typ = "uhd"
|
||
modus = "interaktiv"
|
||
|
||
[storage]
|
||
medien = "/srv/medien"
|
||
temp = "/var/lib/rippy/temp"
|
||
pfad_timeout_sekunden = 5 # § 4.2, nicht-blockierende Prüfung
|
||
|
||
[metadata]
|
||
tmdb_key = "" # oder RIPPY_METADATA__TMDB_KEY
|
||
sprachen = ["de", "en"]
|
||
|
||
[transcode]
|
||
encoder = "auto" # auto | x265 | nvenc | qsv | vaapi | none
|
||
[transcode.preset]
|
||
uhd = "keine" # verlustfrei durchreichen
|
||
bluray = "HQ 1080p30 Surround"
|
||
dvd = "H.264 MKV 576p25"
|
||
|
||
[mediaserver]
|
||
typ = "jellyfin"
|
||
url = "http://192.168.178.20:8096"
|
||
api_key = ""
|
||
```
|
||
|
||
Umgebungsvariablen bilden die Verschachtelung mit doppeltem Unterstrich ab:
|
||
`RIPPY_STORE__PFAD`, `RIPPY_TRANSCODE__ENCODER`. Das ist der Docker-Weg und
|
||
funktioniert dort ohne Konfigurationsdatei.
|
||
|
||
#### CLI
|
||
|
||
```bash
|
||
rippyd --config /etc/rippy/rippy.toml # Daemon (Vordergrund, systemd-tauglich)
|
||
|
||
rippy status # Knoten, Queue, laufende Jobs
|
||
rippy drives # Laufwerke + Disc-Zustand
|
||
rippy drives eject sr0
|
||
rippy scan sr0 # TOC lesen, Metadaten vorschlagen
|
||
rippy rip sr0 --titel 3,5 --ziel /srv/medien/Filme --warten
|
||
rippy queue # Aufträge, Leases, Knoten
|
||
rippy queue retry <job-id> --ab transcode # gezielt neu ab einer Phase
|
||
rippy logs --follow --job <job-id> # SSE im Terminal
|
||
rippy config check # Konfiguration + Werkzeuge + Pfade
|
||
rippy doctor # Vollprüfung (siehe unten)
|
||
```
|
||
|
||
`rippy doctor` ist die CLI-Fassung dessen, was `install.sh` heute in seiner
|
||
Prüfphase macht — und es übernimmt deren wichtigste Eigenschaft: **erst alles
|
||
prüfen, dann berichten, nichts ändern** (`install.sh` Zeile 5 ff.).
|
||
|
||
#### systemd
|
||
|
||
```ini
|
||
# /etc/systemd/system/rippy.service
|
||
[Unit]
|
||
Description=Rippy — automatisches Ripping
|
||
After=network-online.target
|
||
Wants=network-online.target
|
||
|
||
[Service]
|
||
Type=exec
|
||
User=rippy
|
||
Group=rippy
|
||
SupplementaryGroups=cdrom # Zugriff auf /dev/sr* ohne root
|
||
ExecStart=/usr/bin/rippyd --config /etc/rippy/rippy.toml
|
||
Restart=on-failure
|
||
RestartSec=5
|
||
|
||
# Härtung — der Ersatz für das, was v1 über Container-Isolation löste
|
||
ProtectSystem=strict
|
||
ProtectHome=true
|
||
PrivateTmp=true
|
||
NoNewPrivileges=true
|
||
ReadWritePaths=/var/lib/rippy /srv/medien
|
||
DeviceAllow=block-sr rw
|
||
DeviceAllow=char-sg rw
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
`DeviceAllow` für **beide** Knotenklassen ist Pflicht, nicht Kosmetik: MakeMKV
|
||
spricht Laufwerke über SCSI-generic an und findet ohne `sg`-Knoten „keine usable
|
||
optical drives" (`docker-compose.yml`, Kommentar bei `devices:`).
|
||
|
||
Für `sysvinit`/OpenRC liegt ein Init-Skript in `packaging/linux/` — dieselbe
|
||
Binärdatei, dieselben Flags.
|
||
|
||
#### Disc-Erkennung: udev statt Polling
|
||
|
||
Auf blankem Linux geht das, was im Container prinzipbedingt nicht ging
|
||
(`KONZEPT.md` § 10, 23./24.07.2026):
|
||
|
||
```
|
||
# /etc/udev/rules.d/99-rippy.rules
|
||
ACTION=="change", KERNEL=="sr[0-9]*", ENV{ID_CDROM_MEDIA}=="1", \
|
||
RUN+="/usr/bin/rippy internal disc-event --device /dev/%k --aktion eingelegt"
|
||
ACTION=="change", KERNEL=="sr[0-9]*", ENV{ID_CDROM_MEDIA}=="", \
|
||
RUN+="/usr/bin/rippy internal disc-event --device /dev/%k --aktion entfernt"
|
||
```
|
||
|
||
`erkennung = "poll"` bleibt als Rückfallebene (v1s ioctl-Poller, 3 s) — für
|
||
Container und für Systeme ohne udev.
|
||
|
||
#### UI ohne nginx
|
||
|
||
Die kompilierten Vite-Assets werden ins Python-Paket gelegt und über
|
||
`StaticFiles` ausgeliefert. Damit fällt der nginx weg — und mit ihm ein
|
||
v1-Fehler: Hinter dem Proxy war `request.client.host` immer der Proxy, weshalb
|
||
alle Clients einen Rate-Limit-Eimer teilten (`ratelimit.py`). Ohne Proxy ist die
|
||
Peer-Adresse wieder echt. Die `X-Real-IP`-Auswertung bleibt für Nutzer, die
|
||
selbst einen Proxy davorstellen.
|
||
|
||
### 4.3 Modus 3 — Docker
|
||
|
||
#### Ein Image, drei Kommandos
|
||
|
||
```dockerfile
|
||
# packaging/docker/Dockerfile — multi-stage, multi-arch
|
||
FROM node:20-alpine AS ui
|
||
WORKDIR /ui
|
||
COPY ui/package*.json ./
|
||
RUN npm ci
|
||
COPY ui/ ./
|
||
RUN npm run build
|
||
|
||
FROM python:3.12-slim AS runtime
|
||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||
handbrake-cli ffmpeg abcde cdparanoia flac eyed3 \
|
||
libchromaprint-tools ca-certificates \
|
||
&& rm -rf /var/lib/apt/lists/*
|
||
COPY --from=ui /ui/dist /opt/rippy/ui
|
||
COPY dist/rippy-*.whl /tmp/
|
||
RUN pip install --no-cache-dir /tmp/rippy-*.whl[server,distributed]
|
||
ENTRYPOINT ["rippyd"]
|
||
```
|
||
|
||
| Variante | Kommando | Store | Queue |
|
||
|----------|----------|-------|-------|
|
||
| **All-in-One** | `rippyd --profil standalone` | SQLite | LocalQueue |
|
||
| **API-Knoten** | `rippyd --profil api` | Postgres | Celery (Producer) |
|
||
| **Rip/Encode-Knoten** | `rippyd --profil node` | Postgres | Celery (Consumer) |
|
||
|
||
`cifs-utils` fehlt in der Liste — mit Absicht, siehe unten.
|
||
|
||
#### All-in-One-Compose
|
||
|
||
```yaml
|
||
services:
|
||
rippy:
|
||
image: ghcr.io/…/rippy:2
|
||
command: ["--profil", "standalone"]
|
||
ports: ["7788:7788"]
|
||
environment:
|
||
RIPPY_STORE__TREIBER: sqlite
|
||
RIPPY_STORE__PFAD: /data/rippy.db
|
||
RIPPY_STORAGE__MEDIEN: /medien
|
||
volumes:
|
||
- rippy-data:/data
|
||
- /srv/medien:/medien # Host mountet, nicht der Container (!)
|
||
- /srv/rippy/makemkv:/root/.MakeMKV
|
||
devices:
|
||
- /dev/sr0:/dev/sr0
|
||
- /dev/sg1:/dev/sg1
|
||
- /dev/dri:/dev/dri # Intel QSV / AMD VAAPI
|
||
group_add: ["cdrom", "video", "render"]
|
||
restart: unless-stopped
|
||
volumes:
|
||
rippy-data:
|
||
```
|
||
|
||
**Die wichtigste Änderung gegenüber v1** steht in der Zeile mit dem Ausrufezeichen.
|
||
|
||
v1 mountet SMB/NFS *im* API-Container (`mounts.py`, `CAP_SYS_ADMIN`,
|
||
`DAC_READ_SEARCH`, `apparmor:unconfined`, `propagation: rshared`). Der Befund aus
|
||
Etappe 22 steht in `AGENTS.md`: *„Die CIFS-Verbindung lebt in der Netz-Namespace
|
||
des api-Containers und stirbt mit ihm."* Dagegen wurde eine Wache gebaut, die
|
||
das selbst heilt — 8 s Wiederanbindung statt 202 s.
|
||
|
||
v2 löst die Ursache statt des Symptoms: **Mounten ist Aufgabe des Hosts.**
|
||
|
||
- Einfachster Weg: `/etc/fstab` bzw. eine `.mount`-Unit, dann Bind-Mount.
|
||
- Bequemer Weg für Compose-Nutzer: der eingebaute Volume-Treiber —
|
||
|
||
```yaml
|
||
volumes:
|
||
nas-medien:
|
||
driver: local
|
||
driver_opts:
|
||
type: cifs
|
||
device: "//192.168.178.5/medien"
|
||
o: "username=rippy,password=…,uid=1000,gid=1000,vers=3.0"
|
||
```
|
||
|
||
Damit verschwinden `SYS_ADMIN`, `DAC_READ_SEARCH`, `apparmor:unconfined`,
|
||
`propagation: rshared` und die Mount-Wache. Der Container läuft wieder als
|
||
normaler Container.
|
||
|
||
**Was aus `mounts.py` bleibt:** die Prüf-Logik. `ist_erreichbar` mit
|
||
`timeout N ls -d` im Kindprozess, die Unterscheidung „konnte nicht nachsehen"
|
||
vs. „ist nicht da", die SMB-Klartext-Fehlermeldungen (`uebersetze_smb_fehler`)
|
||
und der Pfad-Map-Vorschlag. Nur das *Ausführen* des `mount` fällt weg —
|
||
stattdessen zeigt das UI eine kopierbare Anleitung für Host oder Compose.
|
||
|
||
#### Verteilter Modus
|
||
|
||
```yaml
|
||
services:
|
||
api:
|
||
image: ghcr.io/…/rippy:2
|
||
command: ["--profil", "api"]
|
||
ports: ["7788:7788"]
|
||
environment:
|
||
RIPPY_STORE__TREIBER: postgres
|
||
RIPPY_STORE__URL: postgresql://rippy:${PG_PASS}@postgres/rippy
|
||
RIPPY_QUEUE__TREIBER: celery
|
||
RIPPY_QUEUE__BROKER: redis://redis:6379/0
|
||
depends_on: {postgres: {condition: service_healthy},
|
||
redis: {condition: service_healthy}}
|
||
|
||
ripper: # am Laufwerk
|
||
image: ghcr.io/…/rippy:2
|
||
command: ["--profil", "node", "--faehigkeiten", "rip"]
|
||
devices: ["/dev/sr0:/dev/sr0", "/dev/sg1:/dev/sg1"]
|
||
group_add: ["cdrom"]
|
||
# … Store/Queue wie oben
|
||
|
||
encoder: # GPU-Maschine, ohne Laufwerk
|
||
image: ghcr.io/…/rippy:2
|
||
command: ["--profil", "node", "--faehigkeiten", "transcode"]
|
||
deploy:
|
||
resources:
|
||
reservations:
|
||
devices: [{driver: nvidia, count: all, capabilities: [gpu, video]}]
|
||
environment:
|
||
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||
```
|
||
|
||
Der `--faehigkeiten`-Schalter ist der Ersatz für v1s implizite Queue-Namen. Er
|
||
landet direkt in der `faehigkeiten`-Spalte der Auftrags-Tabelle (§ 3.3) und
|
||
funktioniert deshalb in *beiden* Queue-Treibern gleich.
|
||
|
||
#### Hardware-Beschleunigung
|
||
|
||
| Backend | Durchreichung | Prüfung beim Start |
|
||
|---------|---------------|--------------------|
|
||
| Intel QSV | `devices: /dev/dri`, `group_add: [render]` | `caps.erkenne_encoder()` |
|
||
| AMD VAAPI | dito | dito |
|
||
| Nvidia NVENC | `deploy.resources` + `NVIDIA_DRIVER_CAPABILITIES` | dito |
|
||
| Apple VideoToolbox | nur nativ (kein Docker auf macOS mit GPU) | dito |
|
||
|
||
`caps.py` aus v1 macht das schon richtig: Es **misst** die Encoder über
|
||
`HandBrakeCLI --help`, statt sie zu behaupten. Der Kommentar dazu stammt aus
|
||
Etappe 19 („Encoder wurden behauptet statt gemessen"). Das Modul wandert
|
||
unverändert nach `rippy/transcode/caps.py`.
|
||
|
||
Multi-Arch (`linux/amd64`, `linux/arm64`) über `docker buildx`. Einschränkung
|
||
ehrlich benannt: MakeMKV liefert offiziell **kein** ARM64-Binary. Ein
|
||
ARM64-Image ist damit ein reiner Encode-/API-Knoten, kein Rip-Knoten. Das gehört
|
||
in die Image-Beschreibung, nicht in eine Fußnote.
|
||
|
||
---
|
||
|
||
## 5. Drive Abstraction Layer (DAL)
|
||
|
||
Ein Protocol, drei Treiber. Die gesamte plattformabhängige Hardware-Logik von
|
||
Rippy lebt in `rippy/drives/` und **nirgendwo sonst**.
|
||
|
||
```python
|
||
# rippy/drives/base.py
|
||
@dataclass(frozen=True)
|
||
class Laufwerk:
|
||
id: str # stabil (WWID/Serial), NICHT sr0 oder D:
|
||
pfad: str # /dev/sr0 · \\.\D:
|
||
anzeigename: str # "ASUS BW-16D1HT"
|
||
knoten: str
|
||
faehigkeiten: frozenset[str] # {"cd","dvd","bd","uhd"}
|
||
|
||
class Laufwerkszustand(StrEnum):
|
||
LEER = "leer"; OFFEN = "offen"; BEREIT = "bereit"
|
||
LIEST = "liest"; UNBEKANNT = "unbekannt" # <- ehrlich, nicht "leer"
|
||
```
|
||
|
||
`UNBEKANNT` ist kein Schönheitsfehler, sondern die direkte Umsetzung einer
|
||
v1-Lehre: *„,konnte nicht nachsehen' ist etwas anderes als ,ist nicht da'"*
|
||
(`AGENTS.md`). Ein `ioctl`, das `EBUSY` liefert, darf nie als „leer" ins UI
|
||
durchschlagen.
|
||
|
||
| Operation | Linux | Windows | macOS *(später)* |
|
||
|-----------|-------|---------|------------------|
|
||
| Finden | `glob /dev/sr*` + `/sys/class/block/*/device/{vendor,model,wwid}` | `GetLogicalDrives` + `GetDriveTypeW` | `DiskArbitration` |
|
||
| Zustand | `CDROM_DRIVE_STATUS` (0x5326) | `IOCTL_STORAGE_CHECK_VERIFY2` | `DADiskCopyDescription` |
|
||
| Typ | `CDROM_DISC_STATUS` + `BLKGETSIZE64` → `detection.classify` | `IOCTL_CDROM_DISK_TYPE` + MMC `GET CONFIGURATION` | `drutil status` |
|
||
| Verriegeln | `CDROM_LOCKDOOR` (0x5329) | `IOCTL_STORAGE_MEDIA_REMOVAL` | — |
|
||
| Auswerfen | `CDROMEJECT` (0x5309) | `IOCTL_STORAGE_EJECT_MEDIA` | `DADiskEject` |
|
||
| Ereignisse | udev / ioctl-Poll | `WM_DEVICECHANGE` | `DARegisterDiskAppearedCallback` |
|
||
|
||
**Zwei Regeln, die für alle Treiber gelten:**
|
||
|
||
1. **Auswerfen heißt: entriegeln, auswerfen, nachsehen.** In dieser Reihenfolge,
|
||
immer, auf jeder Plattform. v1 hat das für Linux nach einer Live-Messung
|
||
gelernt (`devices.py:22`); der Windows-Treiber bekommt es von Anfang an.
|
||
2. **Jede Hardware-Operation läuft mit Zeitgrenze in einem Ausführer**, nie
|
||
direkt im Ereignis-Loop. Ein hängendes `ioctl` auf einem defekten Laufwerk
|
||
darf nicht die API blockieren — dieselbe Klasse von Fehler wie die
|
||
CIFS-Blockade in v1.
|
||
|
||
---
|
||
|
||
## 6. API- & Event-Design
|
||
|
||
### 6.1 REST — von 56 flachen Routen zu 8 Routern
|
||
|
||
v1s `main.py` hat 56 Routen in einer 2 254-Zeilen-Datei. v2 gruppiert sie:
|
||
|
||
| Router | Präfix | Kern-Endpunkte |
|
||
|--------|--------|----------------|
|
||
| `jobs` | `/api/v2/jobs` | `GET /` · `POST /` · `GET /{id}` · `DELETE /{id}` · `POST /{id}/cancel` · `POST /{id}/retry` (mit `?ab=rip\|transcode`) · `GET /{id}/files` |
|
||
| `drives` | `/api/v2/drives` | `GET /` · `GET /{id}` · `POST /{id}/eject` · `POST /{id}/scan` · `GET /{id}/tracks` · `PUT /{id}/profil` |
|
||
| `metadata` | `/api/v2/metadata` | `GET /search` · `POST /jobs/{id}/override` · `GET /tv/{id}/season/{n}` · `GET /status` |
|
||
| `storage` | `/api/v2/storage` | `GET /targets` · `GET /mounts` · `GET /browse` · `POST /mkdir` · `GET /freigaben` |
|
||
| `nodes` | `/api/v2/nodes` | `GET /` · `DELETE /{name}` · `GET /{name}/capabilities` |
|
||
| `system` | `/api/v2/system` | `GET /info` · `GET /version` · `GET /updates` · `GET|POST /settings` · `GET /health` |
|
||
| `keys` | `/api/v2/keys` | `GET|POST|DELETE /keydb` · `GET|POST /keystore` · `GET /aacs-dumps` |
|
||
| `events` | `/api/v2/events` | `GET /` (SSE, § 6.3) |
|
||
|
||
Regeln:
|
||
|
||
- **Ein Router ist eine Datei und ruft genau eine Kern-Funktion pro Endpunkt
|
||
auf.** Keine Fachlogik in der Route. Das ist die mechanische Bremse gegen ein
|
||
zweites 94-KB-`main.py`.
|
||
- **`/api/v1/*` bleibt als Weiterleitungs-Schicht bestehen**, solange ein
|
||
v1-Windows-Worker im Netz sein kann. Ein Umzug, bei dem der installierte
|
||
Worker eines Nutzers stirbt, ist kein Umzug.
|
||
- Fehler immer als `{"fehler": "…", "code": "…", "details": {…}}` — v1 mischt
|
||
heute FastAPI-Standard und eigene Formen.
|
||
|
||
### 6.2 Warum SSE und nicht WebSocket
|
||
|
||
| Kriterium | SSE | WebSocket |
|
||
|-----------|-----|-----------|
|
||
| Richtung | Server → Client | beide |
|
||
| Wiederaufnahme | eingebaut (`Last-Event-ID`) | selbst bauen |
|
||
| Proxy/Reverse-Proxy | normales HTTP | Upgrade nötig |
|
||
| Browser-Reconnect | automatisch | selbst bauen |
|
||
| Aufwand im UI | `new EventSource(...)` | Bibliothek |
|
||
|
||
Rippy braucht **einen** Rückkanal: Zustand vom Server zum Browser. Kommandos
|
||
gehen weiter per REST (idempotent, protokollierbar, mit CLI teilbar). Damit ist
|
||
SSE die passende Wahl; WebSocket wäre Aufwand ohne Gegenwert. Sollte später
|
||
etwas Bidirektionales dazukommen (Terminal-Durchreichung o. ä.), lässt sich ein
|
||
WS-Endpunkt daneben stellen, ohne SSE anzufassen.
|
||
|
||
### 6.3 Event-Schema
|
||
|
||
```
|
||
GET /api/v2/events?Last-Event-ID=1042 (oder als Header)
|
||
|
||
id: 1043
|
||
event: job.progress
|
||
data: {"seq":1043,"ts":"2026-08-28T10:14:22Z","typ":"job.progress",
|
||
"entitaet":"job","id":"a4f…","daten":{"phase":"rip","prozent":37,
|
||
"eta_sekunden":1820,"laufwerk":"sr0"}}
|
||
```
|
||
|
||
Ereignistypen:
|
||
|
||
| Typ | Auslöser | `daten` |
|
||
|-----|----------|---------|
|
||
| `snapshot` | **immer beim Verbinden** | vollständiger Zustand: Jobs, Laufwerke, Knoten, Mounts |
|
||
| `drive.changed` | DAL | `{id, zustand, disc_typ}` |
|
||
| `disc.inserted` / `disc.removed` | DAL | `{laufwerk_id, disc_typ, label}` |
|
||
| `job.created` / `job.finished` | Kern | `{job}` |
|
||
| `job.phase` | Kern | `{phase, von, nach}` |
|
||
| `job.progress` | Rip/Transcode (gedrosselt, max. 1/s) | `{phase, prozent, eta_sekunden}` |
|
||
| `log.line` | Log-Brücke | `{level, quelle, text, job_id?}` |
|
||
| `node.seen` / `node.lost` | Lease-Wächter | `{name, encoder, last_seen}` |
|
||
| `mount.changed` | Storage-Wache | `{name, erreichbar, grund}` |
|
||
| `system.notice` | überall | `{level, text}` — Rate-Limit-Treffer, Plattenplatz, … |
|
||
|
||
**Drei Eigenschaften, die aus v1-Schmerzen stammen:**
|
||
|
||
1. **`snapshot` zuerst, dann nur Deltas.** Ein Client, der verbindet, bekommt
|
||
nie ein halbes Bild.
|
||
2. **`seq` ist monoton und lückenlos.** Ein Reconnect mit `Last-Event-ID` liefert
|
||
alles Verpasste nach (Ringpuffer, § 3.5). Ist die Lücke zu groß, schickt der
|
||
Server statt Deltas einen neuen `snapshot` — ausdrücklich, nicht stillschweigend.
|
||
3. **Ein Verbindungsabriss ist keine Aussage.** Das UI behält den letzten Stand
|
||
und zeigt ein Banner. Es setzt **nie** eine Liste auf leer. Das ist genau der
|
||
Fehler, den der Commander als *„wird oft neu geladen"* gemeldet hat und der in
|
||
`AGENTS.md` als `catch(() => [])` dokumentiert ist — jetzt im Protokoll
|
||
verankert statt in fünf Komponenten einzeln.
|
||
|
||
### 6.4 Was das an Last spart
|
||
|
||
| | v1 (Polling) | v2 (SSE) |
|
||
|---|---|---|
|
||
| Dashboard | 5 Endpunkte / 4 s = 75/min | 1 offene Verbindung |
|
||
| Log-Kasten | 2 / 5 s = 24/min | — (im Stream) |
|
||
| Laufwerke | 1 / 5 s = 12/min | — (im Stream) |
|
||
| Worker-Liste | 1 / 15 s = 4/min | — (im Stream) |
|
||
| Log-Seite | 1 / 10 s = 6/min | — (im Stream) |
|
||
| Windows-Tray | 1 / 5 s = 12/min | 1 offene Verbindung |
|
||
| **Summe, ein Tab + ein Worker** | **≈ 133/min** | **≈ 0/min** |
|
||
|
||
Das Rate-Limit (600/min) bleibt als Amok-Bremse bestehen. Aber die Rechnung im
|
||
Kommentar von `ratelimit.py` muss mitgezogen werden — sonst steht dort in einem
|
||
Jahr eine Begründung, die nicht mehr stimmt. **Wer die Zahl anfasst, rechnet die
|
||
eigene Grundlast neu vor.** Das ist in v1 durch
|
||
`test_grenze_deckt_die_eigene_last_ab` mechanisch abgesichert; der Test wandert
|
||
mit und bekommt die neuen Zahlen.
|
||
|
||
---
|
||
|
||
## 7. Die neuen Features — Einordnung
|
||
|
||
Kurz, weil § 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":"<laufwerk-id>"}`, der Slot-Zähler steht in `queue.rip_slots`.
|
||
|
||
**Eine Einschränkung, die gemessen gehört, bevor sie geglaubt wird:** Mehrere
|
||
`makemkvcon`-Prozesse teilen sich ein Datenverzeichnis (`_private_data.tar`,
|
||
`settings.conf`). Parallele Schreibzugriffe darauf sind ein Konflikt. Der
|
||
Entwurf sieht deshalb vor: **Rip-Phase parallel, Schlüssel-Phase serialisiert**
|
||
(ein prozessübergreifendes Schloss auf dem Datenverzeichnis). Das ist eine
|
||
Annahme aus der Aktenlage — sie ist an zwei Laufwerken zu prüfen, bevor die
|
||
Grenze festgeschrieben wird.
|
||
|
||
### 7.2 Zero-Click vs. Interaktiv
|
||
|
||
Pro Laufwerk **und** pro Disc-Typ in `laufwerke.profil` (§ 3.3), Vorgabe aus der
|
||
Konfiguration (§ 4.2). Der Ablauf ist derselbe; nur ob nach `scan` ein
|
||
`bestaetigung`-Zustand eingelegt wird, unterscheidet sich. Die Zustandsmaschine
|
||
bekommt genau einen zusätzlichen Zustand — keine zweite Pipeline.
|
||
|
||
### 7.3 Auto-Presets nach gemessener Hardware
|
||
|
||
`caps.erkenne_encoder()` liefert bereits die Backend-Liste. v2 verheiratet sie
|
||
mit dem Disc-Typ zu einem Vorschlag (UHD → verlustfrei durchreichen oder NVENC
|
||
10-bit; BD → QSV HQ; DVD → x264 mit Deinterlacing). **Vorschlag, nicht Zwang** —
|
||
v1s Entscheidung „Kompression je Disc-Typ abwählbar" (Etappe 20) bleibt.
|
||
|
||
### 7.4 Audio-/Untertitel-Regeln
|
||
|
||
v1 kann Sprachwahl vor dem Rip (Etappe 23). v2 macht daraus ein Regelwerk:
|
||
Originalton immer, Wunschsprachen nach Liste, erzwungene Untertitel behalten,
|
||
Kommentarspuren verwerfen, HD-Tonspuren durchreichen statt downmixen. Der Ort
|
||
dafür existiert schon: `ripping.parse_stream_info` und
|
||
`ripping.sprachen_zusammenfassen`.
|
||
|
||
### 7.5 KeyDB / AACS — Schlüsselkette in drei Stufen
|
||
|
||
**Commander-Entscheid 28.08.2026:** automatischer Abruf, mit den bisherigen
|
||
Wegen als Rückfallebene. Das verschiebt die Grenze aus `KONZEPT.md` § 10
|
||
(25.07.2026) bewusst — dort stand: *„Rippy liefert, lädt und verteilt KEINE
|
||
Disc-Schlüssel."* Die Entscheidung ist getroffen und wird hier dokumentiert,
|
||
nicht still vollzogen (`AGENTS.md` Regel B).
|
||
|
||
Vor einem UHD-Rip arbeitet Rippy drei Stufen der Reihe nach ab und **hält an,
|
||
sobald eine trägt**:
|
||
|
||
| Stufe | Quelle | Wann |
|
||
|-------|--------|------|
|
||
| **1** | **Eigener Bestand** — Schlüsselspeicher im Store | immer zuerst. Gefüllt vom Windows-Knoten (§ 4.1, MakeMKV holt dort selbst) und von jedem Import |
|
||
| **2** | **Automatischer Abruf** — konfigurierte Quelle | wenn Stufe 1 die Disc nicht kennt |
|
||
| **3** | **Import von Hand** — `_private_data.tar`, `KEYDB.cfg` über das UI | wenn Stufe 2 nichts liefert. Unverändert aus v1 |
|
||
|
||
Stufe 1 zuerst ist keine Höflichkeit gegenüber der alten Grenze, sondern das
|
||
schnellere Verfahren: Was der eigene Windows-Rechner schon geholt hat, ist da —
|
||
ein Netzabruf dafür wäre reine Wartezeit.
|
||
|
||
**Fünf Regeln für Stufe 2**, alle aus v1-Lehren abgeleitet:
|
||
|
||
1. **Die Adresse steht in der Konfiguration, nicht im Quelltext.**
|
||
`keys.quelle_url` ist leer vorbelegt; ohne Eintrag ist Stufe 2 schlicht
|
||
übersprungen. Grund steht in `.env.example`: *„eine Adresse, die nicht
|
||
liefert, lässt die Konfiguration gesund aussehen und den Bau später
|
||
scheitern."* Eine vorbelegte Adresse, die irgendwann tot ist, wäre genau
|
||
dieselbe Falle.
|
||
2. **Ein funktionierender Bestand wird nie still überschrieben.** Neue Datei
|
||
kommt daneben, wird geprüft (Größe, Format, parsebar), und erst dann
|
||
getauscht. Der vorherige Stand bleibt eine Version lang liegen.
|
||
3. **Ein Fehlschlag ist laut.** Kein `except: pass`. Ergebnis, Zeitpunkt und
|
||
Quelle landen im Log und als `system.notice`-Ereignis im UI
|
||
(`AGENTS.md`: *„Ein Hintergrund-Prozess, der still scheitert, ist schlimmer
|
||
als einer, der laut scheitert"*).
|
||
4. **Das UI zeigt Herkunft und Alter jedes Eintrags.** Woher, wann, wie viele
|
||
Discs — dieselbe Ehrlichkeit, die v1 für den Schlüsselspeicher schon hat.
|
||
5. **Rippy bringt selbst nichts mit.** Weder Installer noch Docker-Image
|
||
enthalten Schlüssel oder eine vorbelegte Bezugsadresse. Was abgerufen wird,
|
||
bestimmt der Betreiber der Installation.
|
||
|
||
Die Weitergabe **innerhalb der eigenen Installation** (Windows-Knoten →
|
||
Linux-Knoten) bleibt wie in § 4.1 beschrieben und ist von Stufe 2 unabhängig —
|
||
sie funktioniert auch, wenn `keys.quelle_url` leer bleibt.
|
||
|
||
### 7.6 Serien-Intelligenz & Anime
|
||
|
||
v1 kann Laufzeitabgleich gegen TMDb (`medien.matche_episoden`) und hat einen
|
||
Jikan-Client (`clients/jikan.py`). v2 zieht beides in `metadata.matching`
|
||
zusammen und ergänzt AniList als zweite Anime-Quelle. Die v1-Regel bleibt:
|
||
**umbenannt wird nur bei eindeutiger Zuordnung** (`KONZEPT.md` § 10).
|
||
|
||
### 7.7 Medienserver-Push
|
||
|
||
`medien.bibliothek_refresh` existiert für Jellyfin/Emby/Plex. v2 macht daraus
|
||
einen Auftrag mit Wiederholung statt eines Aufrufs am Ende der Pipeline — heute
|
||
schlägt ein Refresh still fehl, wenn Jellyfin gerade neu startet.
|
||
|
||
### 7.8 FFmpeg-Direktpfad
|
||
|
||
Als zweiter Transcode-Adapter neben HandBrake, für Remux ohne Neukodierung
|
||
(Container wechseln, Spuren filtern — Sekunden statt Stunden). Auswahl über
|
||
`transcode.engine = "handbrake" | "ffmpeg"`, Vorgabe bleibt HandBrake.
|
||
|
||
---
|
||
|
||
## 8. Migrationsplan
|
||
|
||
### 8.1 Grundregel
|
||
|
||
> **Jede Etappe endet mit grüner Ampel und einem lauffähigen System.**
|
||
> Kein „großer Umbau", nach dem monatelang nichts geht.
|
||
|
||
Der Weg ist so geschnitten, dass v1 bis Etappe 5 **produktiv weiterläuft**. Das
|
||
ist nicht Vorsicht, sondern Notwendigkeit: Auf der VM liegen echte Medien, und
|
||
`AGENTS.md` Regel A gilt unverändert.
|
||
|
||
### 8.2 Was aus v1 wiederverwendet wird
|
||
|
||
Die gute Nachricht zuerst: **Der Fachkern ist bereits weitgehend reine Logik mit
|
||
Tests.** Von etwa 6 600 Zeilen Python im Repo wandert der größte Teil unverändert.
|
||
|
||
**Unverändert übernehmen** (reine Funktionen, Tests wandern mit):
|
||
|
||
| v1 | → v2 | Tests |
|
||
|----|------|-------|
|
||
| `ripping.py` — Parser & Kommandobau (`parse_tinfo_dauern`, `parse_titel_info`, `parse_stream_info`, `get_progress_from_prgv`, `get_progress_from_line`, `build_*_cmd`, `laengster_titel`, `episoden_titel`) | `rip/parser.py`, `rip/makemkv.py`, `rip/abcde.py` | `test_ripping_helpers.py` (24 KB) |
|
||
| `caps.py` | `transcode/caps.py` | `test_caps.py` |
|
||
| `presets.py` | `transcode/presets.py` | `test_presets.py` |
|
||
| `medien.py` | `library/struktur.py`, `library/nfo.py`, `library/mediaserver.py` | `test_medien.py` |
|
||
| `prescan/prescan.py` | `metadata/prescan.py` | `test_prescan_helpers.py` |
|
||
| `clients/*` (tmdb, tvdb, omdb, musicbrainz, jikan) | `metadata/clients/*` | `test_tmdb_helpers.py`, `test_jikan_helpers.py` |
|
||
| `eta.py`, `phasen.py`, `verwaltung.py` | `core/eta.py`, `core/phasen.py`, `core/verwaltung.py` | `test_eta.py`, `test_phasen.py`, `test_verwaltung.py` |
|
||
| `winlauf.py` | `platform/winlauf.py` | — |
|
||
| `ratelimit.py` | `api/ratelimit.py` | `test_ratelimit.py` |
|
||
|
||
**Zusammenführen** (heute doppelt im Repo):
|
||
|
||
| Duplikat | → v2 |
|
||
|----------|------|
|
||
| `api/detection.py` + `worker/detection.py` (byte-identisch) | `drives/detection.py` |
|
||
| `api/makemkv_daten.py` + `worker/makemkv_daten.py` (byte-identisch) | `rip/makemkv_daten.py` |
|
||
| `api/notify.py` + `worker/notify.py` (byte-identisch) | `core/notify.py` |
|
||
| `api/db.py` + `worker/db.py` (überlappend) | `store/schema.py` + `store/sqlite.py` / `store/postgres.py` |
|
||
|
||
**Umbauen:**
|
||
|
||
| v1 | Umfang | → v2 |
|
||
|----|--------|------|
|
||
| `main.py` (2 254 Z, 56 Routen) | groß | 8 Router + `core/pipeline.py`; die Fachlogik wandert *aus* den Routen heraus |
|
||
| `tasks.py` (993 Z) | mittel | `core/pipeline.py` (Ablauf) + `queue/*` (Zustellung); die Celery-Dekoratoren fallen weg |
|
||
| `mounts.py` (20 KB) | mittel | `storage/mounts.py` — **Prüf-Logik bleibt, `mount`-Ausführung fällt weg** (§ 4.3) |
|
||
| `devices.py` | klein | `drives/linux.py` |
|
||
| `zombies.py` (+ 263 Z Tests) | ersetzt | Lease-Mechanik (§ 3.1) — die Tests werden zu Lease-Tests |
|
||
| `logbruecke.py` | klein | `bus/` — aus Log-Weiterleitung wird ein Ereignistyp |
|
||
| `gui.py` (24 KB Flet) | ersetzt | `platform/tray.py` (pystray) + WebView2 auf dieselbe Web-UI |
|
||
| UI: `Settings.tsx` (1 477 Z), `Dashboard.tsx` (920 Z) | groß | Aufteilen; Polling-Haken → ein `useEventStream` |
|
||
|
||
**Neu:**
|
||
`drives/windows.py` · `drives/darwin.py` · `store/migrations/` (Alembic) ·
|
||
`queue/lokal.py` · `bus/*` · `config.py` · `cli/*` · `api/routers/events.py` ·
|
||
`packaging/*`
|
||
|
||
### 8.3 Etappen
|
||
|
||
| # | Titel | Inhalt | Fertig, wenn |
|
||
|---|-------|--------|--------------|
|
||
| **V2-0** | **Monorepo** | `src/rippy/` anlegen, Duplikate zusammenführen, Tests mitziehen. **Verhalten unverändert** — Container importieren aus dem neuen Paket. | Ampel grün, `docker compose up` verhält sich wie vorher, kein Modul mehr doppelt |
|
||
| **V2-1** | **Ports einziehen** | `Store`/`Queue`/`Bus`/`Drives` als Protocol; v1-Verhalten läuft über Postgres-/Celery-/Redis-/Linux-Treiber. Kein Funktionsgewinn — reine Verdrahtung. | Ampel grün; ein Rip auf der VM läuft durch |
|
||
| **V2-2** | **Standalone** | SQLite-Treiber, Alembic, LocalQueue, Lease. `rippyd --profil standalone` auf blankem Linux. | Ein DVD-Rip komplett ohne Postgres/Redis/Docker |
|
||
| **V2-3** | **Echtzeit** | Bus-Treiber, `/api/v2/events`, UI auf `useEventStream`. Polling raus. | Grundlast ≈ 0/min gemessen; Verbindungsabriss leert keine Liste |
|
||
| **V2-4** | **Windows** | `drives/windows.py`, Dienst + Tray + WebView2, Nuitka-Bau, Inno-Setup. | Frischer Win-11-Rechner: Installer → Disc rein → MKV raus. **Und: UHD-Schlüssel automatisch** |
|
||
| **V2-5** | **Docker neu** | Ein Image, drei Profile, GPU-Durchreichung, **Host-Mounts statt Container-Mounts**. | All-in-One und verteilt laufen; `SYS_ADMIN`/`apparmor:unconfined` sind weg |
|
||
| **V2-6** | **CLI & Pakete** | `rippy status/drives/rip/queue/doctor`, systemd-Units, `.deb`/AppImage. | Headless-Server ohne Browser bedienbar |
|
||
| **V2-7** | **Neue Features** | § 7.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.
|
||
|
||
### Entscheid 4 — Echtes Fenster statt Browser-Tab, aber ohne Electron
|
||
|
||
Nachgefragt vom Commander am 28.08.2026:
|
||
|
||
> „Warum nutzen wir für Windows weiterhin einen Browser? Warum nutzen wir kein
|
||
> Electron oder sowas und machen daraus einen echten Client?"
|
||
|
||
Die erste Hälfte trifft zu und wird umgesetzt: Ein Browser-Tab ist kein Client.
|
||
Er hat eine Adresszeile, die niemand braucht, liegt zwischen fremden Tabs, hat
|
||
kein eigenes Symbol in der Taskleiste, und wer den Browser schließt, glaubt, er
|
||
habe Rippy beendet.
|
||
|
||
Die zweite Hälfte wird **abgelehnt**, und zwar gemessen statt geschätzt:
|
||
|
||
| Weg | Zusatz zum Paket | Was mitkommt |
|
||
|-----|------------------|--------------|
|
||
| **pywebview + WebView2** | **8,0 MB** | nur die Anbindung; der Renderer liegt schon auf dem System |
|
||
| Electron | ~150–210 MB | ein komplettes zweites Chromium **und** eine zweite Laufzeitumgebung neben Python |
|
||
|
||
Windows 11 liefert die WebView2-Laufzeit mit. Auf dem Rechner des Commanders am
|
||
28.08.2026 nachgesehen: **151.0.4129.107**. Und nachgemessen, was sie wirklich
|
||
rendert — nicht angenommen:
|
||
|
||
```
|
||
navigator.userAgent …Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0
|
||
fetch ja EventSource ja CSS Grid ja Pfeilfunktionen ja
|
||
```
|
||
|
||
Das ist dasselbe Chromium, das auch in Electron steckt. Rippy bekommt also
|
||
denselben Renderer, nur ohne ihn ein zweites Mal mitzuschleppen. Damit bleibt
|
||
auch § 4.1 unverändert gültig — dort stand WebView2 von Anfang an.
|
||
|
||
**Drei Dinge gehören zum Entscheid, nicht als Beiwerk:**
|
||
|
||
1. **Kein stiller Rückfall auf MSHTML.** `pywebview` kann unter Windows auch den
|
||
alten IE-Renderer nehmen; der stellt die React-Oberfläche nicht dar. Fehlt
|
||
die WebView2-Laufzeit, öffnet Rippy **den Browser** und sagt warum — ein
|
||
leeres Fenster wäre schlimmer als ein Tab.
|
||
2. **Fenster und Tray sind getrennte Prozesse.** `pystray` belegt unter Windows
|
||
den Haupt-Thread, das WebView2-Fenster braucht ihn genauso; zwei
|
||
Nachrichtenschleifen passen nicht in einen Thread. `--dienst` hält Server und
|
||
Tray, `--oeffnen` ist der Client davor. Beide heißen im Taskmanager
|
||
`Rippy.exe`, und das Fenster lässt sich schließen, ohne den Dienst
|
||
mitzureißen.
|
||
3. **Die eigene Konsole wird versteckt, eine geerbte nie.** `Rippy.exe` ist ein
|
||
Konsolenprogramm, weil der Installer seine Meldungen zeigen muss. Beim
|
||
Doppelklick auf das Desktop-Symbol blitzte damit erst eine schwarze Box auf.
|
||
`GetConsoleProcessList` unterscheidet beides: genau ein Prozess an der
|
||
Konsole heißt, sie gehört uns.
|
||
|
||
**Folge für den Plan:** Kein neuer Etappen-Punkt — das ist Teil von V2-4 und
|
||
dort erledigt. `src/rippy/fenster.py` trägt die vollständige Begründung.
|
||
|
||
### Entscheid 5 — Die Werkzeuge gehören ins Setup, nicht in eine Fehlermeldung
|
||
|
||
Der Commander am 28.08.2026:
|
||
|
||
> „handbrake und makemkv MÜSSEN mitgeliefert werden oder während des Setups
|
||
> separat installiert werden! Ohne das ist das tool NICHT einsatzfähig"
|
||
|
||
Er hat recht, und die Lücke war real: `katalog.py` konnte die Werkzeuge
|
||
finden, `beschaffen.py` konnte sie holen — **das Setup rief beides nie auf.**
|
||
Wer Rippy auf einem frischen Rechner installierte, bekam eine Oberfläche, die
|
||
ihm mitteilte, was fehlt, und keinen Weg, es zu ändern.
|
||
|
||
Die beiden gehen unterschiedliche Wege, und der Grund ist die **Lizenz**,
|
||
nicht die Bequemlichkeit:
|
||
|
||
| | Weg | Warum |
|
||
|---|---|---|
|
||
| **HandBrakeCLI** | **mitgeliefert** (+35 MB) | GPL-2 erlaubt die Weitergabe ausdrücklich, solange Lizenztext und Quellverweis dabei sind. `LIZENZ-HandBrake.txt` liegt daneben. Damit komprimiert Rippy auch ohne Internet. |
|
||
| **MakeMKV** | **beim Einrichten geholt** | Proprietär — die Lizenz erlaubt Dritten keine Weitergabe. Rippy lädt die offizielle Datei vom Hersteller und startet dessen Installer. Der Nutzer bezieht sie also weiterhin von MakeMKV; Rippy nimmt ihm nur die Handgriffe ab. |
|
||
|
||
Das deckt sich mit § 4.1 („MakeMKV wird NICHT mitgeliefert") und mit der
|
||
Black-Box-Trennung aus `KONZEPT.md` § 6. Beides bleibt gültig.
|
||
|
||
**Drei Regeln, die zum Entscheid gehören:**
|
||
|
||
1. **Ein Setup meldet nie Erfolg, während ein Pflichtwerkzeug fehlt.**
|
||
`einrichten.sicherstellen()` liest den Bestand VOR und NACH dem Versuch
|
||
und gibt zurück, was danach wirklich da ist — nicht, dass es versucht
|
||
wurde.
|
||
2. **Ein nicht erreichbarer Download bricht die Installation nicht ab.** Am
|
||
28.08.2026 antwortete makemkv.com mit HTTP 525. Rippy ist dann trotzdem
|
||
installiert und sagt im Klartext, was fehlt und wie man es beschafft.
|
||
3. **Ohne HandBrake ist Rippy einsatzbereit, ohne MakeMKV nicht.** Ein Rip
|
||
läuft ohne Kompression durch — das ist ein vorgesehener Betriebsfall.
|
||
Ohne MakeMKV ist Rippy ein Anzeigeprogramm. Nur `pflicht`-Werkzeuge
|
||
entscheiden über „einsatzbereit".
|
||
|
||
**Nicht im Repo:** Die 70,9 MB HandBrakeCLI holt der **Bau** nach
|
||
`dist/windows/vendor/` (nicht versioniert) — dasselbe Muster wie der
|
||
`vendor/`-Ordner für MakeMKV auf der VM. Ein Binärblob in git wäre bei jedem
|
||
Klon dabei und bei jedem Update ein zweites Mal.
|
||
|
||
|
||
### Entscheid 6 — Die Oberfläche kennt ihren Betrieb, und das Setup fragt
|
||
|
||
Zwei Befunde des Commanders am 28.08.2026, beide am selben Punkt:
|
||
|
||
> „Du hast ja quasi nur rippy genommen und die docker installation für Windows
|
||
> gebaut. […] Das gilt für die ganze standalone version für Windows, auch für
|
||
> die settings und die Anleitung usw."
|
||
|
||
> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert —
|
||
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
|
||
> garnichts Rippy geht einfach auf."
|
||
|
||
#### 6a — Fähigkeiten statt Modus-Name
|
||
|
||
Im Windows-Fenster stand `Worker erreichbar: 0 von 1`, `Container-Platte:
|
||
unbekannt` und `Prüfen: docker compose ps`. Kein Satz davon ergibt dort einen
|
||
Sinn.
|
||
|
||
Die naheliegende Reparatur wäre ein `if (windows)` an dreißig Stellen gewesen
|
||
— dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung. Es
|
||
fehlte etwas anderes: **Das UI hat nie erfahren, worauf es läuft.**
|
||
|
||
`GET /betrieb` meldet deshalb **Fähigkeiten**, keinen Namen:
|
||
|
||
externe_worker Gibt es andere Maschinen, die Jobs übernehmen?
|
||
freigaben_einhaengen Kann Rippy Netzwerk-Freigaben selbst einhängen?
|
||
container_pfade Sind Pfade wie /app/media überhaupt gemeint?
|
||
werkzeuge_verwalten Kann Rippy MakeMKV/HandBrake selbst beschaffen?
|
||
|
||
Ein Modus-Name würde das UI zwingen, aus einem Namen auf Verhalten zu
|
||
schließen — und das bricht beim nächsten Betriebsfall: Ein Docker-All-in-One
|
||
hat Container-Pfade, aber keinen zweiten Worker.
|
||
|
||
#### 6b — Ein Setup, das prüft und fragt
|
||
|
||
Acht Voraussetzungs-Prüfungen, zwei Ordner-Wahlen (Programm **und** Ablage),
|
||
Port, drei Schalter. Drei Regeln:
|
||
|
||
1. **Nur ein Fehler blockiert.** Eine Warnung, die den Knopf sperrt, ist eine
|
||
Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Kein optisches
|
||
Laufwerk ist eine Warnung — eine reine Komprimier-Maschine ist ein
|
||
vorgesehener Betriebsfall.
|
||
2. **Jeder Befund sagt, was zu tun ist.** Ein Test hält das für jede Prüfung
|
||
fest.
|
||
3. **Die Prüfungen kennen kein Fenster.** Sie stehen in `einrichtung.py` und
|
||
sind vollständig ohne WebView2 prüfbar; im Fenstermodul steht nur Aufbau
|
||
und Brücke.
|
||
|
||
WebView2 statt Win32-Dialog: Es ist wegen Entscheid 4 ohnehin da — der
|
||
Assistent kostet **null zusätzliche Bytes**. Die Seite lädt nichts aus dem
|
||
Netz; sie muss auf einem Rechner ohne Internet aufgehen.
|
||
|
||
---
|
||
|
||
|
||
### 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.
|