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')