WAS: Der Auswahl-Dialog zeigt je Titel jede Ton- und Untertitelspur, die der Info-Lauf gemessen hat („German · DTS-HD MA · Surround 5.1", „German · DD · Stereo (Kommentar)", „German · PGS (nur erzwungene)"), vorbelegt wie bisher (je Wunschsprache die erste Tonspur, alle Untertitel der Wunsch- sprachen) — angehakt wird einzeln. Die Untertitel-Vorwahl je Extra ist jetzt eine Spur. Der Parser liest dazu SINFO-Attribute 2 (Anordnung), 22 (Flags) und markiert abgeleitete Spuren (Bit 0x800, an der Kizuna- Disc gemessen): DTS-Kerne werden nicht angeboten, „nur erzwungene"- Untertitel schon. Die Ablage scannt die Roh-Datei und findet die gewählten MakeMKV-Spuren gestuft wieder (Sprache + Codec-Familie + Kanäle → … → Sprache), HandBrake bekommt `--audio 1,3 --subtitle 2,1 --subtitle-default=1`; Fehlendes wird gemeldet, scheitert der Scan, gelten die Sprachen der gewählten Spuren als Liste. MakeMKVs Auswahl- regel lässt Stereo-Doppel jetzt stehen (nur -sel:havecore); Rippys alte Regel wird in der Registry ersetzt, eine fremde bleibt. Dazu F31 (Durchsicht): `--aencoder copy` ohne `--audio-copy-mask` verengt HandBrakeCLI 1.11.2 auf „copy:aac" — AC3/DTS/TrueHD wurden seit 5.0 in AAC umgerechnet. Beide Wege setzen die Maske jetzt ausdrücklich (gemessen 12.09.2026: AC3 bleibt AC3, Passthru-Zeile nennt alle Codecs). WARUM: Commander-Entscheid 12.09.2026 („Jede Spur einzeln anhaken") — bisher ließ sich je Sprache nur „alles oder nichts" wählen, und der Kommentar-Ton fehlte in der Roh-Datei (havemulti). Gemessen am mitgelieferten HandBrakeCLI mit ffmpeg-gebauter Quelle (test/messung.spuren.test.ts, RIPPY_MESSUNG=1): --audio 1,3 → jpn und deu-Kommentar, --subtitle 2,1 --subtitle-default=1 → eng (default) vor deu, --subtitle none → keine Spur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
664 lines
35 KiB
TypeScript
664 lines
35 KiB
TypeScript
// ── 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 (
|
||
<span className="inline-flex flex-wrap items-center gap-1">
|
||
<span className="mr-0.5 font-mono text-[10px] tracking-wider text-nebel">{name}</span>
|
||
{vorhanden.map((s) => {
|
||
const an = gewaehlt.includes(s.lang)
|
||
return (
|
||
<button
|
||
key={s.lang}
|
||
type="button"
|
||
onClick={() => aufWechsel(s.lang)}
|
||
className={`rounded-md border px-1.5 py-0.5 font-mono text-[10.5px] ${
|
||
an ? 'border-gold/50 bg-gold/15 text-gold-hell' : 'border-tinte/12 text-nebel hover:text-dunst'
|
||
}`}
|
||
title={`${s.sprache}${s.spuren > 1 ? ` (${s.spuren} Spuren)` : ''}`}
|
||
>
|
||
{an ? '✓ ' : ''}
|
||
{s.lang}
|
||
{s.spuren > 1 ? `×${s.spuren}` : ''}
|
||
</button>
|
||
)
|
||
})}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** 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 (
|
||
<div className="flex min-w-0 flex-col gap-0.5" data-spuren={art}>
|
||
<span className="font-mono text-[10px] tracking-wider text-nebel">{name}</span>
|
||
{zeilen.map((s) => {
|
||
const an = gewaehlt.includes(s.nr)
|
||
return (
|
||
<label
|
||
key={s.nr}
|
||
className={`inline-flex cursor-pointer items-center gap-1.5 font-mono text-[10.5px] ${an ? 'text-gold-hell' : 'text-nebel hover:text-dunst'}`}
|
||
title={s.beschreibung.length > 0 ? s.beschreibung : spurLabel(s)}
|
||
>
|
||
<input type="checkbox" checked={an} onChange={() => aufWechsel(s.nr)} className="accent-[oklch(0.83_0.11_75)]" />
|
||
{spurLabel(s)}
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 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<string, string>
|
||
/** 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<Set<number> | 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<Record<number, TitelRolle>>({})
|
||
// 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<Record<number, { audio: string[]; untertitel: string[] }>>({})
|
||
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
|
||
// sonst die Sprache. Immer optional, nie eingebrannt.
|
||
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
|
||
// 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<Record<number, { audio: number[]; untertitel: number[] }>>({})
|
||
const [vorwahlSpur, setVorwahlSpur] = useState<Record<number, number | null>>({})
|
||
// 5.6.0: Titel, die zu einem anderen Film gehören.
|
||
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
|
||
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<string>(
|
||
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<number, { film: MetaVorschlag; titel: number }>()
|
||
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}-<Zeitstempel>`,
|
||
})
|
||
// 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 (
|
||
<DialogRahmen schliessen={schliessen} breite="max-w-3xl">
|
||
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
||
|
||
{stand === undefined || stand.laeuft ? (
|
||
<div className="mt-4">
|
||
<p className="font-mono text-xs text-nebel">Rippy liest die Titel der Disc … meist dauert das ein bis zwei Minuten. Solange dreht das Laufwerk.</p>
|
||
{/* 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 && <p className="mt-2 font-mono text-xs text-warn">{stand.einordnung}</p>}
|
||
</div>
|
||
) : stand.fehler.length > 0 ? (
|
||
<p className="mt-4 font-mono text-xs text-rot">✗ {stand.fehler}</p>
|
||
) : (
|
||
<>
|
||
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
|
||
{stand.unsicher ? '⚠ ' : ''}
|
||
{stand.einordnung}
|
||
</p>
|
||
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
<button
|
||
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
|
||
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
|
||
>
|
||
Rippys Vorschlag
|
||
</button>
|
||
<button
|
||
onClick={() =>
|
||
setGewaehlt(
|
||
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
|
||
// Sammeltitel noch Logos/Warnungen.
|
||
new Set(alle.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz')).map((t) => t.nr)),
|
||
)
|
||
}
|
||
className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
|
||
>
|
||
Hauptinhalt + Extras
|
||
</button>
|
||
<button onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
|
||
Alles
|
||
</button>
|
||
<button onClick={() => setGewaehlt(new Set())} className="rounded-lg border border-tinte/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee">
|
||
Nichts
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-1">
|
||
{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 (
|
||
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
|
||
<label className="flex cursor-pointer items-center gap-3 p-2">
|
||
<input type="checkbox" checked={an} onChange={() => umschalten(t.nr)} className="accent-[oklch(0.83_0.11_75)]" />
|
||
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
|
||
<span className="w-24 flex-none font-mono text-xs text-schnee">{dauerText(t.dauerS)}</span>
|
||
<span className="w-20 flex-none font-mono text-[11px] text-dunst">{gb(t.groesseBytes)}</span>
|
||
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">{t.kapitel} Kap.</span>
|
||
{an ? (
|
||
<select
|
||
value={rolle}
|
||
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="flex-none rounded-lg border border-tinte/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
|
||
title="Bestimmt, WOHIN der Titel abgelegt wird"
|
||
>
|
||
<option value="hauptfilm">Hauptfilm</option>
|
||
<option value="extra">Extra</option>
|
||
<option value="folge">Folge</option>
|
||
</select>
|
||
) : (
|
||
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
||
)}
|
||
</label>
|
||
{/* 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) && (
|
||
<div className="flex flex-wrap items-start gap-x-6 gap-y-1.5 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||
<SpurZeilen name="TON" art="audio" vorhanden={t.spuren.audio} gewaehlt={spurNrn.audio} aufWechsel={(nr) => spurWechsel(t, 'audio', nr)} />
|
||
<SpurZeilen
|
||
name="UNTERTITEL"
|
||
art="untertitel"
|
||
vorhanden={t.spuren.untertitel}
|
||
gewaehlt={spurNrn.untertitel}
|
||
aufWechsel={(nr) => spurWechsel(t, 'untertitel', nr)}
|
||
/>
|
||
{spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && (
|
||
<span className="font-mono text-[10.5px] text-warn">keine Tonspur gewählt — HandBrake nimmt die erste Spur</span>
|
||
)}
|
||
{/* 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 && (
|
||
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
|
||
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
|
||
<select
|
||
value={vorwahlSpurWert}
|
||
onChange={(e) => {
|
||
const w = e.target.value
|
||
const neu = { ...vorwahlSpur }
|
||
if (w === 'regel') delete neu[t.nr]
|
||
else neu[t.nr] = w === 'keine' ? null : Number(w)
|
||
setVorwahlSpur(neu)
|
||
}}
|
||
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
|
||
>
|
||
<option value="regel">{regelSpur !== undefined ? `Rippys Regel (${spurLabel(regelSpur)})` : 'Rippys Regel (keine)'}</option>
|
||
<option value="keine">keine</option>
|
||
{spurWahl.untertitel.map((u) => (
|
||
<option key={u.nr} value={String(u.nr)}>
|
||
{spurLabel(u)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* 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) && (
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
|
||
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
|
||
{wahl.audio.length === 0 && t.sprachen.audio.length > 0 && (
|
||
<span className="font-mono text-[10.5px] text-warn">kein Ton gewählt — HandBrake nimmt die erste Spur</span>
|
||
)}
|
||
{/* 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 && (
|
||
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
|
||
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
|
||
<select
|
||
value={vorwahlWert}
|
||
onChange={(e) => {
|
||
const w = e.target.value
|
||
const neu = { ...vorwahl }
|
||
if (w === 'regel') delete neu[t.nr]
|
||
else neu[t.nr] = w === 'keine' ? null : w
|
||
setVorwahl(neu)
|
||
}}
|
||
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
|
||
>
|
||
<option value="regel">{regelWahl.length > 0 ? `Rippys Regel (${regelWahl})` : 'Rippys Regel (keine)'}</option>
|
||
<option value="keine">keine</option>
|
||
{wahl.untertitel.map((u) => (
|
||
<option key={u} value={u}>
|
||
{u}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* 5.6.0: „anderer Film …" — Double Feature, Trilogie-Disc,
|
||
Bonus-Film: eigener Roh-Ordner, Vorgang und Zielordner. */}
|
||
{an && rolle !== 'folge' && (
|
||
<div className="flex flex-wrap items-center gap-2 border-t border-tinte/8 px-2 py-1.5 pl-9">
|
||
{film !== undefined ? (
|
||
<>
|
||
<span className="inline-flex items-center gap-1.5 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
|
||
→ eigener Film: {film.titel}
|
||
{film.jahr !== null ? ` (${film.jahr})` : ''}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const neu = { ...filme }
|
||
delete neu[t.nr]
|
||
setFilme(neu)
|
||
}}
|
||
className="ml-1 text-nebel hover:text-rot"
|
||
title="wieder zur Disc-Zuordnung"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
</>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setFilmSuche(filmSuche?.nr === t.nr ? null : { nr: t.nr, text: '' })}
|
||
className="rounded-md border border-tinte/15 px-2 py-0.5 text-[11px] text-nebel hover:text-schnee"
|
||
title="Dieser Titel ist ein anderer Film (Double Feature, Bonus-Film): eigener Ordner mit Poster und Infodatei"
|
||
>
|
||
anderer Film …
|
||
</button>
|
||
)}
|
||
{filmSuche?.nr === t.nr && (
|
||
<div className="w-full rounded-lg border border-tinte/10 bg-buehne/70 p-2">
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={filmSuche.text}
|
||
autoFocus
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => metadatenSuchen(id, filmSuche.text)}
|
||
className="rounded-lg border border-gold/50 bg-gold/10 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/20"
|
||
>
|
||
Suchen
|
||
</button>
|
||
</div>
|
||
{filmTreffer.length === 0 ? (
|
||
<p className="mt-2 font-mono text-[10.5px] text-nebel">Noch keine Treffer — Titel eingeben und suchen (braucht den TMDb-Schlüssel).</p>
|
||
) : (
|
||
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
|
||
{filmTreffer.map((v) => (
|
||
<button
|
||
key={v.tmdbId}
|
||
type="button"
|
||
onClick={() => {
|
||
setFilme({ ...filme, [t.nr]: v })
|
||
setFilmSuche(null)
|
||
}}
|
||
className="flex w-full items-center gap-2 rounded-lg border border-tinte/10 bg-karte/60 p-1.5 text-left hover:border-gold/50"
|
||
>
|
||
{v.posterPfad.length > 0 ? (
|
||
<img src={posterUrlKlein(v.posterPfad)} alt="" className="h-10 w-7 rounded object-cover" />
|
||
) : (
|
||
<span className="flex h-10 w-7 items-center justify-center rounded bg-samt">
|
||
<IconDisc groesse={14} farbe="oklch(0.55 0.015 80)" />
|
||
</span>
|
||
)}
|
||
<span className="text-xs text-schnee">
|
||
{v.titel}
|
||
{v.jahr !== null ? ` (${v.jahr})` : ''}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
{alle.length === 0 && <p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>}
|
||
</div>
|
||
|
||
{alsSerie && (
|
||
<div className="mt-3 flex flex-wrap items-end gap-3 rounded-xl border border-tinte/8 bg-samt/40 p-3">
|
||
<label className="block">
|
||
<span className="font-mono text-[10px] tracking-wider text-nebel">STAFFEL</span>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={staffel}
|
||
onChange={(e) => setStaffel(e.target.value)}
|
||
placeholder={info !== undefined && info.staffel !== null ? String(info.staffel) : '1'}
|
||
className="mt-1 block w-20 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="font-mono text-[10px] tracking-wider text-nebel">AB FOLGE</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
value={ersteFolge}
|
||
onChange={(e) => setErsteFolge(e.target.value)}
|
||
placeholder="automatisch"
|
||
className="mt-1 block w-28 rounded-lg border border-tinte/12 bg-buehne px-2 py-1 font-mono text-xs text-schnee"
|
||
/>
|
||
</label>
|
||
<p className="min-w-[200px] flex-1 text-[11px] leading-4 text-nebel">
|
||
{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}.`}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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. */}
|
||
<div className="mt-3 rounded-xl border border-tinte/8 bg-buehne/70 p-3 font-mono text-[11px] leading-5">
|
||
<p className="text-[10px] tracking-wider text-nebel">LANDET IN</p>
|
||
{eigeneTitel.length > 0 && (
|
||
<>
|
||
<p className="break-all text-gold-hell">{vorschau[0]}</p>
|
||
<p className="text-dunst">{vorschau[1]}</p>
|
||
</>
|
||
)}
|
||
{[...andereFilme.values()].map(({ film, titel }) => (
|
||
<p key={film.tmdbId} className="break-all text-gold-hell">
|
||
{`${ablage.replace(/[\\/]+$/, '')}\\Filme\\${sichererName(film.titel, film.jahr) || film.titel}\\`}
|
||
<span className="text-dunst">
|
||
{' '}
|
||
— {titel} {titel === 1 ? 'Titel' : 'Titel'}, eigener Vorgang mit Poster und Infodatei
|
||
</span>
|
||
</p>
|
||
))}
|
||
</div>
|
||
|
||
{/* Platz-Prüfung (5.5.0): 40 Minuten Lesen sollen nicht an einem
|
||
vollen Laufwerk scheitern. */}
|
||
{speicher !== null && speicher.arbeit.ok && (
|
||
<p className={`mt-2 font-mono text-[11px] ${platz.hart ? 'text-rot' : platz.text.length > 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' : ''}.`}
|
||
</p>
|
||
)}
|
||
|
||
<p className="mt-3 font-mono text-[11px] text-nebel">
|
||
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} verlustfrei
|
||
{regel.length > 0 ? ` · ${regel}` : ''}
|
||
</p>
|
||
<p className="mt-1 text-[11px] text-nebel">
|
||
{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.
|
||
</p>
|
||
</>
|
||
)}
|
||
|
||
<div className="mt-5 flex justify-between gap-2">
|
||
<button
|
||
onClick={() => {
|
||
// Läuft der Titel-Lauf noch, beendet „Abbrechen" ihn auch im Kern —
|
||
// sonst dreht das Laufwerk weiter, obwohl der Dialog zu ist.
|
||
if (stand !== undefined && stand.laeuft) titelAbbrechen(id)
|
||
schliessen()
|
||
}}
|
||
className="rounded-lg border border-tinte/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
ripStarten(
|
||
id,
|
||
gewaehlteTitel.map((t) => {
|
||
const wahl = sprachenVon(t)
|
||
const rolle = rolleVon(t)
|
||
// 5.7.0: Spuren einzeln — die Sprachlisten reisen abgeleitet
|
||
// mit (Rückfall des Kerns, wenn der Scan der Roh-Datei
|
||
// scheitert; und die alten Hinweise bleiben stimmig).
|
||
if (spurenDa(t)) {
|
||
const spurWahl = gewaehlteSpuren(t)
|
||
return {
|
||
nr: t.nr,
|
||
rolle,
|
||
dauerS: t.dauerS,
|
||
groesseBytes: t.groesseBytes,
|
||
audioSprachen: sprachenDerSpuren(spurWahl.audio),
|
||
untertitelSprachen: sprachenDerSpuren(spurWahl.untertitel),
|
||
audioSpuren: spurWahl.audio,
|
||
untertitelSpuren: spurWahl.untertitel,
|
||
...(rolle === 'extra' && t.nr in vorwahlSpur ? { untertitelVorwahlSpur: vorwahlSpur[t.nr] } : {}),
|
||
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
|
||
}
|
||
}
|
||
return {
|
||
nr: t.nr,
|
||
rolle,
|
||
dauerS: t.dauerS,
|
||
groesseBytes: t.groesseBytes,
|
||
audioSprachen: wahl.audio,
|
||
untertitelSprachen: wahl.untertitel,
|
||
...(rolle === 'extra' && t.nr in vorwahl ? { untertitelVorwahl: vorwahl[t.nr] } : {}),
|
||
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
|
||
}
|
||
}),
|
||
{ staffel: alsSerie ? staffelZahl : null, ersteFolge: alsSerie ? folgeZahl : null },
|
||
)
|
||
schliessen()
|
||
}}
|
||
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft || platz.hart}
|
||
className={KNOPF_GOLD}
|
||
title={platz.hart ? platz.text : ''}
|
||
>
|
||
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
|
||
</button>
|
||
</div>
|
||
</DialogRahmen>
|
||
)
|
||
}
|