Compare commits

2 Commits
Author SHA1 Message Date
HitonabiandClaude Opus 5 678fe276f9 feat(windows): Arbeitsordner im Setup, Beta-Key auf Knopfdruck
Ampel / ampel (push) Successful in 1m18s
Zwei Wuensche des Commanders vom 28.08.2026.

## 1. „kannst du noch einbauen das man den arbeitsordner usw. in den
##     Einstellungen setzen kann und direkt im setup?"

In den Einstellungen ging es seit 35370fd (freies Pfadfeld plus die Laufwerke
als Ein-Klick-Wahl). Im SETUP wurde bisher nur nach Programmordner und Ablage
gefragt — der Arbeitsordner tauchte erst auf, wenn schon installiert war.

Das ist die falsche Reihenfolge: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB
gross. Wer erst NACH der ersten vollen Platte erfaehrt, wo die liegen, hat es
zu spaet erfahren — genau so lief am 25.07.2026 die VM-Platte voll.

Neu ist auch ein Waechter, der alle drei Arten von halb angeschlossenem Feld
faengt (kein Eingabefeld, keine Vorbelegung, wird beim Installieren nicht
mitgeschickt). Der dritte Fall ist der gemeinste: Man waehlt etwas, es
passiert nichts, und niemand sagt warum.

## 2. „der könnte theoretisch auch automatisch ausgelesen werden, ich glaube
##     das web rippy kann das"

Er hatte recht. `makemkv_key.refresh_loop()` laeuft seit jeher taeglich mit
(main.py, Startereignis). GEMESSEN am 28.08.2026: Forum antwortet in 3,3 s,
gueltiger Key mit 62 Zeichen, und in seinen Einstellungen lag bereits einer.

Es war nur nichts davon zu sehen. Kein Knopf, keine Meldung, keine Zeitangabe
— im Feld stand ein Key, und ob der von Hand kam oder von selbst, wusste
niemand. Eine Automatik, die man nicht sehen kann, ist fuer den Benutzer
keine.

Dazu kam eine echte Luecke: Die Schleife startet 60 Sekunden nach dem Server.
Wer gleich nach dem Einrichten eine Blu-ray einlegt, rippt ohne Key — MakeMKV
faellt in den 30-Tage-Testmodus. Das Setup holt ihn jetzt selbst.

Der geholte Key wandert NICHT ueber die Leitung zurueck: Er steht in den
Einstellungen, und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn
zu verlieren.

Rechtlich unveraendert: oeffentliche Beta-LIZENZ der Software, KEIN
Disc-Schluessel. Rippy liefert und verteilt keine Disc-Schluessel.

803 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:53:12 +02:00
HitonabiandClaude Opus 5 d0281f7a1b style: Zeilenenden zurueck auf LF (kein Inhalt geaendert)
In 35370fd habe ich fuenf Dateien versehentlich von LF auf CRLF umgestellt.
Der Inhalt stimmte, aber der Diff war unlesbar: 1363 geaenderte Zeilen fuer
30 echte in RipTargetModal.tsx, 1184 fuer 29 in setup_fenster.py. Nachgewiesen
mit `git diff --ignore-cr-at-eol` — damit blieben 56/9 uebrig.

Ursache: Ein Rueckschreiben im Textmodus stellt die ganze Datei um. Die Regel
stand als Warnung schon in meinem Gedaechtnis, dort aber nur in der anderen
Richtung (CRLF -> LF). Sie gilt in beide.

