Files
rippy/docker/ui/src/components/RipTargetModal.tsx
T
HitonabiandClaude Opus 5 35370fd552 fix(windows): Cover, Arbeitsverzeichnis und die zurueckgelassenen _MEI-Ordner
Drei Befunde des Commanders vom 28.08.2026, alle drei gemessen.

## 1. „Was ist mit dem Cover auf Windows Rippy?"

Es gab keins, weil es keinen Treffer gab. `_scan_video` verlangte woertliche
Gleichheit:

    if movie.get("title", "").lower() == kandidat.lower():

An seiner Disc gemessen:

    'Evangelion 2.22'    1 Treffer   Evangelion: 2.0 You Can (Not) Advance
    'Evangelion'        20 Treffer   irgendein Evangelion

Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
Poster — und wurde verworfen. Jetzt zaehlt die SPEZIFITAET der Anfrage: Wer
auf den vollen Disc-Titel hoechstens drei Treffer bekommt, hat gefragt wie
jemand, der weiss was er sucht. Ergebnis an derselben Disc:

    Evangelion: 2.0 You Can (Not) Advance / 2009 / 80 % / Poster + dt. Text

## 2. „Warum heisst das hier noch container platte? … Waere es moeglich das
##     Arbeitsverzeichnis zu aendern? momentan geht das nicht."

Beide Haelften gehen auf EINE Zeile zurueck: `MEDIA_ROOT = "/app/media"` war
zugleich Vorgabe UND Pfadgrenze. Auf Windows gibt es den Ordner nicht:

* `/storage-targets` fing den OSError und gab still [] zurueck — die Auswahl
  hatte genau einen Eintrag. Das ist „momentan geht das nicht".
* Dessen Text war fest verdrahtet „(Container-Platte)".
* `/browse` antwortete auf jeden Pfad mit 422.

Die Grenze faellt nicht weg, sie wird betriebsabhaengig: Container und
Kopflos-Betrieb bedienen ein Netz, die native App den Menschen davor.
Gemessen auf seinem PC: 7 Laufwerke zur Auswahl, X/Y/Z mit je 2,2 TB frei.

## 3. „Failed to remove temporary directory: …_MEI0000b0882"

GEMESSEN: 20 zurueckgelassene _MEI-Ordner mit 1,1 GB. Ursache: `Popen` ohne
`env=` reicht PyInstallers Auspack-Zeiger an die Kinder weiter (--dienst und
--oeffnen). Beide laufen dann im Ordner des Elternprozesses, der sich zuerst
beendet und ihn loeschen will. Waere das TEILWEISE geglueckt, haetten Dienst
und Fenster mitten im Betrieb ihre Dateien verloren.

Nebenbefund: test_setup_fenster scheiterte in jeder Umgebung ohne pywebview.

794 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:43:41 +02:00

