Auf die Frage des Commanders: "Warum nutzen wir fuer Windows weiterhin
einen Browser? Warum nutzen wir kein Electron oder sowas und machen daraus
einen echten Client."
Die erste Haelfte trifft zu und ist umgesetzt. Die zweite ist abgelehnt --
gemessen, nicht geschaetzt:
pywebview + WebView2 8,0 MB in der Bau-Umgebung, 2,7 MB in der EXE
Electron 150-210 MB, dazu eine zweite Laufzeitumgebung
Windows 11 bringt die WebView2-Laufzeit mit (hier 151.0.4129.107).
Nachgemessen statt angenommen, was sie rendert:
navigator.userAgent ...Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0
fetch ja EventSource ja CSS Grid ja Pfeilfunktionen ja
Dasselbe Chromium, das auch in Electron steckt -- nur ohne es ein zweites
Mal mitzuschleppen. RippySetup.exe waechst von 29,0 auf 31,7 MB.
Drei Dinge gehoeren dazu, nicht als Beiwerk:
* Kein stiller Rueckfall auf MSHTML. Der alte IE-Renderer stellt die
React-Oberflaeche nicht dar. Fehlt WebView2, oeffnet Rippy den Browser
und SAGT warum -- ein leeres Fenster waere schlimmer als ein Tab.
* Fenster und Tray sind getrennte Prozesse. pystray belegt unter Windows
den Haupt-Thread, das Fenster braucht ihn genauso.
* Die eigene Konsole wird versteckt, eine geerbte nie. GetConsoleProcessList
unterscheidet beides: genau ein Prozess an der Konsole heisst, sie
gehoert uns. Ohne diese Unterscheidung haette "Rippy.exe --status" im
Terminal das Terminal des Nutzers verschwinden lassen.
fix(windows): Oberflaeche neben das Programm legen statt aus %TEMP% bedienen
Beim Nachsehen im laufenden Betrieb gefunden: Ein Dienst lief eine Stunde,
/api/health gab HTTP 200, / gab 404. Im Fenster stand {"detail":"Not Found"}.
Der Server war in Ordnung. Sein Entpack-Verzeichnis war es nicht:
_MEI000074b02 31 Eintraege, 5 Ordner -- kein ui, kein api
_MEI000082e82 44 Eintraege, 16 Ordner -- vollstaendig
Eine PyInstaller-Onefile-EXE liest bei JEDER Anfrage aus %TEMP%\_MEIxxxxx.
Ein Temp-Verzeichnis ist kein Ort fuer etwas, das eine Woche liegen bleibt.
Und der Ausfall ist der schlimmstmoegliche: Die API antwortet weiter, der
Dienst gilt als gesund, nur die Oberflaeche ist weg.
Genau davor warnte ROADMAP.md beim Bau-Verfahren ("one-dir statt one-file").
Die Abweichung bleibt, die Luecke wird geschlossen: Der Installer legt die
Oberflaeche neben das Programm, daemon._ui_pfad() nimmt diese Kopie zuerst.
Zeigt sich derselbe Ausfall an den API-Modulen, ist one-dir die Antwort.
Ampel: 562 gruen, ruff sauber. Fenster mit der echten Oberflaeche im
Bildschirmfoto nachgewiesen, nicht nur der Titel geprueft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1348 lines
61 KiB
Markdown
1348 lines
61 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.
|
||
|
||
---
|
||
|
||
### 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.
|