From d0281f7a1b645f41b1fc092053e2fa04a1804b72 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 29 Aug 2026 11:52:08 +0200 Subject: [PATCH] style: Zeilenenden zurueck auf LF (kein Inhalt geaendert) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In 35370fd habe ich fuenf Dateien versehentlich von LF auf CRLF umgestellt. Der Inhalt stimmte, aber der Diff war unlesbar: 1363 geaenderte Zeilen fuer 30 echte in RipTargetModal.tsx, 1184 fuer 29 in setup_fenster.py. Nachgewiesen mit `git diff --ignore-cr-at-eol` — damit blieben 56/9 uebrig. Ursache: Ein Rueckschreiben im Textmodus stellt die ganze Datei um. Die Regel stand als Warnung schon in meinem Gedaechtnis, dort aber nur in der anderen Richtung (CRLF -> LF). Sie gilt in beide. Dieser Commit aendert AUSSCHLIESSLICH Zeilenenden. Nachgewiesen: `git diff --ignore-cr-at-eol` listet keine dieser fuenf Dateien. Co-Authored-By: Claude Opus 5 --- docker/ui/src/components/RipTargetModal.tsx | 1410 +++++++++---------- docker/ui/src/lib/useBetrieb.tsx | 282 ++-- src/rippy/platform/test_winlauf.py | 448 +++--- src/rippy/setup_fenster.py | 1200 ++++++++-------- src/rippy/test_betrieb.py | 386 ++--- 5 files changed, 1863 insertions(+), 1863 deletions(-) diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index cd7e228..e7972e2 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -1,705 +1,705 @@ -import { useState, useEffect } from 'react' -import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react' -import { api } from '../lib/api' -import { Modal } from './ui/Modal' -import { Button } from './ui/Button' -import { Input, Select } from './ui/Input' -import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder' -import { useBetrieb } from '../lib/useBetrieb' - -interface TargetConfig { - id: string - name: string - path: string - type: 'movies' | 'series' | 'music' - isActive: boolean -} - -export interface RipOptionen { - series?: string - season?: number - mainFeatureOnly?: boolean - titles?: number[] - transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto - workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung - // Sprachauswahl (ISO-639-2). Leer = alles behalten. Wirkt bei der - // KOMPRESSION — der Rip bleibt vollständig und verlustfrei. - audioSprachen?: string[] - untertitelSprachen?: string[] -} - -// Eine Sprache, die die Disc anbietet (aus dem Titel-Scan, MakeMKV SINFO). -interface DiscSprache { - lang: string // ISO-639-2, z. B. "deu" - sprache: string // Klartext, z. B. "German" - spuren: number -} - -// Ein Ablageziel aus GET /storage-targets. -interface StorageZiel { - name: string - path: string - is_mount: boolean - free_gb: number | null -} - -interface WorkerWahl { - name: string - node: string | null - online?: boolean - encoders: string[] - // extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die - // Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet - // beides selbst (worker/caps.py). - info?: { extern?: string, pfad_map?: string } -} - -const ENCODER_KURZ: Record = { - 'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1', - 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡', -} - -interface TitelInfo { - nr: number - dauer_s: number - groesse_bytes: number - kapitel: number -} - -interface RipTargetModalProps { - isOpen: boolean - initialType?: TargetConfig['type'] - discTitle?: string - deviceId?: string - onClose: () => void - onSave: (target: TargetConfig, optionen: RipOptionen) => void -} - -function dauerText(s: number): string { - const h = Math.floor(s / 3600) - const m = Math.floor((s % 3600) / 60) - return h > 0 ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min` -} - -interface BrowseDir { - name: string - path: string -} - -export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) { - const [selectedType, setSelectedType] = useState('movies') - const [serienName, setSerienName] = useState('') - const [staffel, setStaffel] = useState(1) - const [nurHauptfilm, setNurHauptfilm] = useState(false) - const [scanStatus, setScanStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle') - const [scanFehler, setScanFehler] = useState('') - const [titelListe, setTitelListe] = useState([]) - const [gewaehlt, setGewaehlt] = useState>(new Set()) - const [workers, setWorkers] = useState([]) - const [encoderNode, setEncoderNode] = useState('') // '' = automatisch - /* - * Sprachen der Disc (Commander-Anforderung 26.07.2026: „Die Disc hat Material - * in X Sprachen und X Untertiteln — Rippy muss VOR dem Rip fragen: Was genau - * willst du haben?"). - * - * Die Auskunft kommt aus demselben Titel-Scan, der schon für die - * Titel-Auswahl läuft — MakeMKV liefert sie in derselben Ausgabe mit, sie - * wurde bisher nur weggeworfen. - * - * Leere Auswahl heißt bewusst „alles behalten": Wer nichts anklickt, bekommt - * das Verhalten von vorher, und niemand verliert versehentlich seine Tonspur. - */ - const [discSprachen, setDiscSprachen] = useState<{ audio: DiscSprache[], untertitel: DiscSprache[] } | null>(null) - const [audioWahl, setAudioWahl] = useState>(new Set()) - const [untertitelWahl, setUntertitelWahl] = useState>(new Set()) - // Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt, - // soweit die Disc sie überhaupt hat. - const [standardAudio, setStandardAudio] = useState([]) - const [standardUntertitel, setStandardUntertitel] = useState([]) - - const scanStarten = async () => { - if (!deviceId) return - setScanStatus('running') - setScanFehler('') - try { - await api.post(`/devices/${deviceId}/scan-tracks`) - } catch (e: any) { - setScanStatus('error') - setScanFehler(e?.response?.data?.detail || 'Scan konnte nicht gestartet werden') - } - } - - useEffect(() => { - if (scanStatus !== 'running' || !deviceId) return - const interval = setInterval(async () => { - try { - const r = await api.get(`/devices/${deviceId}/tracks`) - if (r.data.status === 'done') { - const tracks: TitelInfo[] = r.data.tracks || [] - setTitelListe(tracks) - setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr))) - const spr = r.data.sprachen || null - setDiscSprachen(spr) - // Wunschsprachen vorauswählen — aber nur, was die Disc wirklich hat. - // Sonst stünde da eine Auswahl, die nichts bewirkt. - if (spr) { - const vorhanden = (liste: DiscSprache[], wunsch: string[]) => - new Set(liste.filter(s => wunsch.includes(s.lang)).map(s => s.lang)) - setAudioWahl(vorhanden(spr.audio || [], standardAudio)) - setUntertitelWahl(vorhanden(spr.untertitel || [], standardUntertitel)) - } - setScanStatus('done') - } else if (r.data.status === 'error') { - setScanFehler(r.data.error || 'Scan fehlgeschlagen') - setScanStatus('error') - } - } catch { /* API kurz weg — weiter pollen */ } - }, 3000) - return () => clearInterval(interval) - }, [scanStatus, deviceId]) - - // Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" — - // auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt. - const betrieb = useBetrieb() - - const [targets, setTargets] = useState([ - { id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, - { id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, - { id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true }, - ]) - const [browsePath, setBrowsePath] = useState('') - const [browseParent, setBrowseParent] = useState(null) - const [browseDirs, setBrowseDirs] = useState([]) - const [customPath, setCustomPath] = useState('') - // Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier - // der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht - // wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein - // beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl - // darüber, und der Browser überschrieb sie stillschweigend, sobald man auf - // „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist - // die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen - // und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt. - const [browserOffen, setBrowserOffen] = useState(false) - // Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar, - // nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch - // bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird. - const [arbeitsZiele, setArbeitsZiele] = useState([]) - const [arbeitsDir, setArbeitsDir] = useState('') - const [standardArbeitsDir, setStandardArbeitsDir] = useState('') - // Ein frei erblätterter Arbeitsordner. Er braucht einen eigenen Platz in - // der Liste, sonst zeigt das Auswahlfeld einen Wert an, den es nicht kennt - // — und stünde leer da, obwohl etwas gewählt ist. - const [eigenesArbeitsZiel, setEigenesArbeitsZiel] = useState('') - - useEffect(() => { - if (!isOpen) return - setSelectedType(initialType || 'movies') - setCustomPath('') - setSerienName(discTitle || '') - setStaffel(1) - setScanStatus('idle') - setScanFehler('') - setTitelListe([]) - setGewaehlt(new Set()) - setEncoderNode('') - setArbeitsDir('') - setEigenesArbeitsZiel('') - setBrowserOffen(false) - setDiscSprachen(null) - setAudioWahl(new Set()) - setUntertitelWahl(new Set()) - api.get('/storage-targets') - .then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : [])) - .catch(() => setArbeitsZiele([])) - // Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar) - api.get('/capabilities').then(r => { - setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node)) - }).catch(() => setWorkers([])) - api.get('/settings').then(r => { - const s = r.data || {} - setNurHauptfilm(!!s.mainFeatureOnly) - setStandardArbeitsDir((s.workDir || '').trim()) - const codes = (wert: any) => String(wert || '') - .split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) - setStandardAudio(codes(s.audioSprachen)) - setStandardUntertitel(codes(s.untertitelSprachen)) - const basis = s.outputDir || betrieb.ablage_vorgabe || '/app/media' - setTargets([ - { id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true }, - { id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true }, - { id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true }, - ]) - }).catch(() => {}) - // /browse wird erst beim Aufklappen geholt — der Dialog braucht es im - // Normalfall gar nicht. - }, [isOpen]) - - const laden = async (pfad: string) => { - try { - const r = await api.get('/browse', { params: { path: pfad } }) - setBrowsePath(r.data.path) - setBrowseParent(r.data.parent) - setBrowseDirs(r.data.dirs) - } catch { - setBrowseDirs([]) - } - } - - // Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner. - const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || '' - // Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung → - // leer (= Container-Platte /app/temp, für externe Worker unerreichbar). - const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir - /* - * Was WIRKLICH benutzt wird, wenn niemand etwas wählt. - * - * Commander am 28.08.2026: „Warum heißt das hier noch container platte? Er - * holt sich das Arbeitsverzeichnis ja von der Installation." - * - * Genau so ist es — nur stand hier fest verdrahtet „(Container-Platte)", - * sobald die Einstellung leer war. Auf seinem PC war das die Beschreibung - * eines Ortes, den es nicht gibt. Jetzt sagt der Betrieb, wohin es geht. - */ - const arbeitsVorgabe = standardArbeitsDir || betrieb.arbeits_vorgabe - // Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht - // erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst - // NACH dem Rip auf, weil nur der Worker selbst prüfte. - const gewaehlterWorker = workers.find(w => w.node === encoderNode) - /* - * Ein-Klick-Abhilfe: dasselbe Ziel, aber auf der Freigabe, die der gewählte - * Worker erreicht. Aus /app/media/movies wird /app/media//movies. - * - * Nur wenn es überhaupt eine Freigabe gibt und das Ziel noch nicht darauf - * liegt — sonst stünde ein Knopf da, der nichts tut. - */ - const zielAufFreigabe = (() => { - const freigabe = freigabenAusMapping(gewaehlterWorker?.info?.pfad_map)[0] - if (!freigabe || !zielPfad) return '' - const unterordner = zielPfad.split('/').filter(Boolean).pop() || '' - const neu = `/app/media/${freigabe}/${unterordner}` - return neu === zielPfad ? '' : neu - })() - const pfadWarnung = selectedType === 'music' - ? null - : encoderNode - ? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv) - // „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch - // einen, der die Pfade nicht erreicht. - : automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv) - - const handleSave = () => { - const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm } - if (selectedType === 'series' && serienName.trim()) { - optionen.series = serienName.trim() - optionen.season = Math.max(1, staffel || 1) - } - if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) { - optionen.titles = [...gewaehlt].sort((a, b) => a - b) - } - if (encoderNode) optionen.transcodeNode = encoderNode - if (arbeitsDir) optionen.workDir = arbeitsDir - if (audioWahl.size > 0) optionen.audioSprachen = [...audioWahl] - if (untertitelWahl.size > 0) optionen.untertitelSprachen = [...untertitelWahl] - const target = targets.find(t => t.type === selectedType) - if (customPath) { - onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen) - } else if (target) { - onSave(target, optionen) - } - onClose() - } - - return ( - -
-

- Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar. -

- - {/* Schnellwahl */} -
- {targets.map((t) => { - const isSelected = selectedType === t.type && !customPath - return ( - - ) - })} -
- - {/* Serien-Flow */} - {selectedType === 'series' && ( -
-
- setSerienName(e.target.value)} - placeholder="z. B. Neon Genesis Evangelion" - /> - setStaffel(parseInt(e.target.value) || 1)} - /> -
-

- Ablage: {serienName.trim() || ''}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')} -

-
- )} - - {/* Hauptfilm-Wahl */} - {selectedType === 'movies' && scanStatus !== 'done' && ( - - )} - - {/* - Arbeitsverzeichnis für DIESEN Rip (Commander-Wunsch 25.07.2026). - Warum es hier steht: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß - und lag bisher immer auf der Container-Platte — am 25.07. lief sie - damit voll (74 GB Rohschnitt auf 148 GB Platte). Die Wahl gehört zur - Disc, nicht in eine globale Einstellung. Leer = der Wert aus - Einstellungen → Verarbeitung; genau der greift auch bei - Vollautomatik-Rips, weil dort niemand gefragt wird. - Musik-Rips gehen direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe. - */} - {selectedType !== 'music' && ( -
- -

- - Bei 4K-UHD bis zu 100 GB — nimm ein Laufwerk mit Platz, am besten dasselbe wie das Ziel oben. - Dann muss Rippy am Ende nur umhängen statt zu kopieren. -

-
- )} - - {/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind - (sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */} - {selectedType !== 'music' && workers.length >= 2 && ( -
- -

- Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker. -

-
- )} - - {/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe - Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er - die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */} - {pfadWarnung && ( -
- ⚠️ Dieser Encoder kann so nicht arbeiten. -

{pfadWarnung}

- {/* Ein Knopf statt einer Wegbeschreibung: Er legt das Ziel NUR FÜR - DIESEN RIP auf die Freigabe, die der Worker erreicht — ohne die - globale Ablage anzufassen. „Geh in die Einstellungen" mitten im - Dialog ist lästig, und wer den Rip jetzt starten will, will - jetzt eine Lösung. */} - {zielAufFreigabe && ( - - )} -
- )} - - {/* Titel-Auswahl (Track-Tabelle) */} - {deviceId && selectedType !== 'music' && ( -
-
-
- Titel-Auswahl (optional) - {scanStatus === 'idle' && ( -

- Disc scannen und einzelne Titel an-/abwählen. -

- )} -
- {scanStatus === 'running' ? ( - - - Disc wird gelesen (20–120 s)… - - ) : ( - - )} -
- - {scanStatus === 'error' && ( -

{scanFehler}

- )} - - {scanStatus === 'done' && titelListe.length > 0 && ( - <> -
- {titelListe.map(t => ( - - ))} -
-

- {gewaehlt.size} von {titelListe.length} Titeln gewählt -

- - )} -
- )} - - {/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides - aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */} - {scanStatus === 'done' && discSprachen - && (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && ( -
-
- - Sprachen auf dieser Disc - -

- {discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'} - {discSprachen.untertitel.length > 0 - && `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`} - {' — '}nichts angeklickt heißt alles behalten. -

-
- -
- {(['audio', 'untertitel'] as const).map(art => { - const liste = discSprachen[art] - if (liste.length === 0) return null - const wahl = art === 'audio' ? audioWahl : untertitelWahl - const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl - return ( -
-

- {art === 'audio' ? 'Tonspuren' : 'Untertitel'} -

-
- {liste.map(s => { - const an = wahl.has(s.lang) - return ( - - ) - })} -
-
- ) - })} - - {/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man, - die Disc würde unvollständig gerippt. */} -

- {audioWahl.size > 0 || untertitelWahl.size > 0 ? ( - <> - Der Rip selbst bleibt vollständig und verlustfrei — die - Auswahl wirkt beim Komprimieren. Du kannst also später jederzeit - „Neu komprimieren" mit anderen Sprachen wählen, ohne die Disc noch - einmal einzulegen. - - ) : ( - <>Ohne Auswahl behält Rippy alle Sprachen — wie bisher. - )} -

-
-
- )} - - {/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */} -
- - - {browserOffen && ( -
-
- - - {browsePath || 'Laufwerke'} - - - {/* Der zweite Knopf ist der Grund, warum es diesen Browser für - das Arbeitsverzeichnis überhaupt braucht: Die Schnellwahl - darüber kennt nur Laufwerke und Unterordner der Ablage. Ein - beliebiger Ordner — etwa D:\Rippy-Arbeit — ging bisher gar - nicht („momentan geht das nicht", 28.08.2026). */} - {selectedType !== 'music' && ( - - )} -
-
- {browseDirs.map(d => ( - - ))} - {browseDirs.length === 0 && ( -

Keine Unterordner.

- )} -
-
- )} -
- - {/* Gewähltes Ziel Display */} -
- {customPath ? : } - - {zielPfad} - -
- - {/* Footer */} -
- - -
-
-
- ) -} +import { useState, useEffect } from 'react' +import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react' +import { api } from '../lib/api' +import { Modal } from './ui/Modal' +import { Button } from './ui/Button' +import { Input, Select } from './ui/Input' +import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder' +import { useBetrieb } from '../lib/useBetrieb' + +interface TargetConfig { + id: string + name: string + path: string + type: 'movies' | 'series' | 'music' + isActive: boolean +} + +export interface RipOptionen { + series?: string + season?: number + mainFeatureOnly?: boolean + titles?: number[] + transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto + workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung + // Sprachauswahl (ISO-639-2). Leer = alles behalten. Wirkt bei der + // KOMPRESSION — der Rip bleibt vollständig und verlustfrei. + audioSprachen?: string[] + untertitelSprachen?: string[] +} + +// Eine Sprache, die die Disc anbietet (aus dem Titel-Scan, MakeMKV SINFO). +interface DiscSprache { + lang: string // ISO-639-2, z. B. "deu" + sprache: string // Klartext, z. B. "German" + spuren: number +} + +// Ein Ablageziel aus GET /storage-targets. +interface StorageZiel { + name: string + path: string + is_mount: boolean + free_gb: number | null +} + +interface WorkerWahl { + name: string + node: string | null + online?: boolean + encoders: string[] + // extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die + // Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet + // beides selbst (worker/caps.py). + info?: { extern?: string, pfad_map?: string } +} + +const ENCODER_KURZ: Record = { + 'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1', + 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡', +} + +interface TitelInfo { + nr: number + dauer_s: number + groesse_bytes: number + kapitel: number +} + +interface RipTargetModalProps { + isOpen: boolean + initialType?: TargetConfig['type'] + discTitle?: string + deviceId?: string + onClose: () => void + onSave: (target: TargetConfig, optionen: RipOptionen) => void +} + +function dauerText(s: number): string { + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + return h > 0 ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min` +} + +interface BrowseDir { + name: string + path: string +} + +export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) { + const [selectedType, setSelectedType] = useState('movies') + const [serienName, setSerienName] = useState('') + const [staffel, setStaffel] = useState(1) + const [nurHauptfilm, setNurHauptfilm] = useState(false) + const [scanStatus, setScanStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle') + const [scanFehler, setScanFehler] = useState('') + const [titelListe, setTitelListe] = useState([]) + const [gewaehlt, setGewaehlt] = useState>(new Set()) + const [workers, setWorkers] = useState([]) + const [encoderNode, setEncoderNode] = useState('') // '' = automatisch + /* + * Sprachen der Disc (Commander-Anforderung 26.07.2026: „Die Disc hat Material + * in X Sprachen und X Untertiteln — Rippy muss VOR dem Rip fragen: Was genau + * willst du haben?"). + * + * Die Auskunft kommt aus demselben Titel-Scan, der schon für die + * Titel-Auswahl läuft — MakeMKV liefert sie in derselben Ausgabe mit, sie + * wurde bisher nur weggeworfen. + * + * Leere Auswahl heißt bewusst „alles behalten": Wer nichts anklickt, bekommt + * das Verhalten von vorher, und niemand verliert versehentlich seine Tonspur. + */ + const [discSprachen, setDiscSprachen] = useState<{ audio: DiscSprache[], untertitel: DiscSprache[] } | null>(null) + const [audioWahl, setAudioWahl] = useState>(new Set()) + const [untertitelWahl, setUntertitelWahl] = useState>(new Set()) + // Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt, + // soweit die Disc sie überhaupt hat. + const [standardAudio, setStandardAudio] = useState([]) + const [standardUntertitel, setStandardUntertitel] = useState([]) + + const scanStarten = async () => { + if (!deviceId) return + setScanStatus('running') + setScanFehler('') + try { + await api.post(`/devices/${deviceId}/scan-tracks`) + } catch (e: any) { + setScanStatus('error') + setScanFehler(e?.response?.data?.detail || 'Scan konnte nicht gestartet werden') + } + } + + useEffect(() => { + if (scanStatus !== 'running' || !deviceId) return + const interval = setInterval(async () => { + try { + const r = await api.get(`/devices/${deviceId}/tracks`) + if (r.data.status === 'done') { + const tracks: TitelInfo[] = r.data.tracks || [] + setTitelListe(tracks) + setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr))) + const spr = r.data.sprachen || null + setDiscSprachen(spr) + // Wunschsprachen vorauswählen — aber nur, was die Disc wirklich hat. + // Sonst stünde da eine Auswahl, die nichts bewirkt. + if (spr) { + const vorhanden = (liste: DiscSprache[], wunsch: string[]) => + new Set(liste.filter(s => wunsch.includes(s.lang)).map(s => s.lang)) + setAudioWahl(vorhanden(spr.audio || [], standardAudio)) + setUntertitelWahl(vorhanden(spr.untertitel || [], standardUntertitel)) + } + setScanStatus('done') + } else if (r.data.status === 'error') { + setScanFehler(r.data.error || 'Scan fehlgeschlagen') + setScanStatus('error') + } + } catch { /* API kurz weg — weiter pollen */ } + }, 3000) + return () => clearInterval(interval) + }, [scanStatus, deviceId]) + + // Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" — + // auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt. + const betrieb = useBetrieb() + + const [targets, setTargets] = useState([ + { id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, + { id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, + { id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true }, + ]) + const [browsePath, setBrowsePath] = useState('') + const [browseParent, setBrowseParent] = useState(null) + const [browseDirs, setBrowseDirs] = useState([]) + const [customPath, setCustomPath] = useState('') + // Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier + // der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht + // wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein + // beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl + // darüber, und der Browser überschrieb sie stillschweigend, sobald man auf + // „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist + // die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen + // und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt. + const [browserOffen, setBrowserOffen] = useState(false) + // Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar, + // nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch + // bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird. + const [arbeitsZiele, setArbeitsZiele] = useState([]) + const [arbeitsDir, setArbeitsDir] = useState('') + const [standardArbeitsDir, setStandardArbeitsDir] = useState('') + // Ein frei erblätterter Arbeitsordner. Er braucht einen eigenen Platz in + // der Liste, sonst zeigt das Auswahlfeld einen Wert an, den es nicht kennt + // — und stünde leer da, obwohl etwas gewählt ist. + const [eigenesArbeitsZiel, setEigenesArbeitsZiel] = useState('') + + useEffect(() => { + if (!isOpen) return + setSelectedType(initialType || 'movies') + setCustomPath('') + setSerienName(discTitle || '') + setStaffel(1) + setScanStatus('idle') + setScanFehler('') + setTitelListe([]) + setGewaehlt(new Set()) + setEncoderNode('') + setArbeitsDir('') + setEigenesArbeitsZiel('') + setBrowserOffen(false) + setDiscSprachen(null) + setAudioWahl(new Set()) + setUntertitelWahl(new Set()) + api.get('/storage-targets') + .then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : [])) + .catch(() => setArbeitsZiele([])) + // Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar) + api.get('/capabilities').then(r => { + setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node)) + }).catch(() => setWorkers([])) + api.get('/settings').then(r => { + const s = r.data || {} + setNurHauptfilm(!!s.mainFeatureOnly) + setStandardArbeitsDir((s.workDir || '').trim()) + const codes = (wert: any) => String(wert || '') + .split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) + setStandardAudio(codes(s.audioSprachen)) + setStandardUntertitel(codes(s.untertitelSprachen)) + const basis = s.outputDir || betrieb.ablage_vorgabe || '/app/media' + setTargets([ + { id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true }, + { id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true }, + { id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true }, + ]) + }).catch(() => {}) + // /browse wird erst beim Aufklappen geholt — der Dialog braucht es im + // Normalfall gar nicht. + }, [isOpen]) + + const laden = async (pfad: string) => { + try { + const r = await api.get('/browse', { params: { path: pfad } }) + setBrowsePath(r.data.path) + setBrowseParent(r.data.parent) + setBrowseDirs(r.data.dirs) + } catch { + setBrowseDirs([]) + } + } + + // Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner. + const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || '' + // Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung → + // leer (= Container-Platte /app/temp, für externe Worker unerreichbar). + const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir + /* + * Was WIRKLICH benutzt wird, wenn niemand etwas wählt. + * + * Commander am 28.08.2026: „Warum heißt das hier noch container platte? Er + * holt sich das Arbeitsverzeichnis ja von der Installation." + * + * Genau so ist es — nur stand hier fest verdrahtet „(Container-Platte)", + * sobald die Einstellung leer war. Auf seinem PC war das die Beschreibung + * eines Ortes, den es nicht gibt. Jetzt sagt der Betrieb, wohin es geht. + */ + const arbeitsVorgabe = standardArbeitsDir || betrieb.arbeits_vorgabe + // Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht + // erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst + // NACH dem Rip auf, weil nur der Worker selbst prüfte. + const gewaehlterWorker = workers.find(w => w.node === encoderNode) + /* + * Ein-Klick-Abhilfe: dasselbe Ziel, aber auf der Freigabe, die der gewählte + * Worker erreicht. Aus /app/media/movies wird /app/media//movies. + * + * Nur wenn es überhaupt eine Freigabe gibt und das Ziel noch nicht darauf + * liegt — sonst stünde ein Knopf da, der nichts tut. + */ + const zielAufFreigabe = (() => { + const freigabe = freigabenAusMapping(gewaehlterWorker?.info?.pfad_map)[0] + if (!freigabe || !zielPfad) return '' + const unterordner = zielPfad.split('/').filter(Boolean).pop() || '' + const neu = `/app/media/${freigabe}/${unterordner}` + return neu === zielPfad ? '' : neu + })() + const pfadWarnung = selectedType === 'music' + ? null + : encoderNode + ? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv) + // „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch + // einen, der die Pfade nicht erreicht. + : automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv) + + const handleSave = () => { + const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm } + if (selectedType === 'series' && serienName.trim()) { + optionen.series = serienName.trim() + optionen.season = Math.max(1, staffel || 1) + } + if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) { + optionen.titles = [...gewaehlt].sort((a, b) => a - b) + } + if (encoderNode) optionen.transcodeNode = encoderNode + if (arbeitsDir) optionen.workDir = arbeitsDir + if (audioWahl.size > 0) optionen.audioSprachen = [...audioWahl] + if (untertitelWahl.size > 0) optionen.untertitelSprachen = [...untertitelWahl] + const target = targets.find(t => t.type === selectedType) + if (customPath) { + onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen) + } else if (target) { + onSave(target, optionen) + } + onClose() + } + + return ( + +
+

+ Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar. +

+ + {/* Schnellwahl */} +
+ {targets.map((t) => { + const isSelected = selectedType === t.type && !customPath + return ( + + ) + })} +
+ + {/* Serien-Flow */} + {selectedType === 'series' && ( +
+
+ setSerienName(e.target.value)} + placeholder="z. B. Neon Genesis Evangelion" + /> + setStaffel(parseInt(e.target.value) || 1)} + /> +
+

+ Ablage: {serienName.trim() || ''}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')} +

+
+ )} + + {/* Hauptfilm-Wahl */} + {selectedType === 'movies' && scanStatus !== 'done' && ( + + )} + + {/* + Arbeitsverzeichnis für DIESEN Rip (Commander-Wunsch 25.07.2026). + Warum es hier steht: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß + und lag bisher immer auf der Container-Platte — am 25.07. lief sie + damit voll (74 GB Rohschnitt auf 148 GB Platte). Die Wahl gehört zur + Disc, nicht in eine globale Einstellung. Leer = der Wert aus + Einstellungen → Verarbeitung; genau der greift auch bei + Vollautomatik-Rips, weil dort niemand gefragt wird. + Musik-Rips gehen direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe. + */} + {selectedType !== 'music' && ( +
+ +

+ + Bei 4K-UHD bis zu 100 GB — nimm ein Laufwerk mit Platz, am besten dasselbe wie das Ziel oben. + Dann muss Rippy am Ende nur umhängen statt zu kopieren. +

+
+ )} + + {/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind + (sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */} + {selectedType !== 'music' && workers.length >= 2 && ( +
+ +

+ Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker. +

+
+ )} + + {/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe + Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er + die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */} + {pfadWarnung && ( +
+ ⚠️ Dieser Encoder kann so nicht arbeiten. +

{pfadWarnung}

+ {/* Ein Knopf statt einer Wegbeschreibung: Er legt das Ziel NUR FÜR + DIESEN RIP auf die Freigabe, die der Worker erreicht — ohne die + globale Ablage anzufassen. „Geh in die Einstellungen" mitten im + Dialog ist lästig, und wer den Rip jetzt starten will, will + jetzt eine Lösung. */} + {zielAufFreigabe && ( + + )} +
+ )} + + {/* Titel-Auswahl (Track-Tabelle) */} + {deviceId && selectedType !== 'music' && ( +
+
+
+ Titel-Auswahl (optional) + {scanStatus === 'idle' && ( +

+ Disc scannen und einzelne Titel an-/abwählen. +

+ )} +
+ {scanStatus === 'running' ? ( + + + Disc wird gelesen (20–120 s)… + + ) : ( + + )} +
+ + {scanStatus === 'error' && ( +

{scanFehler}

+ )} + + {scanStatus === 'done' && titelListe.length > 0 && ( + <> +
+ {titelListe.map(t => ( + + ))} +
+

+ {gewaehlt.size} von {titelListe.length} Titeln gewählt +

+ + )} +
+ )} + + {/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides + aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */} + {scanStatus === 'done' && discSprachen + && (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && ( +
+
+ + Sprachen auf dieser Disc + +

+ {discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'} + {discSprachen.untertitel.length > 0 + && `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`} + {' — '}nichts angeklickt heißt alles behalten. +

+
+ +
+ {(['audio', 'untertitel'] as const).map(art => { + const liste = discSprachen[art] + if (liste.length === 0) return null + const wahl = art === 'audio' ? audioWahl : untertitelWahl + const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl + return ( +
+

+ {art === 'audio' ? 'Tonspuren' : 'Untertitel'} +

+
+ {liste.map(s => { + const an = wahl.has(s.lang) + return ( + + ) + })} +
+
+ ) + })} + + {/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man, + die Disc würde unvollständig gerippt. */} +

+ {audioWahl.size > 0 || untertitelWahl.size > 0 ? ( + <> + Der Rip selbst bleibt vollständig und verlustfrei — die + Auswahl wirkt beim Komprimieren. Du kannst also später jederzeit + „Neu komprimieren" mit anderen Sprachen wählen, ohne die Disc noch + einmal einzulegen. + + ) : ( + <>Ohne Auswahl behält Rippy alle Sprachen — wie bisher. + )} +

+
+
+ )} + + {/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */} +
+ + + {browserOffen && ( +
+
+ + + {browsePath || 'Laufwerke'} + + + {/* Der zweite Knopf ist der Grund, warum es diesen Browser für + das Arbeitsverzeichnis überhaupt braucht: Die Schnellwahl + darüber kennt nur Laufwerke und Unterordner der Ablage. Ein + beliebiger Ordner — etwa D:\Rippy-Arbeit — ging bisher gar + nicht („momentan geht das nicht", 28.08.2026). */} + {selectedType !== 'music' && ( + + )} +
+
+ {browseDirs.map(d => ( + + ))} + {browseDirs.length === 0 && ( +

Keine Unterordner.

+ )} +
+
+ )} +
+ + {/* Gewähltes Ziel Display */} +
+ {customPath ? : } + + {zielPfad} + +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/docker/ui/src/lib/useBetrieb.tsx b/docker/ui/src/lib/useBetrieb.tsx index a4f9aa4..dbf23c0 100644 --- a/docker/ui/src/lib/useBetrieb.tsx +++ b/docker/ui/src/lib/useBetrieb.tsx @@ -1,141 +1,141 @@ -/* - * Worauf läuft Rippy — und was kann dieser Betrieb überhaupt? - * - * ## Der Befund des Commanders (28.08.2026) - * - * Im Windows-Fenster stand auf der Server-Status-Kachel: - * - * Worker erreichbar: 0 von 1 - * Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps - * Container-Platte: unbekannt - * Freigaben: keine eingehängt - * - * Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen - * Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort - * selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker - * installation für Windows gebaut." - * - * ## Warum ein Provider und kein Abruf je Seite - * - * Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die - * dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine - * davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur - * Laufzeit nicht; er wird EINMAL geholt. - * - * ## Warum die Vorgabe „Docker" ist - * - * Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die - * Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz - * zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für - * einen Augenblick verschwindet und wieder auftaucht. - * - * Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs": - * `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein - * Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream). - */ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' -import { api } from './api' - -export interface BetriebsFaehigkeiten { - /** Gibt es andere Maschinen, die Jobs übernehmen? */ - externe_worker: boolean - /** Kann Rippy Netzwerk-Freigaben selbst einhängen? */ - freigaben_einhaengen: boolean - /** Sind Pfade wie /app/media überhaupt gemeint? */ - container_pfade: boolean - /** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */ - werkzeuge_verwalten: boolean - /** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */ - frei_blaettern: boolean -} - -export interface Betrieb { - modus: 'standalone' | 'verteilt' - plattform: 'windows' | 'linux' | 'macos' - im_container: boolean - kann: BetriebsFaehigkeiten - /** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */ - ablage_vorgabe: string - /** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */ - arbeits_vorgabe: string - /** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer - * ausführen könnte — dann darf auch keiner dastehen. */ - hilfe_befehl: string - /** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht - * erreichbar" — nicht „es gibt keinen Betrieb". */ - geladen: boolean -} - -const VORGABE: Betrieb = { - modus: 'verteilt', - plattform: 'linux', - im_container: true, - kann: { - externe_worker: true, - freigaben_einhaengen: true, - container_pfade: true, - werkzeuge_verwalten: false, - frei_blaettern: false, - }, - ablage_vorgabe: '/app/media', - arbeits_vorgabe: '/app/temp', - hilfe_befehl: 'docker compose -p rippy ps', - geladen: false, -} - -const BetriebContext = createContext(VORGABE) - -export function BetriebProvider({ children }: { children: ReactNode }) { - const [betrieb, setBetrieb] = useState(VORGABE) - - useEffect(() => { - let abgemeldet = false - api.get('/betrieb') - .then(antwort => { - const d = antwort.data - // Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt - // hieße in JavaScript `undefined` — und `undefined` ist falsch, also - // verschwände ein Bereich stillschweigend. - if (abgemeldet || !d || !d.kann) return - setBetrieb({ - modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone', - plattform: d.plattform || 'linux', - im_container: !!d.im_container, - kann: { - externe_worker: !!d.kann.externe_worker, - freigaben_einhaengen: !!d.kann.freigaben_einhaengen, - container_pfade: !!d.kann.container_pfade, - werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten, - frei_blaettern: !!d.kann.frei_blaettern, - }, - ablage_vorgabe: d.ablage_vorgabe || '', - arbeits_vorgabe: d.arbeits_vorgabe || '', - hilfe_befehl: d.hilfe_befehl || '', - geladen: true, - }) - }) - .catch(() => { - // Nichts tun. Ein misslungener Abruf ist keine Aussage über den - // Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false. - }) - return () => { abgemeldet = true } - }, []) - - return {children} -} - -export function useBetrieb(): Betrieb { - return useContext(BetriebContext) -} - -/** - * Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App - * (Windows-Client oder Docker-All-in-One)? - * - * Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten - * Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut - * die nächste falsche Annahme ein. - */ -export function useAlleinbetrieb(): boolean { - return !useBetrieb().kann.externe_worker -} +/* + * Worauf läuft Rippy — und was kann dieser Betrieb überhaupt? + * + * ## Der Befund des Commanders (28.08.2026) + * + * Im Windows-Fenster stand auf der Server-Status-Kachel: + * + * Worker erreichbar: 0 von 1 + * Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps + * Container-Platte: unbekannt + * Freigaben: keine eingehängt + * + * Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen + * Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort + * selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker + * installation für Windows gebaut." + * + * ## Warum ein Provider und kein Abruf je Seite + * + * Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die + * dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine + * davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur + * Laufzeit nicht; er wird EINMAL geholt. + * + * ## Warum die Vorgabe „Docker" ist + * + * Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die + * Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz + * zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für + * einen Augenblick verschwindet und wieder auftaucht. + * + * Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs": + * `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein + * Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream). + */ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { api } from './api' + +export interface BetriebsFaehigkeiten { + /** Gibt es andere Maschinen, die Jobs übernehmen? */ + externe_worker: boolean + /** Kann Rippy Netzwerk-Freigaben selbst einhängen? */ + freigaben_einhaengen: boolean + /** Sind Pfade wie /app/media überhaupt gemeint? */ + container_pfade: boolean + /** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */ + werkzeuge_verwalten: boolean + /** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */ + frei_blaettern: boolean +} + +export interface Betrieb { + modus: 'standalone' | 'verteilt' + plattform: 'windows' | 'linux' | 'macos' + im_container: boolean + kann: BetriebsFaehigkeiten + /** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */ + ablage_vorgabe: string + /** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */ + arbeits_vorgabe: string + /** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer + * ausführen könnte — dann darf auch keiner dastehen. */ + hilfe_befehl: string + /** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht + * erreichbar" — nicht „es gibt keinen Betrieb". */ + geladen: boolean +} + +const VORGABE: Betrieb = { + modus: 'verteilt', + plattform: 'linux', + im_container: true, + kann: { + externe_worker: true, + freigaben_einhaengen: true, + container_pfade: true, + werkzeuge_verwalten: false, + frei_blaettern: false, + }, + ablage_vorgabe: '/app/media', + arbeits_vorgabe: '/app/temp', + hilfe_befehl: 'docker compose -p rippy ps', + geladen: false, +} + +const BetriebContext = createContext(VORGABE) + +export function BetriebProvider({ children }: { children: ReactNode }) { + const [betrieb, setBetrieb] = useState(VORGABE) + + useEffect(() => { + let abgemeldet = false + api.get('/betrieb') + .then(antwort => { + const d = antwort.data + // Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt + // hieße in JavaScript `undefined` — und `undefined` ist falsch, also + // verschwände ein Bereich stillschweigend. + if (abgemeldet || !d || !d.kann) return + setBetrieb({ + modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone', + plattform: d.plattform || 'linux', + im_container: !!d.im_container, + kann: { + externe_worker: !!d.kann.externe_worker, + freigaben_einhaengen: !!d.kann.freigaben_einhaengen, + container_pfade: !!d.kann.container_pfade, + werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten, + frei_blaettern: !!d.kann.frei_blaettern, + }, + ablage_vorgabe: d.ablage_vorgabe || '', + arbeits_vorgabe: d.arbeits_vorgabe || '', + hilfe_befehl: d.hilfe_befehl || '', + geladen: true, + }) + }) + .catch(() => { + // Nichts tun. Ein misslungener Abruf ist keine Aussage über den + // Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false. + }) + return () => { abgemeldet = true } + }, []) + + return {children} +} + +export function useBetrieb(): Betrieb { + return useContext(BetriebContext) +} + +/** + * Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App + * (Windows-Client oder Docker-All-in-One)? + * + * Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten + * Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut + * die nächste falsche Annahme ein. + */ +export function useAlleinbetrieb(): boolean { + return !useBetrieb().kann.externe_worker +} diff --git a/src/rippy/platform/test_winlauf.py b/src/rippy/platform/test_winlauf.py index 33f9f76..ff2b60f 100644 --- a/src/rippy/platform/test_winlauf.py +++ b/src/rippy/platform/test_winlauf.py @@ -1,224 +1,224 @@ -"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole. - -Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird -deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG, -wohin die Ausgabe geht, wenn es keine Konsole gibt. - -## Warum das nicht Nebensache ist - -Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim -Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`. -Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit -`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten -im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem -AGENTS.md warnt. -""" - -import os -import subprocess -import sys - -import pytest - -from rippy.platform import winlauf - -nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") - - -# ── Kind-Prozesse ─────────────────────────────────────────────────────── -def test_flag_ist_auf_windows_gesetzt_und_sonst_null(): - """Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird - dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten.""" - if os.name == "nt": - assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW - else: - assert winlauf.OHNE_FENSTER == 0 - - -# ── Gibt es ueberhaupt eine Ausgabe? ──────────────────────────────────── -def test_fehlende_standardausgabe_wird_erkannt(monkeypatch): - """Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None.""" - monkeypatch.setattr(sys, "stdout", None) - assert winlauf.ohne_konsole() is True - - -def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch): - """pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein - `fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte, - was jemand gebrauchen kann.""" - class Puffer: - write = staticmethod(lambda _: None) - - monkeypatch.setattr(sys, "stdout", Puffer()) - assert winlauf.ohne_konsole() is True - - -def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path): - datei = tmp_path / "echt.txt" - with open(datei, "w", encoding="utf-8") as f: - monkeypatch.setattr(sys, "stdout", f) - assert winlauf.ohne_konsole() is False - - -# ── Die Protokolldatei ────────────────────────────────────────────────── -def test_protokoll_liegt_bei_rippy(): - """Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und - verschwindet beim Deinstallieren mit.""" - pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis")) - assert pfad.endswith(os.path.join("Rippy", "rippy.log")) - assert pfad.startswith(os.path.join("C:" + os.sep, "Basis")) - - -def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch): - ziel = str(tmp_path / "unterordner" / "rippy.log") - vorher_out, vorher_err = sys.stdout, sys.stderr - try: - assert winlauf.ausgabe_umleiten(ziel) == ziel - print("eine Zeile mit Umlaut: ä") - sys.stdout.flush() - finally: - sys.stdout, sys.stderr = vorher_out, vorher_err - assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read() - - -def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path): - """Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg - — also genau das, was man dann sucht.""" - ziel = str(tmp_path / "rippy.log") - vorher_out, vorher_err = sys.stdout, sys.stderr - try: - winlauf.ausgabe_umleiten(ziel) - print("erster Start") - sys.stdout.flush() - winlauf.ausgabe_umleiten(ziel) - print("zweiter Start") - sys.stdout.flush() - finally: - sys.stdout, sys.stderr = vorher_out, vorher_err - inhalt = open(ziel, encoding="utf-8").read() - assert "erster Start" in inhalt and "zweiter Start" in inhalt - - -def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch): - """Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das - wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines - ohne Protokoll.""" - def geht_nicht(*a, **k): - raise OSError("kein Platz") - - monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht) - vorher_out, vorher_err = sys.stdout, sys.stderr - try: - winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log") - print("das darf nicht werfen") - finally: - sys.stdout, sys.stderr = vorher_out, vorher_err - - -# ── Das Meldungsfenster ───────────────────────────────────────────────── -def test_ohne_windows_kein_meldungsfenster(): - if os.name == "nt": - pytest.skip("prueft das Verhalten auf Nicht-Windows") - assert winlauf.meldung_zeigen("egal") is False - assert winlauf.an_elternkonsole_haengen() is False - - -# ── Der Auspack-Ordner (Befund 28.08.2026) ────────────────────────────── -# -# Commander, mit Bildschirmfoto: -# -# Failed to remove temporary directory: -# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882 -# -# „Und manchmal kommt dieser fehler." -# -# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen -# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die -# Sperre war also voruebergehend, es ist ein Wettlauf. -# -# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe -# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also -# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst, -# will loeschen — und die Kinder halten die DLLs offen. - - -def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben(): - """Der Test, der 1,1 GB Reste verhindert haette.""" - eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123", - "_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123", - "_PYI_ARCHIVE_FILE": "/x/Rippy.exe", - "_PYI_PARENT_PROCESS_LEVEL": "1"} - kind = winlauf.umgebung_ohne_bundle(eltern) - for name in winlauf.PYI_ZEIGER: - assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name - assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben" - - -def test_die_elternumgebung_wird_nicht_veraendert(): - """Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt.""" - eltern = {"_MEIPASS2": "/tmp/_MEI123"} - winlauf.umgebung_ohne_bundle(eltern) - assert eltern == {"_MEIPASS2": "/tmp/_MEI123"} - - -def test_beide_startwege_reichen_den_zeiger_nicht_weiter(): - """Der Waechter: Wer kuenftig einen dritten Startweg baut, muss `env=` - setzen. Sonst kommen die Reste zurueck, und zwar still.""" - import re - - quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py") - with open(os.path.abspath(quelle), encoding="utf-8") as f: - text = f.read() - stellen = [m.start() for m in re.finditer(r"subprocess\.Popen\(\[programm", text)] - assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen) - for stelle in stellen: - aufruf = text[stelle:stelle + 300] - assert "env=umgebung_ohne_bundle()" in aufruf, ( - "Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120]) - - -def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path): - """Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren. - - Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek - weggeraeumt, bevor es an der gesperrten DLL scheitert. - """ - lebt = tmp_path / "_MEI111111" - lebt.mkdir() - (lebt / "python312.dll").write_bytes(b"MZ") - (lebt / "wichtig.pyd").write_bytes(b"x") - - def gesperrt(pfad): - raise OSError(32, "Datei wird von einem anderen Prozess verwendet") - - assert winlauf.reste_aufraeumen(str(tmp_path), eigener="", - jetzt_loeschen=gesperrt) == 0 - assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst" - - -def test_aufraeumen_entfernt_die_reste(tmp_path): - for name in ("_MEI000035002", "_MEI0000b0882"): - ordner = tmp_path / name - ordner.mkdir() - (ordner / "python312.dll").write_bytes(b"MZ") - (ordner / "base_library.zip").write_bytes(b"PK") - - assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2 - assert not list(tmp_path.glob("_MEI*")) - - -def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path): - """Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast.""" - eigen = tmp_path / "_MEI999999" - eigen.mkdir() - (eigen / "python312.dll").write_bytes(b"MZ") - - assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0 - assert eigen.exists() - - -def test_aufraeumen_ignoriert_fremde_ordner(tmp_path): - """Was nicht `_MEI` heisst, geht uns nichts an.""" - (tmp_path / "wichtige-daten").mkdir() - assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0 - assert (tmp_path / "wichtige-daten").exists() +"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole. + +Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird +deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG, +wohin die Ausgabe geht, wenn es keine Konsole gibt. + +## Warum das nicht Nebensache ist + +Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim +Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`. +Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit +`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten +im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem +AGENTS.md warnt. +""" + +import os +import subprocess +import sys + +import pytest + +from rippy.platform import winlauf + +nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") + + +# ── Kind-Prozesse ─────────────────────────────────────────────────────── +def test_flag_ist_auf_windows_gesetzt_und_sonst_null(): + """Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird + dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten.""" + if os.name == "nt": + assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW + else: + assert winlauf.OHNE_FENSTER == 0 + + +# ── Gibt es ueberhaupt eine Ausgabe? ──────────────────────────────────── +def test_fehlende_standardausgabe_wird_erkannt(monkeypatch): + """Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None.""" + monkeypatch.setattr(sys, "stdout", None) + assert winlauf.ohne_konsole() is True + + +def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch): + """pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein + `fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte, + was jemand gebrauchen kann.""" + class Puffer: + write = staticmethod(lambda _: None) + + monkeypatch.setattr(sys, "stdout", Puffer()) + assert winlauf.ohne_konsole() is True + + +def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path): + datei = tmp_path / "echt.txt" + with open(datei, "w", encoding="utf-8") as f: + monkeypatch.setattr(sys, "stdout", f) + assert winlauf.ohne_konsole() is False + + +# ── Die Protokolldatei ────────────────────────────────────────────────── +def test_protokoll_liegt_bei_rippy(): + """Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und + verschwindet beim Deinstallieren mit.""" + pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis")) + assert pfad.endswith(os.path.join("Rippy", "rippy.log")) + assert pfad.startswith(os.path.join("C:" + os.sep, "Basis")) + + +def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch): + ziel = str(tmp_path / "unterordner" / "rippy.log") + vorher_out, vorher_err = sys.stdout, sys.stderr + try: + assert winlauf.ausgabe_umleiten(ziel) == ziel + print("eine Zeile mit Umlaut: ä") + sys.stdout.flush() + finally: + sys.stdout, sys.stderr = vorher_out, vorher_err + assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read() + + +def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path): + """Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg + — also genau das, was man dann sucht.""" + ziel = str(tmp_path / "rippy.log") + vorher_out, vorher_err = sys.stdout, sys.stderr + try: + winlauf.ausgabe_umleiten(ziel) + print("erster Start") + sys.stdout.flush() + winlauf.ausgabe_umleiten(ziel) + print("zweiter Start") + sys.stdout.flush() + finally: + sys.stdout, sys.stderr = vorher_out, vorher_err + inhalt = open(ziel, encoding="utf-8").read() + assert "erster Start" in inhalt and "zweiter Start" in inhalt + + +def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch): + """Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das + wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines + ohne Protokoll.""" + def geht_nicht(*a, **k): + raise OSError("kein Platz") + + monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht) + vorher_out, vorher_err = sys.stdout, sys.stderr + try: + winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log") + print("das darf nicht werfen") + finally: + sys.stdout, sys.stderr = vorher_out, vorher_err + + +# ── Das Meldungsfenster ───────────────────────────────────────────────── +def test_ohne_windows_kein_meldungsfenster(): + if os.name == "nt": + pytest.skip("prueft das Verhalten auf Nicht-Windows") + assert winlauf.meldung_zeigen("egal") is False + assert winlauf.an_elternkonsole_haengen() is False + + +# ── Der Auspack-Ordner (Befund 28.08.2026) ────────────────────────────── +# +# Commander, mit Bildschirmfoto: +# +# Failed to remove temporary directory: +# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882 +# +# „Und manchmal kommt dieser fehler." +# +# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen +# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die +# Sperre war also voruebergehend, es ist ein Wettlauf. +# +# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe +# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also +# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst, +# will loeschen — und die Kinder halten die DLLs offen. + + +def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben(): + """Der Test, der 1,1 GB Reste verhindert haette.""" + eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123", + "_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123", + "_PYI_ARCHIVE_FILE": "/x/Rippy.exe", + "_PYI_PARENT_PROCESS_LEVEL": "1"} + kind = winlauf.umgebung_ohne_bundle(eltern) + for name in winlauf.PYI_ZEIGER: + assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name + assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben" + + +def test_die_elternumgebung_wird_nicht_veraendert(): + """Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt.""" + eltern = {"_MEIPASS2": "/tmp/_MEI123"} + winlauf.umgebung_ohne_bundle(eltern) + assert eltern == {"_MEIPASS2": "/tmp/_MEI123"} + + +def test_beide_startwege_reichen_den_zeiger_nicht_weiter(): + """Der Waechter: Wer kuenftig einen dritten Startweg baut, muss `env=` + setzen. Sonst kommen die Reste zurueck, und zwar still.""" + import re + + quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py") + with open(os.path.abspath(quelle), encoding="utf-8") as f: + text = f.read() + stellen = [m.start() for m in re.finditer(r"subprocess\.Popen\(\[programm", text)] + assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen) + for stelle in stellen: + aufruf = text[stelle:stelle + 300] + assert "env=umgebung_ohne_bundle()" in aufruf, ( + "Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120]) + + +def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path): + """Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren. + + Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek + weggeraeumt, bevor es an der gesperrten DLL scheitert. + """ + lebt = tmp_path / "_MEI111111" + lebt.mkdir() + (lebt / "python312.dll").write_bytes(b"MZ") + (lebt / "wichtig.pyd").write_bytes(b"x") + + def gesperrt(pfad): + raise OSError(32, "Datei wird von einem anderen Prozess verwendet") + + assert winlauf.reste_aufraeumen(str(tmp_path), eigener="", + jetzt_loeschen=gesperrt) == 0 + assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst" + + +def test_aufraeumen_entfernt_die_reste(tmp_path): + for name in ("_MEI000035002", "_MEI0000b0882"): + ordner = tmp_path / name + ordner.mkdir() + (ordner / "python312.dll").write_bytes(b"MZ") + (ordner / "base_library.zip").write_bytes(b"PK") + + assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2 + assert not list(tmp_path.glob("_MEI*")) + + +def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path): + """Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast.""" + eigen = tmp_path / "_MEI999999" + eigen.mkdir() + (eigen / "python312.dll").write_bytes(b"MZ") + + assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0 + assert eigen.exists() + + +def test_aufraeumen_ignoriert_fremde_ordner(tmp_path): + """Was nicht `_MEI` heisst, geht uns nichts an.""" + (tmp_path / "wichtige-daten").mkdir() + assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0 + assert (tmp_path / "wichtige-daten").exists() diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py index 529de1e..090fcea 100644 --- a/src/rippy/setup_fenster.py +++ b/src/rippy/setup_fenster.py @@ -1,600 +1,600 @@ -"""Das Fenster des Einrichtungs-Assistenten. - -## Warum es das gibt - -Commander am 28.08.2026: - -> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — -> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt -> garnichts Rippy geht einfach auf." - -## Warum WebView2 und nicht ein Windows-Dialog - -Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit -(Entscheid 4 in `KONZEPT-V2.md` § 10) — der Assistent kostet damit **null -zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen -ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau -ausdrücklich ausgeschlossen. - -## Warum die Prüfungen NICHT hier stehen - -Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar. -Was hier steht, lässt sich nicht testen — also steht hier so wenig wie -möglich: Aufbau, Anzeige, und die Brücke zwischen beidem. - -## Was passiert, wenn das Fenster nicht aufgeht - -Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein -Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer -als eines, das nicht fragt. -""" - -import threading - -from rippy import einrichtung - -TITEL = "Rippy einrichten" -BREITE = 860 -HOEHE = 760 - - -# Die Abschnitte der Installation und was sie am Balken belegen. -# -# Die Gewichte sind gemessen, nicht geraten: Das Ablegen der Dateien dauert -# rund drei Sekunden (56 MB kopieren, UI und HandBrake auspacken), das Holen -# von MakeMKV je nach Quelle sechs Sekunden bis mehrere Minuten. Der -# Werkzeug-Abschnitt bekommt deshalb den Löwenanteil — ein Balken, der bei -# 90 % minutenlang steht, ist schlimmer als gar keiner. -ABSCHNITTE = { - "dateien": (0.00, 0.08, "Dateien werden abgelegt"), - "ablage": (0.08, 0.10, "Ablage wird gespeichert"), - "werkzeuge": (0.10, 0.97, "Werkzeuge werden geprüft"), - "fertig": (0.97, 1.00, "Fertig"), -} - - -class Fortschritt: - """Rechnet Abschnitt + Teil-Anteil in einen Gesamtstand um. - - Reine Rechnerei, damit sie ohne Fenster prüfbar ist — und weil ein - Fortschrittsbalken, der springt oder zurückläuft, schlimmer aussieht als - keiner. - - Zwei Regeln: - - * **Er läuft nie zurück.** Der Werkzeug-Abschnitt kann mehrere Downloads - enthalten, jeder mit eigenem 0-bis-1. Ohne diese Regel spränge der - Balken bei jedem neuen Werkzeug zurück an den Abschnittsanfang. - * **Ohne Anteil bleibt er stehen.** Eine Textmeldung ohne Zahl ist keine - Aussage über den Fortschritt. - """ - - def __init__(self): - self.prozent = 0 - self.abschnitt = "" - self.zeilen = [] - - def beginne(self, name: str) -> None: - von, _bis, titel = ABSCHNITTE[name] - self.abschnitt = titel - self._von, self._bis, _ = ABSCHNITTE[name] - self.setze(von) - - def melde(self, text, anteil=None) -> None: - if text: - self.zeilen.append(str(text)) - if anteil is None: - return - anteil = min(1.0, max(0.0, float(anteil))) - self.setze(self._von + (self._bis - self._von) * anteil) - - def setze(self, anteil: float) -> None: - self.prozent = max(self.prozent, int(round(anteil * 100))) - - def abholen(self) -> dict: - """Was seit dem letzten Abruf passiert ist. Die Zeilen leeren sich.""" - zeilen, self.zeilen = self.zeilen, [] - return {"zeilen": zeilen, "prozent": self.prozent, - "abschnitt": self.abschnitt} - - -#: pywebview.FOLDER_DIALOG. Steht hier doppelt, damit `ordner_waehlen` -#: auch ohne pywebview pruefbar bleibt (webview/util.py: OPEN=10, -#: FOLDER=20, SAVE=30). -FOLDER_DIALOG = 20 - - -class Bruecke: - """Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML. - - Jede Methode gibt Daten zurück, keine Erfolgsmeldungen — dieselbe Regel - wie überall sonst in diesem Projekt. - """ - - def __init__(self): - self.ergebnis = None - self._fenster = None - self._lauf = Fortschritt() - - # ── Auskunft ──────────────────────────────────────────────────────── - def vorgaben(self) -> dict: - return einrichtung.vorgaben() - - def pruefen(self, ziel: str = "", port: int = 0) -> dict: - """Der Voraussetzungs-Bericht für die angegebenen Werte.""" - vorgabe = einrichtung.vorgaben() - befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"], - int(port or vorgabe["port"])) - return { - "befunde": befunde, - "darf": einrichtung.darf_installieren(befunde), - "text": einrichtung.zusammenfassung(befunde), - } - - def updates(self) -> dict: - """Gibt es für die installierten Werkzeuge etwas Neueres? - - ## Warum das ein EIGENER Aufruf ist (28.08.2026) - - Commander: *„bitte baue ein, das wenn MakeMKV oder Handbrake schon - installiert sind ein update verfügbar ist."* - - Die Auskunft kostet Netz — und makemkv.com braucht dafür im - schlimmsten Fall gut zwanzig Sekunden (gemessen). In der - Voraussetzungs-Prüfung würde das Fenster so lange leer bleiben. - Deshalb getrennt: Die Prüfung zeigt sofort, was installiert ist, und - diese Auskunft trägt die Update-Hinweise nach. - - Leere Werte heißen „nicht erreichbar", nicht „kein Update" — eine - Nichtauskunft ist keine Aussage. - """ - from rippy.tools import beschaffen, einrichten as werkzeuge, katalog - - try: - bestand = katalog.bestand() - neueste = beschaffen.neueste_versionen() - except Exception: # noqa: BLE001 - return {"erreichbar": False, "offen": []} - - offen = [{"name": name, - "titel": eintrag.get("titel", name), - "installiert": eintrag.get("version", ""), - "neueste": neu} - for name, eintrag, neu in werkzeuge.veraltete(bestand, neueste)] - return {"erreichbar": any(neueste.values()), "offen": offen} - - def ordner_waehlen(self, vorschlag: str = "") -> str: - """Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde.""" - # Der Rueckfall ist NICHT fuer den Betrieb — dort ist pywebview immer - # da. Er ist fuer die Ampel: Ohne ihn scheiterten diese zwei Tests in - # jeder Umgebung ohne pywebview mit ModuleNotFoundError. Ein Test, der - # nur an einer Stelle greift, ist eine halbe Zusage (AGENTS.md) — und - # am 28.08.2026 hielt ich die beiden erst faelschlich fuer meinen - # eigenen Fehler. - try: - import webview - art = webview.FOLDER_DIALOG - except ImportError: - art = FOLDER_DIALOG - - try: - gewaehlt = self._fenster.create_file_dialog( - art, directory=vorschlag or "") - except Exception: # noqa: BLE001 - return "" - if not gewaehlt: - return "" - return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt) - - def fortschritt(self) -> dict: - """Was seit dem letzten Abruf passiert ist — Zeilen UND Stand. - - Gibt ein Wörterbuch zurück, keine Liste: Der Balken braucht eine Zahl, - und zwei getrennte Abrufe dafür wären zwei Gelegenheiten, dass Text - und Stand auseinanderlaufen. - """ - return self._lauf.abholen() - - # ── Handeln ───────────────────────────────────────────────────────── - def installieren(self, optionen: dict) -> dict: - """Installiert im Hintergrund-Thread und gibt sofort zurück. - - Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter - Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster - sieht aus wie ein Absturz. - """ - threading.Thread(target=self._installieren_wirklich, - args=(optionen or {},), daemon=True).start() - return {"gestartet": True} - - def _installieren_wirklich(self, optionen: dict) -> None: - from rippy import windows_app - - melden = self._lauf.melde - - try: - self._lauf.beginne("dateien") - melden("Dateien werden abgelegt …") - ziel = windows_app.installieren( - ziel=optionen.get("ziel") or None, - autostart=bool(optionen.get("autostart", True)), - starten_danach=False, - verknuepfen=bool(optionen.get("verknuepfungen", True)), - ) - if optionen.get("ablage"): - self._lauf.beginne("ablage") - melden("Ablage wird gespeichert …") - _ablage_speichern(optionen["ablage"]) - self._lauf.beginne("werkzeuge") - if optionen.get("werkzeuge_holen", True): - melden("Werkzeuge werden geprüft …") - from rippy.tools import einrichten as werkzeug_einrichtung - - bericht = werkzeug_einrichtung.sicherstellen( - fortschritt=melden, - aktualisieren=bool(optionen.get("aktualisieren", False))) - melden(werkzeug_einrichtung.bericht(bericht)) - bereit = bericht["bereit"] - else: - bereit = True - self._lauf.beginne("fertig") - self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit} - melden("Fertig.", 1.0) - except Exception as ex: # noqa: BLE001 - # Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig - # unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr - # passiert. - self.ergebnis = {"ok": False, - "fehler": "%s: %s" % (type(ex).__name__, ex)} - melden("Fehlgeschlagen: %s" % ex) - - def stand(self) -> dict: - """None, solange es läuft. Danach das Ergebnis.""" - return self.ergebnis or {} - - def als_admin(self) -> dict: - """Startet das Setup mit Administratorrechten neu. - - Nur auf Knopfdruck, nie von selbst. Der Grund steht in - `winlauf.als_admin_neu_starten`: Erhöht sieht Rippy die eingebundenen - Netzlaufwerke NICHT mehr — für eine Ablage auf einer Freigabe wäre das - genau der falsche Weg. - - Gibt zurück, ob der Neustart angenommen wurde. Ein „nein" ist der - Normalfall, wenn der Nutzer die UAC-Abfrage wegklickt — dann bleibt - das Fenster einfach offen. - """ - from rippy import windows_app - from rippy.platform import winlauf - - geklappt = winlauf.als_admin_neu_starten(windows_app._eigene_datei()) - if geklappt: - self.schliessen() - return {"gestartet": geklappt} - - def schliessen(self) -> None: - try: - self._fenster.destroy() - except Exception: # noqa: BLE001 - pass - - -def _ablage_speichern(pfad: str) -> None: - """Die gewählte Ablage in die Einstellungen schreiben. - - ## Der Fehler, der das Setup abgebrochen hat (28.08.2026) - - Hier stand `import db as api_db`, nachdem `daemon._api_pfad()` in den - Suchpfad gehängt worden war. Beim Commander brach die Installation ab mit: - - Fehlgeschlagen: ModuleNotFoundError: No module named 'db' - - Und zwar zu Recht: **`docker/api/db.py` gibt es nicht.** Seit der - Zusammenlegung in V2-1 ist der Store `rippy.store`; `main.py` schreibt - seitdem `from rippy import store as db`. Ich habe ein Modul importiert, - das ich mir aus einem Alias zusammengereimt habe — genau die Sorte - erfundener Schnittstelle, vor der AGENTS.md Regel D warnt, nur diesmal - im eigenen Code. - - Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war - ohnehin überflüssig. - """ - 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 - store.save_settings(aktuell) - - -def starten() -> dict: - """Öffnet den Assistenten. Gibt zurück, was daraus geworden ist. - - `{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann, - ob er ohne Assistent installiert. - """ - import webview - - bruecke = Bruecke() - fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke, - width=BREITE, height=HOEHE, - min_size=(720, 560), text_select=True) - bruecke._fenster = fenster - webview.start(gui="edgechromium", private_mode=False) - return bruecke.ergebnis or {} - - -def moeglich() -> tuple: - """Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)""" - from rippy import fenster as fenster_modul - - return fenster_modul.verfuegbar() - - -# ── Die Seite ─────────────────────────────────────────────────────────── -# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine -# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne -# Internet aufgehen — dort wird er am dringendsten gebraucht. -SEITE = r""" - - -
-

Rippy einrichten

-
Wird geprüft, was dein Rechner mitbringt — danach entscheidest du, wohin.
-
-
-
-

Voraussetzungen

-
wird geprüft …
-
- -
-

Wohin

- - - -
- -
-

Sonst noch

- - - - -
- -
-

Läuft

-
- wird vorbereitet … - 0 % -
-
-
-
-
-
-
wird geprüft …
- - - -
- - -""" - -# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests). -SEITE_LAENGE = len(SEITE) - - -def seite_enthaelt(text: str) -> bool: - """Kleine Hilfe für Tests: Steht etwas in der Seite?""" - return text in SEITE +"""Das Fenster des Einrichtungs-Assistenten. + +## Warum es das gibt + +Commander am 28.08.2026: + +> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — +> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt +> garnichts Rippy geht einfach auf." + +## Warum WebView2 und nicht ein Windows-Dialog + +Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit +(Entscheid 4 in `KONZEPT-V2.md` § 10) — der Assistent kostet damit **null +zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen +ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau +ausdrücklich ausgeschlossen. + +## Warum die Prüfungen NICHT hier stehen + +Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar. +Was hier steht, lässt sich nicht testen — also steht hier so wenig wie +möglich: Aufbau, Anzeige, und die Brücke zwischen beidem. + +## Was passiert, wenn das Fenster nicht aufgeht + +Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein +Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer +als eines, das nicht fragt. +""" + +import threading + +from rippy import einrichtung + +TITEL = "Rippy einrichten" +BREITE = 860 +HOEHE = 760 + + +# Die Abschnitte der Installation und was sie am Balken belegen. +# +# Die Gewichte sind gemessen, nicht geraten: Das Ablegen der Dateien dauert +# rund drei Sekunden (56 MB kopieren, UI und HandBrake auspacken), das Holen +# von MakeMKV je nach Quelle sechs Sekunden bis mehrere Minuten. Der +# Werkzeug-Abschnitt bekommt deshalb den Löwenanteil — ein Balken, der bei +# 90 % minutenlang steht, ist schlimmer als gar keiner. +ABSCHNITTE = { + "dateien": (0.00, 0.08, "Dateien werden abgelegt"), + "ablage": (0.08, 0.10, "Ablage wird gespeichert"), + "werkzeuge": (0.10, 0.97, "Werkzeuge werden geprüft"), + "fertig": (0.97, 1.00, "Fertig"), +} + + +class Fortschritt: + """Rechnet Abschnitt + Teil-Anteil in einen Gesamtstand um. + + Reine Rechnerei, damit sie ohne Fenster prüfbar ist — und weil ein + Fortschrittsbalken, der springt oder zurückläuft, schlimmer aussieht als + keiner. + + Zwei Regeln: + + * **Er läuft nie zurück.** Der Werkzeug-Abschnitt kann mehrere Downloads + enthalten, jeder mit eigenem 0-bis-1. Ohne diese Regel spränge der + Balken bei jedem neuen Werkzeug zurück an den Abschnittsanfang. + * **Ohne Anteil bleibt er stehen.** Eine Textmeldung ohne Zahl ist keine + Aussage über den Fortschritt. + """ + + def __init__(self): + self.prozent = 0 + self.abschnitt = "" + self.zeilen = [] + + def beginne(self, name: str) -> None: + von, _bis, titel = ABSCHNITTE[name] + self.abschnitt = titel + self._von, self._bis, _ = ABSCHNITTE[name] + self.setze(von) + + def melde(self, text, anteil=None) -> None: + if text: + self.zeilen.append(str(text)) + if anteil is None: + return + anteil = min(1.0, max(0.0, float(anteil))) + self.setze(self._von + (self._bis - self._von) * anteil) + + def setze(self, anteil: float) -> None: + self.prozent = max(self.prozent, int(round(anteil * 100))) + + def abholen(self) -> dict: + """Was seit dem letzten Abruf passiert ist. Die Zeilen leeren sich.""" + zeilen, self.zeilen = self.zeilen, [] + return {"zeilen": zeilen, "prozent": self.prozent, + "abschnitt": self.abschnitt} + + +#: pywebview.FOLDER_DIALOG. Steht hier doppelt, damit `ordner_waehlen` +#: auch ohne pywebview pruefbar bleibt (webview/util.py: OPEN=10, +#: FOLDER=20, SAVE=30). +FOLDER_DIALOG = 20 + + +class Bruecke: + """Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML. + + Jede Methode gibt Daten zurück, keine Erfolgsmeldungen — dieselbe Regel + wie überall sonst in diesem Projekt. + """ + + def __init__(self): + self.ergebnis = None + self._fenster = None + self._lauf = Fortschritt() + + # ── Auskunft ──────────────────────────────────────────────────────── + def vorgaben(self) -> dict: + return einrichtung.vorgaben() + + def pruefen(self, ziel: str = "", port: int = 0) -> dict: + """Der Voraussetzungs-Bericht für die angegebenen Werte.""" + vorgabe = einrichtung.vorgaben() + befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"], + int(port or vorgabe["port"])) + return { + "befunde": befunde, + "darf": einrichtung.darf_installieren(befunde), + "text": einrichtung.zusammenfassung(befunde), + } + + def updates(self) -> dict: + """Gibt es für die installierten Werkzeuge etwas Neueres? + + ## Warum das ein EIGENER Aufruf ist (28.08.2026) + + Commander: *„bitte baue ein, das wenn MakeMKV oder Handbrake schon + installiert sind ein update verfügbar ist."* + + Die Auskunft kostet Netz — und makemkv.com braucht dafür im + schlimmsten Fall gut zwanzig Sekunden (gemessen). In der + Voraussetzungs-Prüfung würde das Fenster so lange leer bleiben. + Deshalb getrennt: Die Prüfung zeigt sofort, was installiert ist, und + diese Auskunft trägt die Update-Hinweise nach. + + Leere Werte heißen „nicht erreichbar", nicht „kein Update" — eine + Nichtauskunft ist keine Aussage. + """ + from rippy.tools import beschaffen, einrichten as werkzeuge, katalog + + try: + bestand = katalog.bestand() + neueste = beschaffen.neueste_versionen() + except Exception: # noqa: BLE001 + return {"erreichbar": False, "offen": []} + + offen = [{"name": name, + "titel": eintrag.get("titel", name), + "installiert": eintrag.get("version", ""), + "neueste": neu} + for name, eintrag, neu in werkzeuge.veraltete(bestand, neueste)] + return {"erreichbar": any(neueste.values()), "offen": offen} + + def ordner_waehlen(self, vorschlag: str = "") -> str: + """Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde.""" + # Der Rueckfall ist NICHT fuer den Betrieb — dort ist pywebview immer + # da. Er ist fuer die Ampel: Ohne ihn scheiterten diese zwei Tests in + # jeder Umgebung ohne pywebview mit ModuleNotFoundError. Ein Test, der + # nur an einer Stelle greift, ist eine halbe Zusage (AGENTS.md) — und + # am 28.08.2026 hielt ich die beiden erst faelschlich fuer meinen + # eigenen Fehler. + try: + import webview + art = webview.FOLDER_DIALOG + except ImportError: + art = FOLDER_DIALOG + + try: + gewaehlt = self._fenster.create_file_dialog( + art, directory=vorschlag or "") + except Exception: # noqa: BLE001 + return "" + if not gewaehlt: + return "" + return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt) + + def fortschritt(self) -> dict: + """Was seit dem letzten Abruf passiert ist — Zeilen UND Stand. + + Gibt ein Wörterbuch zurück, keine Liste: Der Balken braucht eine Zahl, + und zwei getrennte Abrufe dafür wären zwei Gelegenheiten, dass Text + und Stand auseinanderlaufen. + """ + return self._lauf.abholen() + + # ── Handeln ───────────────────────────────────────────────────────── + def installieren(self, optionen: dict) -> dict: + """Installiert im Hintergrund-Thread und gibt sofort zurück. + + Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter + Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster + sieht aus wie ein Absturz. + """ + threading.Thread(target=self._installieren_wirklich, + args=(optionen or {},), daemon=True).start() + return {"gestartet": True} + + def _installieren_wirklich(self, optionen: dict) -> None: + from rippy import windows_app + + melden = self._lauf.melde + + try: + self._lauf.beginne("dateien") + melden("Dateien werden abgelegt …") + ziel = windows_app.installieren( + ziel=optionen.get("ziel") or None, + autostart=bool(optionen.get("autostart", True)), + starten_danach=False, + verknuepfen=bool(optionen.get("verknuepfungen", True)), + ) + if optionen.get("ablage"): + self._lauf.beginne("ablage") + melden("Ablage wird gespeichert …") + _ablage_speichern(optionen["ablage"]) + self._lauf.beginne("werkzeuge") + if optionen.get("werkzeuge_holen", True): + melden("Werkzeuge werden geprüft …") + from rippy.tools import einrichten as werkzeug_einrichtung + + bericht = werkzeug_einrichtung.sicherstellen( + fortschritt=melden, + aktualisieren=bool(optionen.get("aktualisieren", False))) + melden(werkzeug_einrichtung.bericht(bericht)) + bereit = bericht["bereit"] + else: + bereit = True + self._lauf.beginne("fertig") + self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit} + melden("Fertig.", 1.0) + except Exception as ex: # noqa: BLE001 + # Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig + # unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr + # passiert. + self.ergebnis = {"ok": False, + "fehler": "%s: %s" % (type(ex).__name__, ex)} + melden("Fehlgeschlagen: %s" % ex) + + def stand(self) -> dict: + """None, solange es läuft. Danach das Ergebnis.""" + return self.ergebnis or {} + + def als_admin(self) -> dict: + """Startet das Setup mit Administratorrechten neu. + + Nur auf Knopfdruck, nie von selbst. Der Grund steht in + `winlauf.als_admin_neu_starten`: Erhöht sieht Rippy die eingebundenen + Netzlaufwerke NICHT mehr — für eine Ablage auf einer Freigabe wäre das + genau der falsche Weg. + + Gibt zurück, ob der Neustart angenommen wurde. Ein „nein" ist der + Normalfall, wenn der Nutzer die UAC-Abfrage wegklickt — dann bleibt + das Fenster einfach offen. + """ + from rippy import windows_app + from rippy.platform import winlauf + + geklappt = winlauf.als_admin_neu_starten(windows_app._eigene_datei()) + if geklappt: + self.schliessen() + return {"gestartet": geklappt} + + def schliessen(self) -> None: + try: + self._fenster.destroy() + except Exception: # noqa: BLE001 + pass + + +def _ablage_speichern(pfad: str) -> None: + """Die gewählte Ablage in die Einstellungen schreiben. + + ## Der Fehler, der das Setup abgebrochen hat (28.08.2026) + + Hier stand `import db as api_db`, nachdem `daemon._api_pfad()` in den + Suchpfad gehängt worden war. Beim Commander brach die Installation ab mit: + + Fehlgeschlagen: ModuleNotFoundError: No module named 'db' + + Und zwar zu Recht: **`docker/api/db.py` gibt es nicht.** Seit der + Zusammenlegung in V2-1 ist der Store `rippy.store`; `main.py` schreibt + seitdem `from rippy import store as db`. Ich habe ein Modul importiert, + das ich mir aus einem Alias zusammengereimt habe — genau die Sorte + erfundener Schnittstelle, vor der AGENTS.md Regel D warnt, nur diesmal + im eigenen Code. + + Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war + ohnehin überflüssig. + """ + 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 + store.save_settings(aktuell) + + +def starten() -> dict: + """Öffnet den Assistenten. Gibt zurück, was daraus geworden ist. + + `{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann, + ob er ohne Assistent installiert. + """ + import webview + + bruecke = Bruecke() + fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke, + width=BREITE, height=HOEHE, + min_size=(720, 560), text_select=True) + bruecke._fenster = fenster + webview.start(gui="edgechromium", private_mode=False) + return bruecke.ergebnis or {} + + +def moeglich() -> tuple: + """Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)""" + from rippy import fenster as fenster_modul + + return fenster_modul.verfuegbar() + + +# ── Die Seite ─────────────────────────────────────────────────────────── +# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine +# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne +# Internet aufgehen — dort wird er am dringendsten gebraucht. +SEITE = r""" + + +
+

Rippy einrichten

+
Wird geprüft, was dein Rechner mitbringt — danach entscheidest du, wohin.
+
+
+
+

Voraussetzungen

+
wird geprüft …
+
+ +
+

Wohin

+ + + +
+ +
+

Sonst noch

+ + + + +
+ +
+

Läuft

+
+ wird vorbereitet … + 0 % +
+
+
+
+
+
+
wird geprüft …
+ + + +
+ + +""" + +# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests). +SEITE_LAENGE = len(SEITE) + + +def seite_enthaelt(text: str) -> bool: + """Kleine Hilfe für Tests: Steht etwas in der Seite?""" + return text in SEITE diff --git a/src/rippy/test_betrieb.py b/src/rippy/test_betrieb.py index 5bd1ce3..e379617 100644 --- a/src/rippy/test_betrieb.py +++ b/src/rippy/test_betrieb.py @@ -1,193 +1,193 @@ -"""Der Betriebsmodus und seine Faehigkeiten. - -## Warum es diese Tests gibt - -Commander am 28.08.2026, zum Windows-Fenster: - - Worker erreichbar: 0 von 1 - Kein Worker antwortet — Pruefen: docker compose ps - Container-Platte: unbekannt - Freigaben: keine eingehaengt - -Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte -nie erfahren, worauf sie laeuft — also hat sie angenommen. - -Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne -ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur -auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler -nie auf. -""" - -from rippy import betrieb - -DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}} -WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}} - - -# ── Plattform ─────────────────────────────────────────────────────────── -def test_plattform_wird_zu_einem_klaren_namen(): - assert betrieb.plattform("win32") == "windows" - assert betrieb.plattform("linux") == "linux" - assert betrieb.plattform("darwin") == "macos" - - -def test_unbekannte_plattform_gilt_als_linux(): - """FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf - etwas Bekanntes ist besser als ein leeres Feld.""" - assert betrieb.plattform("freebsd14") == "linux" - - -# ── Container ─────────────────────────────────────────────────────────── -def test_container_wird_gemessen_nicht_geraten(): - """`/.dockerenv` legt Docker selbst an — verlaesslicher als eine - Umgebungsvariable, die jeder setzen und vergessen kann.""" - assert betrieb.im_container(lambda p: p == "/.dockerenv") is True - assert betrieb.im_container(lambda p: False) is False - - -# ── Modus ─────────────────────────────────────────────────────────────── -def test_celery_heisst_verteilt(): - assert betrieb.modus(DOCKER) == "verteilt" - - -def test_lokale_queue_heisst_standalone(): - assert betrieb.modus(WINDOWS) == "standalone" - - -def test_ohne_angaben_gilt_standalone(): - """Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es - nicht gibt. Andersherum entstuenden die Phantom-Worker wieder.""" - assert betrieb.modus({}) == "standalone" - - -# ── Die Faehigkeiten — hier sass der gemeldete Fehler ─────────────────── -def test_windows_hat_KEINE_externen_worker(): - """DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet." - Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man - warten koennte. Die Meldung war nicht nur unpassend, sie war falsch.""" - a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") - assert a["kann"]["externe_worker"] is False - - -def test_windows_kennt_KEINE_container_pfade(): - """`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner - anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht - falsch.""" - a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") - assert a["kann"]["container_pfade"] is False - assert "/app/" not in a["ablage_vorgabe"] - - -def test_windows_haengt_keine_freigaben_ein(): - """Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum - Einhaengen waere eine Bedienung, die ins Leere fuehrt.""" - a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") - assert a["kann"]["freigaben_einhaengen"] is False - - -def test_windows_darf_seine_werkzeuge_selbst_pflegen(): - """Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native - Installation kann MakeMKV/HandBrake selbst beschaffen.""" - a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") - assert a["kann"]["werkzeuge_verwalten"] is True - - -def test_docker_behaelt_alles_was_es_hatte(): - """Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen.""" - a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux") - assert a["kann"]["externe_worker"] is True - assert a["kann"]["container_pfade"] is True - assert a["kann"]["freigaben_einhaengen"] is True - assert a["ablage_vorgabe"] == "/app/media" - assert a["hilfe_befehl"] == "docker compose -p rippy ps" - - -def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf(): - """„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den - niemand befolgen kann.""" - a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") - assert a["hilfe_befehl"] == "" - - -def test_docker_allinone_hat_container_aber_keine_externen_worker(): - """Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN - Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden, - der von aussen Jobs uebernimmt.""" - a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}}, - container=True, plattform_name="linux") - assert a["kann"]["container_pfade"] is True - assert a["kann"]["externe_worker"] is False - - -# ── Die Ablage-Vorgabe ────────────────────────────────────────────────── -def test_eingestellte_ablage_schlaegt_jede_vorgabe(): - a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}}, - container=False, plattform_name="win32") - assert a["ablage_vorgabe"] == "D:\\Filme" - - -def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch): - monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test") - a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") - assert a["ablage_vorgabe"].startswith("C:\\Users\\Test") - assert a["ablage_vorgabe"].endswith("Rippy") - - -# ── Der Vertrag als Ganzes ────────────────────────────────────────────── -def test_die_auskunft_ist_vollstaendig(): - """Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist - in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend - einen Bereich. Lieber hier rot.""" - a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") - assert set(a) == {"modus", "plattform", "im_container", "kann", - "ablage_vorgabe", "arbeits_vorgabe", "hilfe_befehl"} - assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen", - "container_pfade", "werkzeuge_verwalten", - "frei_blaettern"} - assert all(isinstance(v, bool) for v in a["kann"].values()) - - -# ── Wo Platz gemessen wird ────────────────────────────────────────────── -def test_windows_misst_NICHT_in_app_media(): - """DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt". - - `main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter - Windows nicht, `shutil.disk_usage` warf, die Liste blieb leer — und aus - einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht. - """ - orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32") - assert orte, "es muss mindestens ein Ort gemessen werden" - assert all("/app/" not in o["pfad"] for o in orte), orte - - -def test_docker_misst_weiter_die_container_pfade(): - orte = betrieb.platz_orte(DOCKER, container=True, plattform_name="linux") - assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"] - - -def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt(): - """Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte - und ist eine.""" - orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}}, - container=False, plattform_name="win32") - assert len(orte) == 1, orte - - -def test_zweites_laufwerk_bekommt_eine_eigene_zeile(): - orte = betrieb.platz_orte( - {"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}}, - container=False, plattform_name="win32") - assert len(orte) == 2, orte - - -def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck(): - """Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer - will trotzdem wissen, ob auf dem LAUFWERK Platz ist.""" - da = r"C:\Users\Test" - assert betrieb.naechster_vorhandener( - r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da - - -def test_wenn_gar_nichts_existiert_wird_nichts_behauptet(): - assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht", - existiert=lambda p: False) == "" +"""Der Betriebsmodus und seine Faehigkeiten. + +## Warum es diese Tests gibt + +Commander am 28.08.2026, zum Windows-Fenster: + + Worker erreichbar: 0 von 1 + Kein Worker antwortet — Pruefen: docker compose ps + Container-Platte: unbekannt + Freigaben: keine eingehaengt + +Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte +nie erfahren, worauf sie laeuft — also hat sie angenommen. + +Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne +ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur +auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler +nie auf. +""" + +from rippy import betrieb + +DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}} +WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}} + + +# ── Plattform ─────────────────────────────────────────────────────────── +def test_plattform_wird_zu_einem_klaren_namen(): + assert betrieb.plattform("win32") == "windows" + assert betrieb.plattform("linux") == "linux" + assert betrieb.plattform("darwin") == "macos" + + +def test_unbekannte_plattform_gilt_als_linux(): + """FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf + etwas Bekanntes ist besser als ein leeres Feld.""" + assert betrieb.plattform("freebsd14") == "linux" + + +# ── Container ─────────────────────────────────────────────────────────── +def test_container_wird_gemessen_nicht_geraten(): + """`/.dockerenv` legt Docker selbst an — verlaesslicher als eine + Umgebungsvariable, die jeder setzen und vergessen kann.""" + assert betrieb.im_container(lambda p: p == "/.dockerenv") is True + assert betrieb.im_container(lambda p: False) is False + + +# ── Modus ─────────────────────────────────────────────────────────────── +def test_celery_heisst_verteilt(): + assert betrieb.modus(DOCKER) == "verteilt" + + +def test_lokale_queue_heisst_standalone(): + assert betrieb.modus(WINDOWS) == "standalone" + + +def test_ohne_angaben_gilt_standalone(): + """Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es + nicht gibt. Andersherum entstuenden die Phantom-Worker wieder.""" + assert betrieb.modus({}) == "standalone" + + +# ── Die Faehigkeiten — hier sass der gemeldete Fehler ─────────────────── +def test_windows_hat_KEINE_externen_worker(): + """DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet." + Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man + warten koennte. Die Meldung war nicht nur unpassend, sie war falsch.""" + a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") + assert a["kann"]["externe_worker"] is False + + +def test_windows_kennt_KEINE_container_pfade(): + """`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner + anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht + falsch.""" + a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") + assert a["kann"]["container_pfade"] is False + assert "/app/" not in a["ablage_vorgabe"] + + +def test_windows_haengt_keine_freigaben_ein(): + """Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum + Einhaengen waere eine Bedienung, die ins Leere fuehrt.""" + a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") + assert a["kann"]["freigaben_einhaengen"] is False + + +def test_windows_darf_seine_werkzeuge_selbst_pflegen(): + """Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native + Installation kann MakeMKV/HandBrake selbst beschaffen.""" + a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") + assert a["kann"]["werkzeuge_verwalten"] is True + + +def test_docker_behaelt_alles_was_es_hatte(): + """Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen.""" + a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux") + assert a["kann"]["externe_worker"] is True + assert a["kann"]["container_pfade"] is True + assert a["kann"]["freigaben_einhaengen"] is True + assert a["ablage_vorgabe"] == "/app/media" + assert a["hilfe_befehl"] == "docker compose -p rippy ps" + + +def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf(): + """„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den + niemand befolgen kann.""" + a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") + assert a["hilfe_befehl"] == "" + + +def test_docker_allinone_hat_container_aber_keine_externen_worker(): + """Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN + Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden, + der von aussen Jobs uebernimmt.""" + a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}}, + container=True, plattform_name="linux") + assert a["kann"]["container_pfade"] is True + assert a["kann"]["externe_worker"] is False + + +# ── Die Ablage-Vorgabe ────────────────────────────────────────────────── +def test_eingestellte_ablage_schlaegt_jede_vorgabe(): + a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}}, + container=False, plattform_name="win32") + assert a["ablage_vorgabe"] == "D:\\Filme" + + +def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch): + monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test") + a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") + assert a["ablage_vorgabe"].startswith("C:\\Users\\Test") + assert a["ablage_vorgabe"].endswith("Rippy") + + +# ── Der Vertrag als Ganzes ────────────────────────────────────────────── +def test_die_auskunft_ist_vollstaendig(): + """Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist + in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend + einen Bereich. Lieber hier rot.""" + a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") + assert set(a) == {"modus", "plattform", "im_container", "kann", + "ablage_vorgabe", "arbeits_vorgabe", "hilfe_befehl"} + assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen", + "container_pfade", "werkzeuge_verwalten", + "frei_blaettern"} + assert all(isinstance(v, bool) for v in a["kann"].values()) + + +# ── Wo Platz gemessen wird ────────────────────────────────────────────── +def test_windows_misst_NICHT_in_app_media(): + """DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt". + + `main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter + Windows nicht, `shutil.disk_usage` warf, die Liste blieb leer — und aus + einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht. + """ + orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32") + assert orte, "es muss mindestens ein Ort gemessen werden" + assert all("/app/" not in o["pfad"] for o in orte), orte + + +def test_docker_misst_weiter_die_container_pfade(): + orte = betrieb.platz_orte(DOCKER, container=True, plattform_name="linux") + assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"] + + +def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt(): + """Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte + und ist eine.""" + orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}}, + container=False, plattform_name="win32") + assert len(orte) == 1, orte + + +def test_zweites_laufwerk_bekommt_eine_eigene_zeile(): + orte = betrieb.platz_orte( + {"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}}, + container=False, plattform_name="win32") + assert len(orte) == 2, orte + + +def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck(): + """Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer + will trotzdem wissen, ob auf dem LAUFWERK Platz ist.""" + da = r"C:\Users\Test" + assert betrieb.naechster_vorhandener( + r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da + + +def test_wenn_gar_nichts_existiert_wird_nichts_behauptet(): + assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht", + existiert=lambda p: False) == ""