706 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
'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<TargetConfig['type']>('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<TitelInfo[]>([])
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [workers, setWorkers] = useState<WorkerWahl[]>([])
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<Set<string>>(new Set())
const [untertitelWahl, setUntertitelWahl] = useState<Set<string>>(new Set())
// Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt,
// soweit die Disc sie überhaupt hat.
const [standardAudio, setStandardAudio] = useState<string[]>([])
const [standardUntertitel, setStandardUntertitel] = useState<string[]>([])
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<TargetConfig[]>([
{ 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<string>('')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
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<StorageZiel[]>([])
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/<freigabe>/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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Rip-Ziel auswählen"
maxWidth="lg"
>
<div className="space-y-5">
<p className="text-xs text-slate-500 dark:text-slate-400">
Schnellwahl oder eigenen Ordner wählen Netzwerk-Ziele (NAS, PC) sind unter Einstellungen Speicherziele einhängbar.
</p>
{/* Schnellwahl */}
<div className="grid grid-cols-3 gap-3">
{targets.map((t) => {
const isSelected = selectedType === t.type && !customPath
return (
<button
key={t.type}
onClick={() => { setSelectedType(t.type); setCustomPath('') }}
className={`flex flex-col items-center justify-center gap-1.5 p-3.5 rounded-xl border transition-all ${
isSelected
? 'bg-amber-500/15 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold shadow-md shadow-amber-500/10'
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-700'
}`}
>
{t.type === 'movies' && <Film size={22} className={isSelected ? 'text-amber-500' : ''} />}
{t.type === 'series' && <Tv size={22} className={isSelected ? 'text-amber-500' : ''} />}
{t.type === 'music' && <Music size={22} className={isSelected ? 'text-amber-500' : ''} />}
<span className="text-sm">{t.name}</span>
</button>
)
})}
</div>
{/* Serien-Flow */}
{selectedType === 'series' && (
<div className="rounded-xl p-4 space-y-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
<div className="grid grid-cols-[1fr_90px] gap-3">
<Input
label="Serienname"
value={serienName}
onChange={e => setSerienName(e.target.value)}
placeholder="z. B. Neon Genesis Evangelion"
/>
<Input
label="Staffel"
type="number"
min={1}
value={staffel}
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')}
</p>
</div>
)}
{/* Hauptfilm-Wahl */}
{selectedType === 'movies' && scanStatus !== 'done' && (
<label className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={nurHauptfilm}
onChange={e => setNurHauptfilm(e.target.checked)}
className="w-4 h-4 accent-amber-500 rounded"
/>
Nur Hauptfilm rippen (längster Titel Extras/Trailer bleiben weg)
</label>
)}
{/*
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' && (
<div>
<Select
label="Arbeitsverzeichnis für die Rohdaten"
value={arbeitsDir}
onChange={e => setArbeitsDir(e.target.value)}
>
<option value="">
Standard{arbeitsVorgabe ? ` — ${arbeitsVorgabe}` : ''}
</option>
{arbeitsZiele.map(z => (
<option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
</option>
))}
{eigenesArbeitsZiel && (
<option value={eigenesArbeitsZiel}>{eigenesArbeitsZiel}</option>
)}
</Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<HardDrive size={13} />
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.
</p>
</div>
)}
{/* 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 && (
<div>
<Select
label="Encoder / Worker für die Kompression"
value={encoderNode}
onChange={e => setEncoderNode(e.target.value)}
>
<option value="">Automatisch (erster freier Worker)</option>
{workers.map(w => (
<option key={w.node!} value={w.node!}>
{w.name} {w.encoders.map(e => ENCODER_KURZ[e] || e).join(', ')}
</option>
))}
</Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<Cpu size={13} /> Wähle gezielt eine Maschine (z. B. die mit GPU) sonst nimmt der erste freie Worker.
</p>
</div>
)}
{/* 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 && (
<div className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
<strong>⚠️ Dieser Encoder kann so nicht arbeiten.</strong>
<p className="mt-1">{pfadWarnung}</p>
{/* 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 && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => { setCustomPath(zielAufFreigabe); setBrowserOffen(false) }}
>
Ziel für diesen Rip auf die Freigabe legen
</Button>
)}
</div>
)}
{/* Titel-Auswahl (Track-Tabelle) */}
{deviceId && selectedType !== 'music' && (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
<div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">Titel-Auswahl (optional)</span>
{scanStatus === 'idle' && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Disc scannen und einzelne Titel an-/abwählen.
</p>
)}
</div>
{scanStatus === 'running' ? (
<span className="text-xs flex items-center gap-2 text-slate-500 dark:text-slate-400">
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-amber-500 border-t-transparent" />
Disc wird gelesen (20120 s)
</span>
) : (
<Button variant="secondary" size="sm" onClick={scanStarten}>
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
</Button>
)}
</div>
{scanStatus === 'error' && (
<p className="text-xs text-rose-500 px-4 py-2.5">{scanFehler}</p>
)}
{scanStatus === 'done' && titelListe.length > 0 && (
<>
<div className="max-h-44 overflow-y-auto">
{titelListe.map(t => (
<label
key={t.nr}
className="flex items-center gap-3 px-4 py-2 text-sm cursor-pointer border-b border-slate-200/50 dark:border-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800/60"
>
<input
type="checkbox"
checked={gewaehlt.has(t.nr)}
onChange={e => {
const neu = new Set(gewaehlt)
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
setGewaehlt(neu)
}}
className="w-4 h-4 accent-amber-500 rounded"
/>
<span className="font-mono text-xs w-14 font-semibold text-slate-700 dark:text-slate-300">Titel {t.nr}</span>
<span className="text-slate-600 dark:text-slate-300">{dauerText(t.dauer_s)}</span>
{t.kapitel > 0 && (
<span className="text-xs text-slate-400">{t.kapitel} Kap.</span>
)}
<span className="ml-auto text-xs font-mono text-slate-400">
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
</span>
</label>
))}
</div>
<p className="text-xs px-4 py-2 bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400">
{gewaehlt.size} von {titelListe.length} Titeln gewählt
</p>
</>
)}
</div>
)}
{/* 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) && (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
Sprachen auf dieser Disc
</span>
<p className="text-xs text-slate-500 dark:text-slate-400">
{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 <strong>alles behalten</strong>.
</p>
</div>
<div className="p-4 space-y-3">
{(['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 (
<div key={art}>
<p className="text-xs font-medium mb-1.5 text-slate-700 dark:text-slate-300">
{art === 'audio' ? 'Tonspuren' : 'Untertitel'}
</p>
<div className="flex flex-wrap gap-2">
{liste.map(s => {
const an = wahl.has(s.lang)
return (
<button
key={s.lang}
onClick={() => {
const neu = new Set(wahl)
if (an) neu.delete(s.lang); else neu.add(s.lang)
setzen(neu)
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
an
? 'bg-amber-500 border-amber-500 text-slate-950 font-bold'
: 'bg-slate-100 dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
{sprachName(s.lang, s.sprache)}
<span className="opacity-70"> ({s.lang})</span>
{s.spuren > 1 && <span className="opacity-70"> · {s.spuren}×</span>}
</button>
)
})}
</div>
</div>
)
})}
{/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man,
die Disc würde unvollständig gerippt. */}
<p className="text-xs text-slate-500 dark:text-slate-400">
{audioWahl.size > 0 || untertitelWahl.size > 0 ? (
<>
Der Rip selbst bleibt <strong>vollständig und verlustfrei</strong> 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.</>
)}
</p>
</div>
</div>
)}
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
<div>
<button
onClick={() => {
const auf = !browserOffen
setBrowserOffen(auf)
if (auf && browseDirs.length === 0) laden('')
}}
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
>
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{selectedType === 'music'
? 'Anderen Zielordner wählen (nur für diesen Rip)'
: 'Ordner durchsuchen — Ziel oder Arbeitsordner (nur für diesen Rip)'}
</button>
{browserOffen && (
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
<button
onClick={() => browseParent && laden(browseParent)}
disabled={!browseParent}
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">
{browsePath || 'Laufwerke'}
</span>
<Button
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm"
disabled={!browsePath}
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
>
{customPath === browsePath ? '✓ Ziel' : 'Als Ziel'}
</Button>
{/* 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' && (
<Button
variant={arbeitsDir === browsePath ? 'amber' : 'secondary'}
size="sm"
disabled={!browsePath}
onClick={() => {
if (arbeitsDir === browsePath) { setArbeitsDir(''); return }
setEigenesArbeitsZiel(browsePath)
setArbeitsDir(browsePath)
}}
>
{arbeitsDir === browsePath ? '✓ Arbeitsordner' : 'Als Arbeitsordner'}
</Button>
)}
</div>
<div className="max-h-36 overflow-y-auto p-1">
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={14} className="text-slate-400" />
{d.name}
</button>
))}
{browseDirs.length === 0 && (
<p className="px-3 py-1.5 text-xs text-slate-400">Keine Unterordner.</p>
)}
</div>
</div>
)}
</div>
{/* Gewähltes Ziel Display */}
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
{zielPfad}
</span>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 pt-3 border-t border-slate-200 dark:border-slate-800">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button variant="amber" onClick={handleSave}>
Rippen starten
</Button>
</div>
</div>
</Modal>
)
}