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 */}
) }