// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ─────────────────────────── // Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und // Alterskennzeichen inklusive. Der Info-Lauf dauert 20–120 s, deshalb // beginnt er erst hier und nicht schon beim Einlegen. // 5.4.0: Sprachen je Titel, Staffel/Folge von Hand, Zielpfad-Vorschau. // 5.5.0: Platz-Prüfung vor dem Rip und der Serien-Merker (Folge von Disc 1 // zählt auf Disc 2 weiter). // 5.6.0 (Commander): Je Extra WELCHE Untertitel-Spur beim Abspielen // vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je // Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen // eigenen Roh-Ordner, Vorgang und Zielordner. // 5.7.0 (Commander, 12.09.2026): JEDE Spur einzeln anhaken — „Deutsch · // DTS-HD MA · 5.1" und „Deutsch · DD · 2.0 (Kommentar)" getrennt, Untertitel // genauso. Die Sprach-Chips bleiben nur als Rückfall, wenn ein Titel keine // einzelnen Spuren meldet. import { useState } from 'react' import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad' import { platzUrteil } from '../../gemeinsam/platz' import type { DiscInfoStand, DiscTitelVorschlag, GeraeteInfo, MetaVorschlag, SpeicherStand, SprachEintrag, SpurAngabe, TitelRolle, TitelStand, } from '../../gemeinsam/nachrichten' import { angeboten, sprachenDerSpuren, spurenVorbelegt, spurLabel } from '../../gemeinsam/spuren' import { DialogRahmen, IconDisc, KNOPF_GOLD, datumKurz, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine' import { metadatenSuchen, ripStarten, titelAbbrechen } from '../kernverbindung' /** Sprach-Chips eines Titels — anklickbar, außerhalb des Auswahl-Labels * (ein Knopf IM Label würde die Checkbox mit umschalten). */ function SprachChips({ name, vorhanden, gewaehlt, aufWechsel, }: { name: string vorhanden: readonly SprachEintrag[] gewaehlt: readonly string[] aufWechsel: (lang: string) => void }) { if (vorhanden.length === 0) return null return ( {name} {vorhanden.map((s) => { const an = gewaehlt.includes(s.lang) return ( ) })} ) } /** Spur-Zeilen eines Titels (5.7.0): jede Ton- bzw. Untertitelspur einzeln * anhaken, mit Codec und Kanälen, wie MakeMKV sie gemessen hat. Außerhalb * des Auswahl-Labels (ein Haken IM Label würde den Titel mit umschalten). */ function SpurZeilen({ name, art, vorhanden, gewaehlt, aufWechsel, }: { name: string art: 'audio' | 'untertitel' vorhanden: readonly SpurAngabe[] gewaehlt: readonly number[] aufWechsel: (nr: number) => void }) { const zeilen = vorhanden.filter((s) => angeboten(s, art)) if (zeilen.length === 0) return null return (
{name} {zeilen.map((s) => { const an = gewaehlt.includes(s.nr) return ( ) })}
) } /** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie * kern/ablauf/auftrag.ts spurwahl, 5.7.0): Spielt die erste gewählte * Tonspur nicht in der Wunschsprache, die erste gewählte Untertitelspur * der Wunschsprache — sonst nichts. */ function regelVorwahlSpur(audio: readonly SpurAngabe[], untertitel: readonly SpurAngabe[], wunsch: string): SpurAngabe | undefined { if (wunsch.length === 0 || audio.length === 0 || audio[0].lang.toLowerCase() === wunsch) return undefined return untertitel.find((s) => s.lang.toLowerCase() === wunsch) } /** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie * kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */ function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string { if (wunsch.length === 0 || audio.length === 0 || audio[0] === wunsch) return '' return untertitel.includes(wunsch) ? wunsch : '' } export function TitelWahlDialog({ id, typ, stand, info, einstellungen, regel, speicher, vorschlaege, schliessen, }: { id: string typ: GeraeteInfo['typ'] stand: TitelStand | undefined info: DiscInfoStand | undefined einstellungen: Record /** Klartext der Aufbewahrungs-Regel — damit klar ist, was mit dem * Roh-Material passiert. */ regel: string speicher: SpeicherStand | null /** Treffer der Metadaten-Suche (für „anderer Film …"). */ vorschlaege: MetaVorschlag[] schliessen: () => void }) { const [gewaehlt, setGewaehlt] = useState | null>(null) // Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys // Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra'). const [rollen, setRollen] = useState>({}) // Sprachen je Titel (5.4.0): Was hier nicht drinsteht, ist vorbelegt // (Einstellung, soweit der Titel sie hat — sonst alles, was er hat). const [sprachen, setSprachen] = useState>({}) // 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine; // sonst die Sprache. Immer optional, nie eingebrannt. const [vorwahl, setVorwahl] = useState>({}) // 5.7.0: Spuren je Titel EINZELN (MakeMKV-Stream-Nummern). Was hier nicht // drinsteht, ist vorbelegt: je Wunschsprache die erste Tonspur, alle // Untertitel der Wunschsprachen (gemeinsam/spuren.ts). Dazu die // Untertitel-Vorwahl je Extra als SPUR — fehlt: Regel; null: keine. const [spuren, setSpuren] = useState>({}) const [vorwahlSpur, setVorwahlSpur] = useState>({}) // 5.6.0: Titel, die zu einem anderen Film gehören. const [filme, setFilme] = useState>({}) const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null) // Staffel und erste Folge von Hand (5.4.0) — die Staffel kommt von der // Disc, die erste Folge vom Serien-Merker (5.5.0: die vorige Disc dieser // Serie) oder vom Nutzer („auf Disc 2 sind 3–4"). const merker = info?.serienMerker ?? null const merkerGilt = merker !== null && (info?.discNr === null || info === undefined || (info.discNr ?? 1) > 1) const [staffel, setStaffel] = useState( info !== undefined && info.staffel !== null ? String(info.staffel) : merker !== null ? String(merker.staffel) : '', ) const [ersteFolge, setErsteFolge] = useState(merkerGilt && merker !== null ? String(merker.naechsteFolge) : '') // Rippys Vorauswahl gilt, bis der Nutzer sie anfasst. const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr)) const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle const sprachenVon = (t: DiscTitelVorschlag): { audio: string[]; untertitel: string[] } => sprachen[t.nr] ?? { audio: sprachenVorbelegt(t.sprachen.audio, einstellungen['audioSprachen'] ?? ''), untertitel: sprachenVorbelegt(t.sprachen.untertitel, einstellungen['untertitelSprachen'] ?? ''), } const sprachWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', lang: string): void => { const jetzt = sprachenVon(t) const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang] setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } }) } const wunschListe = (schluessel: string): string[] => (einstellungen[schluessel] ?? '') .split(',') .map((s) => s.trim().toLowerCase()) .filter((s) => s.length > 0) /** Meldet dieser Titel einzelne Spuren? Sonst gelten die Sprach-Chips. */ const spurenDa = (t: DiscTitelVorschlag): boolean => t.spuren !== undefined && (t.spuren.audio.length > 0 || t.spuren.untertitel.length > 0) const spurenVon = (t: DiscTitelVorschlag): { audio: number[]; untertitel: number[] } => spuren[t.nr] ?? { audio: spurenVorbelegt(t.spuren?.audio ?? [], wunschListe('audioSprachen'), 'audio'), untertitel: spurenVorbelegt(t.spuren?.untertitel ?? [], wunschListe('untertitelSprachen'), 'untertitel'), } const spurWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', nr: number): void => { const jetzt = spurenVon(t) const liste = jetzt[art].includes(nr) ? jetzt[art].filter((n) => n !== nr) : [...jetzt[art], nr].sort((a, b) => a - b) setSpuren({ ...spuren, [t.nr]: { ...jetzt, [art]: liste } }) } /** Die gewählten Spuren als Angaben, in Disc-Reihenfolge. */ const gewaehlteSpuren = (t: DiscTitelVorschlag): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } => { const nrn = spurenVon(t) return { audio: (t.spuren?.audio ?? []).filter((s) => nrn.audio.includes(s.nr)), untertitel: (t.spuren?.untertitel ?? []).filter((s) => nrn.untertitel.includes(s.nr)), } } const umschalten = (nr: number): void => { const neu = new Set(aktiv) if (neu.has(nr)) neu.delete(nr) else neu.add(nr) setGewaehlt(neu) } const wunschSprache = (einstellungen['audioSprachen'] ?? '').split(',')[0]?.trim().toLowerCase() ?? '' const alle = stand?.titel ?? [] const gewaehlteTitel = alle.filter((t) => aktiv.has(t.nr)) const gesamtBytes = gewaehlteTitel.reduce((s, t) => s + t.groesseBytes, 0) const alsSerie = gewaehlteTitel.some((t) => rolleVon(t) === 'folge') const eigeneTitel = gewaehlteTitel.filter((t) => filme[t.nr] === undefined) const hauptTitel = eigeneTitel.filter((t) => rolleVon(t) !== 'extra').length const extras = eigeneTitel.filter((t) => rolleVon(t) === 'extra').length // Andere Filme (5.6.0): je Film die Zahl der Titel, für die Vorschau. const andereFilme = new Map() for (const t of gewaehlteTitel) { const f = filme[t.nr] if (f === undefined) continue const e = andereFilme.get(f.tmdbId) if (e !== undefined) e.titel += 1 else andereFilme.set(f.tmdbId, { film: f, titel: 1 }) } const zahlOderNull = (text: string, mindestens: number): number | null => { const n = Number(text.trim()) return text.trim().length > 0 && Number.isInteger(n) && n >= mindestens ? n : null } const staffelZahl = zahlOderNull(staffel, 0) const folgeZahl = zahlOderNull(ersteFolge, 1) const ablage = einstellungen['ablage'] ?? 'Videos\\Rippy' const vorschau = zielVorschau({ ablage, alsSerie, titel: info?.titel ?? '', jahr: info?.jahr ?? null, staffel: staffelZahl, ersteFolge: folgeZahl, hauptTitel, extras, vorgangsName: `${typ}-`, }) // Platz-Prüfung (5.5.0): gegen den Arbeitsordner — dort landen Roh und Encode. const komprimiert = einstellungen['transcodeEnabled'] !== 'false' const platz = speicher !== null && speicher.arbeit.ok ? platzUrteil(speicher.arbeit.freiBytes, gesamtBytes, komprimiert) : { text: '', hart: false } const filmTreffer = vorschlaege.filter((v) => v.typ === 'movie') return (

Was soll gerippt werden?

{stand === undefined || stand.laeuft ? (

Rippy liest die Titel der Disc … meist dauert das ein bis zwei Minuten. Solange dreht das Laufwerk.

{/* 5.7.0 (Kandidat 2): Ab der zweiten Minute sagt der Kern, wie lange es schon läuft und wo die Grenze liegt. */} {stand !== undefined && stand.einordnung.length > 0 &&

{stand.einordnung}

}
) : stand.fehler.length > 0 ? (

✗ {stand.fehler}

) : ( <>

{stand.unsicher ? '⚠ ' : ''} {stand.einordnung}

{alle.map((t) => { const an = aktiv.has(t.nr) const wahl = sprachenVon(t) const rolle = rolleVon(t) const film = filme[t.nr] const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache) const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel' const spurNrn = spurenVon(t) const spurWahl = gewaehlteSpuren(t) const regelSpur = regelVorwahlSpur(spurWahl.audio, spurWahl.untertitel, wunschSprache) const vorwahlSpurWert = t.nr in vorwahlSpur ? (vorwahlSpur[t.nr] === null ? 'keine' : String(vorwahlSpur[t.nr])) : 'regel' return (
{/* Spuren je Titel (5.7.0): jede Ton- und Untertitelspur, die der Info-Lauf an DIESEM Titel gemessen hat — einzeln anhaken, was in die Datei soll. */} {an && spurenDa(t) && (
spurWechsel(t, 'audio', nr)} /> spurWechsel(t, 'untertitel', nr)} /> {spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && ( keine Tonspur gewählt — HandBrake nimmt die erste Spur )} {/* 5.6.0/5.7.0: Vorwahl NUR bei Extras, IMMER optional — eine weiche Spur, die der Spieler von selbst einblendet; eingebrannt wird nie. Hier als SPUR. */} {rolle === 'extra' && spurWahl.untertitel.length > 0 && ( )}
)} {/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM Titel gemessen hat — anhaken, was in die Datei soll. Der Commander-Fall: japanischer Ton, deutsche Untertitel. Seit 5.7.0 nur noch der Rückfall ohne einzelne Spuren. */} {an && !spurenDa(t) && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
sprachWechsel(t, 'audio', lang)} /> sprachWechsel(t, 'untertitel', lang)} /> {wahl.audio.length === 0 && t.sprachen.audio.length > 0 && ( kein Ton gewählt — HandBrake nimmt die erste Spur )} {/* 5.6.0: Vorwahl NUR bei Extras, IMMER optional — eine weiche Spur, die der Spieler von selbst einblendet; eingebrannt wird nie. */} {rolle === 'extra' && wahl.untertitel.length > 0 && ( )}
)} {/* 5.6.0: „anderer Film …" — Double Feature, Trilogie-Disc, Bonus-Film: eigener Roh-Ordner, Vorgang und Zielordner. */} {an && rolle !== 'folge' && (
{film !== undefined ? ( <> → eigener Film: {film.titel} {film.jahr !== null ? ` (${film.jahr})` : ''} ) : ( )} {filmSuche?.nr === t.nr && (
setFilmSuche({ nr: t.nr, text: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') metadatenSuchen(id, filmSuche.text) }} placeholder="Filmtitel suchen …" className="flex-1 rounded-lg border border-tinte/12 bg-samt px-3 py-1.5 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none" />
{filmTreffer.length === 0 ? (

Noch keine Treffer — Titel eingeben und suchen (braucht den TMDb-Schlüssel).

) : (
{filmTreffer.map((v) => ( ))}
)}
)}
)}
) })} {alle.length === 0 &&

Die Disc gab keine Titel her.

}
{alsSerie && (

{merkerGilt && merker !== null ? `Serien-Merker: Die vorige Disc dieser Serie endete mit Folge ${merker.naechsteFolge - 1} (${datumKurz(merker.wann).slice(0, 10)}) — Rippy zählt ab ${merker.naechsteFolge} weiter. Stimmt das nicht, ändere die Zahl.` : 'Leer heißt: Rippy ordnet die Folgen über die TMDb-Laufzeiten zu, und nur, wenn es eindeutig ist. Weißt du es besser („auf Disc 2 sind die Folgen 3 und 4"), trag es hier ein — dann zählt Rippy fortlaufend in Disc-Reihenfolge.'} {info !== undefined && info.discNr !== null && ` Die Disc nennt sich selbst Disc ${info.discNr}.`}

)} {/* Vorschau (5.4.0): Rolle, Serie, Staffel und Extras entscheiden einiges — zwei Zeilen Zielpfad ersparen Überraschungen. 5.6.0: jeder weitere Film bekommt seine Zeile. */}

