Files
rippy/KONZEPT-V2.md
HitonabiandClaude Opus 5 95beb91364
Ampel / ampel (push) Successful in 1m1s
feat(tools): Ausweichquellen fuer MakeMKV — recherchiert und gemessen
Commander: "bitte baue fuer MakeMKV fallback seiten ein."

Anlass war ein echter Ausfall. Am 28.08.2026 gemessen:

    https://www.makemkv.com/download/   HTTP 525   (dauerhaft)
    https://makemkv.com/download/       HTTP 525
    http://www.makemkv.com/download/    HTTP 403
    https://forum.makemkv.com/          200 / 522 / Zeitablauf (wechselnd)
    web.archive.org/web/2025id_/...exe  200, 16.432.607 Bytes

525/522 heissen: Cloudflare erreicht den Ursprungsserver nicht. Eine Stoerung
beim Hersteller -- dieselbe, die im Projekt schon einmal jeden Worker-Build
lahmgelegt hat.

## Die Kette

Version:  Hersteller-Seite (massgeblich) -> Forum-Ankuendigungen -> Archiv
Datei:    eigene Quelle -> Hersteller -> Internet Archive

Der Hersteller zuerst, immer. Das Archiv ist Rueckfallebene, und Rippy nennt
hinterher die Quelle, aus der die Datei kam.

Zwei Feinheiten, beide aus Messungen statt aus dem Kopf:

* Die HOECHSTE Nummer gewinnt, nicht die erste. Antwortet das Forum nicht,
  meldet das Archiv einen aelteren Stand (1.18.2 statt 1.18.4) -- "neueste
  Fassung" waere dann eine falsche Aussage.
* Ein zweiter Anlauf, mit knapper Zeitgrenze. Drei Abrufe am Forum:
  TimeoutError, HTTP 522, dann Erfolg. Ohne Wiederholung fiel die Quelle in
  zwei von drei Faellen aus; mit 20-Sekunden-Grenzen dauerte der schlimmste
  Fall 46 Sekunden, in denen das Setup eingefroren aussah. Jetzt acht
  Sekunden je Versuch, schlimmstenfalls gut zwanzig.

## Warum eine fremde Quelle trotzdem sicher ist

Der naheliegende Schutz geht NICHT: MakeMKV signiert seinen Installer nicht
(Get-AuthenticodeSignature -> NotSigned, an der echten Datei gemessen). Eine
Signaturpruefung waere eine, die immer fehlschlaegt -- schlimmer als keine,
weil sie Sicherheit vortaeuscht.

Geprueft wird die Versions-Ressource, ebenfalls gemessen:

    CompanyName      GuinpinSoft inc
    FileDescription  MakeMKV installer
    FileVersion      v1.18.4

Das ersetzt keine Signatur, faengt aber ab, was hier wirklich droht: eine
Fehlerseite mit .exe-Namen, ein abgebrochener Download, eine falsche Fassung.

Ende zu Ende nachgewiesen, ohne etwas zu installieren:

    Version laut Kette: 1.18.4
    Hersteller:         HTTP 525 -> uebersprungen
    Internet Archive:   15,7 MB in 6,1s
    Pruefung:           BESTANDEN (MakeMKV v1.18.4)

Die Grenze aus KONZEPT.md 6 bleibt: Rippy liefert MakeMKV weiterhin NICHT
mit. Es holt die Datei des Herstellers -- im Rueckfall aus einem Archiv, das
genau diese Datei aufbewahrt.

Ampel lokal: 733 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:19:46 +02:00

68 KiB
Raw Permalink Blame History

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
12 Wie ist v2 aufgebaut, und warum genau so?
3 Wie kann dieselbe Anwendung mit SQLite allein UND mit Postgres+Redis+Remote-Workern laufen?
45 Wie sieht das auf Windows, auf einem nackten Linux-Server und in Docker konkret aus?
6 Welche Endpunkte und welche Live-Ereignisse gibt es?
710 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 &amp; 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-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 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:

-- 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 3090-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.
# 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):

  • MemoryBusasyncio.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   → 1N 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_DIRECTCurrent 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
# /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/fstab bzw. 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 + BLKGETSIZE64detection.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
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:

  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 § 16 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.pyPrü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.17.8, darin die Schlüsselkette in drei Stufen (§ 7.5, Entscheid 2) je Feature ein Nachweis

Etappen 01 ändern kein beobachtbares Verhalten. Das ist der Preis dafür, dass 27 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 13: 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 ~150210 MB ein komplettes zweites Chromium und eine zweite Laufzeitumgebung neben Python

Windows 11 liefert die WebView2-Laufzeit mit. Auf dem Rechner des Commanders am 28.08.2026 nachgesehen: 151.0.4129.107. Und nachgemessen, was sie wirklich rendert — nicht angenommen:

navigator.userAgent   …Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0
fetch  ja      EventSource  ja      CSS Grid  ja      Pfeilfunktionen  ja

