diff --git a/rippy-windows/package-lock.json b/rippy-windows/package-lock.json index 0a174cf..13d3f9e 100644 --- a/rippy-windows/package-lock.json +++ b/rippy-windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "rippy", - "version": "5.0.0-w0", + "version": "5.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rippy", - "version": "5.0.0-w0", + "version": "5.1.1", "license": "UNLICENSED", "dependencies": { "electron-updater": "^6.8.9", @@ -2229,6 +2229,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", diff --git a/rippy-windows/package.json b/rippy-windows/package.json index fd92739..18c867c 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -1,7 +1,7 @@ { "name": "rippy", "productName": "Rippy", - "version": "5.1.0", + "version": "5.1.1", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "main": "out/haupt/index.js", "author": "KrBrZ", diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index 7fcea57..f33261c 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -90,6 +90,18 @@ function sicherheitsWort(confidence: number): string { return 'unsicher' } +/** 'Staffel 1 · Disc 2 · 2 Folgen' — was die Disc über ihre Lage in der + * Ausgabe sagt. Die Folgenzahl stammt aus ihrem eigenen + * Inhaltsverzeichnis, ist also gemessen und nicht geraten. '' wenn die + * Disc nichts davon verrät. */ +function discHerkunft(info: DiscInfoStand): string { + const teile: string[] = [] + if (info.staffel !== null) teile.push(`Staffel ${info.staffel}`) + if (info.discNr !== null) teile.push(`Disc ${info.discNr}`) + if (info.folgen > 0) teile.push(info.folgen === 1 ? '1 Folge' : `${info.folgen} Folgen`) + return teile.join(' · ') +} + /** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern * (eigene Wahl → allgemeines Preset → Empfehlung → Default). */ function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string { @@ -446,6 +458,9 @@ function LaufwerkKachel({ ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} /> )} + {info !== undefined && discHerkunft(info).length > 0 && ( + + )} {info !== undefined && info.schonGerippt !== null && ( )} diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts index c84d818..d76ee4b 100644 --- a/rippy-windows/src/gemeinsam/nachrichten.ts +++ b/rippy-windows/src/gemeinsam/nachrichten.ts @@ -75,6 +75,13 @@ export interface DiscInfoStand { /** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am * Fingerabdruck wieder (§ 6.6); null wenn neu. */ schonGerippt: string | null + /** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */ + discNr: number | null + /** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */ + staffel: number | null + /** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines + * gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */ + folgen: number } /** Ein Eintrag der Bibliothek (§ 6.6). */ diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index 97c6f86..9c224bf 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -26,7 +26,7 @@ import { listOptischeLaufwerke, } from './laufwerk/disc' import { DiscWache } from './laufwerk/wache' -import { discFingerprint, discTitel } from './metadaten/discmerkmale' +import { discFingerprint, discTitel, type DiscTitel } from './metadaten/discmerkmale' import { OmdbClient } from './metadaten/omdb' import { TmdbClient } from './metadaten/tmdb' import { zuordnen, type Zuordnung } from './metadaten/zuordnung' @@ -94,15 +94,21 @@ let wache: DiscWache | null = null // ── Metadaten (W-4): Was liegt da drin? ───────────────────────────────── const discZuordnungen = new Map() const discFingerprints = new Map() +// Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl) — +// das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt. +const discMerkmale = new Map() async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise { try { const gefunden = discTitel(geraetepfad) const fingerprint = discFingerprint(geraetepfad, groesseBytes) discFingerprints.set(id, fingerprint) + discMerkmale.set(id, gefunden) const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '') - const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb) + // Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) — + // trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. + const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, { istSerie: gefunden.istSerie }) discZuordnungen.set(id, zuordnung) anHauptUndFenster({ art: 'disc-info', @@ -118,6 +124,9 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb // § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal // durchgelaufen ist — und fragt nach, statt stumm zu doppeln. schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null, + discNr: gefunden.discNr, + staffel: gefunden.staffel, + folgen: gefunden.folgen, }, }) } catch (fehler) { @@ -203,6 +212,10 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise = (async () => { } else if (ereignis.art === 'disc-entnommen') { discZuordnungen.delete(ereignis.geraet.id) discFingerprints.delete(ereignis.geraet.id) + discMerkmale.delete(ereignis.geraet.id) anHauptUndFenster({ art: 'disc-info', info: { @@ -239,6 +253,9 @@ const laufwerkeBereit: Promise = (async () => { backdropPfad: '', beschreibung: '', schonGerippt: null, + discNr: null, + staffel: null, + folgen: 0, }, }) } diff --git a/rippy-windows/src/kern/metadaten/discmerkmale.ts b/rippy-windows/src/kern/metadaten/discmerkmale.ts index 8d3ca3a..707f54b 100644 --- a/rippy-windows/src/kern/metadaten/discmerkmale.ts +++ b/rippy-windows/src/kern/metadaten/discmerkmale.ts @@ -105,29 +105,118 @@ export function udfVolumeLabel(geraetepfad: string): string { } } -/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — bdmt_eng bevorzugt, - * Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */ -export function titelAusBdmt(wurzel: string): string { +/** Was in BDMV/META/DL/bdmt_*.xml steht. Bis zum 01.09.2026 nahm Rippy von + * dieser Datei NUR den Titel — das Inhaltsverzeichnis daneben ist der + * stärkere Hinweis und lag ungelesen da. An SPARTACUS_GOTA_D2 gemessen: + * + * Spartacus: Gods Of The Arena - Disc 2 + * EP 1 + * EP 2 + * DUB GER 1 … usw. + * + * Die Disc sagt damit selbst, dass sie eine SERIE mit zwei Folgen trägt. + * Das ist ein Beweis, keine Vermutung — und schlägt jede Titel-Raterei. */ +export interface BdmtInhalt { + /** — der Studio-Titel im Klartext; '' wenn keiner dasteht. */ + titel: string + /** — was die Disc trägt: Folgen, Tonspuren, Trailer. */ + eintraege: string[] +} + +/** Liest die bdmt-Datei EINMAL — bdmt_eng bevorzugt. Regex statt + * XML-Parser (Namespaces variieren je Authoring-Werkzeug). */ +export function inhaltAusBdmt(wurzel: string): BdmtInhalt { + // Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine + // Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer. + const leer: BdmtInhalt = { titel: '', eintraege: [] } const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL') - if (!existsSync(metaOrdner)) return '' + if (!existsSync(metaOrdner)) return leer let kandidaten: string[] try { kandidaten = readdirSync(metaOrdner).sort() } catch { - return '' + return leer } const bdmt = kandidaten.find((k) => k === 'bdmt_eng.xml') ?? kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml')) - if (bdmt === undefined) return '' + if (bdmt === undefined) return leer let inhalt: string try { inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8') } catch { - return '' + return leer } - const treffer = /([^<]{2,120})<\/di:name>/.exec(inhalt) - return treffer === null ? '' : treffer[1].trim() + const name = /([^<]{2,120})<\/di:name>/.exec(inhalt) + const eintraege: string[] = [] + for (const t of inhalt.matchAll(/]*>([^<]{1,120})<\/di:titleName>/g)) { + const text = t[1].trim() + if (text.length > 0) eintraege.push(text) + } + return { titel: name === null ? '' : name[1].trim(), eintraege } +} + +/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */ +export function titelAusBdmt(wurzel: string): string { + return inhaltAusBdmt(wurzel).titel +} + +/** Sieht dieser Inhaltseintrag aus wie eine Folge? Bewusst ENG: An der + * echten Disc stehen neben `EP 1`/`EP 2` auch `DUB GER 1`, `MM ITA` und + * `101 LOGO` — Tonspuren, Trailer und Warnhinweise sind keine Folgen. */ +export function istFolgenName(name: string): boolean { + return /^(?:ep|episode|folge)\s*0*\d{1,3}$/i.test(name.trim()) +} + +/** Wie viele Einträge des Inhaltsverzeichnisses sind Folgen? */ +export function folgenZahl(eintraege: readonly string[]): number { + return eintraege.filter(istFolgenName).length +} + +// Verpackungs-Zusätze am ENDE eines Disc-Titels. Was hier NICHT steht, +// steht mit Absicht nicht hier: +// * `Teil`/`Part` — „Kill Bill Teil 2" ist ein Filmtitel, keine Disc-Nummer. +// * eine nackte Zahl — sonst würde aus „Rocky 2" die „Rocky", Disc 2. +// Die Ein-Buchstaben-Formen `d`/`s` verlangen einen TRENNER davor: ohne ihn +// würde „Ocean's 11" an der Apostroph-Wortgrenze zu „Ocean'", Staffel 11. +const TRENNER = String.raw`[\s\-–—_:,.]` +const DISC_MUSTER = new RegExp(`${TRENNER}+(?:disc|disk|dvd|scheibe|d)\\s*0*(\\d{1,2})\\s*$`, 'i') +const STAFFEL_MUSTER = new RegExp(`${TRENNER}+(?:season|staffel|series|s)\\s*0*(\\d{1,2})\\s*$`, 'i') + +export interface DiscZusatz { + /** Der Titel ohne die erkannten Verpackungs-Zusätze. */ + titel: string + /** Nummer der Disc innerhalb der Ausgabe — null, wenn keine dasteht. */ + discNr: number | null + /** Staffel-Nummer — null, wenn keine dasteht. */ + staffel: number | null +} + +/** `Spartacus: Gods Of The Arena - Disc 2` → Titel + Disc 2 (pur). + * Abtrennen UND merken: Der Suchtitel wird brauchbar, und die Nummer + * braucht die Ablage später sowieso, um Folgen richtig einzusortieren. */ +export function discZusatzAbtrennen(titel: string): DiscZusatz { + let rest = titel.trim() + let discNr: number | null = null + let staffel: number | null = null + // Mehrfach, weil ein Label wie `SHOW_S1_D2` beide Zusätze hintereinander + // trägt; die Schleife endet, sobald hinten nichts Bekanntes mehr steht. + for (let runde = 0; runde < 4; runde++) { + const disc = DISC_MUSTER.exec(rest) + if (disc !== null && discNr === null && Number(disc[1]) > 0) { + discNr = Number(disc[1]) + rest = rest.slice(0, disc.index) + continue + } + const staffelTreffer = STAFFEL_MUSTER.exec(rest) + if (staffelTreffer !== null && staffel === null && Number(staffelTreffer[1]) > 0) { + staffel = Number(staffelTreffer[1]) + rest = rest.slice(0, staffelTreffer.index) + continue + } + break + } + return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel } } /** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */ @@ -138,22 +227,50 @@ export function discWurzel(geraetepfad: string): string { } export interface DiscTitel { + /** Der Titel, wie die Disc ihn nennt — MIT Zusätzen, ungekürzt. */ titel: string /** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */ quelle: 'bdmt' | 'label' | '' + /** Nummer der Disc in der Ausgabe (aus Titel/Label) — null wenn keine. */ + discNr: number | null + /** Staffel-Nummer aus Titel/Label — null wenn keine. */ + staffel: number | null + /** Folgen im bdmt-Inhaltsverzeichnis (0, wenn es keines gibt). */ + folgen: number + /** Die Disc bezeugt selbst, dass sie Folgen trägt — ab zwei ist es + * keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */ + istSerie: boolean } -/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der - * BDMT-Titel wird NICHT normalisiert („2.22" würde zu „2 22"). */ +/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als + * bewiesen gilt — eine einzelne könnte auch eine Zugabe sein. */ +export const FOLGEN_FUER_SERIE = 2 + +function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number): DiscTitel { + const zusatz = discZusatzAbtrennen(titel) + return { + titel, + quelle, + discNr: zusatz.discNr, + staffel: zusatz.staffel, + folgen, + istSerie: folgen >= FOLGEN_FUER_SERIE, + } +} + +/** Alles, was die Disc über sich selbst sagt: BDMT zuerst, sonst Label. + * Der BDMT-Titel wird NICHT normalisiert ('2.22' würde zu '2 22'). */ export function discTitel(geraetepfad: string): DiscTitel { const wurzel = discWurzel(geraetepfad) + let folgen = 0 if (wurzel.length > 0) { - const bdmt = titelAusBdmt(wurzel) - if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' } + const bdmt = inhaltAusBdmt(wurzel) + folgen = folgenZahl(bdmt.eintraege) + if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen) } const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad) - if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' } - return { titel: '', quelle: '' } + if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen) + return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE } } /** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der @@ -163,25 +280,42 @@ export function discFingerprint(geraetepfad: string, groesseBytes: number): stri return `${label}|${groesseBytes}` } +/** Höchstens so viele Suchvarianten — jede kostet API-Anfragen. */ +export const HOECHSTENS_KANDIDATEN = 7 + /** Suchvarianten für die Metadaten-APIs (progressive Degradation, - * ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne - * Klammern, dann hintere Worte strippen. */ + * ARM-Lehre) — von SPEZIFISCH nach vage: + * + * voller Titel → ohne Disc-/Staffel-Zusatz → ohne Doppelpunkt → + * ohne Klammern → hintere Worte strippen → Doppelpunkt-Vorspann + * + * Die Reihenfolge ist teuer gelernt (01.09.2026, echte Disc): Der + * Doppelpunkt-Vorspann stand auf Platz 2 und wirft am meisten weg + * ('Spartacus: Gods Of The Arena' → 'Spartacus'). TMDb hat einen Film, + * der wörtlich so heißt — die richtige Serie wurde nie gefragt. Er + * steht jetzt GANZ hinten; die Sicherheit deckelt ihn zusätzlich + * (MINDEST_ABDECKUNG in zuordnung.ts). */ export function titelKandidaten(titel: string): string[] { const kandidaten: string[] = [] + const nachlaufend = new RegExp(`${TRENNER}+$`) const merke = (t: string): void => { - const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(/[ .]+$/, '').replace(/^[ .]+/, '') + const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(nachlaufend, '').replace(/^[ .]+/, '') if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber) } merke(titel) - if (titel.includes(':')) { - merke(titel.split(':', 1)[0]) - merke(titel.replaceAll(':', '')) - } - merke(titel.replace(/\([^)]*\)/g, '')) - let worte = titel.split(/\s+/).filter(Boolean) - while (worte.length > 1 && kandidaten.length < 6) { + // Ohne den Verpackungs-Zusatz ist der Titel erst suchbar — und das ist + // der Kandidat, der die Serie findet, nicht der gestutzte Vorspann. + const ohneZusatz = discZusatzAbtrennen(titel).titel + merke(ohneZusatz) + // Volume-Labels können keinen Doppelpunkt tragen, TMDb schreibt ihn: + // beide Schreibweisen fragen. + if (ohneZusatz.includes(':')) merke(ohneZusatz.replaceAll(':', '')) + merke(ohneZusatz.replace(/\([^)]*\)/g, '')) + let worte = ohneZusatz.split(/\s+/).filter(Boolean) + while (worte.length > 1 && kandidaten.length < HOECHSTENS_KANDIDATEN - 1) { worte = worte.slice(0, -1) merke(worte.join(' ')) } + if (ohneZusatz.includes(':')) merke(ohneZusatz.split(':', 1)[0]) return kandidaten } diff --git a/rippy-windows/src/kern/metadaten/zuordnung.ts b/rippy-windows/src/kern/metadaten/zuordnung.ts index 169a4a9..544911f 100644 --- a/rippy-windows/src/kern/metadaten/zuordnung.ts +++ b/rippy-windows/src/kern/metadaten/zuordnung.ts @@ -12,7 +12,7 @@ // // Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira // (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5). -import { titelKandidaten } from './discmerkmale' +import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale' import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb' import type { OmdbClient, OmdbTreffer } from './omdb' @@ -94,17 +94,21 @@ const UNBEKANNT = (titel: string): Zuordnung => ({ }, }) +/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung, + * Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */ +function worte(t: string): Set { + return new Set( + t + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter((w) => w.length > 0), + ) +} + /** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen * selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb * aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */ export function wortUeberlappung(a: string, b: string): number { - const worte = (t: string): Set => - new Set( - t - .toLowerCase() - .split(/[^\p{L}\p{N}]+/u) - .filter((w) => w.length > 0), - ) const wa = worte(a) const wb = worte(b) if (wa.size === 0 || wb.size === 0) return 0 @@ -113,59 +117,130 @@ export function wortUeberlappung(a: string, b: string): number { return gemeinsam / (wa.size + wb.size - gemeinsam) } +/** Gleicher Titel, wenn man Satzzeichen und Groß-/Kleinschreibung + * weglässt. Ein Volume-Label KANN keinen Doppelpunkt tragen, TMDb + * schreibt ihn: 'Spartacus Gods Of The Arena' gegen 'Spartacus: Gods of + * the Arena' war zeichengenau verglichen kein Treffer — der exakt + * richtige Fund fiel an einem Satzzeichen durch (01.09.2026 gemessen). */ +export function gleichBedeutend(a: string, b: string): boolean { + // Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich + // 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen. + const nackt = (t: string): string => + t + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter((w) => w.length > 0) + .join(' ') + const na = nackt(a) + return na.length > 0 && na === nackt(b) +} + +/** Wie viel vom Disc-Titel steckt noch in dieser Suchvariante (0–1)? */ +export function abdeckung(discTitel: string, kandidat: string): number { + const ganz = worte(discTitel) + if (ganz.size === 0) return 0 + let drin = 0 + for (const w of worte(kandidat)) if (ganz.has(w)) drin += 1 + return drin / ganz.size +} + +/** Sicherheit eines Treffers, den das UI als Vorschlag zeigt und nachfragt. */ +export const VORSCHLAG = 0.6 + +/** Darunter ist ein Treffer GERATEN, nicht gefunden — und wird zum + * Vorschlag herabgestuft, egal wie wörtlich er aussieht. + * + * Der Grund, an der echten Disc gemessen (01.09.2026): Aus + * 'Spartacus: Gods Of The Arena - Disc 2' blieb nach dem Stutzen die + * Frage 'Spartacus' übrig — sechs von sieben Wörtern weggeworfen. TMDb + * antwortete mit einem wörtlichen Filmtreffer, und Rippy meldete mit + * 95 % Sicherheit den falschen Film. Die alte Kaskade maß nur die ART + * des Treffers, nie die QUALITÄT der Frage. */ +export const MINDEST_ABDECKUNG = 0.5 + +export interface DiscHinweis { + /** Die Disc bezeugt über ihr EIGENES Inhaltsverzeichnis, dass sie Folgen + * trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige + * aus: Was bewiesen ist, muss nicht mehr geraten werden. */ + istSerie?: boolean +} + /** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist. * - * Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → (nur beim - * vollen Titel) spezifisch Film → spezifisch Serie. Erst dann der nächste, - * gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026: - * Beim Disc-Titel „Spartacus Gods Of The Arena" erschlug der stark - * gekürzte Kandidat „Spartacus" mit einem wörtlichen Treffer (Film von - * 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */ + * Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → spezifisch + * Film → spezifisch Serie. Erst dann der nächste, ärmere Kandidat. + * + * Drei Sicherungen, alle an echten Discs bezahlt: + * + * * `hinweis.istSerie` schaltet die Film-Zweige ganz ab. + * * Jede Sicherheit wird an der ABDECKUNG gedeckelt (MINDEST_ABDECKUNG): + * Wer den halben Titel wegwerfen musste, um zu treffen, hat geraten — + * das ist ein Vorschlag, kein Fund. Der Fall vom 01.09.2026: + * 'Spartacus: Gods Of The Arena - Disc 2' wurde über die Restfrage + * 'Spartacus' zum Film von 1960, mit 95 % Sicherheit gemeldet. + * * Die spezifischen Treffer (0,80) verlangen einen VOLLSTÄNDIGEN + * Kandidaten — er muss noch jedes Wort des Titels tragen (Zusätze wie + * '- Disc 2' zählen nicht mit). Vorher hing das an „Kandidat Nummer + * 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat + * Nummer 1 und fiel durch dieses Raster. + */ export async function zuordnen( discTitel: string, tmdb: Pick, omdb: Pick, + hinweis: DiscHinweis = {}, ): Promise { const titel = discTitel.trim() if (titel.length === 0) return UNBEKANNT('') + const nurSerie = hinweis.istSerie === true + // Maßstab für die Abdeckung ist der Titel OHNE Verpackungs-Zusatz: + // '- Disc 2' ist Rauschen und darf einer guten Frage nicht angelastet werden. + const massstab = discZusatzAbtrennen(titel).titel || titel const kandidaten = titelKandidaten(titel) if (kandidaten.length === 0) kandidaten.push(titel) - let ersteTreffer: FilmTreffer[] = [] + let ersteFilme: FilmTreffer[] = [] + let ersteSerien: FilmTreffer[] = [] - for (let i = 0; i < kandidaten.length; i++) { - const kandidat = kandidaten[i] - const filme = (await tmdb.searchMovie(kandidat)) ?? [] - if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme + for (const kandidat of kandidaten) { + const deckung = abdeckung(massstab, kandidat) + const vollstaendig = deckung >= 1 + /** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */ + const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG)) + + const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat)) ?? []) + if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme // Wörtlicher Filmtreffer (0,95) for (const film of filme) { - if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) { + if (gleichBedeutend(film.title ?? '', kandidat)) { const details = await tmdb.movieDetails(film.id) if (details !== null) { const meta = ausDetails('movie', film.id, details) - return { titel: meta.titel, jahr: meta.jahr, confidence: 0.95, meta } + return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta } } } } const serien = (await tmdb.searchTv(kandidat)) ?? [] + if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien + // Wörtlicher Serientreffer (0,90) for (const serie of serien) { - if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) { + if (gleichBedeutend(serie.name ?? '', kandidat)) { const details = await tmdb.tvDetails(serie.id) if (details !== null) { const meta = ausDetails('tv', serie.id, details) - return { titel: meta.titel, jahr: meta.jahr, confidence: 0.9, meta } + return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta } } } } - // Spezifische Treffer NUR für den vollen, ungekürzten Titel (0,80): - // Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie - // jemand, der weiß, was er sucht (der Evangelion-Fund). - if (i === 0) { + // Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine + // Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht + // (der Evangelion-Fund). + if (vollstaendig) { if (filme.length > 0 && filme.length <= WENIGE_TREFFER) { const details = await tmdb.movieDetails(filme[0].id) if (details !== null) { @@ -189,6 +264,9 @@ export async function zuordnen( for (const kandidat of kandidaten) { const treffer = await omdb.lookup(kandidat) if (treffer !== null) { + // Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen, + // was sie ist. + if (nurSerie && treffer.type !== 'tv') continue // Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische // Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream // 5". Ohne echte Wort-Überlappung ist das kein Treffer. @@ -208,17 +286,28 @@ export async function zuordnen( if (deutsch['type'] === 'tv') meta.typ = 'tv' } } - return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta } + // Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage + // ist ein Vorschlag, egal von welchem Dienst er kommt. + const sicher = abdeckung(massstab, kandidat) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG + return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta } } } } - // 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. - if (ersteTreffer.length > 0) { - const details = await tmdb.movieDetails(ersteTreffer[0].id) + // 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer + // Serien-Disc ist die Filmliste leer, dort zählen nur die Serien. + if (ersteFilme.length > 0) { + const details = await tmdb.movieDetails(ersteFilme[0].id) if (details !== null) { - const meta = ausDetails('movie', ersteTreffer[0].id, details) - return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta } + const meta = ausDetails('movie', ersteFilme[0].id, details) + return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta } + } + } + if (ersteSerien.length > 0) { + const details = await tmdb.tvDetails(ersteSerien[0].id) + if (details !== null) { + const meta = ausDetails('tv', ersteSerien[0].id, details) + return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta } } } diff --git a/rippy-windows/test/messung.disc-erkennung.test.ts b/rippy-windows/test/messung.disc-erkennung.test.ts new file mode 100644 index 0000000..070afc2 --- /dev/null +++ b/rippy-windows/test/messung.disc-erkennung.test.ts @@ -0,0 +1,47 @@ +// MESSUNG an einer ECHTEN Disc im Laufwerk — läuft nur auf Verlangen und +// nur unter Windows (die CI-Ampel auf Linux überspringt sie): +// +// RIPPY_MESSUNG_DISC=G npx vitest run test/messung.disc-erkennung.test.ts --reporter=verbose +// +// Der Anlass (01.09.2026): Der Commander legte Disc 2 der Serie +// „Spartacus: Gods of the Arena" ein, Rippy meldete den FILM „Spartacus" +// von 1960. Gemessen am Laufwerk G: — Volume-Label SPARTACUS_GOTA_D2, +// UDF, 33 759 100 928 Bytes. Die Disc trug die richtige Antwort die +// ganze Zeit selbst mit sich: In G:\BDMV\META\DL\bdmt_eng.xml steht der +// Studio-Titel MIT Disc-Nummer und ein Inhaltsverzeichnis, das `EP 1` +// und `EP 2` nennt. Rippy las davon nur die erste Zeile. +import { describe, expect, it } from 'vitest' + +const buchstabe = (process.env.RIPPY_MESSUNG_DISC ?? '').replace(/:$/, '').toUpperCase() +const messen = /^[A-Z]$/.test(buchstabe) && process.platform === 'win32' + +describe.skipIf(!messen)('Disc-Erkennung an der eingelegten Disc', () => { + it('liest Titel, Verpackungs-Zusatz und Inhaltsverzeichnis', async () => { + const { discTitel, discWurzel, inhaltAusBdmt, titelKandidaten } = await import( + '../src/kern/metadaten/discmerkmale' + ) + const geraetepfad = `\\\\.\\${buchstabe}:` + const gefunden = discTitel(geraetepfad) + + console.log(`MESSUNG Laufwerk ${buchstabe}: Titel "${gefunden.titel}" (Quelle: ${gefunden.quelle || 'keine'})`) + console.log( + `MESSUNG Disc ${gefunden.discNr ?? '—'} | Staffel ${gefunden.staffel ?? '—'} | ` + + `Folgen ${gefunden.folgen} | Serie bewiesen: ${gefunden.istSerie}`, + ) + const wurzel = discWurzel(geraetepfad) + if (wurzel.length > 0) { + console.log(`MESSUNG Inhaltsverzeichnis: ${inhaltAusBdmt(wurzel).eintraege.join(' | ') || '(keines)'}`) + } + console.log(`MESSUNG Suchvarianten: ${titelKandidaten(gefunden.titel).join(' -> ')}`) + + // Eine eingelegte Disc muss sich benennen lassen — sonst ist die + // Messung selbst kaputt und nicht die Erkennung. + expect(gefunden.titel.length).toBeGreaterThan(0) + }) +}) + +describe.skipIf(messen)('Disc-Messung übersprungen', () => { + it('läuft nur mit RIPPY_MESSUNG_DISC= unter Windows', () => { + expect(true).toBe(true) + }) +}) diff --git a/rippy-windows/test/messung.metadaten.test.ts b/rippy-windows/test/messung.metadaten.test.ts index 265dea6..e82b68d 100644 --- a/rippy-windows/test/messung.metadaten.test.ts +++ b/rippy-windows/test/messung.metadaten.test.ts @@ -16,6 +16,9 @@ const FAELLE = [ 'Akira', 'Evangelion 2.22', 'Spartacus Gods Of The Arena', + // Der echte Studio-Titel der Disc vom 01.09.2026 — MIT Doppelpunkt und + // Verpackungs-Zusatz, genau so, wie er von der Disc kommt. + 'Spartacus: Gods Of The Arena - Disc 2', 'Bd Evg D2', ] diff --git a/rippy-windows/test/metadaten.test.ts b/rippy-windows/test/metadaten.test.ts index 9b25d78..456f88d 100644 --- a/rippy-windows/test/metadaten.test.ts +++ b/rippy-windows/test/metadaten.test.ts @@ -6,12 +6,23 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { + discZusatzAbtrennen, + folgenZahl, + inhaltAusBdmt, + istFolgenName, normalizeDiscLabel, parseUdfDstring, titelAusBdmt, titelKandidaten, } from '../src/kern/metadaten/discmerkmale' -import { istSicherGenug, wortUeberlappung, zuordnen, WENIGE_TREFFER } from '../src/kern/metadaten/zuordnung' +import { + abdeckung, + gleichBedeutend, + istSicherGenug, + wortUeberlappung, + zuordnen, + WENIGE_TREFFER, +} from '../src/kern/metadaten/zuordnung' import { posterUrl } from '../src/kern/ablage/poster' let wegwerf: string[] = [] @@ -169,10 +180,13 @@ describe('zuordnen — die Confidence-Kaskade', () => { expect(z.meta.quelle).toBe('omdb') }) - it('der Spartacus-Fund: spezifische Serie auf den vollen Titel schlägt den wörtlichen Treffer eines STARK gekürzten Kandidaten', async () => { + it('der Spartacus-Fund: die Serie schlägt den wörtlichen Treffer eines STARK gekürzten Kandidaten', async () => { // Live gemessen (30.08.2026): 'Spartacus Gods Of The Arena' wurde zum // Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf, // BEVOR die volle Anfrage als spezifische Serie gewertet wurde. + // Seit dem 01.09.2026 ist es sogar ein WÖRTLICHER Serientreffer (0,90 + // statt 0,80): Der Vergleich stolpert nicht mehr über den Doppelpunkt, + // den TMDb schreibt und ein Volume-Label nicht tragen kann. const z = await zuordnen( 'Spartacus Gods Of The Arena', fakeTmdb( @@ -181,7 +195,7 @@ describe('zuordnen — die Confidence-Kaskade', () => { ), keinOmdb, ) - expect(z.confidence).toBe(0.8) + expect(z.confidence).toBe(0.9) expect(z.meta.typ).toBe('tv') expect(z.titel).toBe('Spartacus: Gods of the Arena') }) @@ -224,6 +238,179 @@ describe('zuordnen — die Confidence-Kaskade', () => { }) }) +// ── Der Spartacus-Fund des Commanders (01.09.2026) ────────────────────── +// +// Gemessen an der Disc, die im Laufwerk lag: Volume-Label +// SPARTACUS_GOTA_D2 (UDF, 33 759 100 928 Bytes), und in +// G:\BDMV\META\DL\bdmt_eng.xml stand alles, was Rippy brauchte — es las +// nur die erste Zeile davon. Rippy meldete „Spartacus" (Film, 1960). + +/** Das echte bdmt_eng.xml der Disc, gekürzt auf das Wesentliche. */ +const ECHTES_BDMT = + '' + + '' + + 'Spartacus: Gods Of The Arena - Disc 2' + + '11' + + '' + + 'EP 1' + + 'EP 2' + + 'MM ITA' + + 'ILSM' + + 'DUB GER 1' + + 'DUB GER 2' + + '101 LOGO' + + '104 GER FSK' + + '105 WARNINGS' + + '' + +describe('inhaltAusBdmt — die Disc sagt selbst, was sie ist', () => { + it('liest Titel UND Inhaltsverzeichnis der echten Disc', () => { + const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-')) + wegwerf.push(wurzel) + const meta = join(wurzel, 'BDMV', 'META', 'DL') + mkdirSync(meta, { recursive: true }) + writeFileSync(join(meta, 'bdmt_eng.xml'), ECHTES_BDMT) + const inhalt = inhaltAusBdmt(wurzel) + expect(inhalt.titel).toBe('Spartacus: Gods Of The Arena - Disc 2') + expect(inhalt.eintraege).toContain('EP 1') + expect(inhalt.eintraege).toContain('DUB GER 1') + // Zwei Folgen — das deckt sich mit der Disc-Struktur: genau zwei große + // Ströme (15,76 GB und 14,89 GB), alles andere unter 200 MB. + expect(folgenZahl(inhalt.eintraege)).toBe(2) + }) + + it('Tonspuren, Trailer und Warnhinweise sind KEINE Folgen', () => { + expect(istFolgenName('EP 1')).toBe(true) + expect(istFolgenName('Episode 12')).toBe(true) + expect(istFolgenName('Folge 3')).toBe(true) + expect(istFolgenName('DUB GER 1')).toBe(false) + expect(istFolgenName('101 LOGO')).toBe(false) + expect(istFolgenName('104 GER FSK')).toBe(false) + expect(istFolgenName('MM ITA')).toBe(false) + }) + + it('ohne bdmt-Datei bleibt alles leer, ohne Absturz', () => { + const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-')) + wegwerf.push(wurzel) + expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [] }) + }) +}) + +describe('discZusatzAbtrennen — abtrennen UND merken', () => { + it('trennt den Disc-Zusatz vom echten Titel', () => { + expect(discZusatzAbtrennen('Spartacus: Gods Of The Arena - Disc 2')).toEqual({ + titel: 'Spartacus: Gods Of The Arena', + discNr: 2, + staffel: null, + }) + }) + + it('auch aus dem Volume-Label (SPARTACUS_GOTA_D2)', () => { + expect(discZusatzAbtrennen(normalizeDiscLabel('SPARTACUS_GOTA_D2'))).toEqual({ + titel: 'Spartacus Gota', + discNr: 2, + staffel: null, + }) + }) + + it('Staffel und Disc hintereinander', () => { + expect(discZusatzAbtrennen('Akte X S1 D2')).toEqual({ titel: 'Akte X', discNr: 2, staffel: 1 }) + }) + + it('lässt echte Titel in Ruhe — die vier Fallen', () => { + // Nackte Zahl am Ende: sonst würde aus 'Rocky 2' die 'Rocky', Disc 2. + expect(discZusatzAbtrennen('Rocky 2').titel).toBe('Rocky 2') + // 'Teil'/'Part' sind Titel-Wörter, keine Verpackung. + expect(discZusatzAbtrennen('Kill Bill Teil 2').titel).toBe('Kill Bill Teil 2') + // Die Apostroph-Wortgrenze: ohne den Trenner-Zwang würde hieraus + // "Ocean'" mit Staffel 11. + expect(discZusatzAbtrennen("Ocean's 11").titel).toBe("Ocean's 11") + expect(discZusatzAbtrennen("Ocean's 11").staffel).toBe(null) + expect(discZusatzAbtrennen('Pulp Fiction')).toEqual({ titel: 'Pulp Fiction', discNr: null, staffel: null }) + }) +}) + +describe('titelKandidaten — der Doppelpunkt-Vorspann steht GANZ hinten', () => { + it('die Reihenfolge der echten Disc', () => { + const k = titelKandidaten('Spartacus: Gods Of The Arena - Disc 2') + // Platz 1 war bis zum 01.09.2026 'Spartacus' — und erschlug damit alles. + expect(k[0]).toBe('Spartacus: Gods Of The Arena - Disc 2') + expect(k[1]).toBe('Spartacus: Gods Of The Arena') + expect(k).toContain('Spartacus Gods Of The Arena') + expect(k[k.length - 1]).toBe('Spartacus') + expect(k.indexOf('Spartacus')).toBeGreaterThan(k.indexOf('Spartacus: Gods Of The Arena')) + // Kein Müll-Zwischenschritt mehr, der auf einem Trennzeichen endet. + expect(k.some((t) => /[\s\-–—_:,.]$/.test(t))).toBe(false) + }) +}) + +describe('gleichBedeutend und abdeckung', () => { + it('der Doppelpunkt kostet keinen Treffer mehr', () => { + expect(gleichBedeutend('Spartacus: Gods of the Arena', 'Spartacus Gods Of The Arena')).toBe(true) + }) + + it('aber Wortdopplungen bleiben unterscheidbar', () => { + expect(gleichBedeutend('New York, New York', 'New York')).toBe(false) + expect(gleichBedeutend('', 'x')).toBe(false) + }) + + it('abdeckung misst, wie viel von der Frage übrig ist', () => { + expect(abdeckung('Spartacus: Gods Of The Arena', 'Spartacus')).toBeCloseTo(0.2) + expect(abdeckung('Spartacus: Gods Of The Arena', 'Spartacus: Gods Of The Arena')).toBe(1) + expect(abdeckung('', 'x')).toBe(0) + }) +}) + +describe('zuordnen — der Fund des Commanders, an der echten Disc', () => { + const echteWelt = () => + fakeTmdb( + // TMDb hat einen Film, der WÖRTLICH 'Spartacus' heißt — das ist die Falle. + { Spartacus: [{ id: 5, title: 'Spartacus' }] }, + { 'Spartacus: Gods Of The Arena': [{ id: 6, title: 'Spartacus: Gods of the Arena' }] }, + ) + + it('der echte Disc-Titel führt zur Serie, nicht zum Film von 1960', async () => { + const z = await zuordnen('Spartacus: Gods Of The Arena - Disc 2', echteWelt(), keinOmdb) + expect(z.meta.typ).toBe('tv') + expect(z.titel).toBe('Spartacus: Gods of the Arena') + expect(z.confidence).toBe(0.9) + }) + + it('mit dem Serien-Hinweis der Disc kann ein Film gar nicht mehr gewinnen', async () => { + const z = await zuordnen('Spartacus: Gods Of The Arena - Disc 2', echteWelt(), keinOmdb, { istSerie: true }) + expect(z.meta.typ).toBe('tv') + expect(z.titel).toBe('Spartacus: Gods of the Arena') + }) + + it('DER REGRESSIONSTEST: kennt TMDb nur den Film, wird daraus ein VORSCHLAG', async () => { + // Genau der Fehler, den der Commander sah: Die Restfrage 'Spartacus' + // trifft wörtlich — vorher 95 % Sicherheit auf den falschen Film. + // Jetzt deckelt die Abdeckung (1 von 5 Wörtern übrig) auf 0,60: Rippy + // zeigt es als Vorschlag und fragt nach, statt sich sicher zu irren. + const z = await zuordnen( + 'Spartacus: Gods Of The Arena - Disc 2', + fakeTmdb({ Spartacus: [{ id: 5, title: 'Spartacus' }] }), + keinOmdb, + ) + expect(z.titel).toBe('Spartacus') + expect(z.confidence).toBe(0.6) + expect(istSicherGenug(z)).toBe(false) + }) + + it('eine Serien-Disc nimmt auch aus dem Label keinen Filmtreffer an', async () => { + // Ohne bdmt bliebe nur 'Spartacus Gota D2' — die Folgen im + // Inhaltsverzeichnis bezeugen trotzdem, dass hier kein Film liegt. + const z = await zuordnen( + normalizeDiscLabel('SPARTACUS_GOTA_D2'), + fakeTmdb({ Spartacus: [{ id: 5, title: 'Spartacus' }] }), + keinOmdb, + { istSerie: true }, + ) + expect(z.meta.typ).not.toBe('movie') + expect(z.confidence).toBe(0.3) + }) +}) + describe('posterUrl', () => { it('relative TMDb-Pfade bekommen das w500-Präfix', () => { expect(posterUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg')