fix(windows): Drueberinstallieren, Linux-Reste im Windows-Betrieb, Ordner-Waehler
Ampel / ampel (push) Successful in 1m27s
Ampel / ampel (push) Successful in 1m27s
Vier Fragen des Commanders vom 29.08.2026, drei davon mit Codefolge.
## 1. „Was passiert wenn man die Setup.exe einfach drueber installiert?"
Bis hierher: nicht zuverlaessig. Rippy startet mit Windows, laeuft also fast
immer — dann ist `Rippy.exe` gesperrt, `shutil.copy2` warf PermissionError,
und die neue Fassung landete als `Rippy.exe.neu` daneben. Dazu die Meldung
„wird beim naechsten Start uebernommen".
**Diese Zusage hat niemand eingeloest.** `.neu` kam im ganzen Projekt genau
einmal vor: an der Stelle, die es schrieb. Wer drueberinstallierte, behielt
still die alte Fassung, und das Setup meldete Erfolg.
Jetzt wird der laufende Rippy vorher beendet (`dienst_beenden` gibt es seit
der Deinstallation und wartet auch die zwei Sekunden ab, die Windows fuer die
Dateihandles braucht). Eine von einer aelteren Setup-Fassung liegengelassene
`.neu` wird dabei uebernommen. Bleibt die Datei DANN noch gesperrt, gibt es
einen klaren Fehler statt einer Zusage — Rippy im Infobereich beenden und das
Setup erneut starten.
Der Tausch laeuft bewusst im SETUP und nicht beim Dienststart: Windows sperrt
eine laufende .exe, und `Rippy.exe` waere genau die zu ersetzende Datei.
## 3. Linux-Reste im Windows-Betrieb (Docker/Headless unveraendert)
**`caps.py`: `os.path.isdir("/app")`.** Damit hielt sich der eigenstaendige
Windows-Rippy fuer einen FREMDEN Worker — und das UI warnte vor fehlender
Pfad-Uebersetzung auf einer Maschine ohne Container und ohne Freigabe.
„Extern" heisst jetzt, was es meint: Rippy laeuft woanders als dieser Worker.
**`caps.py`: `shutil.which("makemkvcon")` + `os.path.ismount(daten_dir)`.**
Beide unter Windows immer falsch (Programme liegen nicht im PATH, ein
normaler Ordner ist kein Mount). Die Schluessel-Auskunft blieb dauerhaft
„unbekannt", obwohl MakeMKV samt Datenverzeichnis da war. Der Mount-Test
bleibt fuer den Container, wo er einen Zweck hat.
**`rohdaten.py`: `/app/temp/raw` und `/app/media` fest.** Dieses Modul findet
die Rohdaten eines Jobs wieder — fuer den Wiederholen-Dialog und fuer
„Rohdaten mitloeschen". Unter Windows fand es NIE etwas: Der Dialog meldete
„keine Rohdaten", das Aufraeumen loeschte nichts, und die Bruchstuecke eines
abgebrochenen Rips blieben liegen (bei 4K-UHD bis 100 GB).
Sieben Tests wurden dabei rot, und zwar zu Recht: Sie pruefen Container-Regeln,
liefen aber unter Windows. Die Wurzeln sind jetzt einspritzbar — beide
Betriebsfaelle auf jedem Rechner pruefbar statt vom laufenden abhaengig.
## 4. „Der Durchsuchen button fehlt. Wie es der Installer auch macht"
Neu: `OrdnerWaehler` — Pfadfeld plus „Durchsuchen …", benutzt fuer Ablage und
Arbeitsverzeichnis. Es waere der DRITTE fest eingebaute Ordner-Browser
geworden (RipTargetModal, StorageMounts); dieser hier ist wiederverwendbar.
`/browse` weiss seit dem 28.08. selbst, in welchem Betrieb es laeuft.
⚠️ Beim Einbau fiel der Import unter den Tisch. `vite` pruefte das NICHT — das
Buendel blieb byte-gleich gross, und zur Laufzeit waere es der naechste leere
Bildschirm gewesen. Aufgefallen nur, weil die erwartete Anzahl Ersetzungen
nicht stimmte. Im Browser gegengeprueft: alle sieben Laufwerke, Navigation in
D:\, keine Konsolenfehler.
843 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
21b1757aa4
commit
b2acddbdfa
+5
-3
@@ -6,7 +6,6 @@ from typing import List, Optional, Dict
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
@@ -836,8 +835,11 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
||||
if job["id"] in vorrat:
|
||||
return bool(vorrat[job["id"]])
|
||||
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
|
||||
# dort nicht hängen (im Gegensatz zu allem unter /app/media).
|
||||
return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"]))
|
||||
# dort nicht hängen (im Gegensatz zu allem unter /app/media). Die Wurzel
|
||||
# kommt vom Betrieb — auf Windows gibt es kein /app/temp (29.08.2026).
|
||||
from rippy import pfade
|
||||
|
||||
return os.path.isdir(pfade.verbinden(rohdaten.wurzeln()[0], job["id"]))
|
||||
|
||||
|
||||
@app.get("/jobs", response_model=List[Job])
|
||||
|
||||
+51
-13
@@ -41,15 +41,44 @@ Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||
import posixpath
|
||||
import subprocess
|
||||
|
||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall
|
||||
# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`.
|
||||
RAW_STANDARD = "/app/temp/raw"
|
||||
MEDIA_ROOT = "/app/media"
|
||||
|
||||
|
||||
def wurzeln(werte=None) -> tuple:
|
||||
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
|
||||
|
||||
## Warum das nicht fest sein darf (Befund 29.08.2026)
|
||||
|
||||
Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den
|
||||
Wiederholen-Dialog („auf der Platte liegen X GB Rohdaten") und fuer
|
||||
„Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und
|
||||
`/app/media`.
|
||||
|
||||
Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der
|
||||
Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die
|
||||
Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen — bei einer
|
||||
4K-UHD bis zu 100 GB.
|
||||
"""
|
||||
from rippy import betrieb, config
|
||||
|
||||
if werte is None:
|
||||
try:
|
||||
werte = config.laden()
|
||||
except Exception: # noqa: BLE001
|
||||
werte = {}
|
||||
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
|
||||
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||
betrieb.frei_blaettern(werte))
|
||||
|
||||
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||||
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||||
|
||||
|
||||
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
||||
def kandidaten(job_id: str, work_dir: str, media_unterordner,
|
||||
orte_wurzeln=None) -> list:
|
||||
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||||
|
||||
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
||||
@@ -65,13 +94,18 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
||||
"""
|
||||
if not job_id:
|
||||
return []
|
||||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
||||
wahl = (work_dir or "").strip().rstrip("/")
|
||||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
||||
orte.append(posixpath.join(wahl, job_id))
|
||||
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
|
||||
from rippy import pfade
|
||||
|
||||
orte = [pfade.verbinden(roh, job_id)]
|
||||
wahl = (work_dir or "").strip().rstrip("/\\")
|
||||
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
|
||||
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
|
||||
if wahl and (frei or wahl == medien or wahl.startswith(medien + "/")):
|
||||
orte.append(pfade.verbinden(wahl, job_id))
|
||||
for name in media_unterordner or []:
|
||||
if name:
|
||||
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
|
||||
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
|
||||
gesehen, eindeutig = set(), []
|
||||
for ort in orte:
|
||||
if ort not in gesehen:
|
||||
@@ -145,7 +179,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
||||
return pruefen(pfad, laufen) == "da"
|
||||
|
||||
|
||||
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
||||
def suche(job_id: str, work_dir: str, listdir, isdir,
|
||||
orte_wurzeln=None) -> list:
|
||||
"""Die Orte, an denen wirklich etwas liegt.
|
||||
|
||||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||||
@@ -156,12 +191,13 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
||||
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
||||
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||||
"""
|
||||
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||
try:
|
||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
gefunden = []
|
||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||||
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||
try:
|
||||
if isdir(ort):
|
||||
gefunden.append(ort)
|
||||
@@ -172,7 +208,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
||||
return gefunden
|
||||
|
||||
|
||||
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
||||
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None,
|
||||
orte_wurzeln=None) -> dict:
|
||||
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
||||
|
||||
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
||||
@@ -181,12 +218,13 @@ def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
||||
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
||||
"""
|
||||
pruefe = pruefer or pruefen
|
||||
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||
try:
|
||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||||
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||
except OSError:
|
||||
unterordner = []
|
||||
gefunden, unklar = [], False
|
||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||||
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||
antwort = pruefe(ort)
|
||||
if antwort == "da":
|
||||
gefunden.append(ort)
|
||||
|
||||
@@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden():
|
||||
assert orte[0] == f"/app/temp/raw/{JOB}"
|
||||
|
||||
|
||||
# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese
|
||||
# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte
|
||||
# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026
|
||||
# prompt sieben Tests rot geworden).
|
||||
CONTAINER = ("/app/temp/raw", "/app/media", False)
|
||||
|
||||
|
||||
def test_suche_liefert_nur_was_existiert():
|
||||
vorhanden = {f"/app/media/rippy/{JOB}"}
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "",
|
||||
listdir=lambda p: ["movies", "rippy"],
|
||||
isdir=lambda p: p in vorhanden,
|
||||
)
|
||||
orte_wurzeln=CONTAINER)
|
||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||
|
||||
|
||||
@@ -64,7 +71,7 @@ def test_kaputter_mount_reisst_die_suche_nicht_mit():
|
||||
return p == f"/app/temp/raw/{JOB}"
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
|
||||
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt, orte_wurzeln=CONTAINER)
|
||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
@@ -73,7 +80,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
|
||||
raise OSError("kein /app/media")
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
|
||||
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True, orte_wurzeln=CONTAINER)
|
||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
@@ -177,7 +184,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
||||
JOB, "",
|
||||
listdir=lambda p: ["bluray", "movies", "rippy"],
|
||||
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||
)
|
||||
orte_wurzeln=CONTAINER)
|
||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||
|
||||
|
||||
@@ -206,7 +213,7 @@ def test_suche_mit_status_meldet_ungepruefte_orte():
|
||||
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
||||
|
||||
e = rohdaten.suche_mit_status(
|
||||
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
|
||||
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||
assert e == {"pfade": [], "unklar": True}
|
||||
|
||||
|
||||
@@ -215,7 +222,7 @@ def test_suche_mit_status_ohne_zweifel():
|
||||
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
||||
|
||||
e = rohdaten.suche_mit_status(
|
||||
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
|
||||
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
||||
|
||||
|
||||
@@ -227,6 +234,6 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
|
||||
return "unklar" if "totes-nas" in pfad else "weg"
|
||||
|
||||
e = rohdaten.suche_mit_status(
|
||||
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
|
||||
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||
assert e["unklar"] is True
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Ein Pfadfeld mit „Durchsuchen …" — wie im Installer.
|
||||
*
|
||||
* ## Der Wunsch des Commanders (29.08.2026)
|
||||
*
|
||||
* > „Wenn man im Nachhinein noch die Verzeichnisse ändern will, geht das
|
||||
* > heute nur manuell über die einstellungen. Der Durchsuchen button fehlt.
|
||||
* > Wie es der Installer auch macht"
|
||||
*
|
||||
* Er hat recht, und der Unterschied ist größer als ein Knopf: Im Setup wählt
|
||||
* man den Ordner, danach musste man ihn abtippen. Einen Pfad wie
|
||||
* `\\192.168.179.62\rippy\movies` tippt niemand zweimal richtig.
|
||||
*
|
||||
* ## Warum eine eigene Datei
|
||||
*
|
||||
* Weil es der DRITTE Ordner-Browser in diesem Projekt gewesen wäre:
|
||||
* `RipTargetModal` hat einen, `StorageMounts` hat einen. Beide sind fest in
|
||||
* ihre Karte eingebaut. Diesen hier kann jedes Feld benutzen.
|
||||
*
|
||||
* Der Browser selbst ist die API — `/browse` weiß seit dem 28.08.2026, in
|
||||
* welchem Betrieb es läuft: im Container die Medien-Wurzel, nativ die Liste
|
||||
* der Laufwerke. Diese Komponente muss darüber nichts wissen.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ArrowUp, Folder, FolderOpen } from 'lucide-react'
|
||||
|
||||
import { api } from '../lib/api'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input } from './ui/Input'
|
||||
|
||||
interface Eintrag {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
onChange: (pfad: string) => void
|
||||
}
|
||||
|
||||
export function OrdnerWaehler({ label, value, placeholder, onChange }: Props) {
|
||||
const [offen, setOffen] = useState(false)
|
||||
const [pfad, setPfad] = useState('')
|
||||
const [eltern, setEltern] = useState<string | null>(null)
|
||||
const [ordner, setOrdner] = useState<Eintrag[]>([])
|
||||
const [fehler, setFehler] = useState('')
|
||||
const [laedt, setLaedt] = useState(false)
|
||||
|
||||
const laden = async (ziel: string) => {
|
||||
setLaedt(true)
|
||||
setFehler('')
|
||||
try {
|
||||
const r = await api.get('/browse', { params: { path: ziel } })
|
||||
setPfad(r.data.path || '')
|
||||
setEltern(r.data.parent)
|
||||
setOrdner(Array.isArray(r.data.dirs) ? r.data.dirs : [])
|
||||
} catch (e: any) {
|
||||
// Ein nicht lesbarer Ordner ist kein Grund, den Dialog zu schließen —
|
||||
// man kommt mit „nach oben" wieder heraus.
|
||||
setFehler(e?.response?.data?.detail || 'Ordner nicht lesbar')
|
||||
setOrdner([])
|
||||
} finally {
|
||||
setLaedt(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!offen) return
|
||||
// Beim eingetragenen Wert einsteigen, sonst ganz oben. Leerer Pfad heißt
|
||||
// für /browse „oberste Ebene" — nativ also die Laufwerksliste.
|
||||
laden(value || '')
|
||||
}, [offen])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label={label}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setOffen(true)}>
|
||||
Durchsuchen …
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={offen}
|
||||
onClose={() => setOffen(false)}
|
||||
title={label}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||
<button
|
||||
onClick={() => eltern !== null && laden(eltern)}
|
||||
disabled={eltern === null}
|
||||
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
|
||||
title="Eine Ebene höher"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
<span className="text-xs font-mono truncate flex-1 text-slate-600 dark:text-slate-300">
|
||||
{pfad || 'Laufwerke'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto rounded-xl border border-slate-200 dark:border-slate-800 p-1">
|
||||
{laedt && (
|
||||
<p className="px-3 py-2 text-xs text-slate-400">wird gelesen …</p>
|
||||
)}
|
||||
{!laedt && fehler && (
|
||||
<p className="px-3 py-2 text-xs text-amber-600 dark:text-amber-400">{fehler}</p>
|
||||
)}
|
||||
{!laedt && !fehler && ordner.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-slate-400">Keine Unterordner.</p>
|
||||
)}
|
||||
{ordner.map((o) => (
|
||||
<button
|
||||
key={o.path}
|
||||
onClick={() => laden(o.path)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Folder size={14} className="text-slate-400" />
|
||||
{o.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<FolderOpen size={13} />
|
||||
Ein Netzwerkpfad lässt sich auch direkt ins Feld schreiben.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="amber"
|
||||
disabled={!pfad}
|
||||
onClick={() => {
|
||||
onChange(pfad)
|
||||
setOffen(false)
|
||||
}}
|
||||
>
|
||||
Diesen Ordner nutzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { PageHeader } from '../components/ui/PageHeader'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||
import { OrdnerWaehler } from '../components/OrdnerWaehler'
|
||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||
import { ENCODER_BADGES } from '../lib/design'
|
||||
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
||||
@@ -1000,11 +1001,11 @@ export default function SettingsPage() {
|
||||
* Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe.
|
||||
* Deshalb hier ein Feld, in das man ihn schreiben kann.
|
||||
*/
|
||||
<Input
|
||||
<OrdnerWaehler
|
||||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||
value={settings.workDir}
|
||||
placeholder={betrieb.arbeits_vorgabe}
|
||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||
onChange={(pfad) => handleChange('workDir', pfad)}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
@@ -1097,11 +1098,11 @@ export default function SettingsPage() {
|
||||
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
||||
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
||||
Bedienung, die nichts bedient. */
|
||||
<Input
|
||||
<OrdnerWaehler
|
||||
label="Ordner"
|
||||
value={settings.outputDir}
|
||||
placeholder={betrieb.ablage_vorgabe}
|
||||
onChange={(e) => handleChange('outputDir', e.target.value)}
|
||||
onChange={(pfad) => handleChange('outputDir', pfad)}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
|
||||
+53
-4
@@ -8,13 +8,38 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from rippy.platform.winlauf import OHNE_FENSTER
|
||||
from rippy.tools import katalog as werkzeuge
|
||||
|
||||
|
||||
def _extern(werte=None, container=None) -> str:
|
||||
"""Läuft Rippy woanders als dieser Worker? „ja"/„nein" (einspritzbar).
|
||||
|
||||
Drei Fälle, und nur einer davon ist „ja":
|
||||
|
||||
* **Im Container** — der Worker ist Teil von Rippy. Nein.
|
||||
* **Nativ und eigenständig** (Windows-App) — dieser Prozess IST Rippy.
|
||||
Nein. Es gibt hier weder Container-Pfade noch eine Freigabe, über die
|
||||
etwas zu übersetzen wäre.
|
||||
* **Nativ und verteilt** — ein Worker auf einem anderen Rechner, der sich
|
||||
bei einem Docker-Rippy meldet. Ja; er braucht RIPPY_PATH_MAP.
|
||||
"""
|
||||
from rippy import betrieb, config
|
||||
|
||||
if container is None:
|
||||
container = betrieb.im_container()
|
||||
if container:
|
||||
return "nein"
|
||||
if werte is None:
|
||||
try:
|
||||
werte = config.laden()
|
||||
except Exception: # noqa: BLE001
|
||||
werte = {}
|
||||
return "ja" if betrieb.modus(werte) == "verteilt" else "nein"
|
||||
|
||||
|
||||
def _hb() -> str:
|
||||
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
||||
|
||||
@@ -343,8 +368,18 @@ def werkzeug_versionen() -> dict:
|
||||
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
||||
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
||||
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
|
||||
# /app ist im Rippy-Image immer vorhanden — kein Ratespiel.
|
||||
"extern": "nein" if os.path.isdir("/app") else "ja",
|
||||
#
|
||||
# ⚠️ Hier stand `os.path.isdir("/app")` (Befund 29.08.2026). Im Image
|
||||
# stimmt das. Auf einem Windows-PC gibt es `/app` nicht — und damit
|
||||
# hielt sich der eigenständige Windows-Rippy für einen FREMDEN Worker.
|
||||
# Folge: Das UI warnte vor fehlender Pfad-Übersetzung auf einer
|
||||
# Maschine, auf der es weder Container noch Freigabe gibt.
|
||||
#
|
||||
# „Extern" heißt: Rippy läuft woanders als dieser Worker. In der
|
||||
# eigenständigen Installation IST dieser Worker Rippy — dort ist die
|
||||
# Frage gegenstandslos. Deshalb entscheidet der Betrieb, nicht ein
|
||||
# Ordnername.
|
||||
"extern": _extern(),
|
||||
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
||||
# grundsätzlich nicht komprimieren.
|
||||
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
||||
@@ -402,7 +437,21 @@ def werkzeug_versionen() -> dict:
|
||||
daten_dir = makemkv_daten.DATEN_DIR
|
||||
except Exception:
|
||||
daten_dir = ""
|
||||
if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir):
|
||||
# ⚠️ `shutil.which` und `os.path.ismount` sind hier BEIDE Linux-Annahmen
|
||||
# (Befund 29.08.2026): Unter Windows liegt makemkvcon in „Programme" und
|
||||
# nicht im PATH, und ein normaler Ordner ist kein Mount. Beide Prüfungen
|
||||
# waren dort also immer falsch — die Schlüssel-Auskunft blieb dauerhaft
|
||||
# „unbekannt", obwohl MakeMKV samt Datenverzeichnis da war.
|
||||
#
|
||||
# Der Mount-Test bleibt für den Container: Dort teilen sich Rippy und ein
|
||||
# reiner Encoder-Worker dasselbe Image, aber nur einer bekommt den Mount.
|
||||
# Nativ zählt stattdessen, ob der Ordner überhaupt existiert.
|
||||
from rippy import betrieb
|
||||
|
||||
_im_container = betrieb.im_container()
|
||||
daten_da = bool(daten_dir) and (os.path.ismount(daten_dir) if _im_container
|
||||
else os.path.isdir(daten_dir))
|
||||
if werkzeuge.finden("makemkv") and daten_da:
|
||||
try:
|
||||
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
||||
except Exception:
|
||||
|
||||
@@ -214,3 +214,33 @@ def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
|
||||
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
||||
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||
]
|
||||
|
||||
|
||||
# ── „Extern" heisst: Rippy laeuft woanders (Befund 29.08.2026) ──────────
|
||||
#
|
||||
# Hier stand `os.path.isdir("/app")`. Im Image stimmt das. Auf einem
|
||||
# Windows-PC gibt es `/app` nicht — und damit hielt sich der eigenstaendige
|
||||
# Windows-Rippy fuer einen FREMDEN Worker. Folge: Das UI warnte vor fehlender
|
||||
# Pfad-Uebersetzung auf einer Maschine, auf der es weder Container noch
|
||||
# Freigabe gibt.
|
||||
|
||||
|
||||
def test_im_container_ist_nichts_extern():
|
||||
from caps import _extern
|
||||
|
||||
assert _extern({}, container=True) == "nein"
|
||||
|
||||
|
||||
def test_die_eigenstaendige_windows_app_ist_NICHT_extern():
|
||||
"""Dieser Prozess IST Rippy — die Frage ist dort gegenstandslos."""
|
||||
from caps import _extern
|
||||
|
||||
assert _extern({"profil": "standalone"}, container=False) == "nein"
|
||||
|
||||
|
||||
def test_ein_worker_bei_einem_docker_rippy_ist_extern():
|
||||
"""Der Fall, fuer den die Auskunft gemacht ist: Er braucht RIPPY_PATH_MAP."""
|
||||
from caps import _extern
|
||||
|
||||
assert _extern({"profil": "node", "queue": {"treiber": "celery"}},
|
||||
container=False) == "ja"
|
||||
|
||||
@@ -513,3 +513,48 @@ def test_laufender_dienst_wird_nicht_noch_einmal_gestartet(monkeypatch):
|
||||
monkeypatch.setattr(windows_app, "starte_hintergrund",
|
||||
lambda p: pytest.fail("es laeuft doch schon einer"))
|
||||
assert windows_app.bereitstellen(7788) is True
|
||||
|
||||
|
||||
# ── Drueberinstallieren (Befund 29.08.2026) ─────────────────────────────
|
||||
#
|
||||
# Commander: „Was passiert wenn man die Setup.exe einfach drüber installiert?
|
||||
# Geht das, ist es dann die aktuellste version?"
|
||||
#
|
||||
# Bis hierher: nicht zuverlaessig. Rippy startet mit Windows, laeuft also fast
|
||||
# immer — dann ist `Rippy.exe` gesperrt, und die neue Fassung landete als
|
||||
# `Rippy.exe.neu` daneben, mit der Meldung „wird beim naechsten Start
|
||||
# uebernommen". **Diese Zusage hat niemand eingeloest**: `.neu` kam im ganzen
|
||||
# Projekt genau einmal vor, naemlich an der Stelle, die es schrieb.
|
||||
|
||||
|
||||
def test_eine_liegengebliebene_fassung_wird_uebernommen(tmp_path):
|
||||
from rippy import windows_app
|
||||
|
||||
(tmp_path / "Rippy.exe").write_bytes(b"alt")
|
||||
(tmp_path / "Rippy.exe.neu").write_bytes(b"neu")
|
||||
|
||||
assert windows_app.ausstehende_fassung_uebernehmen(str(tmp_path))
|
||||
assert (tmp_path / "Rippy.exe").read_bytes() == b"neu"
|
||||
assert not (tmp_path / "Rippy.exe.neu").exists()
|
||||
|
||||
|
||||
def test_ohne_liegengebliebene_fassung_passiert_nichts(tmp_path):
|
||||
from rippy import windows_app
|
||||
|
||||
(tmp_path / "Rippy.exe").write_bytes(b"alt")
|
||||
assert windows_app.ausstehende_fassung_uebernehmen(str(tmp_path)) == ""
|
||||
assert (tmp_path / "Rippy.exe").read_bytes() == b"alt"
|
||||
|
||||
|
||||
def test_ein_fehlschlag_beim_tausch_wirft_nicht(tmp_path):
|
||||
"""Rippy laeuft dann mit der alten Fassung weiter — besser als gar nicht."""
|
||||
from rippy import windows_app
|
||||
|
||||
(tmp_path / "Rippy.exe").write_bytes(b"alt")
|
||||
(tmp_path / "Rippy.exe.neu").write_bytes(b"neu")
|
||||
|
||||
def gesperrt(a, b):
|
||||
raise OSError(32, "in Benutzung")
|
||||
|
||||
assert windows_app.ausstehende_fassung_uebernehmen(str(tmp_path), gesperrt) == ""
|
||||
assert (tmp_path / "Rippy.exe").read_bytes() == b"alt"
|
||||
|
||||
@@ -127,17 +127,44 @@ def installieren(ziel: str = None, autostart: bool = True,
|
||||
quelle = _eigene_datei()
|
||||
programm = os.path.join(ziel, "Rippy.exe")
|
||||
|
||||
# Sich selbst kopieren. Läuft gerade eine ältere Fassung, ist die Datei
|
||||
# gesperrt — dann daneben legen und beim nächsten Start tauschen.
|
||||
# ⚠️ ERST den laufenden Rippy beenden (Befund 29.08.2026).
|
||||
#
|
||||
# Commander: „Was passiert wenn man die Setup.exe einfach drüber
|
||||
# installiert? Geht das, ist es dann die aktuellste version?"
|
||||
#
|
||||
# Bis hierher: NEIN, nicht zuverlässig. Rippy startet mit Windows, läuft
|
||||
# also fast immer — dann ist `Rippy.exe` gesperrt, `shutil.copy2` warf
|
||||
# `PermissionError`, und die neue Fassung landete als `Rippy.exe.neu`
|
||||
# daneben. Dazu die Meldung „wird beim nächsten Start übernommen".
|
||||
#
|
||||
# **Diese Zusage hat niemand eingelöst.** `Rippy.exe.neu` kam im ganzen
|
||||
# Projekt genau einmal vor: hier. Wer drüberinstallierte, behielt still
|
||||
# die alte Fassung — und das Setup meldete Erfolg.
|
||||
#
|
||||
# Jetzt wird der laufende Rippy vorher beendet. `dienst_beenden` gibt es
|
||||
# seit der Deinstallation; es wartet auch die zwei Sekunden ab, die
|
||||
# Windows braucht, um die Dateihandles freizugeben.
|
||||
if os.path.isfile(programm) and \
|
||||
os.path.abspath(quelle).lower() != os.path.abspath(programm).lower():
|
||||
if dienst_beenden(ziel):
|
||||
melden(" Laufender Rippy beendet — die Dateien sind jetzt frei.")
|
||||
# Und aufräumen, was eine ÄLTERE Setup-Fassung liegen ließ.
|
||||
ausstehende_fassung_uebernehmen(ziel)
|
||||
|
||||
# Sich selbst kopieren.
|
||||
if os.path.abspath(quelle).lower() != os.path.abspath(programm).lower():
|
||||
try:
|
||||
shutil.copy2(quelle, programm)
|
||||
except PermissionError:
|
||||
ersatz = programm + ".neu"
|
||||
shutil.copy2(quelle, ersatz)
|
||||
melden("Rippy läuft gerade — die neue Fassung wird beim nächsten "
|
||||
"Start übernommen.")
|
||||
programm = ersatz
|
||||
except PermissionError as e:
|
||||
# Ehrlich scheitern statt eine Zusage zu geben, die niemand
|
||||
# einlöst. Hier lag bis zum 29.08.2026 der `.neu`-Rückfall — mit
|
||||
# der Meldung „wird beim nächsten Start übernommen" und ohne
|
||||
# jeden Code, der das getan hätte.
|
||||
raise RuntimeError(
|
||||
"Rippy.exe liess sich nicht ersetzen: %s\n"
|
||||
"Rippy laeuft noch. Bitte im Infobereich rechts unten "
|
||||
"beenden (Rechtsklick auf das Rippy-Symbol) und das Setup "
|
||||
"erneut starten." % e) from e
|
||||
|
||||
icon = _icon_pfad()
|
||||
if icon and os.path.isfile(icon):
|
||||
@@ -587,6 +614,46 @@ class Dienst:
|
||||
return False
|
||||
|
||||
|
||||
def ausstehende_fassung_uebernehmen(ordner: str = None, ersetzen=None) -> str:
|
||||
"""Eine beim Update danebengelegte `Rippy.exe.neu` jetzt übernehmen.
|
||||
|
||||
Gibt den übernommenen Pfad zurück, sonst "".
|
||||
|
||||
## Die uneingelöste Zusage (Befund 29.08.2026)
|
||||
|
||||
Beim Drüberinstallieren schrieb `installieren()` bei gesperrter Datei eine
|
||||
`Rippy.exe.neu` daneben und meldete: „wird beim nächsten Start
|
||||
übernommen." **Diese Zeile war das einzige Vorkommen von `.neu` im ganzen
|
||||
Projekt** — es gab niemanden, der sie übernommen hätte. Wer über eine
|
||||
laufende Installation installierte, behielt still die alte Fassung, und
|
||||
das Setup sagte „fertig".
|
||||
|
||||
## Warum das hier läuft und NICHT beim Dienststart
|
||||
|
||||
Der naheliegende Ort wäre der Start von Rippy selbst. Er funktioniert
|
||||
nicht: Windows sperrt eine laufende `.exe`, und `Rippy.exe` ist genau die
|
||||
Datei, die ersetzt werden müsste. `os.replace` scheitert dort immer.
|
||||
|
||||
Das Setup ist ein ANDERES Programm (`RippySetup.exe`) und hat den
|
||||
laufenden Rippy vorher beendet — hier ist die Datei frei. Aufgeräumt wird
|
||||
damit auch, was eine ältere Setup-Fassung liegen gelassen hat.
|
||||
"""
|
||||
ordner = ordner or os.path.dirname(_eigene_datei())
|
||||
ziel = os.path.join(ordner, "Rippy.exe")
|
||||
neu = ziel + ".neu"
|
||||
if not os.path.isfile(neu):
|
||||
return ""
|
||||
try:
|
||||
(ersetzen or os.replace)(neu, ziel)
|
||||
except OSError as e:
|
||||
# Nicht werfen: Rippy laeuft mit der alten Fassung weiter, das ist
|
||||
# besser als gar nicht zu starten. Gesagt wird es trotzdem.
|
||||
melden(" Neue Fassung konnte nicht uebernommen werden (%s)." % e)
|
||||
return ""
|
||||
melden(" Neue Fassung uebernommen: %s" % ziel)
|
||||
return ziel
|
||||
|
||||
|
||||
def _reste_wegraeumen() -> None:
|
||||
"""Zurueckgelassene Auspack-Ordner entfernen. Darf nie stoeren."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user