Das ist dasselbe Chromium, das auch in Electron steckt. Rippy bekommt also denselben Renderer, nur ohne ihn ein zweites Mal mitzuschleppen. Damit bleibt auch § 4.1 unverändert gültig — dort stand WebView2 von Anfang an.

Drei Dinge gehören zum Entscheid, nicht als Beiwerk:

  1. Kein stiller Rückfall auf MSHTML. pywebview kann unter Windows auch den alten IE-Renderer nehmen; der stellt die React-Oberfläche nicht dar. Fehlt die WebView2-Laufzeit, öffnet Rippy den Browser und sagt warum — ein leeres Fenster wäre schlimmer als ein Tab.
  2. Fenster und Tray sind getrennte Prozesse. pystray belegt unter Windows den Haupt-Thread, das WebView2-Fenster braucht ihn genauso; zwei Nachrichtenschleifen passen nicht in einen Thread. --dienst hält Server und Tray, --oeffnen ist der Client davor. Beide heißen im Taskmanager Rippy.exe, und das Fenster lässt sich schließen, ohne den Dienst mitzureißen.
  3. Die eigene Konsole wird versteckt, eine geerbte nie. Rippy.exe ist ein Konsolenprogramm, weil der Installer seine Meldungen zeigen muss. Beim Doppelklick auf das Desktop-Symbol blitzte damit erst eine schwarze Box auf. GetConsoleProcessList unterscheidet beides: genau ein Prozess an der Konsole heißt, sie gehört uns.

Folge für den Plan: Kein neuer Etappen-Punkt — das ist Teil von V2-4 und dort erledigt. src/rippy/fenster.py trägt die vollständige Begründung.

Entscheid 5 — Die Werkzeuge gehören ins Setup, nicht in eine Fehlermeldung

Der Commander am 28.08.2026:

„handbrake und makemkv MÜSSEN mitgeliefert werden oder während des Setups separat installiert werden! Ohne das ist das tool NICHT einsatzfähig"

Er hat recht, und die Lücke war real: katalog.py konnte die Werkzeuge finden, beschaffen.py konnte sie holen — das Setup rief beides nie auf. Wer Rippy auf einem frischen Rechner installierte, bekam eine Oberfläche, die ihm mitteilte, was fehlt, und keinen Weg, es zu ändern.

Die beiden gehen unterschiedliche Wege, und der Grund ist die Lizenz, nicht die Bequemlichkeit:

Weg Warum
HandBrakeCLI mitgeliefert (+35 MB) GPL-2 erlaubt die Weitergabe ausdrücklich, solange Lizenztext und Quellverweis dabei sind. LIZENZ-HandBrake.txt liegt daneben. Damit komprimiert Rippy auch ohne Internet.
MakeMKV beim Einrichten geholt Proprietär — die Lizenz erlaubt Dritten keine Weitergabe. Rippy lädt die offizielle Datei vom Hersteller und startet dessen Installer. Der Nutzer bezieht sie also weiterhin von MakeMKV; Rippy nimmt ihm nur die Handgriffe ab.

