WAS: Der Store spricht jetzt beide Datenbanken (PostgreSQL wie bisher, SQLite fuer den Standalone-Betrieb). Dazu die LocalQueue: Auftraege als Tabelle, Vergabe per bedingtem UPDATE, Lease statt Zombie-Jagd. Und die Konfigurationsschicht mit EINER Praezedenz fuer alle drei Betriebsarten. WARUM: Ohne SQLite und ohne broker-lose Queue gibt es keinen Standalone- Betrieb — und ohne den keine Windows-App und keine Headless-Variante. Beides haengt an dieser Etappe. DER GRUNDSATZ (KONZEPT-V2.md §3.1): Die Datenbank ist die Wahrheit ueber den Job-Zustand, der Broker ist nur der Wecker. Daraus folgt die ganze Queue: Ein Auftrag ist eine Zeile mit claimed_by und lease_until, ein Knoten uebernimmt ihn per bedingtem UPDATE (es gewinnt genau EINER, auch wenn zehn gleichzeitig fragen), und er haelt ihn per Lease am Leben. Laeuft die Lease ab, ist der Auftrag frei — egal ob Absturz, Netzausfall oder gezogener Stecker. Damit gibt es den Zustand "laeuft, aber niemand arbeitet daran" nicht mehr, den v1 mit zombies.py (206 Zeilen + 263 Zeilen Tests) nachtraeglich einsammeln musste. Er kann hoechstens 60 Sekunden bestehen und heilt sich dann selbst. Beides ist geprueft: zwei Knoten bekommen NICHT denselben Auftrag, und eine abgelaufene Lease gibt ihn wirklich wieder her. KEINE QUEUE-BIBLIOTHEK (Taskiq/ARQ/Celery-lite), Begruendung im Modul-Docstring: Der teure Teil ist kein Task, sondern ein 30-90-Minuten- Subprozess mit Fortschritts-Parsing. Was die Bibliotheken loesen, ist nicht das Problem; was das Problem ist, muss man ohnehin selbst bauen. ABWEICHUNG VOM ENTWURF — KEIN ALEMBIC (in KONZEPT-V2.md §3.2 vermerkt): Die Datenbank auf der VM gibt es schon, Alembic muesste sie erst stempeln — ein Handgriff auf einer laufenden Installation, den beim naechsten Deploy jemand vergisst. Und es gibt hier nichts zu versionieren: drei nachgetragene Spalten. store.migrieren() fragt stattdessen per inspect() nach und legt nur Fehlendes an; das laeuft auf beiden Dialekten. Der alte Weg (ADD COLUMN IF NOT EXISTS) war korrekte Postgres-Syntax und waere auf SQLite gebrochen. EIN KONSTRUKTIONSFEHLER, SELBST GEFUNDEN: lokal.py hatte anfangs `from rippy.store import engine` — ein Import bindet den Wert EINMAL, ein spaeteres store.verbinden() waere nie angekommen, und das Modul haette weiter mit der alten Datenbank gesprochen. Jetzt wird store.engine zur Aufrufzeit gelesen; die Warnung steht an beiden Stellen im Code. GEMESSEN: ruff sauber, 346 Tests gruen + 3 uebersprungen (vorher 301). Neu: 30 Tests gegen ECHTES SQLite (kein Fake) — inklusive der Gegenprobe, dass WAL und busy_timeout wirklich gesetzt sind, und dass migrieren() eine weggenommene Spalte zurueckholt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
59 KiB
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
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:
apihat keinen Pfeil aufripoderdrives. In v1 hat sie den:main.pyimportiertdevices.pyund rufteject()direkt auf. Deshalb braucht der API-Container heute einendevices:-Eintrag in der Compose-Datei undSYS_ADMIN.- Die Ports sitzen zwischen Kern und Treibern — nicht daneben. Ein Aufruf von
corenachSQLitegibt es nicht, nurcore → Store → SQLite. - 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-Protocols — keine Basisklassen, keine Vererbung, testbar
ohne Mocking-Bibliothek.
# 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 fragtstore.migrieren()perinspect()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:
-- 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:
- 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. - 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.
- Mit § 3.1 ist der Treiber trivial. LocalQueue ist ein
SELECT … WHERE status='wartend' … LIMIT 1plus bedingtesUPDATEplus einProcessPoolExecutor. Das sind etwa 150 Zeilen, vollständig testbar, ohne laufenden Broker.
# 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.Queueje 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 Tabelleereignisse(§ 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
# 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 jedemHandBrakeCLI --versionein Konsolenfenster auf. v1-Befund 26.07.2026.- Standby verhindern:
SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)beim ersten laufenden Auftrag, zurücksetzen beim letzten. OhneES_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
# /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
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
# /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
# 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
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/fstabbzw. eine.mount-Unit, dann Bind-Mount. - Bequemer Weg für Compose-Nutzer: der eingebaute Volume-Treiber —
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
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.
# 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:
- 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. - Jede Hardware-Operation läuft mit Zeitgrenze in einem Ausführer, nie
direkt im Ereignis-Loop. Ein hängendes
ioctlauf 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 |
keys |
/api/v2/keys |
`GET |
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:
snapshotzuerst, dann nur Deltas. Ein Client, der verbindet, bekommt nie ein halbes Bild.seqist monoton und lückenlos. Ein Reconnect mitLast-Event-IDliefert alles Verpasste nach (Ringpuffer, § 3.5). Ist die Lücke zu groß, schickt der Server statt Deltas einen neuensnapshot— ausdrücklich, nicht stillschweigend.- 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.mdalscatch(() => [])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:
- Die Adresse steht in der Konfiguration, nicht im Quelltext.
keys.quelle_urlist 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. - 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.
- Ein Fehlschlag ist laut. Kein
except: pass. Ergebnis, Zeitpunkt und Quelle landen im Log und alssystem.notice-Ereignis im UI (AGENTS.md: „Ein Hintergrund-Prozess, der still scheitert, ist schlimmer als einer, der laut scheitert"). - Das UI zeigt Herkunft und Alter jedes Eintrags. Woher, wann, wie viele Discs — dieselbe Ehrlichkeit, die v1 für den Schlüsselspeicher schon hat.
- 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.shund die Gitea-CI — unverändert; die Ampel prüft ab V2-0 das neue Paket mit.- Die alte Compose-Datei — bleibt bis V2-5 als lauffähiger Rückweg liegen.
9. Risiken & offene Punkte
| Risiko | Bewertung | Behandlung |
|---|---|---|
| Umbau bricht die laufende VM | hoch × mittel | V2-0/V2-1 ändern kein Verhalten; alte Compose bleibt bis V2-5 |
| SQLite unter Last | mittel × niedrig | WAL + busy_timeout; ein Schreiber-Kontext. Grenze (~4 Rip-Knoten) dokumentiert, darüber Postgres |
| Nuitka/PyInstaller + Virenscanner | mittel | One-dir statt one-file; signierte EXE erwägen; Ausnahme-Anleitung in der Doku |
| Win32-DAL auf fremder Hardware | mittel | Nur die IOCTLs benutzen, die im MS-Storage-Stack dokumentiert sind; UNBEKANNT statt Raten; auf ≥ 2 Laufwerken prüfen |
| Parallel-Rip vs. MakeMKV-Datenverzeichnis | mittel | § 7.1 — messen, bevor die Grenze festgeschrieben wird |
| MakeMKV hat kein ARM64-Binary | niedrig × sicher | ARM64-Image = Encode/API-Knoten, in der Image-Beschreibung benannt |
| SSE hinter fremdem Reverse-Proxy | niedrig | X-Accel-Buffering: no, Heartbeat-Kommentar alle 15 s, Doku-Abschnitt |
| Zwei Queue-Treiber divergieren | mittel | Ein gemeinsamer Vertragstest läuft gegen beide Treiber — die Testsuite ist die Bremse |
Mount-Passwörter im Klartext (v1: storage_mounts.passwort) |
mittel | v2: Verweis auf OS-Schlüsselspeicher (DPAPI / libsecret / Datei mit 0600). Heimnetz-Kompromiss aus v1 wird damit aufgelöst |
| KeyDB-Quelle wird irgendwann tot sein | mittel × sicher | § 7.5 Regel 1–3: Adresse in der Konfiguration, Bestand wird nie still überschrieben, Fehlschlag ist laut. Stufe 1 und 3 tragen weiter |
| Verantwortung für die Bezugsquelle | Betreiber-Sache | § 7.5 Regel 5: Rippy bringt weder Schlüssel noch eine vorbelegte Adresse mit. Was abgerufen wird, trägt die Installation ein |
10. Getroffene Entscheidungen
Alle drei offenen Fragen sind am 28.08.2026 vom Commander entschieden.
Entscheid 1 — Reihenfolge: Echtzeit vor Windows
Der Plan bleibt wie in § 8.3: V2-3 (Polling raus, SSE rein) kommt vor V2-4 (Windows). Begründung, die dafür gesprochen hat: Der Windows-Modus braucht LocalQueue und SQLite ohnehin; in dieser Reihenfolge wird er eine Treiber-Datei statt eines zweiten Sonderwegs. Praktische Folge für den Alltag: Das ständige Neuladen im UI verschwindet zuerst.
Keine Änderung am Plan.
Entscheid 2 — KeyDB: automatischer Abruf mit Rückfallebene
Der Commander hat sich für den automatischen Abruf entschieden, mit den
bisherigen Wegen als Rückfallebene. Damit wird die Grenze aus KONZEPT.md
§ 10 (25.07.2026) — „Rippy liefert, lädt und verteilt KEINE Disc-Schlüssel" —
bewusst verschoben.
Umgesetzt als Kette in drei Stufen (§ 7.5): eigener Bestand → automatischer Abruf → Import von Hand. Die fünf Regeln dort sind Teil des Entscheids, nicht Beiwerk — insbesondere: die Bezugsadresse steht in der Konfiguration und ist leer vorbelegt, und Rippy bringt weder im Installer noch im Docker-Image Schlüssel oder eine vorbelegte Adresse mit.
Folge für den Plan: Etappe V2-7 bekommt „Schlüsselkette in drei Stufen" als
eigenen Punkt. KONZEPT.md § 10 braucht eine Fortschreibung mit diesem Datum —
sonst widersprechen sich die beiden Dokumente.
Entscheid 3 — NAS-Ordner: Zeile zum Kopieren
v2 nimmt der Anwendung das Mounten aus der Hand (§ 4.3): Der Host mountet, Rippy
prüft und meldet. Statt des Knopfes im Browser zeigt das UI eine fertige,
kopierbare Zeile — für /etc/fstab, für eine .mount-Unit oder als
volumes:-Block für die compose.yml, passend zum erkannten Betriebsmodus.
Folge für den Plan: Die Prüf- und Übersetzungs-Logik aus mounts.py bleibt
vollständig erhalten (Erreichbarkeit mit Zeitgrenze, SMB-Klartextfehler,
Pfad-Map-Vorschlag) und bekommt einen neuen Nachbarn: einen Generator, der aus
den eingegebenen Daten die passende Zeile baut. Das UI behält also seine
Eingabemaske — nur der Knopf „Verbinden" wird zu „Zeile kopieren".
Konsequenz, die dazugehört: SYS_ADMIN, DAC_READ_SEARCH,
apparmor:unconfined, propagation: rshared und die Mount-Wache fallen in
V2-5 ersatzlos weg.
Was jetzt noch fehlt, bevor gebaut wird
KONZEPT.md§ 10 fortschreiben — Entscheid 2 widerspricht dem Eintrag vom 25.07.2026. Solange beide Dokumente nebeneinander stehen, ist unklar, welches gilt.ROADMAP.md— Etappen V2-0 bis V2-7 eintragen.SAVEPOINT.md— Stand festhalten, damit die nächste Sitzung nicht bei null anfängt.
Anhang A — Fortschreibung von KONZEPT.md
Dieses Dokument streicht kein Muss-Feature aus KONZEPT.md § 4. Es ändert
die Umsetzung an vier Stellen; alle vier sind oben begründet:
KONZEPT.md sagt |
v2 macht | § |
|---|---|---|
| „React-UI (statisch via Nginx)" | statisch via FastAPI StaticFiles; nginx optional |
4.2 |
| „Celery-Queue + Redis mit AOF-Persistence" | im verteilten Modus unverändert; standalone LocalQueue | 3.4 |
| „PostgreSQL für Job-Logs" | im verteilten Modus unverändert; standalone SQLite/WAL | 3.2 |
| „Disc-Erkennung via udev-Event" (in v1 zu ioctl-Poll geändert) | udev wieder möglich (nativ), Poll als Rückfallebene | 4.2 |
Neu hinzu kommen die drei Betriebsmodi (§ 4), der DAL (§ 5) und das Ereignis-Protokoll (§ 6) — alles Ergänzungen, keine Ersetzungen.