diff --git a/docker/api/main.py b/docker/api/main.py
index 271f5c2..c20ec6b 100644
--- a/docker/api/main.py
+++ b/docker/api/main.py
@@ -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).
diff --git a/docker/api/test_makemkv_key.py b/docker/api/test_makemkv_key.py
index ecfd257..c13c5cb 100644
--- a/docker/api/test_makemkv_key.py
+++ b/docker/api/test_makemkv_key.py
@@ -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"]
diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx
index e6ce294..479c958 100644
--- a/docker/ui/src/pages/Settings.tsx
+++ b/docker/ui/src/pages/Settings.tsx
@@ -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([])
+ // 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(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef(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() {
- 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.
+ */}
+
+ handleChange('makemkvAppKey', e.target.value)}
+ placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
+ />
+
+
+
+ {keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
+ + 'der Key wechselt etwa monatlich.'}
+
+
+
{/*
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
diff --git a/src/rippy/einrichtung.py b/src/rippy/einrichtung.py
index 1f853e4..2604699 100644
--- a/src/rippy/einrichtung.py
+++ b/src/rippy/einrichtung.py
@@ -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,
diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py
index 090fcea..0a8608f 100644
--- a/src/rippy/setup_fenster.py
+++ b/src/rippy/setup_fenster.py
@@ -221,10 +221,18 @@ class Bruecke:
starten_danach=False,
verknuepfen=bool(optionen.get("verknuepfungen", True)),
)
+ orte = {}
if optionen.get("ablage"):
+ orte["outputDir"] = optionen["ablage"]
+ # Der Arbeitsordner gehoert mit ins Setup (Commander 28.08.2026).
+ # Leer heisst „nimm die Vorgabe" — dann steht hier nichts, und der
+ # Betrieb rechnet ihn sich neben der Ablage aus.
+ if optionen.get("arbeit"):
+ orte["workDir"] = optionen["arbeit"]
+ if orte:
self._lauf.beginne("ablage")
- melden("Ablage wird gespeichert …")
- _ablage_speichern(optionen["ablage"])
+ melden("Ordner werden gespeichert …")
+ _einstellungen_schreiben(orte)
self._lauf.beginne("werkzeuge")
if optionen.get("werkzeuge_holen", True):
melden("Werkzeuge werden geprüft …")
@@ -235,6 +243,7 @@ class Bruecke:
aktualisieren=bool(optionen.get("aktualisieren", False)))
melden(werkzeug_einrichtung.bericht(bericht))
bereit = bericht["bereit"]
+ _beta_key_holen(melden)
else:
bereit = True
self._lauf.beginne("fertig")
@@ -299,16 +308,73 @@ def _ablage_speichern(pfad: str) -> None:
Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war
ohnehin überflüssig.
"""
+ _einstellungen_schreiben({"outputDir": pfad})
+
+
+def _einstellungen_schreiben(neu: dict) -> None:
+ """Werte in die Einstellungen mischen — lesen, setzen, zurückschreiben.
+
+ `save_settings` überschreibt das GANZE JSON, deshalb erst lesen.
+ """
from rippy import config, store
werte = config.laden()
store.verbinden(config.datenbank_url(werte))
store.init_db()
aktuell = store.get_settings() or {}
- aktuell["outputDir"] = pfad
+ aktuell.update(neu)
store.save_settings(aktuell)
+def _beta_key_holen(melden=None) -> str:
+ """Den kostenlosen MakeMKV-Beta-Key aus dem Forum holen und ablegen.
+
+ ## Warum das ins Setup gehört (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 hat recht, es kann: `docker/api/makemkv_key.py` holt den Key aus dem
+ Forum-Thread t=1053 und läuft seit jeher täglich mit. Nachgemessen am
+ 28.08.2026: Antwort in 3,3 Sekunden, gültiger Key mit 62 Zeichen.
+
+ Nur half das beim ERSTEN Rip nicht: Die Schleife startet 60 Sekunden nach
+ dem Serverstart. Wer gleich nach dem Einrichten eine Blu-ray einlegt, rippt
+ ohne Key — und MakeMKV fällt in den 30-Tage-Testmodus.
+
+ Ohne Key ist Rippy nicht kaputt; DVDs gehen weiterhin. Deshalb ist ein
+ Fehlschlag hier auch kein Abbruch, sondern eine Zeile im Protokoll.
+
+ Rechtlich unverändert: Das ist die öffentliche Beta-LIZENZ der Software,
+ NICHT ein Disc-Schlüssel. Rippy liefert und verteilt keine Disc-Schlüssel.
+ """
+ melden = melden or (lambda *a, **k: None)
+ try:
+ import sys
+
+ from rippy.daemon import _api_pfad
+
+ # `_api_pfad` gibt den Pfad NUR zurueck — eingehaengt wird er in
+ # daemon.starten(). Hier laeuft der Assistent, nicht der Dienst.
+ pfad = _api_pfad()
+ if pfad and pfad not in sys.path:
+ sys.path.insert(0, pfad)
+ import makemkv_key
+
+ key = makemkv_key.fetch_current_key()
+ except Exception as ex: # noqa: BLE001
+ melden(" Beta-Key nicht erreichbar (%s) — nachtragbar unter "
+ "Einstellungen." % type(ex).__name__)
+ return ""
+ if not key:
+ melden(" Kein Beta-Key im Forum gefunden — nachtragbar unter "
+ "Einstellungen.")
+ return ""
+ _einstellungen_schreiben({"makemkvAppKey": key})
+ melden(" MakeMKV-Beta-Key geholt (…%s)." % key[-6:])
+ return key
+
+
def starten() -> dict:
"""Öffnet den Assistenten. Gibt zurück, was daraus geworden ist.
@@ -426,6 +492,15 @@ SEITE = r"""
+
@@ -532,6 +607,7 @@ SEITE = r"""
await pywebview.api.installieren({
ziel: $('ziel').value,
ablage: $('ablage').value,
+ arbeit: $('arbeit').value,
port: Number($('port').value),
autostart: $('autostart').checked,
verknuepfungen: $('verknuepfungen').checked,
@@ -578,6 +654,7 @@ SEITE = r"""
const v = await pywebview.api.vorgaben();
$('ziel').value = v.ziel;
$('ablage').value = v.ablage;
+ $('arbeit').value = v.arbeit;
$('port').value = v.port;
$('autostart').checked = v.autostart;
$('verknuepfungen').checked = v.verknuepfungen;
diff --git a/src/rippy/test_einrichtung.py b/src/rippy/test_einrichtung.py
index dbf80a5..948ad0a 100644
--- a/src/rippy/test_einrichtung.py
+++ b/src/rippy/test_einrichtung.py
@@ -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():
diff --git a/src/rippy/test_setup_fenster.py b/src/rippy/test_setup_fenster.py
index c80ff76..f45f1fc 100644
--- a/src/rippy/test_setup_fenster.py
+++ b/src/rippy/test_setup_fenster.py
@@ -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 == {}