Das deckt sich mit § 4.1 („MakeMKV wird NICHT mitgeliefert") und mit der Black-Box-Trennung aus KONZEPT.md § 6. Beides bleibt gültig.

Drei Regeln, die zum Entscheid gehören:

  1. Ein Setup meldet nie Erfolg, während ein Pflichtwerkzeug fehlt. einrichten.sicherstellen() liest den Bestand VOR und NACH dem Versuch und gibt zurück, was danach wirklich da ist — nicht, dass es versucht wurde.
  2. Ein nicht erreichbarer Download bricht die Installation nicht ab. Am 28.08.2026 antwortete makemkv.com mit HTTP 525. Rippy ist dann trotzdem installiert und sagt im Klartext, was fehlt und wie man es beschafft.
  3. Ohne HandBrake ist Rippy einsatzbereit, ohne MakeMKV nicht. Ein Rip läuft ohne Kompression durch — das ist ein vorgesehener Betriebsfall. Ohne MakeMKV ist Rippy ein Anzeigeprogramm. Nur pflicht-Werkzeuge entscheiden über „einsatzbereit".

Nicht im Repo: Die 70,9 MB HandBrakeCLI holt der Bau nach dist/windows/vendor/ (nicht versioniert) — dasselbe Muster wie der vendor/-Ordner für MakeMKV auf der VM. Ein Binärblob in git wäre bei jedem Klon dabei und bei jedem Update ein zweites Mal.

Entscheid 6 — Die Oberfläche kennt ihren Betrieb, und das Setup fragt

Zwei Befunde des Commanders am 28.08.2026, beide am selben Punkt:

„Du hast ja quasi nur rippy genommen und die docker installation für Windows gebaut. […] Das gilt für die ganze standalone version für Windows, auch für die settings und die Anleitung usw."

„Dann hätte ich beim Setup' auch erwartet das nen echtes setup passiert — wo will ich das hinspeichern, nen pre requirement check usw. Da kommt garnichts Rippy geht einfach auf."

6a — Fähigkeiten statt Modus-Name

Im Windows-Fenster stand Worker erreichbar: 0 von 1, Container-Platte: unbekannt und Prüfen: docker compose ps. Kein Satz davon ergibt dort einen Sinn.

Die naheliegende Reparatur wäre ein if (windows) an dreißig Stellen gewesen — dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung. Es fehlte etwas anderes: Das UI hat nie erfahren, worauf es läuft.

GET /betrieb meldet deshalb Fähigkeiten, keinen Namen:

externe_worker        Gibt es andere Maschinen, die Jobs übernehmen?
freigaben_einhaengen  Kann Rippy Netzwerk-Freigaben selbst einhängen?
container_pfade       Sind Pfade wie /app/media überhaupt gemeint?
werkzeuge_verwalten   Kann Rippy MakeMKV/HandBrake selbst beschaffen?

Ein Modus-Name würde das UI zwingen, aus einem Namen auf Verhalten zu schließen — und das bricht beim nächsten Betriebsfall: Ein Docker-All-in-One hat Container-Pfade, aber keinen zweiten Worker.

6b — Ein Setup, das prüft und fragt

Acht Voraussetzungs-Prüfungen, zwei Ordner-Wahlen (Programm und Ablage), Port, drei Schalter. Drei Regeln:

  1. Nur ein Fehler blockiert. Eine Warnung, die den Knopf sperrt, ist eine Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Kein optisches Laufwerk ist eine Warnung — eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall.
  2. Jeder Befund sagt, was zu tun ist. Ein Test hält das für jede Prüfung fest.
  3. Die Prüfungen kennen kein Fenster. Sie stehen in einrichtung.py und sind vollständig ohne WebView2 prüfbar; im Fenstermodul steht nur Aufbau und Brücke.

WebView2 statt Win32-Dialog: Es ist wegen Entscheid 4 ohnehin da — der Assistent kostet null zusätzliche Bytes. Die Seite lädt nichts aus dem Netz; sie muss auf einem Rechner ohne Internet aufgehen.

5a — Ausweichquellen für MakeMKV (28.08.2026, nachgetragen)

Der Commander: „bitte baue für MakeMKV fallback seiten ein." Anlass war ein echter Ausfall. Gemessen:

https://www.makemkv.com/download/   HTTP 525   (dauerhaft)
https://forum.makemkv.com/          Zeitablauf / 522 / OK  (wechselnd)
web.archive.org/web/2025id_/…exe    HTTP 200, 16.432.607 Bytes

525/522 heißen: Cloudflare erreicht den Ursprungsserver nicht. Eine Störung beim Hersteller, nichts, was Rippy reparieren könnte.

Die Kette, in dieser Reihenfolge:

Version Datei
eigene Quelle (basis) zuerst, wer sie einträgt hat sich etwas dabei gedacht
Hersteller-Seite maßgeblich — wer sie erreicht, sucht nicht weiter ja
Hersteller-Forum ja (Ankündigungs-Bereich)
Internet Archive ja (Rückfall) ja

Zwei Feinheiten, beide aus Messungen:

  • Die höchste Nummer gewinnt, nicht die erste. Antwortet das Forum nicht, meldet das Archiv einen älteren Stand (1.18.2 statt 1.18.4) — „neueste Fassung" wäre dann eine falsche Aussage.
  • Ein zweiter Anlauf, mit knapper Zeitgrenze. Drei Abrufe am Forum: Zeitablauf, HTTP 522, dann Erfolg. Ohne Wiederholung fiel die Quelle in zwei von drei Fällen aus; mit 20-Sekunden-Grenzen dauerte der schlimmste Fall 46 Sekunden, in denen das Setup eingefroren aussah. Jetzt: acht Sekunden je Versuch, schlimmstenfalls gut zwanzig insgesamt.

Und warum das trotzdem sicher ist. Der naheliegende Schutz geht nicht: MakeMKV signiert seinen Installer nicht (Get-AuthenticodeSignatureNotSigned, an der echten Datei gemessen). Eine Signaturprüfung wäre eine, die immer fehlschlägt. Geprüft wird stattdessen die Versions-Ressource:

CompanyName      GuinpinSoft inc
FileDescription  MakeMKV installer
FileVersion      v1.18.4

Das ersetzt keine Signatur — wer die Datei fälscht, fälscht auch die Ressource. Es fängt aber zuverlässig ab, was hier wirklich droht: eine Fehlerseite mit .exe-Namen, ein abgebrochener Download, eine falsche Fassung. Und Rippy nennt hinterher die Quelle, aus der die Datei kam.

Die Grenze aus KONZEPT.md § 6 bleibt unangetastet: Rippy liefert MakeMKV weiterhin NICHT mit. Es holt die Datei des Herstellers — im Rückfall aus einem Archiv, das genau diese Datei aufbewahrt.

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.