Dieser Commit aendert AUSSCHLIESSLICH Zeilenenden. Nachgewiesen:
`git diff --ignore-cr-at-eol` listet keine dieser fuenf Dateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:52:08 +02:00
11 changed files with 2232 additions and 1873 deletions
+49
View File
@@ -2406,6 +2406,55 @@ async def get_keydb_status():
return await asyncio.to_thread(sammle)
@app.post("/system/makemkv-key/holen")
async def makemkv_key_holen():
"""Holt den kostenlosen MakeMKV-Beta-Key JETZT aus dem Forum.
## Warum es diesen Knopf gibt (Commander 28.08.2026)
> „Bezüglich des MKV Beta Keys — der könnte theoretisch auch automatisch
> ausgelesen werden, ich glaube das web rippy kann das"
Er hatte recht: `makemkv_key.refresh_loop()` läuft seit jeher täglich mit
(main.py, Startereignis). Nachgemessen: Forum antwortet in 3,3 Sekunden,
Key mit 62 Zeichen.
Nur war davon **nichts zu sehen**. Kein Knopf, keine Zeitangabe, kein
Hinweis — im Einstellungsfeld stand ein Key, und ob der von Hand kam oder
von selbst, wusste niemand. Eine Automatik, die man nicht sehen kann, ist
für den Benutzer keine.
Der Key wechselt etwa monatlich. Wer nicht warten will, bis die Schleife
das nächste Mal nachsieht, drückt hier.
Rechtlich unverändert: öffentliche Beta-LIZENZ der Software, KEIN
Disc-Schlüssel.
"""
import makemkv_key as key_modul
try:
key = await asyncio.to_thread(key_modul.fetch_current_key)
except Exception as e: # noqa: BLE001
raise HTTPException(
status_code=502,
detail=f"Das MakeMKV-Forum antwortet nicht ({type(e).__name__}). "
"Der Key lässt sich weiter von Hand eintragen.")
if not key:
raise HTTPException(
status_code=502,
detail="Im Forum-Beitrag stand kein Key im erwarteten Format. "
"Vermutlich hat sich die Seite geändert.")
geaendert = await asyncio.to_thread(key_modul._apply_key, key)
await asyncio.to_thread(
db.add_log, "success", "makemkv-key",
"Beta-Key aus dem Forum geholt (…%s)%s"
% (key[-6:], "" if geaendert else " — war schon der aktuelle"))
# Der Key selbst wird NICHT zurueckgegeben: Er steht in den Einstellungen,
# und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn zu
# verlieren.
return {"geholt": True, "geaendert": geaendert, "endet_auf": key[-6:]}
@app.post("/system/keydb")
async def set_keydb(request: KeydbRequest):
"""Legt die vom Nutzer mitgebrachte KEYDB.cfg ab (atomar, ersetzt die alte).
+70
View File
@@ -1,4 +1,6 @@
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
import pytest
from makemkv_key import extract_key
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
@@ -26,3 +28,71 @@ def test_extract_key_liefert_ganzen_key():
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
assert treffer == _KEY
assert treffer.startswith("T-")
# ── Der Knopf „Jetzt aus dem Forum holen" (Commander 28.08.2026) ─────────
#
# > „Bezüglich des MKV Beta Keys - der könnte theoretisch auch automatisch
# > ausgelesen werden, ich glaube das web rippy kann das"
#
# Er hatte recht: `refresh_loop()` laeuft seit jeher taeglich mit (main.py,
# Startereignis). Gemessen am 28.08.2026: Forum antwortet in 3,3 s, Key mit 62
# Zeichen. Nur war davon NICHTS zu sehen — kein Knopf, keine Meldung. Eine
# Automatik, die man nicht sehen kann, ist fuer den Benutzer keine.
@pytest.fixture
def klient():
from fastapi.testclient import TestClient
from main import app
return TestClient(app)
def test_der_knopf_holt_und_meldet_was_passiert_ist(klient, monkeypatch):
import main as api
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
monkeypatch.setattr("makemkv_key._apply_key", lambda key: True)
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
antwort = klient.post("/system/makemkv-key/holen")
assert antwort.status_code == 200
daten = antwort.json()
assert daten["geholt"] is True
assert daten["geaendert"] is True
assert daten["endet_auf"] == _KEY[-6:]
def test_der_key_selbst_wandert_NICHT_ueber_die_leitung(klient, monkeypatch):
"""Er steht in den Einstellungen. Ein zweiter Weg zu demselben Wert ist
ein zweiter Weg, ihn zu verlieren."""
import main as api
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
monkeypatch.setattr("makemkv_key._apply_key", lambda key: False)
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
text = klient.post("/system/makemkv-key/holen").text
assert _KEY not in text
assert _KEY[:20] not in text
def test_ein_stummes_forum_ist_ein_klarer_fehler_kein_leerer_erfolg(klient,
monkeypatch):
"""Sonst hiesse „geholt: true" mit leerem Key, es sei alles in Ordnung."""
def wirft(*a, **k):
raise OSError("Verbindung abgelehnt")
monkeypatch.setattr("makemkv_key.fetch_current_key", wirft)
antwort = klient.post("/system/makemkv-key/holen")
assert antwort.status_code == 502
assert "Forum" in antwort.json()["detail"]
def test_geaendertes_seitenformat_wird_benannt(klient, monkeypatch):
"""Kein Treffer heisst NICHT „kein Key noetig" — es heisst, die Seite hat
sich geaendert. Das muss dastehen, sonst sucht niemand danach."""
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: None)
antwort = klient.post("/system/makemkv-key/holen")
assert antwort.status_code == 502
assert "geändert" in antwort.json()["detail"]
File diff suppressed because it is too large Load Diff
+141 -141
View File
@@ -1,141 +1,141 @@
/*
* Worauf läuft Rippy — und was kann dieser Betrieb überhaupt?
*
* ## Der Befund des Commanders (28.08.2026)
*
* Im Windows-Fenster stand auf der Server-Status-Kachel:
*
* Worker erreichbar: 0 von 1
* Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
* Container-Platte: unbekannt
* Freigaben: keine eingehängt
*
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
* Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort
* selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker
* installation für Windows gebaut."
*
* ## Warum ein Provider und kein Abruf je Seite
*
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
* dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
* Laufzeit nicht; er wird EINMAL geholt.
*
* ## Warum die Vorgabe „Docker" ist
*
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
* Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
* einen Augenblick verschwindet und wieder auftaucht.
*
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs":
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api } from './api'
export interface BetriebsFaehigkeiten {
/** Gibt es andere Maschinen, die Jobs übernehmen? */
externe_worker: boolean
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
freigaben_einhaengen: boolean
/** Sind Pfade wie /app/media überhaupt gemeint? */
container_pfade: boolean
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
werkzeuge_verwalten: boolean
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
frei_blaettern: boolean
}
export interface Betrieb {
modus: 'standalone' | 'verteilt'
plattform: 'windows' | 'linux' | 'macos'
im_container: boolean
kann: BetriebsFaehigkeiten
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
ablage_vorgabe: string
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
arbeits_vorgabe: string
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
* ausführen könnte — dann darf auch keiner dastehen. */
hilfe_befehl: string
/** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht
* erreichbar" — nicht „es gibt keinen Betrieb". */
geladen: boolean
}
const VORGABE: Betrieb = {
modus: 'verteilt',
plattform: 'linux',
im_container: true,
kann: {
externe_worker: true,
freigaben_einhaengen: true,
container_pfade: true,
werkzeuge_verwalten: false,
frei_blaettern: false,
},
ablage_vorgabe: '/app/media',
arbeits_vorgabe: '/app/temp',
hilfe_befehl: 'docker compose -p rippy ps',
geladen: false,
}
const BetriebContext = createContext<Betrieb>(VORGABE)
export function BetriebProvider({ children }: { children: ReactNode }) {
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
useEffect(() => {
let abgemeldet = false
api.get('/betrieb')
.then(antwort => {
const d = antwort.data
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
// verschwände ein Bereich stillschweigend.
if (abgemeldet || !d || !d.kann) return
setBetrieb({
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
plattform: d.plattform || 'linux',
im_container: !!d.im_container,
kann: {
externe_worker: !!d.kann.externe_worker,
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
container_pfade: !!d.kann.container_pfade,
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
frei_blaettern: !!d.kann.frei_blaettern,
},
ablage_vorgabe: d.ablage_vorgabe || '',
arbeits_vorgabe: d.arbeits_vorgabe || '',
hilfe_befehl: d.hilfe_befehl || '',
geladen: true,
})
})
.catch(() => {
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
})
return () => { abgemeldet = true }
}, [])
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
}
export function useBetrieb(): Betrieb {
return useContext(BetriebContext)
}
/**
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
* (Windows-Client oder Docker-All-in-One)?
*
* Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
* die nächste falsche Annahme ein.
*/
export function useAlleinbetrieb(): boolean {
return !useBetrieb().kann.externe_worker
}
/*
* Worauf läuft Rippy — und was kann dieser Betrieb überhaupt?
*
* ## Der Befund des Commanders (28.08.2026)
*
* Im Windows-Fenster stand auf der Server-Status-Kachel:
*
* Worker erreichbar: 0 von 1
* Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
* Container-Platte: unbekannt
* Freigaben: keine eingehängt
*
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
* Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort
* selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker
* installation für Windows gebaut."
*
* ## Warum ein Provider und kein Abruf je Seite
*
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
* dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
* Laufzeit nicht; er wird EINMAL geholt.
*
* ## Warum die Vorgabe „Docker" ist
*
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
* Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
* einen Augenblick verschwindet und wieder auftaucht.
*
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs":
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api } from './api'
export interface BetriebsFaehigkeiten {
/** Gibt es andere Maschinen, die Jobs übernehmen? */
externe_worker: boolean
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
freigaben_einhaengen: boolean
/** Sind Pfade wie /app/media überhaupt gemeint? */
container_pfade: boolean
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
werkzeuge_verwalten: boolean
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
frei_blaettern: boolean
}
export interface Betrieb {
modus: 'standalone' | 'verteilt'
plattform: 'windows' | 'linux' | 'macos'
im_container: boolean
kann: BetriebsFaehigkeiten
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
ablage_vorgabe: string
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
arbeits_vorgabe: string
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
* ausführen könnte — dann darf auch keiner dastehen. */
hilfe_befehl: string
/** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht
* erreichbar" — nicht „es gibt keinen Betrieb". */
geladen: boolean
}
const VORGABE: Betrieb = {
modus: 'verteilt',
plattform: 'linux',
im_container: true,
kann: {
externe_worker: true,
freigaben_einhaengen: true,
container_pfade: true,
werkzeuge_verwalten: false,
frei_blaettern: false,
},
ablage_vorgabe: '/app/media',
arbeits_vorgabe: '/app/temp',
hilfe_befehl: 'docker compose -p rippy ps',
geladen: false,
}
const BetriebContext = createContext<Betrieb>(VORGABE)
export function BetriebProvider({ children }: { children: ReactNode }) {
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
useEffect(() => {
let abgemeldet = false
api.get('/betrieb')
.then(antwort => {
const d = antwort.data
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
// verschwände ein Bereich stillschweigend.
if (abgemeldet || !d || !d.kann) return
setBetrieb({
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
plattform: d.plattform || 'linux',
im_container: !!d.im_container,
kann: {
externe_worker: !!d.kann.externe_worker,
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
container_pfade: !!d.kann.container_pfade,
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
frei_blaettern: !!d.kann.frei_blaettern,
},
ablage_vorgabe: d.ablage_vorgabe || '',
arbeits_vorgabe: d.arbeits_vorgabe || '',
hilfe_befehl: d.hilfe_befehl || '',
geladen: true,
})
})
.catch(() => {
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
})
return () => { abgemeldet = true }
}, [])
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
}
export function useBetrieb(): Betrieb {
return useContext(BetriebContext)
}
/**
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
* (Windows-Client oder Docker-All-in-One)?
*
* Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
* die nächste falsche Annahme ein.
*/
export function useAlleinbetrieb(): boolean {
return !useBetrieb().kann.externe_worker
}
+69 -7
View File
@@ -213,6 +213,9 @@ export default function SettingsPage() {
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
const [ziele, setZiele] = useState<StorageZiel[]>([])
// Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
const [keyHolt, setKeyHolt] = useState(false)
const [keyMeldung, setKeyMeldung] = useState('')
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef<HTMLInputElement>(null)
@@ -253,6 +256,34 @@ export default function SettingsPage() {
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
}
/*
* Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
*
* Der geholte Key wird nicht angezeigt — er wandert in die Einstellungen,
* und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
* zwei Wege, ihn zu verlieren.
*/
const keyJetztHolen = async () => {
setKeyHolt(true)
setKeyMeldung('')
try {
const r = await api.post('/system/makemkv-key/holen')
const antwort = await api.get('/settings')
setSettings({ ...defaultSettings, ...antwort.data })
setKeyMeldung(r.data?.geaendert
? `Neuer Key geholt (endet auf …${r.data.endet_auf}).`
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
} catch (e: any) {
const grund = e?.response?.data?.detail
|| 'Das Forum ist nicht erreichbar.'
setKeyMeldung('Fehlgeschlagen: ' + grund)
toast('error', grund)
} finally {
setKeyHolt(false)
}
}
const keydbHochladen = async (datei: File) => {
setKeydbBusy(true)
try {
@@ -1295,13 +1326,44 @@ export default function SettingsPage() {
</p>
</div>
<Input
type="password"
label="MakeMKV Beta-Key"
value={settings.makemkvAppKey}
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
placeholder="T- (Forum-Thread MakeMKV is free while in beta)"
/>
{/*
Der Beta-Key holt sich selbst — man sah es nur nirgends.
Commander am 28.08.2026: „Bezüglich des MKV Beta Keys — der
könnte theoretisch auch automatisch ausgelesen werden, ich
glaube das web rippy kann das." Er hatte recht:
`makemkv_key.refresh_loop()` läuft täglich mit, seit es die
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
Zeichen). Hier stand aber nur ein Passwortfeld ob der Wert
darin von Hand kam oder von selbst, war nicht zu erkennen.
Eine Automatik, die man nicht sehen kann, ist für den
Benutzer keine.
*/}
<div className="space-y-2">
<Input
type="password"
label="MakeMKV Beta-Key"
value={settings.makemkvAppKey}
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
/>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="secondary"
size="sm"
disabled={keyHolt}
onClick={keyJetztHolen}
>
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
</Button>
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
? 'text-red-600 dark:text-red-400'
: 'text-slate-500 dark:text-slate-400'}`}>
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
+ 'der Key wechselt etwa monatlich.'}
</span>
</div>
</div>
{/*
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
+7
View File
@@ -403,6 +403,13 @@ def vorgaben() -> dict:
return {
"ziel": reg.standard_installationsordner(),
"ablage": betrieb.ablage_vorgabe({}, container=False, system="windows"),
# Der Arbeitsordner gehoert ins Setup, nicht erst in die Einstellungen
# (Commander-Wunsch 28.08.2026). Grund: Der Roh-Rip einer 4K-UHD ist
# bis zu 100 GB gross. Wer das erst NACH der ersten vollen Platte
# erfaehrt, hat es zu spaet erfahren — genau so lief am 25.07.2026
# die VM-Platte voll.
"arbeit": betrieb.arbeits_vorgabe({}, container=False,
plattform_name="win32"),
"port": STANDARD_PORT,
"autostart": True,
"verknuepfungen": True,
+224 -224
View File
@@ -1,224 +1,224 @@
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
wohin die Ausgabe geht, wenn es keine Konsole gibt.
## Warum das nicht Nebensache ist
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
AGENTS.md warnt.
"""
import os
import subprocess
import sys
import pytest
from rippy.platform import winlauf
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
# ── Kind-Prozesse ───────────────────────────────────────────────────────
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
if os.name == "nt":
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
else:
assert winlauf.OHNE_FENSTER == 0
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
monkeypatch.setattr(sys, "stdout", None)
assert winlauf.ohne_konsole() is True
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
was jemand gebrauchen kann."""
class Puffer:
write = staticmethod(lambda _: None)
monkeypatch.setattr(sys, "stdout", Puffer())
assert winlauf.ohne_konsole() is True
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
datei = tmp_path / "echt.txt"
with open(datei, "w", encoding="utf-8") as f:
monkeypatch.setattr(sys, "stdout", f)
assert winlauf.ohne_konsole() is False
# ── Die Protokolldatei ──────────────────────────────────────────────────
def test_protokoll_liegt_bei_rippy():
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
verschwindet beim Deinstallieren mit."""
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
ziel = str(tmp_path / "unterordner" / "rippy.log")
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
assert winlauf.ausgabe_umleiten(ziel) == ziel
print("eine Zeile mit Umlaut: ä")
sys.stdout.flush()
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
— also genau das, was man dann sucht."""
ziel = str(tmp_path / "rippy.log")
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
winlauf.ausgabe_umleiten(ziel)
print("erster Start")
sys.stdout.flush()
winlauf.ausgabe_umleiten(ziel)
print("zweiter Start")
sys.stdout.flush()
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
inhalt = open(ziel, encoding="utf-8").read()
assert "erster Start" in inhalt and "zweiter Start" in inhalt
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
ohne Protokoll."""
def geht_nicht(*a, **k):
raise OSError("kein Platz")
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
print("das darf nicht werfen")
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
# ── Das Meldungsfenster ─────────────────────────────────────────────────
def test_ohne_windows_kein_meldungsfenster():
if os.name == "nt":
pytest.skip("prueft das Verhalten auf Nicht-Windows")
assert winlauf.meldung_zeigen("egal") is False
assert winlauf.an_elternkonsole_haengen() is False
# ── Der Auspack-Ordner (Befund 28.08.2026) ──────────────────────────────
#
# Commander, mit Bildschirmfoto:
#
# Failed to remove temporary directory:
# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882
#
# „Und manchmal kommt dieser fehler."
#
# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen
# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die
# Sperre war also voruebergehend, es ist ein Wettlauf.
#
# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe
# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also
# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst,
# will loeschen — und die Kinder halten die DLLs offen.
def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben():
"""Der Test, der 1,1 GB Reste verhindert haette."""
eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123",
"_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123",
"_PYI_ARCHIVE_FILE": "/x/Rippy.exe",
"_PYI_PARENT_PROCESS_LEVEL": "1"}
kind = winlauf.umgebung_ohne_bundle(eltern)
for name in winlauf.PYI_ZEIGER:
assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name
assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben"
def test_die_elternumgebung_wird_nicht_veraendert():
"""Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt."""
eltern = {"_MEIPASS2": "/tmp/_MEI123"}
winlauf.umgebung_ohne_bundle(eltern)
assert eltern == {"_MEIPASS2": "/tmp/_MEI123"}
def test_beide_startwege_reichen_den_zeiger_nicht_weiter():
"""Der Waechter: Wer kuenftig einen dritten Startweg baut, muss `env=`
setzen. Sonst kommen die Reste zurueck, und zwar still."""
import re
quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py")
with open(os.path.abspath(quelle), encoding="utf-8") as f:
text = f.read()
stellen = [m.start() for m in re.finditer(r"subprocess\.Popen\(\[programm", text)]
assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen)
for stelle in stellen:
aufruf = text[stelle:stelle + 300]
assert "env=umgebung_ohne_bundle()" in aufruf, (
"Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120])
def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path):
"""Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren.
Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek
weggeraeumt, bevor es an der gesperrten DLL scheitert.
"""
lebt = tmp_path / "_MEI111111"
lebt.mkdir()
(lebt / "python312.dll").write_bytes(b"MZ")
(lebt / "wichtig.pyd").write_bytes(b"x")
def gesperrt(pfad):
raise OSError(32, "Datei wird von einem anderen Prozess verwendet")
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="",
jetzt_loeschen=gesperrt) == 0
assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst"
def test_aufraeumen_entfernt_die_reste(tmp_path):
for name in ("_MEI000035002", "_MEI0000b0882"):
ordner = tmp_path / name
ordner.mkdir()
(ordner / "python312.dll").write_bytes(b"MZ")
(ordner / "base_library.zip").write_bytes(b"PK")
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2
assert not list(tmp_path.glob("_MEI*"))
def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path):
"""Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast."""
eigen = tmp_path / "_MEI999999"
eigen.mkdir()
(eigen / "python312.dll").write_bytes(b"MZ")
assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0
assert eigen.exists()
def test_aufraeumen_ignoriert_fremde_ordner(tmp_path):
"""Was nicht `_MEI` heisst, geht uns nichts an."""
(tmp_path / "wichtige-daten").mkdir()
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0
assert (tmp_path / "wichtige-daten").exists()
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
wohin die Ausgabe geht, wenn es keine Konsole gibt.
## Warum das nicht Nebensache ist
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
AGENTS.md warnt.
"""
import os
import subprocess
import sys
import pytest
from rippy.platform import winlauf
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
# ── Kind-Prozesse ───────────────────────────────────────────────────────
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
if os.name == "nt":
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
else:
assert winlauf.OHNE_FENSTER == 0
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
monkeypatch.setattr(sys, "stdout", None)
assert winlauf.ohne_konsole() is True
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
was jemand gebrauchen kann."""
class Puffer:
write = staticmethod(lambda _: None)
monkeypatch.setattr(sys, "stdout", Puffer())
assert winlauf.ohne_konsole() is True
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
datei = tmp_path / "echt.txt"
with open(datei, "w", encoding="utf-8") as f:
monkeypatch.setattr(sys, "stdout", f)
assert winlauf.ohne_konsole() is False
# ── Die Protokolldatei ──────────────────────────────────────────────────
def test_protokoll_liegt_bei_rippy():
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
verschwindet beim Deinstallieren mit."""
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
ziel = str(tmp_path / "unterordner" / "rippy.log")
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
assert winlauf.ausgabe_umleiten(ziel) == ziel
print("eine Zeile mit Umlaut: ä")
sys.stdout.flush()
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
— also genau das, was man dann sucht."""
ziel = str(tmp_path / "rippy.log")
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
winlauf.ausgabe_umleiten(ziel)
print("erster Start")
sys.stdout.flush()
winlauf.ausgabe_umleiten(ziel)
print("zweiter Start")
sys.stdout.flush()
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
inhalt = open(ziel, encoding="utf-8").read()
assert "erster Start" in inhalt and "zweiter Start" in inhalt
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
ohne Protokoll."""
def geht_nicht(*a, **k):
raise OSError("kein Platz")
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
print("das darf nicht werfen")
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
# ── Das Meldungsfenster ─────────────────────────────────────────────────
def test_ohne_windows_kein_meldungsfenster():
if os.name == "nt":
pytest.skip("prueft das Verhalten auf Nicht-Windows")
assert winlauf.meldung_zeigen("egal") is False
assert winlauf.an_elternkonsole_haengen() is False
# ── Der Auspack-Ordner (Befund 28.08.2026) ──────────────────────────────
#
# Commander, mit Bildschirmfoto:
#
# Failed to remove temporary directory:
# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882
#
# „Und manchmal kommt dieser fehler."
#
# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen
# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die
# Sperre war also voruebergehend, es ist ein Wettlauf.
#
# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe
# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also
# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst,
# will loeschen — und die Kinder halten die DLLs offen.
def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben():
"""Der Test, der 1,1 GB Reste verhindert haette."""
eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123",
"_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123",
"_PYI_ARCHIVE_FILE": "/x/Rippy.exe",
"_PYI_PARENT_PROCESS_LEVEL": "1"}
kind = winlauf.umgebung_ohne_bundle(eltern)
for name in winlauf.PYI_ZEIGER:
assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name
assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben"
def test_die_elternumgebung_wird_nicht_veraendert():
"""Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt."""
eltern = {"_MEIPASS2": "/tmp/_MEI123"}
winlauf.umgebung_ohne_bundle(eltern)
assert eltern == {"_MEIPASS2": "/tmp/_MEI123"}
def test_beide_startwege_reichen_den_zeiger_nicht_weiter():
"""Der Waechter: Wer kuenftig einen dritten Startweg baut, muss `env=`
setzen. Sonst kommen die Reste zurueck, und zwar still."""
import re
quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py")
with open(os.path.abspath(quelle), encoding="utf-8") as f:
text = f.read()
stellen = [m.start() for m in re.finditer(r"subprocess\.Popen\(\[programm", text)]
assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen)
for stelle in stellen:
aufruf = text[stelle:stelle + 300]
assert "env=umgebung_ohne_bundle()" in aufruf, (
"Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120])
def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path):
"""Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren.
Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek
weggeraeumt, bevor es an der gesperrten DLL scheitert.
"""
lebt = tmp_path / "_MEI111111"
lebt.mkdir()
(lebt / "python312.dll").write_bytes(b"MZ")
(lebt / "wichtig.pyd").write_bytes(b"x")
def gesperrt(pfad):
raise OSError(32, "Datei wird von einem anderen Prozess verwendet")
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="",
jetzt_loeschen=gesperrt) == 0
assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst"
def test_aufraeumen_entfernt_die_reste(tmp_path):
for name in ("_MEI000035002", "_MEI0000b0882"):
ordner = tmp_path / name
ordner.mkdir()
(ordner / "python312.dll").write_bytes(b"MZ")
(ordner / "base_library.zip").write_bytes(b"PK")
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2
assert not list(tmp_path.glob("_MEI*"))
def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path):
"""Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast."""
eigen = tmp_path / "_MEI999999"
eigen.mkdir()
(eigen / "python312.dll").write_bytes(b"MZ")
assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0
assert eigen.exists()
def test_aufraeumen_ignoriert_fremde_ordner(tmp_path):
"""Was nicht `_MEI` heisst, geht uns nichts an."""
(tmp_path / "wichtige-daten").mkdir()
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0
assert (tmp_path / "wichtige-daten").exists()
+677 -600
View File
File diff suppressed because it is too large Load Diff
+193 -193
View File
@@ -1,193 +1,193 @@
"""Der Betriebsmodus und seine Faehigkeiten.
## Warum es diese Tests gibt
Commander am 28.08.2026, zum Windows-Fenster:
Worker erreichbar: 0 von 1
Kein Worker antwortet — Pruefen: docker compose ps
Container-Platte: unbekannt
Freigaben: keine eingehaengt
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte
nie erfahren, worauf sie laeuft — also hat sie angenommen.
Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne
ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur
auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler
nie auf.
"""
from rippy import betrieb
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
# ── Plattform ───────────────────────────────────────────────────────────
def test_plattform_wird_zu_einem_klaren_namen():
assert betrieb.plattform("win32") == "windows"
assert betrieb.plattform("linux") == "linux"
assert betrieb.plattform("darwin") == "macos"
def test_unbekannte_plattform_gilt_als_linux():
"""FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf
etwas Bekanntes ist besser als ein leeres Feld."""
assert betrieb.plattform("freebsd14") == "linux"
# ── Container ───────────────────────────────────────────────────────────
def test_container_wird_gemessen_nicht_geraten():
"""`/.dockerenv` legt Docker selbst an — verlaesslicher als eine
Umgebungsvariable, die jeder setzen und vergessen kann."""
assert betrieb.im_container(lambda p: p == "/.dockerenv") is True
assert betrieb.im_container(lambda p: False) is False
# ── Modus ───────────────────────────────────────────────────────────────
def test_celery_heisst_verteilt():
assert betrieb.modus(DOCKER) == "verteilt"
def test_lokale_queue_heisst_standalone():
assert betrieb.modus(WINDOWS) == "standalone"
def test_ohne_angaben_gilt_standalone():
"""Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es
nicht gibt. Andersherum entstuenden die Phantom-Worker wieder."""
assert betrieb.modus({}) == "standalone"
# ── Die Faehigkeiten — hier sass der gemeldete Fehler ───────────────────
def test_windows_hat_KEINE_externen_worker():
"""DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet."
Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man
warten koennte. Die Meldung war nicht nur unpassend, sie war falsch."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["externe_worker"] is False
def test_windows_kennt_KEINE_container_pfade():
"""`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner
anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht
falsch."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["container_pfade"] is False
assert "/app/" not in a["ablage_vorgabe"]
def test_windows_haengt_keine_freigaben_ein():
"""Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum
Einhaengen waere eine Bedienung, die ins Leere fuehrt."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["freigaben_einhaengen"] is False
def test_windows_darf_seine_werkzeuge_selbst_pflegen():
"""Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native
Installation kann MakeMKV/HandBrake selbst beschaffen."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["werkzeuge_verwalten"] is True
def test_docker_behaelt_alles_was_es_hatte():
"""Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen."""
a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux")
assert a["kann"]["externe_worker"] is True
assert a["kann"]["container_pfade"] is True
assert a["kann"]["freigaben_einhaengen"] is True
assert a["ablage_vorgabe"] == "/app/media"
assert a["hilfe_befehl"] == "docker compose -p rippy ps"
def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf():
"""„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den
niemand befolgen kann."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["hilfe_befehl"] == ""
def test_docker_allinone_hat_container_aber_keine_externen_worker():
"""Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN
Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden,
der von aussen Jobs uebernimmt."""
a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}},
container=True, plattform_name="linux")
assert a["kann"]["container_pfade"] is True
assert a["kann"]["externe_worker"] is False
# ── Die Ablage-Vorgabe ──────────────────────────────────────────────────
def test_eingestellte_ablage_schlaegt_jede_vorgabe():
a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}},
container=False, plattform_name="win32")
assert a["ablage_vorgabe"] == "D:\\Filme"
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch):
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test")
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test")
assert a["ablage_vorgabe"].endswith("Rippy")
# ── Der Vertrag als Ganzes ──────────────────────────────────────────────
def test_die_auskunft_ist_vollstaendig():
"""Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist
in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend
einen Bereich. Lieber hier rot."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert set(a) == {"modus", "plattform", "im_container", "kann",
"ablage_vorgabe", "arbeits_vorgabe", "hilfe_befehl"}
assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen",
"container_pfade", "werkzeuge_verwalten",
"frei_blaettern"}
assert all(isinstance(v, bool) for v in a["kann"].values())
# ── Wo Platz gemessen wird ──────────────────────────────────────────────
def test_windows_misst_NICHT_in_app_media():
"""DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt".
`main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter
Windows nicht, `shutil.disk_usage` warf, die Liste blieb leer — und aus
einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht.
"""
orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32")
assert orte, "es muss mindestens ein Ort gemessen werden"
assert all("/app/" not in o["pfad"] for o in orte), orte
def test_docker_misst_weiter_die_container_pfade():
orte = betrieb.platz_orte(DOCKER, container=True, plattform_name="linux")
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt():
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte
und ist eine."""
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
container=False, plattform_name="win32")
assert len(orte) == 1, orte
def test_zweites_laufwerk_bekommt_eine_eigene_zeile():
orte = betrieb.platz_orte(
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
container=False, plattform_name="win32")
assert len(orte) == 2, orte
def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
"""Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer
will trotzdem wissen, ob auf dem LAUFWERK Platz ist."""
da = r"C:\Users\Test"
assert betrieb.naechster_vorhandener(
r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
existiert=lambda p: False) == ""
"""Der Betriebsmodus und seine Faehigkeiten.
## Warum es diese Tests gibt
Commander am 28.08.2026, zum Windows-Fenster:
Worker erreichbar: 0 von 1
Kein Worker antwortet — Pruefen: docker compose ps
Container-Platte: unbekannt
Freigaben: keine eingehaengt
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte
nie erfahren, worauf sie laeuft — also hat sie angenommen.
Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne
ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur
auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler
nie auf.
"""
from rippy import betrieb
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
# ── Plattform ───────────────────────────────────────────────────────────
def test_plattform_wird_zu_einem_klaren_namen():
assert betrieb.plattform("win32") == "windows"
assert betrieb.plattform("linux") == "linux"
assert betrieb.plattform("darwin") == "macos"
def test_unbekannte_plattform_gilt_als_linux():
"""FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf
etwas Bekanntes ist besser als ein leeres Feld."""
assert betrieb.plattform("freebsd14") == "linux"
# ── Container ───────────────────────────────────────────────────────────
def test_container_wird_gemessen_nicht_geraten():
"""`/.dockerenv` legt Docker selbst an — verlaesslicher als eine
Umgebungsvariable, die jeder setzen und vergessen kann."""
assert betrieb.im_container(lambda p: p == "/.dockerenv") is True
assert betrieb.im_container(lambda p: False) is False
# ── Modus ───────────────────────────────────────────────────────────────
def test_celery_heisst_verteilt():
assert betrieb.modus(DOCKER) == "verteilt"
def test_lokale_queue_heisst_standalone():
assert betrieb.modus(WINDOWS) == "standalone"
def test_ohne_angaben_gilt_standalone():
"""Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es
nicht gibt. Andersherum entstuenden die Phantom-Worker wieder."""
assert betrieb.modus({}) == "standalone"
# ── Die Faehigkeiten — hier sass der gemeldete Fehler ───────────────────
def test_windows_hat_KEINE_externen_worker():
"""DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet."
Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man
warten koennte. Die Meldung war nicht nur unpassend, sie war falsch."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["externe_worker"] is False
def test_windows_kennt_KEINE_container_pfade():
"""`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner
anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht
falsch."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["container_pfade"] is False
assert "/app/" not in a["ablage_vorgabe"]
def test_windows_haengt_keine_freigaben_ein():
"""Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum
Einhaengen waere eine Bedienung, die ins Leere fuehrt."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["freigaben_einhaengen"] is False
def test_windows_darf_seine_werkzeuge_selbst_pflegen():
"""Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native
Installation kann MakeMKV/HandBrake selbst beschaffen."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["kann"]["werkzeuge_verwalten"] is True
def test_docker_behaelt_alles_was_es_hatte():
"""Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen."""
a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux")
assert a["kann"]["externe_worker"] is True
assert a["kann"]["container_pfade"] is True
assert a["kann"]["freigaben_einhaengen"] is True
assert a["ablage_vorgabe"] == "/app/media"
assert a["hilfe_befehl"] == "docker compose -p rippy ps"
def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf():
"""„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den
niemand befolgen kann."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["hilfe_befehl"] == ""
def test_docker_allinone_hat_container_aber_keine_externen_worker():
"""Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN
Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden,
der von aussen Jobs uebernimmt."""
a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}},
container=True, plattform_name="linux")
assert a["kann"]["container_pfade"] is True
assert a["kann"]["externe_worker"] is False
# ── Die Ablage-Vorgabe ──────────────────────────────────────────────────
def test_eingestellte_ablage_schlaegt_jede_vorgabe():
a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}},
container=False, plattform_name="win32")
assert a["ablage_vorgabe"] == "D:\\Filme"
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch):
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test")
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test")
assert a["ablage_vorgabe"].endswith("Rippy")
# ── Der Vertrag als Ganzes ──────────────────────────────────────────────
def test_die_auskunft_ist_vollstaendig():
"""Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist
in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend
einen Bereich. Lieber hier rot."""
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert set(a) == {"modus", "plattform", "im_container", "kann",
"ablage_vorgabe", "arbeits_vorgabe", "hilfe_befehl"}
assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen",
"container_pfade", "werkzeuge_verwalten",
"frei_blaettern"}
assert all(isinstance(v, bool) for v in a["kann"].values())
# ── Wo Platz gemessen wird ──────────────────────────────────────────────
def test_windows_misst_NICHT_in_app_media():
"""DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt".
`main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter
Windows nicht, `shutil.disk_usage` warf, die Liste blieb leer — und aus
einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht.
"""
orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32")
assert orte, "es muss mindestens ein Ort gemessen werden"
assert all("/app/" not in o["pfad"] for o in orte), orte
def test_docker_misst_weiter_die_container_pfade():
orte = betrieb.platz_orte(DOCKER, container=True, plattform_name="linux")
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt():
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte
und ist eine."""
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
container=False, plattform_name="win32")
assert len(orte) == 1, orte
def test_zweites_laufwerk_bekommt_eine_eigene_zeile():
orte = betrieb.platz_orte(
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
container=False, plattform_name="win32")
assert len(orte) == 2, orte
def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
"""Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer
will trotzdem wissen, ob auf dem LAUFWERK Platz ist."""
da = r"C:\Users\Test"
assert betrieb.naechster_vorhandener(
r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
existiert=lambda p: False) == ""
+4 -3
View File
@@ -238,11 +238,12 @@ def test_vorgaben_sind_vollstaendig():
import pytest
pytest.skip("Registry-Vorgaben gibt es nur unter Windows")
v = e.vorgaben()
assert set(v) == {"ziel", "ablage", "port", "autostart",
assert set(v) == {"ziel", "ablage", "arbeit", "port", "autostart",
"verknuepfungen", "werkzeuge_holen"}
assert v["port"] == 7788
assert v["ziel"] and v["ablage"]
assert "/app/" not in v["ablage"], "Container-Pfad in einer Windows-Vorgabe"
assert v["ziel"] and v["ablage"] and v["arbeit"]
for feld in ("ablage", "arbeit"):
assert "/app/" not in v[feld], "Container-Pfad in einer Windows-Vorgabe"
def test_netzwerkpfad_als_administrator_wird_erklaert():
+93
View File
@@ -284,3 +284,96 @@ def test_updates_melden_was_offen_ist(monkeypatch):
assert [o["titel"] for o in stand["offen"]] == ["MakeMKV"]
assert stand["offen"][0]["installiert"] == "1.18.2"
assert stand["offen"][0]["neueste"] == "1.18.4"
# ── Der Arbeitsordner im Setup (Commander 28.08.2026) ───────────────────
#
# > „kannst du noch einbauen das man den arbeitsordner usw. in den
# > Einstellungen setzen kann und direkt im setup?"
#
# Warum das nicht Kosmetik ist: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB
# gross. Wer erst NACH der ersten vollen Platte erfaehrt, wo die liegen, hat
# es zu spaet erfahren — am 25.07.2026 lief genau so die VM-Platte voll.
def test_die_seite_fragt_nach_dem_arbeitsordner():
assert setup_fenster.seite_enthaelt('id="arbeit"')
assert setup_fenster.seite_enthaelt("Arbeitsordner für die Rohdaten")
def test_der_arbeitsordner_hat_einen_durchsuchen_knopf():
"""Einen Pfad abzutippen ist die Sorte Bedienung, die niemand macht."""
assert setup_fenster.seite_enthaelt("waehlen('arbeit')")
def test_jede_vorgabe_hat_ein_feld_UND_wird_mitgeschickt():
"""Der Waechter gegen halb angeschlossene Felder.
Ein Feld kann auf drei Arten kaputt sein, und alle drei sehen im Fenster
gleich aus: kein Eingabefeld, keine Vorbelegung, oder es wird beim
Installieren nicht mitgeschickt. Der dritte Fall ist der gemeinste — man
waehlt etwas, es passiert nichts, und niemand sagt warum.
"""
from rippy import einrichtung
# Schalter heissen im HTML anders (checkbox) und werden separat geprueft.
pfade_und_zahlen = {"ziel", "ablage", "arbeit", "port"}
for name in pfade_und_zahlen:
assert name in einrichtung.vorgaben(), "%s fehlt in vorgaben()" % name
assert setup_fenster.seite_enthaelt('id="%s"' % name), \
"kein Eingabefeld fuer %s" % name
assert setup_fenster.seite_enthaelt("$('%s').value = v.%s" % (name, name)), \
"%s wird nicht vorbelegt" % name
assert setup_fenster.seite_enthaelt("%s: " % name), \
"%s wird beim Installieren nicht mitgeschickt" % name
def test_der_arbeitsordner_landet_als_workDir_in_den_einstellungen(monkeypatch):
"""Der Name muss stimmen: Die Oberflaeche und der Worker lesen `workDir`."""
geschrieben = {}
monkeypatch.setattr(setup_fenster, "_einstellungen_schreiben",
lambda neu: geschrieben.update(neu))
monkeypatch.setattr(setup_fenster, "_beta_key_holen", lambda *a: "")
class StilleWerkzeuge:
@staticmethod
def sicherstellen(**k):
return {"bereit": True}
@staticmethod
def bericht(b):
return "ok"
import sys
import types
modul = types.ModuleType("rippy.tools.einrichten")
modul.sicherstellen = StilleWerkzeuge.sicherstellen
modul.bericht = StilleWerkzeuge.bericht
monkeypatch.setitem(sys.modules, "rippy.tools.einrichten", modul)
from rippy import windows_app
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ablage": r"D:\Filme", "arbeit": r"E:\Roh",
"werkzeuge_holen": True})
assert geschrieben.get("workDir") == r"E:\Roh"
assert geschrieben.get("outputDir") == r"D:\Filme"
assert b.ergebnis["ok"] is True
def test_ohne_angabe_wird_NICHTS_geschrieben(monkeypatch):
"""Leer heisst „nimm die Vorgabe" — dann darf kein leerer Pfad in die
Einstellungen wandern und die Berechnung ueberschreiben."""
geschrieben = {}
monkeypatch.setattr(setup_fenster, "_einstellungen_schreiben",
lambda neu: geschrieben.update(neu))
monkeypatch.setattr(setup_fenster, "_beta_key_holen", lambda *a: "")
from rippy import windows_app
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ablage": "", "arbeit": "", "werkzeuge_holen": False})
assert geschrieben == {}