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>
706 lines
33 KiB
TypeScript
706 lines
33 KiB
TypeScript
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 (20–120 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>
|
||
)
|
||
}
|