feat(v5): Titel auswaehlen statt immer alles — und ein Defekt kostet nicht mehr alles
Ampel / ampel (push) Successful in 1m34s
Ampel / ampel (push) Successful in 1m34s
Commander (01.09.2026): "es fehlt generell noch komplett die auswahl WAS
man rippen moechte. Bei Serien eben genau das die folgen, bei filmen z.B.
nur das hauptfeature oder auch die extras."
Bis hierher uebergab die Pipeline fest `titel: 'all'` — Rippy rippte
IMMER alles, samt Trailer, Werbung und Alterskennzeichen. Beide Wuensche
brauchen denselben Umbau, deshalb in einem Zug:
TITEL FUER TITEL statt einem 'all'-Aufruf. Nur so laesst sich auswaehlen,
und nur so ueberlebt der Rest, wenn EIN Titel an einem Disc-Defekt haengt.
Was schon da war und nur nicht verbunden: titelInfoLesen() (Titel mit
Dauer/Groesse/Kapiteln) rief niemand auf, und buildRipArgs() konnte
laengst einen einzelnen Titel. Dazu kennt Rippy seit heute das
Inhaltsverzeichnis der Disc (EP 1, EP 2, DUB GER 1, 104 GER FSK).
Neu kern/rip/titelwahl.ts (pur, ohne Laufwerk):
* Serie (von der Disc BEZEUGT): so viele Titel wie das Inhaltsverzeichnis
Folgen nennt; passen sie nicht zusammen, sagt Rippy das.
* Film: der laengste Titel.
* Zwei Fallen mit Tests abgesichert:
- SAMMELTITEL: Ein Titel, der so lang ist wie die anderen zusammen
("alle Folgen am Stueck"), waere der laengste und wuerde die
Film-Auswahl gewinnen — Rippy wuerde alles doppelt sichern.
- ANGEL/Seamless Branching: Mehrere fast gleich lange Fassungen. Rippy
nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist.
* Unklar heisst: nichts vorausgewaehlt. Lieber fragen als raten.
Der Defekt-Waechter:
* offsetAusMeldung() liest die Stelle aus MakeMKVs Meldung — als LETZTE
gequotete Zahl, damit es unabhaengig von MakeMKVs Sprache bleibt
(deutsch "bei Offset", englisch "at offset").
* RipAuswertung zaehlt Wiederholungen derselben Stelle; ab HAENGT_AB gilt
der Lauf als haengend. Fortschritt raeumt den Verdacht wieder ab.
* Das Fenster zeigt dann Klartext plus "Titel ueberspringen" — der
laufende makemkvcon wird beendet, der naechste Titel laeuft weiter.
Gemessen am lebenden Objekt (Spartacus Disc 2, 01.09.2026): MakeMKV
meldete eine halbe Stunde denselben Offset 56881152, makemkvcon
verbrauchte dabei 1,7 s CPU (es wartete), und Windows protokollierte 41
fehlerhafte Bloecke in 10 Minuten. Der Defekt lag in Folge 1 — Folge 2
lag heil daneben und ging trotzdem verloren.
17 neue Tests in test/titelwahl.test.ts, alle beim ersten Lauf gruen.
254 Tests gesamt (vorher 237), Typpruefung sauber. Version 5.2.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1253148652
commit
a8e8b2bef3
@@ -8,7 +8,7 @@
|
||||
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4
|
||||
// (Fehler stehen im Fenster — bei Systemfehlern als rote Karte).
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
||||
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus, TitelStand } from '../gemeinsam/nachrichten'
|
||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||
import {
|
||||
aufVerbindung,
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
metadatenSuchen,
|
||||
ripAbbrechen,
|
||||
ripStarten,
|
||||
ripUeberspringen,
|
||||
schluesselPruefen,
|
||||
titelLesen,
|
||||
verbindungStarten,
|
||||
zuordnungSetzen,
|
||||
type Verbindung,
|
||||
@@ -352,6 +354,141 @@ function AendernDialog({
|
||||
|
||||
// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
|
||||
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
|
||||
|
||||
// ── 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.
|
||||
function dauerText(s: number): string {
|
||||
const m = Math.floor(s / 60)
|
||||
return m >= 60
|
||||
? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`
|
||||
: `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min`
|
||||
}
|
||||
|
||||
function TitelWahlDialog({
|
||||
id,
|
||||
stand,
|
||||
schliessen,
|
||||
}: {
|
||||
id: string
|
||||
stand: TitelStand | undefined
|
||||
schliessen: () => void
|
||||
}) {
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
|
||||
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
|
||||
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.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 alle = stand?.titel ?? []
|
||||
const gesamtBytes = alle.filter((t) => aktiv.has(t.nr)).reduce((s, t) => s + t.groesseBytes, 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
|
||||
onClick={schliessen}
|
||||
>
|
||||
<div
|
||||
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
|
||||
|
||||
{stand === undefined || stand.laeuft ? (
|
||||
<p className="mt-4 font-mono text-xs text-nebel">
|
||||
Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk.
|
||||
</p>
|
||||
) : 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(new Set(alle.map((t) => t.nr)))}
|
||||
className="rounded-lg border border-white/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-white/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) => (
|
||||
<label
|
||||
key={t.nr}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-2 ${
|
||||
aktiv.has(t.nr) ? 'border-gold/40 bg-gold/8' : 'border-white/8 bg-samt/40'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aktiv.has(t.nr)}
|
||||
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>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
||||
</label>
|
||||
))}
|
||||
{alle.length === 0 && (
|
||||
<p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 font-mono text-[11px] text-nebel">
|
||||
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-between gap-2">
|
||||
<button
|
||||
onClick={schliessen}
|
||||
className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
ripStarten(id, [...aktiv])
|
||||
schliessen()
|
||||
}}
|
||||
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft}
|
||||
className="rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4)] hover:brightness-105 disabled:opacity-35"
|
||||
>
|
||||
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LaufwerkKachel({
|
||||
geraet,
|
||||
rip,
|
||||
@@ -360,6 +497,7 @@ function LaufwerkKachel({
|
||||
ripStartMs,
|
||||
protokoll,
|
||||
verbindung,
|
||||
titelStand,
|
||||
}: {
|
||||
geraet: GeraeteInfo
|
||||
rip: RipStatus | undefined
|
||||
@@ -368,8 +506,10 @@ function LaufwerkKachel({
|
||||
ripStartMs: number | undefined
|
||||
protokoll: string[]
|
||||
verbindung: Verbindung
|
||||
titelStand: TitelStand | undefined
|
||||
}) {
|
||||
const [aendernOffen, setAendernOffen] = useState(false)
|
||||
const [titelOffen, setTitelOffen] = useState(false)
|
||||
const [protokollOffen, setProtokollOffen] = useState(false)
|
||||
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
||||
const discDa = geraet.status === 'ready'
|
||||
@@ -416,10 +556,14 @@ function LaufwerkKachel({
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => ripStarten(geraet.id)}
|
||||
onClick={() => {
|
||||
// Erst die Titel lesen (20-120 s), dann waehlen lassen.
|
||||
setTitelOffen(true)
|
||||
titelLesen(geraet.id)
|
||||
}}
|
||||
disabled={!discDa}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
|
||||
title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
|
||||
title="Titel der Disc lesen und auswaehlen — danach verlustfrei rippen"
|
||||
>
|
||||
<IconPlay />
|
||||
Rippen
|
||||
@@ -550,6 +694,41 @@ function LaufwerkKachel({
|
||||
)}
|
||||
</div>
|
||||
{ripLaeuft && <SegmentBalken prozent={rip.prozent} />}
|
||||
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
|
||||
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
|
||||
Titel {rip.titelNr} von {rip.titelGesamt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Der Defekt-Waechter (§ 6.2): Dieselbe Stelle wieder und
|
||||
wieder ist kein Fortschritt, sondern ein physischer
|
||||
Defekt. Vorher lief MakeMKV dort stundenlang weiter,
|
||||
waehrend identische Zeilen durchs Protokoll rauschten. */}
|
||||
{rip.haengt != null && (
|
||||
<div className="mt-3 rounded-xl border border-warn/45 bg-warn/10 p-3">
|
||||
<p className="font-mono text-[11px] font-semibold text-warn">
|
||||
Die Disc hat an einer Stelle einen Defekt.
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-[11px] leading-5 text-dunst">
|
||||
MakeMKV versucht es seit {rip.haengt.wiederholungen} Meldungen immer wieder an
|
||||
derselben Stelle (Byte {rip.haengt.offset}). Das wird nicht besser — der Rest der
|
||||
Disc ist davon nicht betroffen.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => ripUeberspringen(geraet.id)}
|
||||
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
|
||||
title="Diesen Titel aufgeben und mit dem naechsten weitermachen"
|
||||
>
|
||||
Titel ueberspringen
|
||||
</button>
|
||||
<span className="font-mono text-[10.5px] text-nebel">
|
||||
oder weiter versuchen — abbrechen geht oben.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
|
||||
> {rip.text}
|
||||
</p>
|
||||
@@ -587,6 +766,10 @@ function LaufwerkKachel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{titelOffen && (
|
||||
<TitelWahlDialog id={geraet.id} stand={titelStand} schliessen={() => setTitelOffen(false)} />
|
||||
)}
|
||||
|
||||
{aendernOffen && (
|
||||
<AendernDialog
|
||||
id={geraet.id}
|
||||
@@ -1488,6 +1671,7 @@ export default function App() {
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
discTitel: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
@@ -1690,6 +1874,7 @@ export default function App() {
|
||||
ripStartMs={verbindung.ripStart[geraet.id]}
|
||||
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
|
||||
verbindung={verbindung}
|
||||
titelStand={verbindung.discTitel[geraet.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,300 +1,319 @@
|
||||
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
|
||||
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
|
||||
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
|
||||
//
|
||||
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
|
||||
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
|
||||
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import {
|
||||
istKernNachricht,
|
||||
type BeschaffungStand,
|
||||
type BibliothekEintrag,
|
||||
type DiscInfoStand,
|
||||
type GeraeteInfo,
|
||||
type KernNachricht,
|
||||
type MetaVorschlag,
|
||||
type RipStatus,
|
||||
type WerkzeugAuskunft,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface Verbindung {
|
||||
verbunden: boolean
|
||||
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
|
||||
pongLatenzMs: number | null
|
||||
laufwerke: GeraeteInfo[]
|
||||
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
|
||||
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
|
||||
rips: Record<string, RipStatus>
|
||||
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
|
||||
discInfos: Record<string, DiscInfoStand>
|
||||
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
||||
vorschlaege: Record<string, MetaVorschlag[]>
|
||||
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||
ripStart: Record<string, number>
|
||||
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||||
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
|
||||
* Vorgang beginnt. */
|
||||
ripProtokoll: Record<string, string[]>
|
||||
einstellungen: Record<string, string>
|
||||
werkzeuge: WerkzeugAuskunft | null
|
||||
bibliothek: BibliothekEintrag[]
|
||||
schluessel: {
|
||||
quelle: string
|
||||
angenommen: boolean | null
|
||||
weiterBetrieb: boolean
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
version: string
|
||||
} | null
|
||||
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
|
||||
beschaffung: BeschaffungStand | null
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
|
||||
type Horcher = (stand: Verbindung) => void
|
||||
|
||||
const MAX_MELDUNGEN = 20
|
||||
|
||||
const stand: Verbindung = {
|
||||
verbunden: false,
|
||||
kern: null,
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
schluessel: null,
|
||||
beschaffung: null,
|
||||
meldungen: [],
|
||||
}
|
||||
const horcher = new Set<Horcher>()
|
||||
let gestartet = false
|
||||
let port: MessagePort | null = null
|
||||
|
||||
function melden(): void {
|
||||
for (const h of horcher) {
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function meldung(text: string): void {
|
||||
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
|
||||
}
|
||||
|
||||
function kernNachricht(nachricht: KernNachricht): void {
|
||||
switch (nachricht.art) {
|
||||
case 'kern-bereit':
|
||||
stand.kern = nachricht
|
||||
break
|
||||
case 'pong':
|
||||
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
|
||||
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
|
||||
window.rippy.melden('kern-pong')
|
||||
break
|
||||
case 'kern-fehler':
|
||||
meldung(`⚠ ${nachricht.text}`)
|
||||
break
|
||||
case 'laufwerke':
|
||||
stand.laufwerke = nachricht.liste
|
||||
for (const ereignis of nachricht.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
|
||||
} else if (ereignis.art === 'laufwerk-weg') {
|
||||
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'auswurf-ergebnis': {
|
||||
const text =
|
||||
nachricht.grund === 'ok'
|
||||
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
|
||||
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}`
|
||||
meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`)
|
||||
break
|
||||
}
|
||||
case 'disc-info': {
|
||||
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
|
||||
if (nachricht.info.titel.length > 0) {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'metadaten-vorschlaege':
|
||||
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
|
||||
break
|
||||
case 'einstellungen':
|
||||
stand.einstellungen = nachricht.werte
|
||||
break
|
||||
case 'werkzeuge':
|
||||
stand.werkzeuge = nachricht.auskunft
|
||||
break
|
||||
case 'bibliothek':
|
||||
stand.bibliothek = nachricht.eintraege
|
||||
break
|
||||
case 'schluessel-status':
|
||||
stand.schluessel = nachricht.stand
|
||||
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'beschaffung-status':
|
||||
stand.beschaffung = nachricht.stand
|
||||
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
||||
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
|
||||
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
|
||||
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
|
||||
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
|
||||
} else if (!laeuft) {
|
||||
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
|
||||
stand.ripStart = rest
|
||||
}
|
||||
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
|
||||
// wiederholen den Text — nur echte Änderungen zählen).
|
||||
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
|
||||
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
|
||||
stand.ripProtokoll = {
|
||||
...stand.ripProtokoll,
|
||||
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
|
||||
}
|
||||
}
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
||||
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
|
||||
)
|
||||
} else if (nachricht.status.phase === 'fehler') {
|
||||
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
|
||||
} else if (nachricht.status.phase === 'abgebrochen') {
|
||||
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
melden()
|
||||
}
|
||||
|
||||
function portAnnehmen(neuerPort: MessagePort): void {
|
||||
port = neuerPort
|
||||
stand.verbunden = true
|
||||
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||||
const nachricht: unknown = ereignis.data
|
||||
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
|
||||
}
|
||||
neuerPort.start()
|
||||
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
|
||||
neuerPort.postMessage({ art: 'einstellungen-laden' })
|
||||
neuerPort.postMessage({ art: 'werkzeuge-laden' })
|
||||
neuerPort.postMessage({ art: 'bibliothek-laden' })
|
||||
melden()
|
||||
}
|
||||
|
||||
/** Idempotent — der erste Aufruf registriert den Horcher und meldet der
|
||||
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
|
||||
export function verbindungStarten(): void {
|
||||
if (gestartet) return
|
||||
gestartet = true
|
||||
window.addEventListener('message', (ereignis: MessageEvent) => {
|
||||
const daten: unknown = ereignis.data
|
||||
if (
|
||||
typeof daten === 'object' &&
|
||||
daten !== null &&
|
||||
(daten as Record<string, unknown>).art === 'kern-port' &&
|
||||
ereignis.ports.length > 0
|
||||
) {
|
||||
portAnnehmen(ereignis.ports[0])
|
||||
}
|
||||
})
|
||||
window.rippy.bereit()
|
||||
}
|
||||
|
||||
export function aufVerbindung(h: Horcher): () => void {
|
||||
horcher.add(h)
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage(nachricht)
|
||||
}
|
||||
|
||||
export function auswerfen(id: string): void {
|
||||
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
||||
}
|
||||
|
||||
export function ripStarten(id: string): void {
|
||||
anKern({ art: 'rip-start', id }, `Rip ${id}`)
|
||||
}
|
||||
|
||||
export function ripAbbrechen(id: string): void {
|
||||
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
|
||||
}
|
||||
|
||||
export function metadatenSuchen(id: string, text: string): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'metadaten-suchen', id, text })
|
||||
}
|
||||
|
||||
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
|
||||
}
|
||||
|
||||
export function makemkvUpdate(): void {
|
||||
if (port === null) {
|
||||
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'makemkv-update' })
|
||||
}
|
||||
|
||||
export function schluesselPruefen(): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'schluessel-pruefen' })
|
||||
}
|
||||
|
||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
|
||||
}
|
||||
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
|
||||
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
|
||||
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
|
||||
//
|
||||
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
|
||||
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
|
||||
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import {
|
||||
istKernNachricht,
|
||||
type BeschaffungStand,
|
||||
type BibliothekEintrag,
|
||||
type DiscInfoStand,
|
||||
type GeraeteInfo,
|
||||
type KernNachricht,
|
||||
type MetaVorschlag,
|
||||
type TitelStand,
|
||||
type RipStatus,
|
||||
type WerkzeugAuskunft,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface Verbindung {
|
||||
verbunden: boolean
|
||||
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
|
||||
pongLatenzMs: number | null
|
||||
laufwerke: GeraeteInfo[]
|
||||
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
|
||||
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
|
||||
rips: Record<string, RipStatus>
|
||||
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
|
||||
discInfos: Record<string, DiscInfoStand>
|
||||
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
|
||||
vorschlaege: Record<string, MetaVorschlag[]>
|
||||
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
|
||||
discTitel: Record<string, TitelStand>
|
||||
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
|
||||
ripStart: Record<string, number>
|
||||
/** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare
|
||||
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
|
||||
* Vorgang beginnt. */
|
||||
ripProtokoll: Record<string, string[]>
|
||||
einstellungen: Record<string, string>
|
||||
werkzeuge: WerkzeugAuskunft | null
|
||||
bibliothek: BibliothekEintrag[]
|
||||
schluessel: {
|
||||
quelle: string
|
||||
angenommen: boolean | null
|
||||
weiterBetrieb: boolean
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
version: string
|
||||
} | null
|
||||
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
|
||||
beschaffung: BeschaffungStand | null
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
|
||||
type Horcher = (stand: Verbindung) => void
|
||||
|
||||
const MAX_MELDUNGEN = 20
|
||||
|
||||
const stand: Verbindung = {
|
||||
verbunden: false,
|
||||
kern: null,
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
discTitel: {},
|
||||
vorschlaege: {},
|
||||
ripStart: {},
|
||||
ripProtokoll: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
schluessel: null,
|
||||
beschaffung: null,
|
||||
meldungen: [],
|
||||
}
|
||||
const horcher = new Set<Horcher>()
|
||||
let gestartet = false
|
||||
let port: MessagePort | null = null
|
||||
|
||||
function melden(): void {
|
||||
for (const h of horcher) {
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function meldung(text: string): void {
|
||||
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
|
||||
}
|
||||
|
||||
function kernNachricht(nachricht: KernNachricht): void {
|
||||
switch (nachricht.art) {
|
||||
case 'kern-bereit':
|
||||
stand.kern = nachricht
|
||||
break
|
||||
case 'pong':
|
||||
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
|
||||
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
|
||||
window.rippy.melden('kern-pong')
|
||||
break
|
||||
case 'kern-fehler':
|
||||
meldung(`⚠ ${nachricht.text}`)
|
||||
break
|
||||
case 'laufwerke':
|
||||
stand.laufwerke = nachricht.liste
|
||||
for (const ereignis of nachricht.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
|
||||
} else if (ereignis.art === 'laufwerk-weg') {
|
||||
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'auswurf-ergebnis': {
|
||||
const text =
|
||||
nachricht.grund === 'ok'
|
||||
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
|
||||
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}`
|
||||
meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`)
|
||||
break
|
||||
}
|
||||
case 'disc-info': {
|
||||
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
|
||||
if (nachricht.info.titel.length > 0) {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'disc-titel':
|
||||
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
|
||||
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
|
||||
break
|
||||
case 'metadaten-vorschlaege':
|
||||
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
|
||||
break
|
||||
case 'einstellungen':
|
||||
stand.einstellungen = nachricht.werte
|
||||
break
|
||||
case 'werkzeuge':
|
||||
stand.werkzeuge = nachricht.auskunft
|
||||
break
|
||||
case 'bibliothek':
|
||||
stand.bibliothek = nachricht.eintraege
|
||||
break
|
||||
case 'schluessel-status':
|
||||
stand.schluessel = nachricht.stand
|
||||
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'beschaffung-status':
|
||||
stand.beschaffung = nachricht.stand
|
||||
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
|
||||
break
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
|
||||
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
|
||||
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
|
||||
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
|
||||
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
|
||||
} else if (!laeuft) {
|
||||
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
|
||||
stand.ripStart = rest
|
||||
}
|
||||
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
|
||||
// wiederholen den Text — nur echte Änderungen zählen).
|
||||
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
|
||||
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
|
||||
stand.ripProtokoll = {
|
||||
...stand.ripProtokoll,
|
||||
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
|
||||
}
|
||||
}
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
|
||||
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
|
||||
)
|
||||
} else if (nachricht.status.phase === 'fehler') {
|
||||
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
|
||||
} else if (nachricht.status.phase === 'abgebrochen') {
|
||||
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
melden()
|
||||
}
|
||||
|
||||
function portAnnehmen(neuerPort: MessagePort): void {
|
||||
port = neuerPort
|
||||
stand.verbunden = true
|
||||
neuerPort.onmessage = (ereignis: MessageEvent) => {
|
||||
const nachricht: unknown = ereignis.data
|
||||
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
|
||||
}
|
||||
neuerPort.start()
|
||||
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
|
||||
neuerPort.postMessage({ art: 'einstellungen-laden' })
|
||||
neuerPort.postMessage({ art: 'werkzeuge-laden' })
|
||||
neuerPort.postMessage({ art: 'bibliothek-laden' })
|
||||
melden()
|
||||
}
|
||||
|
||||
/** Idempotent — der erste Aufruf registriert den Horcher und meldet der
|
||||
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
|
||||
export function verbindungStarten(): void {
|
||||
if (gestartet) return
|
||||
gestartet = true
|
||||
window.addEventListener('message', (ereignis: MessageEvent) => {
|
||||
const daten: unknown = ereignis.data
|
||||
if (
|
||||
typeof daten === 'object' &&
|
||||
daten !== null &&
|
||||
(daten as Record<string, unknown>).art === 'kern-port' &&
|
||||
ereignis.ports.length > 0
|
||||
) {
|
||||
portAnnehmen(ereignis.ports[0])
|
||||
}
|
||||
})
|
||||
window.rippy.bereit()
|
||||
}
|
||||
|
||||
export function aufVerbindung(h: Horcher): () => void {
|
||||
horcher.add(h)
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
vorschlaege: { ...stand.vorschlaege },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
function anKern(nachricht: { art: string; id: string; titel?: number[] }, wasWar: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage(nachricht)
|
||||
}
|
||||
|
||||
export function auswerfen(id: string): void {
|
||||
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
|
||||
}
|
||||
|
||||
/** Den Info-Lauf anstoßen (20–120 s) — erst auf Zuruf, nie beim Einlegen. */
|
||||
export function titelLesen(id: string): void {
|
||||
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
|
||||
}
|
||||
|
||||
/** `titel` leer heißt: alles rippen. */
|
||||
export function ripStarten(id: string, titel: number[] = []): void {
|
||||
anKern({ art: 'rip-start', id, titel }, `Rip ${id}`)
|
||||
}
|
||||
|
||||
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
|
||||
export function ripUeberspringen(id: string): void {
|
||||
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`)
|
||||
}
|
||||
|
||||
export function ripAbbrechen(id: string): void {
|
||||
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
|
||||
}
|
||||
|
||||
export function metadatenSuchen(id: string, text: string): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'metadaten-suchen', id, text })
|
||||
}
|
||||
|
||||
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
|
||||
}
|
||||
|
||||
export function makemkvUpdate(): void {
|
||||
if (port === null) {
|
||||
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'makemkv-update' })
|
||||
}
|
||||
|
||||
export function schluesselPruefen(): void {
|
||||
if (port === null) return
|
||||
port.postMessage({ art: 'schluessel-pruefen' })
|
||||
}
|
||||
|
||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
|
||||
}
|
||||
|
||||
@@ -57,6 +57,40 @@ export interface RipStatus {
|
||||
text: string
|
||||
dateien: string[]
|
||||
lesefehler: boolean
|
||||
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
|
||||
* insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
|
||||
* und in Fehler-Meldungen; die kennen keine Titel. */
|
||||
titelNr?: number
|
||||
titelGesamt?: number
|
||||
/** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer
|
||||
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
|
||||
haengt?: { offset: string; wiederholungen: number } | null
|
||||
}
|
||||
|
||||
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
|
||||
export interface DiscTitelVorschlag {
|
||||
nr: number
|
||||
dauerS: number
|
||||
groesseBytes: number
|
||||
kapitel: number
|
||||
/** Von Rippy vorausgewählt. */
|
||||
gewaehlt: boolean
|
||||
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
|
||||
grund: string
|
||||
}
|
||||
|
||||
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
|
||||
* deshalb trägt der Stand ein eigenes `laeuft` — das Fenster steht in der
|
||||
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
|
||||
export interface TitelStand {
|
||||
id: string
|
||||
laeuft: boolean
|
||||
titel: DiscTitelVorschlag[]
|
||||
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
|
||||
einordnung: string
|
||||
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
|
||||
unsicher: boolean
|
||||
fehler: string
|
||||
}
|
||||
|
||||
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
||||
@@ -136,6 +170,7 @@ export type KernNachricht =
|
||||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||
| { art: 'rip-status'; status: RipStatus }
|
||||
| { art: 'disc-info'; info: DiscInfoStand }
|
||||
| { art: 'disc-titel'; stand: TitelStand }
|
||||
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
|
||||
| { art: 'einstellungen'; werte: Record<string, string> }
|
||||
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
|
||||
@@ -163,8 +198,16 @@ export type KernNachricht =
|
||||
export type FensterNachricht =
|
||||
| { art: 'ping'; zeit: number }
|
||||
| { art: 'auswerfen'; id: string }
|
||||
| { art: 'rip-start'; id: string }
|
||||
/** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 s, deshalb erst auf Zuruf
|
||||
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||||
* sehr sehr lange"). */
|
||||
| { art: 'titel-lesen'; id: string }
|
||||
/** `titel` leer heißt: alles rippen — wie vor 5.2. */
|
||||
| { art: 'rip-start'; id: string; titel?: number[] }
|
||||
| { art: 'rip-abbruch'; id: string }
|
||||
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
|
||||
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
|
||||
| { art: 'rip-ueberspringen'; id: string }
|
||||
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
|
||||
| { art: 'metadaten-suchen'; id: string; text: string }
|
||||
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
|
||||
@@ -250,6 +293,13 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
||||
typeof (wert.info as Record<string, unknown>).confidence === 'number'
|
||||
)
|
||||
case 'disc-titel':
|
||||
return (
|
||||
istObjekt(wert.stand) &&
|
||||
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
|
||||
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
|
||||
Array.isArray((wert.stand as Record<string, unknown>).titel)
|
||||
)
|
||||
case 'einstellungen':
|
||||
return istObjekt(wert.werte)
|
||||
case 'werkzeuge':
|
||||
|
||||
@@ -27,7 +27,12 @@ import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
/** Beendet den GANZEN Auftrag. */
|
||||
abbruch: AbortController
|
||||
/** Beendet nur den gerade laufenden Titel (überspringen). */
|
||||
titelAbbruch?: AbortController
|
||||
/** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */
|
||||
uebersprungen?: boolean
|
||||
status: RipStatus
|
||||
}
|
||||
|
||||
@@ -86,8 +91,19 @@ export class Pipeline {
|
||||
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
||||
}
|
||||
|
||||
/** Startet die Kette für die ganze Disc ('all'). */
|
||||
async starten(id: string): Promise<void> {
|
||||
/** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten
|
||||
* weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026:
|
||||
* MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1,
|
||||
* während Folge 2 heil daneben lag). */
|
||||
ueberspringen(id: string): void {
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang === undefined || vorgang.titelAbbruch === undefined) return
|
||||
vorgang.uebersprungen = true
|
||||
vorgang.titelAbbruch.abort()
|
||||
}
|
||||
|
||||
/** Startet die Kette. `titelWahl` leer heißt: alles rippen (wie früher). */
|
||||
async starten(id: string, titelWahl: readonly number[] = []): Promise<void> {
|
||||
if (this.laufend.has(id)) {
|
||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||
return
|
||||
@@ -143,48 +159,134 @@ export class Pipeline {
|
||||
}
|
||||
|
||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||
mkdirSync(rohOrdner, { recursive: true })
|
||||
|
||||
const start: RipStatus = {
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rippe verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
}
|
||||
this.laufend.set(id, { abbruch, status: start })
|
||||
this.melden(start)
|
||||
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
|
||||
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
|
||||
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
|
||||
// Trailer, Werbung und Alterskennzeichen.
|
||||
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
||||
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
||||
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
|
||||
const auftrag = titelWahl.length > 0 ? titelWahl.map(String) : ['all']
|
||||
const alleDateien = new Set<string>()
|
||||
let lesefehlerGesamt = false
|
||||
const gescheitert: string[] = []
|
||||
const uebersprungen: string[] = []
|
||||
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: 'all',
|
||||
abbruch: abbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
this.laufend.set(id, {
|
||||
abbruch,
|
||||
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false },
|
||||
})
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||
return
|
||||
for (let i = 0; i < auftrag.length; i++) {
|
||||
const welcher = auftrag[i]
|
||||
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
|
||||
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
|
||||
const titelAbbruch = new AbortController()
|
||||
const weiterreichen = (): void => titelAbbruch.abort()
|
||||
abbruch.signal.addEventListener('abort', weiterreichen, { once: true })
|
||||
const vorgang = this.laufend.get(id)
|
||||
if (vorgang !== undefined) {
|
||||
vorgang.titelAbbruch = titelAbbruch
|
||||
vorgang.uebersprungen = false
|
||||
}
|
||||
|
||||
const beschriftung =
|
||||
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${auftrag.length})`
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
titelNr: i + 1,
|
||||
titelGesamt: auftrag.length,
|
||||
haengt: null,
|
||||
})
|
||||
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: welcher,
|
||||
abbruch: titelAbbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
aufHaengt: (klemmt) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
|
||||
},
|
||||
})
|
||||
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
// Nur dieser Titel — oder der ganze Auftrag?
|
||||
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'abgebrochen',
|
||||
prozent: -1,
|
||||
text: 'Abgebrochen.',
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
})
|
||||
return
|
||||
}
|
||||
uebersprungen.push(beschriftung)
|
||||
continue
|
||||
}
|
||||
|
||||
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
|
||||
if (rip.status === 'error') {
|
||||
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
|
||||
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||
continue
|
||||
}
|
||||
// rippen() liefert den ganzen Zielordner zurück, also auch die
|
||||
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
|
||||
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
|
||||
for (const d of rip.dateien) alleDateien.add(d)
|
||||
if (neu.length === 0) {
|
||||
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
|
||||
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
|
||||
gescheitert.push(`${beschriftung}: keine Datei entstanden`)
|
||||
}
|
||||
}
|
||||
if (rip.status === 'error') {
|
||||
|
||||
const nachLauf = this.laufend.get(id)
|
||||
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
|
||||
|
||||
if (alleDateien.size === 0) {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fehler',
|
||||
prozent: -1,
|
||||
text: gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.',
|
||||
dateien: [],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const lesefehlerHinweis = rip.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: ''
|
||||
const rip = { dateien: [...alleDateien].sort(), lesefehler: lesefehlerGesamt }
|
||||
|
||||
const lesefehlerHinweis =
|
||||
(rip.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: '') +
|
||||
(uebersprungen.length > 0 ? ` ⚠ Übersprungen: ${uebersprungen.join(', ')}.` : '') +
|
||||
(gescheitert.length > 0 ? ` ⚠ Nicht gesichert: ${gescheitert.join(' · ')}` : '')
|
||||
|
||||
// ── Kompression (W-3) ────────────────────────────────────────────────
|
||||
const einstellungen = this.umgebung.einstellungen()
|
||||
|
||||
@@ -32,6 +32,8 @@ import { TmdbClient } from './metadaten/tmdb'
|
||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||
import { Datenbank } from './speicher/db'
|
||||
import { titelInfoLesen } from './rip/makemkv'
|
||||
import { titelVorauswahl } from './rip/titelwahl'
|
||||
import { finden } from './werkzeuge/katalog'
|
||||
|
||||
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
||||
@@ -278,6 +280,53 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
||||
// ── Rippen (W-2) ────────────────────────────────────────────────────────
|
||||
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
|
||||
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
|
||||
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
|
||||
// Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und
|
||||
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
|
||||
// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der
|
||||
// Zeit nicht stumm dasteht (R2).
|
||||
async function titelLesen(id: string): Promise<void> {
|
||||
const leer = { id, titel: [], einordnung: '', unsicher: false }
|
||||
if (ripVerwaltung.laeuft(id)) {
|
||||
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' } })
|
||||
return
|
||||
}
|
||||
const geraet = geraetVon(id)
|
||||
if (geraet === null || laufwerkApi === null) {
|
||||
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } })
|
||||
return
|
||||
}
|
||||
const geraetepfad = geraeteInfo(laufwerkApi, geraet).pfad
|
||||
const programm = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
if (programm.length === 0) {
|
||||
anFenster({
|
||||
art: 'disc-titel',
|
||||
stand: { ...leer, laeuft: false, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' },
|
||||
})
|
||||
return
|
||||
}
|
||||
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: true, fehler: '' } })
|
||||
try {
|
||||
const info = await titelInfoLesen(programm, geraetepfad)
|
||||
const merkmale = discMerkmale.get(id)
|
||||
const wahl = titelVorauswahl(info.titel, {
|
||||
istSerie: merkmale?.istSerie ?? false,
|
||||
folgen: merkmale?.folgen ?? 0,
|
||||
})
|
||||
anFenster({
|
||||
art: 'disc-titel',
|
||||
stand: { id, laeuft: false, titel: wahl.titel, einordnung: wahl.einordnung, unsicher: wahl.unsicher, fehler: '' },
|
||||
})
|
||||
} catch (fehler) {
|
||||
// R4: laut scheitern. „Konnte nicht nachsehen" ist etwas anderes als
|
||||
// „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
|
||||
anFenster({
|
||||
art: 'disc-titel',
|
||||
stand: { ...leer, laeuft: false, fehler: `Titel lesen: ${String(fehler).slice(0, 200)}` },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function ablageOrdner(): string {
|
||||
const eingestellt = db?.einstellungLesen('ablage')
|
||||
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
|
||||
@@ -581,10 +630,14 @@ process.parentPort.on('message', (ereignis) => {
|
||||
anFenster({ art: 'pong', zeit: frage.zeit })
|
||||
} else if (frage.art === 'auswerfen') {
|
||||
void auswerfen(frage.id)
|
||||
} else if (frage.art === 'titel-lesen') {
|
||||
void titelLesen(frage.id)
|
||||
} else if (frage.art === 'rip-start') {
|
||||
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
|
||||
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.titel ?? []))
|
||||
} else if (frage.art === 'rip-abbruch') {
|
||||
ripVerwaltung.abbrechen(frage.id)
|
||||
} else if (frage.art === 'rip-ueberspringen') {
|
||||
ripVerwaltung.ueberspringen(frage.id)
|
||||
} else if (frage.art === 'metadaten-suchen') {
|
||||
void metadatenSuchen(frage.id, frage.text)
|
||||
} else if (frage.art === 'zuordnung-setzen') {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
KRITISCHE_CODES,
|
||||
KRITISCHE_TEXTE,
|
||||
LESEFEHLER_MARKE,
|
||||
offsetAusMeldung,
|
||||
parseMsg,
|
||||
parseStreamInfo,
|
||||
parseTitelInfo,
|
||||
@@ -38,18 +39,37 @@ export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: str
|
||||
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
|
||||
}
|
||||
|
||||
/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
|
||||
* Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
|
||||
* zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer
|
||||
* von einem echten Defekt zu unterscheiden. */
|
||||
export const HAENGT_AB = 12
|
||||
|
||||
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
|
||||
export class RipAuswertung {
|
||||
prozent = -1
|
||||
letzteMeldung = ''
|
||||
lesefehler = false
|
||||
kritischeMeldungen: string[] = []
|
||||
/** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
|
||||
haengtBei = ''
|
||||
/** Wie oft dieselbe Stelle schon gemeldet wurde. */
|
||||
haengtWiederholungen = 0
|
||||
|
||||
/** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
|
||||
* der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */
|
||||
haengt(): boolean {
|
||||
return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
|
||||
}
|
||||
|
||||
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
|
||||
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
|
||||
const p = progressAusPrgv(zeile)
|
||||
if (p >= 0) {
|
||||
if (p === this.prozent) return {}
|
||||
// Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
|
||||
this.haengtBei = ''
|
||||
this.haengtWiederholungen = 0
|
||||
this.prozent = p
|
||||
return { prozent: p }
|
||||
}
|
||||
@@ -57,6 +77,16 @@ export class RipAuswertung {
|
||||
if (meldung === null) return {}
|
||||
this.letzteMeldung = meldung.text
|
||||
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
|
||||
// Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
|
||||
const stelle = offsetAusMeldung(meldung.text)
|
||||
if (stelle.length > 0) {
|
||||
if (stelle === this.haengtBei) {
|
||||
this.haengtWiederholungen += 1
|
||||
} else {
|
||||
this.haengtBei = stelle
|
||||
this.haengtWiederholungen = 1
|
||||
}
|
||||
}
|
||||
if (meldung.code in KRITISCHE_CODES) {
|
||||
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
|
||||
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
|
||||
@@ -154,6 +184,9 @@ export interface RipLaufOptionen {
|
||||
titel: string
|
||||
aufFortschritt?: (prozent: number) => void
|
||||
aufMeldung?: (code: number, text: string) => void
|
||||
/** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) —
|
||||
* das ist ein Disc-Defekt, kein Fortschrittsproblem. */
|
||||
aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void
|
||||
abbruch?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -179,12 +212,22 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
|
||||
}
|
||||
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
|
||||
|
||||
// Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder
|
||||
// Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster.
|
||||
let klemmteVorher = false
|
||||
const zeile = (z: string): void => {
|
||||
const ergebnis = auswertung.verarbeite(z)
|
||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
||||
if (ergebnis.meldung !== undefined) {
|
||||
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
|
||||
}
|
||||
const klemmt = auswertung.haengt()
|
||||
if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) {
|
||||
klemmteVorher = klemmt
|
||||
optionen.aufHaengt?.(
|
||||
klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
|
||||
@@ -249,6 +249,26 @@ export function episodenTitel(dauern: Map<number, number>, episodenMinuten: numb
|
||||
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
|
||||
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
|
||||
|
||||
/** Die STELLE, an der ein Lesefehler auftrat. MakeMKV hängt sie ans Ende
|
||||
* der Meldung, in jeder Sprache als letzte Zahl in Anführungszeichen:
|
||||
*
|
||||
* … während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'
|
||||
* … while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'
|
||||
*
|
||||
* Deshalb wird die letzte gequotete Zahl gelesen und nicht das Wort davor
|
||||
* — das wäre von der MakeMKV-Sprache abhängig. '' wenn keine dasteht.
|
||||
*
|
||||
* Wozu (01.09.2026, an der Spartacus-Disc gesehen): Wiederholt sich
|
||||
* DIESELBE Stelle, ist das kein Fortschritt, sondern ein physischer
|
||||
* Defekt — MakeMKV versucht es dann stundenlang, das Laufwerk hämmert,
|
||||
* und Windows protokolliert einen fehlerhaften Block nach dem anderen. */
|
||||
export function offsetAusMeldung(text: string): string {
|
||||
// Mindestens zwei Ziffern: Titel- und Kapitelnummern stehen ebenfalls in
|
||||
// Anführungszeichen, ein Offset ist immer groß.
|
||||
const treffer = /'(\d{2,})'\s*$/.exec(text.trim())
|
||||
return treffer === null ? '' : treffer[1]
|
||||
}
|
||||
|
||||
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
|
||||
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
|
||||
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Welche Titel einer Disc will man wirklich? — PUR, ohne Laufwerk, damit
|
||||
// die Ampel das auf Linux prüfen kann.
|
||||
//
|
||||
// Anlass (01.09.2026, Commander): „es fehlt generell noch komplett die
|
||||
// auswahl WAS man rippen möchte. Bei Serien eben genau das die folgen,
|
||||
// bei filmen z. B. nur das hauptfeature oder auch die extras."
|
||||
//
|
||||
// Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte
|
||||
// IMMER alles, inklusive Trailer, Werbung und Alterskennzeichen.
|
||||
//
|
||||
// Diese Datei RÄT NICHT INS BLAUE. Sie rechnet mit dem, was messbar ist
|
||||
// (Dauer, Größe, Kapitelzahl) und mit dem, was die Disc selbst bezeugt
|
||||
// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts).
|
||||
// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und
|
||||
// zu schweigen.
|
||||
import type { TitelInfo } from './parser'
|
||||
|
||||
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine
|
||||
* Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
|
||||
* standardmäßig für seine eigene Mindestlänge. */
|
||||
export const KURZ_S = 120
|
||||
|
||||
/** Folgen einer Staffel sind ähnlich lang — 45 bis 55 Minuten sind eine
|
||||
* Serie, 50 Minuten und 3 Minuten sind es nicht. */
|
||||
export const AEHNLICH = 0.15
|
||||
|
||||
/** Mehrere Titel dicht am längsten heißen fast immer „Seamless Branching"
|
||||
* bzw. Kamerawinkel: dieselbe Fassung in Varianten. Dann ist die Wahl
|
||||
* nicht eindeutig, und Rippy sagt das. */
|
||||
export const FILM_GLEICHAUF = 0.05
|
||||
|
||||
/** Ein Titel, der ungefähr so lang ist wie die anderen ZUSAMMEN, ist ein
|
||||
* Sammeltitel („Alle Folgen am Stück"). Der ist der längste und würde
|
||||
* eine Film-Auswahl sonst gewinnen — und dann rippt Rippy alles doppelt. */
|
||||
export const SAMMEL_TOLERANZ = 0.1
|
||||
|
||||
export interface TitelVorschlag extends TitelInfo {
|
||||
/** Von Rippy vorausgewählt? */
|
||||
gewaehlt: boolean
|
||||
/** Warum — im Klartext, das Fenster zeigt es an. */
|
||||
grund: string
|
||||
}
|
||||
|
||||
export interface TitelWahl {
|
||||
titel: TitelVorschlag[]
|
||||
/** Einordnung fürs Fenster, z. B. „Serie: 2 Folgen erwartet, 2 gefunden". */
|
||||
einordnung: string
|
||||
/** Rippy ist sich NICHT sicher — das Fenster fragt dann deutlicher nach. */
|
||||
unsicher: boolean
|
||||
}
|
||||
|
||||
/** Was die Disc über sich selbst bezeugt (discmerkmale.discTitel()). */
|
||||
export interface DiscHinweis {
|
||||
istSerie: boolean
|
||||
/** Folgen laut bdmt-Inhaltsverzeichnis; 0 wenn es keines gibt. */
|
||||
folgen: number
|
||||
}
|
||||
|
||||
function dauerText(s: number): string {
|
||||
const m = Math.round(s / 60)
|
||||
return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
|
||||
}
|
||||
|
||||
/** Ist dieser Titel ungefähr so lang wie die kürzeren zusammen? */
|
||||
function istSammeltitel(kandidat: TitelInfo, alle: readonly TitelInfo[]): boolean {
|
||||
const kuerzere = alle.filter((t) => t.nr !== kandidat.nr && t.dauerS >= KURZ_S && t.dauerS < kandidat.dauerS)
|
||||
// Mit weniger als zwei Teilen gibt es nichts zu sammeln.
|
||||
if (kuerzere.length < 2) return false
|
||||
const summe = kuerzere.reduce((s, t) => s + t.dauerS, 0)
|
||||
if (summe <= 0) return false
|
||||
return Math.abs(kandidat.dauerS - summe) / kandidat.dauerS <= SAMMEL_TOLERANZ
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Vorauswahl. Sie wählt VOR — entschieden wird im Fenster.
|
||||
*
|
||||
* Drei Fälle:
|
||||
* * Serie (von der Disc bezeugt): die längsten Titel, so viele wie das
|
||||
* Inhaltsverzeichnis Folgen nennt.
|
||||
* * Film: der längste Titel, der kein Sammeltitel ist.
|
||||
* * Unklar: nichts vorausgewählt — lieber fragen als raten.
|
||||
*/
|
||||
export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis): TitelWahl {
|
||||
if (alle.length === 0) {
|
||||
return { titel: [], einordnung: 'Keine Titel gefunden — die Disc gab nichts her.', unsicher: true }
|
||||
}
|
||||
|
||||
const sammel = new Set(alle.filter((t) => istSammeltitel(t, alle)).map((t) => t.nr))
|
||||
const kandidaten = alle
|
||||
.filter((t) => t.dauerS >= KURZ_S && !sammel.has(t.nr))
|
||||
.sort((a, b) => b.dauerS - a.dauerS)
|
||||
|
||||
const gewaehlt = new Set<number>()
|
||||
let einordnung: string
|
||||
let unsicher = false
|
||||
|
||||
if (kandidaten.length === 0) {
|
||||
einordnung = `Kein Titel über ${Math.round(KURZ_S / 60)} Minuten — hier ist nichts, was nach Hauptinhalt aussieht.`
|
||||
unsicher = true
|
||||
} else if (hinweis.istSerie && hinweis.folgen >= 2) {
|
||||
const folgen = kandidaten.slice(0, hinweis.folgen)
|
||||
for (const t of folgen) gewaehlt.add(t.nr)
|
||||
const laengste = folgen[0].dauerS
|
||||
const passen = folgen.every((t) => Math.abs(t.dauerS - laengste) / laengste <= AEHNLICH)
|
||||
if (folgen.length < hinweis.folgen) {
|
||||
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, nur ${folgen.length} gefunden — bitte nachsehen.`
|
||||
unsicher = true
|
||||
} else if (!passen) {
|
||||
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, aber die gefundenen sind unterschiedlich lang — bitte nachsehen.`
|
||||
unsicher = true
|
||||
} else {
|
||||
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, ${folgen.length} gefunden — passt.`
|
||||
}
|
||||
} else {
|
||||
const laengster = kandidaten[0]
|
||||
const gleichauf = kandidaten.filter(
|
||||
(t) => Math.abs(t.dauerS - laengster.dauerS) / laengster.dauerS <= FILM_GLEICHAUF,
|
||||
)
|
||||
if (gleichauf.length > 1) {
|
||||
// Mehrere Fassungen derselben Länge: die mit den meisten Kapiteln ist
|
||||
// erfahrungsgemäß die Hauptfassung — sicher ist das nicht.
|
||||
const beste = [...gleichauf].sort((a, b) => b.kapitel - a.kapitel || a.nr - b.nr)[0]
|
||||
gewaehlt.add(beste.nr)
|
||||
einordnung = `${gleichauf.length} Titel sind fast gleich lang (${dauerText(laengster.dauerS)}) — Rippy hat den mit den meisten Kapiteln gewählt. Bitte nachsehen.`
|
||||
unsicher = true
|
||||
} else {
|
||||
gewaehlt.add(laengster.nr)
|
||||
einordnung = `Film: ein Hauptinhalt von ${dauerText(laengster.dauerS)} gefunden.`
|
||||
}
|
||||
}
|
||||
|
||||
const titel: TitelVorschlag[] = alle.map((t) => ({
|
||||
...t,
|
||||
gewaehlt: gewaehlt.has(t.nr),
|
||||
grund: gewaehlt.has(t.nr)
|
||||
? hinweis.istSerie && hinweis.folgen >= 2
|
||||
? 'Folge'
|
||||
: 'Hauptinhalt'
|
||||
: sammel.has(t.nr)
|
||||
? 'Sammeltitel — enthält vermutlich alles am Stück'
|
||||
: t.dauerS < KURZ_S
|
||||
? 'zu kurz — Logo, Warnung oder Menü'
|
||||
: 'Zugabe',
|
||||
}))
|
||||
|
||||
return { titel, einordnung, unsicher }
|
||||
}
|
||||
Reference in New Issue
Block a user