LANDET IN

{eigeneTitel.length > 0 && ( <>

{vorschau[0]}

{vorschau[1]}

)} {[...andereFilme.values()].map(({ film, titel }) => (

{`${ablage.replace(/[\\/]+$/, '')}\\Filme\\${sichererName(film.titel, film.jahr) || film.titel}\\`} {' '} — {titel} {titel === 1 ? 'Titel' : 'Titel'}, eigener Vorgang mit Poster und Infodatei

))}
{/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem vollen Laufwerk scheitern. */} {speicher !== null && speicher.arbeit.ok && (

0 ? 'text-warn' : 'text-nebel'}`}> {platz.text.length > 0 ? `${platz.hart ? '✗' : '⚠'} ${platz.text}` : `Platz: ${gbKurz(speicher.arbeit.freiBytes)} frei im Arbeitsordner — reicht für ${gb(gesamtBytes)} verlustfrei${komprimiert ? ' plus Kompression' : ''}.`}

)}

{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei {regel.length > 0 ? ` · ${regel}` : ''}

{alsSerie ? 'Folgen landen unter Serien/… /Season NN. Ohne Vorgabe setzt Rippy die Episoden-Nummern nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.' : 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}{' '} Das Laufwerk ist frei, sobald die Roh-Dateien liegen — die Kompression läuft danach im Hintergrund.

)}
) }