Compare commits
3
Commits
d9780a4951
...
21b1757aa4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b1757aa4 | ||
|
|
423374e65a | ||
|
|
27c9d9a4bb |
+68
-1
@@ -1,6 +1,73 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v4.0-rc5 — **Rippy rippt** (29.08.2026)
|
## Aktueller Stand: v4.0-rc6 — leerer Bildschirm behoben (29.08.2026)
|
||||||
|
|
||||||
|
> **Dein Befund war zweimal richtig und einmal irreführend:** Der Bildschirm
|
||||||
|
> war wirklich leer — aber der Rip lief. Man sah ihn nur nie.
|
||||||
|
|
||||||
|
### Der leere Hintergrund
|
||||||
|
|
||||||
|
Nachgestellt auf einem Testdienst hier, nicht bei dir. Nach dem Klick auf
|
||||||
|
„Rippen starten" stand in der Browser-Konsole:
|
||||||
|
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||||
|
|
||||||
|
Und währenddessen, direkt an der Schnittstelle gemessen:
|
||||||
|
|
||||||
|
status: processing · progress: 12
|
||||||
|
|
||||||
|
**Er startet also sehr wohl.** Die Oberfläche war nur weg, bevor sie es zeigen
|
||||||
|
konnte.
|
||||||
|
|
||||||
|
Die Ursache: Das Ereignis „Job angelegt" trug nur Status, Fortschritt, Titel
|
||||||
|
und Fehler — **keinen Typ**. Die Oberfläche fügt so einen halben Job in ihre
|
||||||
|
Liste ein, das Live-Log liest `job.type.toUpperCase()`, und React baut bei
|
||||||
|
einem Fehler im Zeichnen den **ganzen** Baum ab. Eine Fehlergrenze, die das
|
||||||
|
auffängt, gab es in diesem Projekt nirgends — deshalb leerer Bildschirm ohne
|
||||||
|
jede Meldung.
|
||||||
|
|
||||||
|
Drei Reparaturen, weil es drei Fehler waren:
|
||||||
|
|
||||||
|
1. **Das Ereignis trägt den Job** — Typ, Laufwerk und Startzeit fahren mit.
|
||||||
|
Sie ändern sich nie, kosten also kein zusätzliches Ereignis; ohne die
|
||||||
|
Startzeit stand in der Jobliste sekundenlang „Invalid Date".
|
||||||
|
2. **Die Oberfläche verträgt sein Fehlen.** Zeile 108 derselben Datei hatte
|
||||||
|
die Absicherung längst, Zeile 48 nicht.
|
||||||
|
3. **Eine Fehlergrenze.** Jetzt steht da, was los ist, die Navigation bleibt
|
||||||
|
bedienbar, und der Hinweis sagt das Wichtigste: *laufende Rips gehen
|
||||||
|
weiter*.
|
||||||
|
|
||||||
|
Warum es niemand gefunden hat: Die Tests reichten der Vergleichsfunktion
|
||||||
|
immer ihre eigenen Wörterbücher herein — **die Kurzform selbst war nie
|
||||||
|
geprüft.** Jetzt bewacht ein Vertrag die Felder mechanisch.
|
||||||
|
|
||||||
|
### Der zweite Fund: `F:\app\temp`
|
||||||
|
|
||||||
|
Beim Aufräumen des Testlaufs fand ich 436 MB Rohdaten in einem Ordner namens
|
||||||
|
`app` auf dem Laufwerk, von dem Rippy gerade lief. Zwei weitere
|
||||||
|
Container-Wurzeln:
|
||||||
|
|
||||||
|
RAW_DIR = /app/temp/raw
|
||||||
|
MEDIA_ROOT = /app/media
|
||||||
|
|
||||||
|
Und schlimmer als der falsche Standard: Die Prüfung verwarf **auch eine
|
||||||
|
ausdrückliche Wahl**. `D:\Roh` liegt nicht unter `/app/media`, also fiel es
|
||||||
|
still zurück.
|
||||||
|
|
||||||
|
**Damit kam der Arbeitsordner, den du gestern bestellt hast, unter Windows nie
|
||||||
|
an.** Der Dialog zeigte ihn, das Setzen ging, der Worker ignorierte ihn — ohne
|
||||||
|
ein Wort. Dasselbe galt fürs Ziel: Deine UNC-Freigabe liegt unter keiner
|
||||||
|
lokalen Wurzel, die fertige Datei wäre in `X:\app\media\bluray` gelandet.
|
||||||
|
|
||||||
|
Jetzt kommen beide Wurzeln aus dem Betrieb. Im Container ändert sich nichts.
|
||||||
|
|
||||||
|
### Aufgeräumt
|
||||||
|
|
||||||
|
Ich habe für die Prüfung zweimal einen echten Rip auf deinem Laufwerk
|
||||||
|
gestartet und beide sofort abgebrochen. Die 436 MB Rohdaten und der Ordner
|
||||||
|
`F:\app` sind entfernt, die Testdienste beendet.
|
||||||
|
|
||||||
|
## Letzter Stand davor: v4.0-rc5 — **Rippy rippt** (29.08.2026)
|
||||||
|
|
||||||
> **Der erste bewiesene Rip unter Windows.** Dein Fehlerbericht enthielt drei
|
> **Der erste bewiesene Rip unter Windows.** Dein Fehlerbericht enthielt drei
|
||||||
> Fehler auf einmal — alle drei gefunden, behoben und an deinem Laufwerk
|
> Fehler auf einmal — alle drei gefunden, behoben und an deinem Laufwerk
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import FirstRunWizard from './components/FirstRunWizard'
|
|||||||
import { api } from './lib/api'
|
import { api } from './lib/api'
|
||||||
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
||||||
import { BetriebProvider } from './lib/useBetrieb'
|
import { BetriebProvider } from './lib/useBetrieb'
|
||||||
|
import { Fehlergrenze } from './components/Fehlergrenze'
|
||||||
|
|
||||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||||
|
|
||||||
@@ -161,10 +162,19 @@ function AppInhalt() {
|
|||||||
|
|
||||||
{/* Main Content Viewport */}
|
{/* Main Content Viewport */}
|
||||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Je Seite eine eigene Grenze: Faellt eine aus, bleibt die
|
||||||
|
Navigation stehen und man kommt woanders hin. Ohne das war am
|
||||||
|
29.08.2026 nach einem Klick auf „Rippen starten" der ganze
|
||||||
|
Bildschirm leer — samt Kopfzeile. */}
|
||||||
|
<Fehlergrenze bereich={
|
||||||
|
currentPage === 'dashboard' ? 'Das Dashboard'
|
||||||
|
: currentPage === 'anleitung' ? 'Die Anleitung'
|
||||||
|
: currentPage === 'logs' ? 'Die Protokolle' : 'Die Einstellungen'}>
|
||||||
{currentPage === 'dashboard' && <Dashboard />}
|
{currentPage === 'dashboard' && <Dashboard />}
|
||||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||||
{currentPage === 'logs' && <LogsPage />}
|
{currentPage === 'logs' && <LogsPage />}
|
||||||
{currentPage === 'settings' && <SettingsPage />}
|
{currentPage === 'settings' && <SettingsPage />}
|
||||||
|
</Fehlergrenze>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -178,10 +188,12 @@ export default function App() {
|
|||||||
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
||||||
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
||||||
return (
|
return (
|
||||||
|
<Fehlergrenze>
|
||||||
<BetriebProvider>
|
<BetriebProvider>
|
||||||
<EventStreamProvider>
|
<EventStreamProvider>
|
||||||
<AppInhalt />
|
<AppInhalt />
|
||||||
</EventStreamProvider>
|
</EventStreamProvider>
|
||||||
</BetriebProvider>
|
</BetriebProvider>
|
||||||
|
</Fehlergrenze>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Eine Fehlergrenze — damit ein Fehler nicht die ganze Oberfläche verschluckt.
|
||||||
|
*
|
||||||
|
* ## Der Befund des Commanders (29.08.2026)
|
||||||
|
*
|
||||||
|
* > „wenn man auf rippen starten klickt passiert irgendwas, was das Programm
|
||||||
|
* > nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
|
||||||
|
* > auch keine fehlermeldung."
|
||||||
|
*
|
||||||
|
* Nachgestellt und in der Browser-Konsole gemessen:
|
||||||
|
*
|
||||||
|
* TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||||
|
*
|
||||||
|
* Der Job war in Wirklichkeit angelegt und lief (gemessen: `processing`,
|
||||||
|
* 12 %). Nur zeigte die Oberfläche das nie — sie war weg. Grund: React baut
|
||||||
|
* bei einem Fehler im Zeichnen den GESAMTEN Baum ab, wenn ihn niemand
|
||||||
|
* auffängt. Und aufgefangen hat ihn niemand: Es gab in diesem Projekt keine
|
||||||
|
* einzige Fehlergrenze.
|
||||||
|
*
|
||||||
|
* ## Warum die eigentliche Reparatur woanders sitzt
|
||||||
|
*
|
||||||
|
* Die Ursache war ein Ereignis, das den halben Job schickte
|
||||||
|
* (`rippy/bus/waechter.py`), und zwei Stellen, die einen vollständigen
|
||||||
|
* annahmen. Das ist behoben. Diese Datei behebt etwas anderes: die WIRKUNG.
|
||||||
|
*
|
||||||
|
* Ein Programmierfehler in einer Karte darf den Rest des Bildschirms nicht
|
||||||
|
* mitnehmen — und schon gar nicht schweigend. Der nächste Fehler dieser Art
|
||||||
|
* kommt bestimmt; dann steht wenigstens da, was los ist, und der Rest der
|
||||||
|
* Oberfläche bleibt bedienbar.
|
||||||
|
*
|
||||||
|
* Deshalb wird sie ZWEIMAL eingesetzt: einmal um die ganze Anwendung, und
|
||||||
|
* einmal um den Job-Bereich allein. Fällt eine Karte aus, bleibt der Rest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
/** Was hier kaputtgehen kann — steht in der Meldung. */
|
||||||
|
bereich?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
fehler: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Fehlergrenze extends Component<Props, State> {
|
||||||
|
state: State = { fehler: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(fehler: Error): State {
|
||||||
|
return { fehler }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(fehler: Error, info: ErrorInfo) {
|
||||||
|
// In die Konsole, damit die Ursache auffindbar bleibt. Ein stiller
|
||||||
|
// Fehler ist genau das, was diesen Befund so schwer gemacht hat.
|
||||||
|
console.error('Rippy: Fehler im Bereich', this.props.bereich || 'Oberfläche',
|
||||||
|
fehler, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
neuLaden = () => {
|
||||||
|
this.setState({ fehler: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.fehler) return this.props.children
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="m-4 p-5 rounded-xl border border-amber-500/40 bg-amber-500/10">
|
||||||
|
<h2 className="font-semibold text-amber-700 dark:text-amber-300">
|
||||||
|
{this.props.bereich
|
||||||
|
? `${this.props.bereich} lässt sich gerade nicht anzeigen`
|
||||||
|
: 'Diese Ansicht lässt sich gerade nicht anzeigen'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mt-2 text-slate-600 dark:text-slate-300">
|
||||||
|
Der Fehler betrifft nur die Anzeige.{' '}
|
||||||
|
<strong>Laufende Rips gehen weiter</strong> — Rippy arbeitet im
|
||||||
|
Hintergrund, auch wenn dieser Bereich leer bleibt.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-3 font-mono text-slate-500 dark:text-slate-400 break-all">
|
||||||
|
{this.state.fehler.message || String(this.state.fehler)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={this.neuLaden}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600"
|
||||||
|
>
|
||||||
|
Nochmal versuchen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
Seite neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,10 @@ export default function LiveLogSection() {
|
|||||||
<CardTitle>Live-Log</CardTitle>
|
<CardTitle>Live-Log</CardTitle>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{currentJob
|
{currentJob
|
||||||
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
// `type?.` ist hier kein Beiwerk: Ein frisch angelegter Job kann
|
||||||
|
// noch ohne Typ ankommen, und ohne das Fragezeichen riss diese
|
||||||
|
// eine Zeile am 29.08.2026 die GANZE Oberflaeche mit.
|
||||||
|
? `Aktiver Job: ${currentJob.type?.toUpperCase() || 'Disc'} (${currentJob.progress ?? 0}%)`
|
||||||
: 'Kein aktiver Job'}
|
: 'Kein aktiver Job'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ interface TypeBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
||||||
|
// Ein fehlender Typ darf kein Absturz sein. Am 29.08.2026 hat genau diese
|
||||||
|
// Annahme (an anderer Stelle) die ganze Oberflaeche geleert: Ein frisch
|
||||||
|
// angelegter Job kommt ueber den Ereignisstrom ohne `type` an.
|
||||||
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
||||||
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||||
label: type.toUpperCase(),
|
label: type ? type.toUpperCase() : 'DISC',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+69
-14
@@ -157,7 +157,7 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
|||||||
return pfad == sauber or pfad.startswith(sauber + "/")
|
return pfad == sauber or pfad.startswith(sauber + "/")
|
||||||
|
|
||||||
|
|
||||||
def _zielbasis(target_dir, disc_type: str) -> str:
|
def _zielbasis(target_dir, disc_type: str, wurzeln=None) -> str:
|
||||||
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
||||||
|
|
||||||
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
||||||
@@ -167,16 +167,70 @@ def _zielbasis(target_dir, disc_type: str) -> str:
|
|||||||
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
||||||
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
||||||
"""
|
"""
|
||||||
|
wurzel, _vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||||
if target_dir:
|
if target_dir:
|
||||||
normalisiert = posixpath.normpath(target_dir)
|
normalisiert = _normalisiert(target_dir)
|
||||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
if frei or unter_wurzel(normalisiert, wurzel):
|
||||||
return normalisiert
|
return normalisiert
|
||||||
return posixpath.join(RIP_OUTPUT_DIR, disc_type)
|
# Ohne Wahl: die Wurzel dieses Betriebs, nicht die des Containers. Sonst
|
||||||
|
# landete die fertige Datei unter Windows in `X:\app\media\bluray` —
|
||||||
|
# einem Ordner, den niemand gesucht hat (gemessen 29.08.2026).
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return pfade.verbinden(wurzel if frei else RIP_OUTPUT_DIR, disc_type)
|
||||||
|
|
||||||
|
|
||||||
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
def _betriebs_wurzeln() -> tuple:
|
||||||
|
"""`(medien_wurzel, arbeits_vorgabe, frei)` für DIESEN Betrieb.
|
||||||
|
|
||||||
|
## Warum das hier gebraucht wird (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch —
|
||||||
|
und die Rohdaten landeten in **`F:\\app\\temp\\raw`**. Also in einem
|
||||||
|
Ordner namens `app` auf dem Laufwerk, von dem Rippy gerade lief.
|
||||||
|
|
||||||
|
`RAW_DIR` ist `/app/temp/raw` und `MEDIA_ROOT` ist `/app/media`; unter
|
||||||
|
Windows sind das keine Pfade, sondern Unfälle. Schlimmer noch: Die
|
||||||
|
Prüfung `unter_wurzel(wahl, MEDIA_ROOT)` verwarf **auch eine ausdrückliche
|
||||||
|
Wahl** — ein Arbeitsordner wie `D:\\Roh` liegt nicht unter `/app/media`,
|
||||||
|
also fiel er still auf den Container-Standard zurück.
|
||||||
|
|
||||||
|
Damit kam der Arbeitsordner, den der Commander am 28.08.2026 ausdrücklich
|
||||||
|
bestellt hat („kannst du noch einbauen das man den arbeitsordner … setzen
|
||||||
|
kann"), unter Windows nie an. Der Dialog zeigte ihn, das Setzen ging, und
|
||||||
|
der Worker ignorierte ihn — ohne ein Wort.
|
||||||
|
|
||||||
|
Im Container ändert sich nichts: Dort ist `/app/media` die Wurzel, und
|
||||||
|
`frei` ist falsch.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
return (betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||||
|
betrieb.arbeits_vorgabe(werte) or RAW_DIR,
|
||||||
|
betrieb.frei_blaettern(werte))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalisiert(wert: str) -> str:
|
||||||
|
"""Pfad säubern — nach seiner FORM, nicht nach dem laufenden Rechner.
|
||||||
|
|
||||||
|
`posixpath` für Container-Pfade (sonst macht Windows Backslashes daraus
|
||||||
|
und die Wurzelprüfung greift nicht mehr), `os.path` für echte
|
||||||
|
Windows-Pfade. Dieselbe Regel wie in `rippy.pfade`.
|
||||||
|
"""
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return os.path.normpath(wert) if pfade.ist_windows_pfad(wert) \
|
||||||
|
else posixpath.normpath(wert)
|
||||||
|
|
||||||
|
|
||||||
|
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
|
||||||
|
wurzeln=None) -> str:
|
||||||
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
||||||
`workDir` → Container-Default /app/temp/raw.
|
`workDir` → Vorgabe dieses Betriebs (Container: /app/temp/raw).
|
||||||
|
|
||||||
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||||||
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||||
@@ -187,17 +241,18 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
|||||||
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
||||||
denen niemand gefragt wird.
|
denen niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
|
wurzel, vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||||
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
|
|
||||||
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
|
|
||||||
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
|
|
||||||
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
||||||
wert = (kandidat or "").strip()
|
wert = (kandidat or "").strip()
|
||||||
if wert:
|
if not wert:
|
||||||
normalisiert = posixpath.normpath(wert)
|
continue
|
||||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
normalisiert = _normalisiert(wert)
|
||||||
|
# Nativ zählt die Wahl des Nutzers — dort IST sein Laufwerk die
|
||||||
|
# Grenze. Im Container bleibt die Wurzelprüfung: Ein Pfad ausserhalb
|
||||||
|
# von /app/media wäre dort ein Pfad ins Nichts.
|
||||||
|
if frei or unter_wurzel(normalisiert, wurzel):
|
||||||
return normalisiert
|
return normalisiert
|
||||||
return RAW_DIR
|
return vorgabe
|
||||||
|
|
||||||
|
|
||||||
def _frei_bytes(pfad: str) -> int:
|
def _frei_bytes(pfad: str) -> int:
|
||||||
|
|||||||
@@ -121,24 +121,33 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
|||||||
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
||||||
|
|
||||||
|
|
||||||
|
# Die Wurzeln je Betrieb — eingespritzt, damit BEIDE Faelle ueberall pruefbar
|
||||||
|
# sind. Vorher hingen diese Tests am laufenden Rechner: Unter Windows ist
|
||||||
|
# `frei` wahr, und die Container-Regeln galten dort nicht mehr (am 29.08.2026
|
||||||
|
# prompt rot geworden).
|
||||||
|
CONTAINER = ("/app/media", "/app/temp/raw", False)
|
||||||
|
NATIV = (r"C:\Users\Tobi\Videos\Rippy", r"C:\Users\Tobi\Videos\Rippy\_arbeit", True)
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||||
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
||||||
|
|
||||||
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
|
Reihenfolge: Wahl dieses Rips -> Setting -> Vorgabe des Betriebs. Der
|
||||||
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
||||||
niemand gefragt wird.
|
niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
import ablauf as tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
einst = {"workDir": "/app/media/movies"}
|
einst = {"workDir": "/app/media/movies"}
|
||||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy", w) == "/app/media/rippy"
|
||||||
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis(einst, "", w) == "/app/media/movies"
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", w) == "/app/temp/raw"
|
||||||
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
||||||
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/etc", w) == "/app/temp/raw"
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc", w) == "/app/temp/raw"
|
||||||
# Leere Wahl fällt sauber auf die Einstellung zurück
|
# Leere Wahl fällt sauber auf die Einstellung zurück
|
||||||
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, " ", w) == "/app/media/movies"
|
||||||
|
|
||||||
|
|
||||||
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||||
@@ -160,16 +169,67 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
|||||||
|
|
||||||
|
|
||||||
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
||||||
|
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
|
||||||
|
Windows-Reparatur nichts."""
|
||||||
import ablauf as tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
|
w = CONTAINER
|
||||||
|
assert tasks._zielbasis("/app/media/movies", "bluray", w) == "/app/media/movies"
|
||||||
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
||||||
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
|
assert tasks._zielbasis("/app/media-boese", "bluray", w) != "/app/media-boese"
|
||||||
assert tasks._zielbasis("/etc", "bluray") != "/etc"
|
assert tasks._zielbasis("/etc", "bluray", w) != "/etc"
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
||||||
import ablauf as tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media-boese", w) == "/app/temp/raw"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}, "", w) == "/app/temp/raw"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nativ: der Befund vom 29.08.2026 ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch, und
|
||||||
|
# die Rohdaten landeten in `F:\app\temp\raw` — einem Ordner namens `app` auf
|
||||||
|
# dem Laufwerk, von dem Rippy gerade lief. Ursache: `RAW_DIR` ist
|
||||||
|
# `/app/temp/raw`, und die Pruefung `unter_wurzel(wahl, "/app/media")` verwarf
|
||||||
|
# sogar eine AUSDRUECKLICHE Wahl.
|
||||||
|
#
|
||||||
|
# Damit kam der Arbeitsordner, den der Commander am 28.08.2026 bestellt hat,
|
||||||
|
# unter Windows nie an: Der Dialog zeigte ihn, das Setzen ging, der Worker
|
||||||
|
# ignorierte ihn — ohne ein Wort.
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_zaehlt_die_wahl_des_nutzers():
|
||||||
|
"""DER Befund. `D:\\Roh` liegt unter keiner Container-Wurzel und wurde
|
||||||
|
deshalb still verworfen."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, r"D:\Roh", NATIV) == r"D:\Roh"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": r"E:\Arbeit"}, "", NATIV) == r"E:\Arbeit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_faellt_auf_den_ort_aus_der_installation_zurueck():
|
||||||
|
"""Nicht auf `/app/temp/raw` — das wurde unter Windows zu `X:\\app\\temp`."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", NATIV) == NATIV[1]
|
||||||
|
assert "/app/" not in tasks._arbeitsverzeichnis({}, "", NATIV)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_nimmt_auch_eine_freigabe_als_ziel():
|
||||||
|
"""Sein Ziel ist eine UNC-Freigabe — die liegt unter gar keiner lokalen
|
||||||
|
Wurzel."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
unc = r"\\192.168.179.62\rippy\movies"
|
||||||
|
assert tasks._zielbasis(unc, "bluray", NATIV) == unc
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_ohne_wahl_landet_unter_der_eigenen_ablage():
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
ziel = tasks._zielbasis("", "bluray", NATIV)
|
||||||
|
assert ziel.startswith(NATIV[0])
|
||||||
|
assert "/app/" not in ziel
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ genau die Eigenschaft, die dem ersten Anlauf der SSE-Tests gefehlt hat
|
|||||||
(Ampel-Lauf 170 lief rot, weil ein Test eine Datenbank brauchte).
|
(Ampel-Lauf 170 lief rot, weil ein Test eine Datenbank brauchte).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rippy.bus.waechter import Waechter, laufwerks_unterschiede, unterschiede
|
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz, Waechter, laufwerks_unterschiede, unterschiede
|
||||||
|
|
||||||
|
|
||||||
class FakeBus:
|
class FakeBus:
|
||||||
@@ -277,3 +277,70 @@ def test_laufwerke_werden_seltener_abgefragt_als_jobs():
|
|||||||
w.einmal()
|
w.einmal()
|
||||||
w.einmal()
|
w.einmal()
|
||||||
assert len(aufrufe) == 1, "Laufwerke wurden mehrfach im selben Takt gelesen"
|
assert len(aufrufe) == 1, "Laufwerke wurden mehrfach im selben Takt gelesen"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Der Vertrag mit der Oberflaeche (Befund 29.08.2026) ─────────────────
|
||||||
|
#
|
||||||
|
# Commander: „wenn man auf rippen starten klickt passiert irgendwas, was das
|
||||||
|
# Programm nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es
|
||||||
|
# gibt auch keine fehlermeldung."
|
||||||
|
#
|
||||||
|
# Nachgestellt und in der Browser-Konsole gemessen:
|
||||||
|
#
|
||||||
|
# TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||||
|
#
|
||||||
|
# Der Job lief in Wahrheit (gemessen: processing, 12 %). Aber `job.created`
|
||||||
|
# trug keinen `type`; das UI fuegte einen halben Job ein, las
|
||||||
|
# `job.type.toUpperCase()` — und weil es keine Fehlergrenze gab, riss der
|
||||||
|
# Fehler die GANZE Oberflaeche mit.
|
||||||
|
#
|
||||||
|
# ⚠️ Warum es niemand gefunden hat: `unterschiede()` bekommt die Kurzform
|
||||||
|
# schon fertig, die Tests oben reichen ihre eigenen Woerterbuecher herein.
|
||||||
|
# **`_job_kurz` selbst war nie geprueft.** Diese Tests schliessen die Luecke.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _db_zeile():
|
||||||
|
"""Eine Job-Zeile, wie die API sie liefert (Felder aus /jobs)."""
|
||||||
|
return {"id": "j1", "type": "bluray", "device": r"\\.\G:",
|
||||||
|
"startTime": "2026-08-29T12:07:02", "endTime": None,
|
||||||
|
"status": "processing", "progress": 12, "title": "Evangelion 2.22",
|
||||||
|
"error": None, "meta": {"confidence": 0.3}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_job_ereignis_traegt_alles_was_die_oberflaeche_braucht():
|
||||||
|
"""DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander
|
||||||
|
leer — ohne Fehlermeldung."""
|
||||||
|
kurz = _job_kurz(_db_zeile())
|
||||||
|
for feld in UI_PFLICHTFELDER:
|
||||||
|
assert feld in kurz, "%s fehlt — das UI muesste raten" % feld
|
||||||
|
assert kurz[feld] is not None, "%s ist leer" % feld
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_typ_kommt_wirklich_durch():
|
||||||
|
"""Der konkrete Fehler: `type` fehlte, das UI rief .toUpperCase() darauf."""
|
||||||
|
assert _job_kurz(_db_zeile())["type"] == "bluray"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlende_felder_werden_zu_None_statt_zu_werfen():
|
||||||
|
"""Eine unvollstaendige Zeile darf den Waechter nicht umbringen — er
|
||||||
|
laeuft in der Ereignisschleife des Servers."""
|
||||||
|
kurz = _job_kurz({"id": "j1"})
|
||||||
|
assert kurz["type"] is None and kurz["status"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_kurzform_traegt_NUR_was_gebraucht_wird():
|
||||||
|
"""Kein Nachziehen der ganzen Zeile: `meta` und `endTime` gehoeren nicht
|
||||||
|
in ein Aenderungs-Ereignis, sonst feuert es bei jeder Kleinigkeit."""
|
||||||
|
kurz = _job_kurz(_db_zeile())
|
||||||
|
assert "meta" not in kurz
|
||||||
|
assert "endTime" not in kurz
|
||||||
|
|
||||||
|
|
||||||
|
def test_unveraenderliche_felder_erzeugen_keine_zusatz_ereignisse():
|
||||||
|
"""`type`, `device` und `startTime` aendern sich ueber die Lebenszeit
|
||||||
|
eines Jobs nie — sie kosten also nichts, obwohl sie mitfahren."""
|
||||||
|
eins = _job_kurz(_db_zeile())
|
||||||
|
zwei = _job_kurz(dict(_db_zeile(), progress=13))
|
||||||
|
assert unterschiede({"j1": eins}, {"j1": zwei}) == [
|
||||||
|
("job.progress", "j1", zwei)]
|
||||||
|
|||||||
@@ -79,8 +79,38 @@ ENDE = ("completed", "failed", "canceled")
|
|||||||
|
|
||||||
|
|
||||||
def _job_kurz(zeile: dict) -> dict:
|
def _job_kurz(zeile: dict) -> dict:
|
||||||
"""Nur die Felder, deren Änderung ein Ereignis wert ist."""
|
"""Nur die Felder, deren Änderung ein Ereignis wert ist.
|
||||||
|
|
||||||
|
## Warum `type` dazugehört (Befund des Commanders, 29.08.2026)
|
||||||
|
|
||||||
|
> „wenn man auf rippen starten klickt passiert irgendwas, was das Programm
|
||||||
|
> nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
|
||||||
|
> auch keine fehlermeldung."
|
||||||
|
|
||||||
|
Nachgestellt und in der Browser-Konsole gemessen:
|
||||||
|
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||||
|
|
||||||
|
Der Ablauf: Der Job startet in Wahrheit sehr wohl (gemessen: `processing`,
|
||||||
|
12 %). Aber `job.created` trug nur `status`, `progress`, `title` und
|
||||||
|
`error` — **keinen `type`**. Das UI fügt so einen halben Job in seine
|
||||||
|
Liste ein, das Live-Log liest `job.type.toUpperCase()`, und weil es keine
|
||||||
|
Fehlergrenze gab, riss der Fehler die GESAMTE Oberfläche mit. Leerer
|
||||||
|
Bildschirm, keine Meldung.
|
||||||
|
|
||||||
|
Der Fehler ist damit an drei Stellen zugleich repariert: Das Ereignis
|
||||||
|
trägt den Typ (hier), das UI verträgt sein Fehlen, und eine Fehlergrenze
|
||||||
|
fängt den nächsten Fall dieser Art ab.
|
||||||
|
|
||||||
|
**Ein Ereignis „Job angelegt", das den halben Job weglässt, zwingt jeden
|
||||||
|
Empfänger zum Raten.** `type` und `device` ändern sich über die Lebenszeit
|
||||||
|
eines Jobs nie — sie kosten hier nichts und ersparen dem Empfänger die
|
||||||
|
Frage, ob er gerade einen ganzen oder einen halben Job vor sich hat.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
|
"type": zeile.get("type"),
|
||||||
|
"device": zeile.get("device"),
|
||||||
|
"startTime": zeile.get("startTime"),
|
||||||
"status": zeile.get("status"),
|
"status": zeile.get("status"),
|
||||||
"progress": zeile.get("progress"),
|
"progress": zeile.get("progress"),
|
||||||
"title": zeile.get("title"),
|
"title": zeile.get("title"),
|
||||||
@@ -88,6 +118,17 @@ def _job_kurz(zeile: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Was ein Job-Ereignis mitbringen MUSS, damit die Oberflaeche ihn zeichnen
|
||||||
|
#: kann, ohne zu raten. Gegengeprueft von test_waechter.py — es gibt kein
|
||||||
|
#: gemeinsames Typsystem zwischen Python und dem UI, also steht der Vertrag
|
||||||
|
#: hier und wird mechanisch bewacht.
|
||||||
|
#:
|
||||||
|
#: `startTime` steht dabei, weil ohne ihn in der Jobliste „Invalid Date"
|
||||||
|
#: stand (am 29.08.2026 im Browser gesehen), bis Sekunden spaeter der
|
||||||
|
#: naechste Schnappschuss kam.
|
||||||
|
UI_PFLICHTFELDER = ("type", "startTime", "status", "progress")
|
||||||
|
|
||||||
|
|
||||||
def unterschiede(vorher: dict, jetzt: dict) -> list:
|
def unterschiede(vorher: dict, jetzt: dict) -> list:
|
||||||
"""Was hat sich geändert? (pure Funktion — deshalb testbar ohne DB)
|
"""Was hat sich geändert? (pure Funktion — deshalb testbar ohne DB)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user