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 == {}