fix(v5): Disc-Erkennung — die Disc selbst befragen statt raten
Ampel / ampel (push) Successful in 1m38s
Ampel / ampel (push) Successful in 1m38s
Der Commander legte Disc 2 von „Spartacus: Gods of the Arena" ein, Rippy meldete den FILM „Spartacus" von 1960. An der echten Disc gemessen (Laufwerk G:, Label SPARTACUS_GOTA_D2, UDF, 33.759.100.928 Bytes): Der stark gestutzte Kandidat „Spartacus" stand auf Platz 2 der Suchvarianten und holte dort einen wörtlichen Filmtreffer mit 95 Prozent — die richtige Serie stand auf Platz 6 und wurde nie gefragt. Vier Ursachen, alle behoben: 1. Die Sicherheit maß die ART des Treffers, nie die QUALITÄT der Frage. Neu: abdeckung() + MINDEST_ABDECKUNG — wer mehr als die Hälfte des Titels wegwerfen musste, um zu treffen, bekommt höchstens einen Vorschlag (0,60), und das UI fragt nach, statt sich sicher zu irren. 2. Verpackungs-Zusätze galten als Wortmüll. Neu: discZusatzAbtrennen() trennt "- Disc 2" / "_D2" / "S1" ab UND merkt sie — die Nummer braucht die Ablage später sowieso. Vier Fallen sind abgesichert: "Rocky 2", "Kill Bill Teil 2", "Ocean s 11" (Apostroph-Wortgrenze) und nackte Zahlen am Ende. 3. Der Titelvergleich war zeichengenau. Ein Volume-Label KANN keinen Doppelpunkt tragen, TMDb schreibt ihn — der exakt richtige Treffer fiel an einem Satzzeichen durch. Neu: gleichBedeutend(). 4. Rippy öffnete das Inhaltsverzeichnis der Disc und las nur die erste Zeile daraus. Neu: inhaltAusBdmt() liest auch <di:titleName>. "EP 1" und "EP 2" beweisen die Serie, "DUB GER 1" und "104 GER FSK" nicht. Ist die Serie bewiesen, sind die Film-Zweige der Kaskade AUS. Gemessen an der eingelegten Disc (neue messung.disc-erkennung.test.ts): Titel "Spartacus: Gods Of The Arena - Disc 2" (Quelle: bdmt) Disc 2 | Staffel — | Folgen 2 | Serie bewiesen: true Varianten: "...Arena - Disc 2" -> "...Arena" -> ... -> "Spartacus" 225 Tests grün (vorher 209), Typprüfung sauber. Disc-Nummer, Staffel und Folgenzahl stehen jetzt auch im Fenster (Kachel neben der Erkennung). Version 5.1.1 — zugleich der erste echte Selbst-Update-Beweis (§ 6.8). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
673f7ff3e7
commit
38a3a87f70
Generated
+68
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"version": "5.0.0-w0",
|
"version": "5.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"version": "5.0.0-w0",
|
"version": "5.1.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
@@ -2229,6 +2229,72 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"productName": "Rippy",
|
"productName": "Rippy",
|
||||||
"version": "5.1.0",
|
"version": "5.1.1",
|
||||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||||
"main": "out/haupt/index.js",
|
"main": "out/haupt/index.js",
|
||||||
"author": "KrBrZ",
|
"author": "KrBrZ",
|
||||||
|
|||||||
@@ -90,6 +90,18 @@ function sicherheitsWort(confidence: number): string {
|
|||||||
return 'unsicher'
|
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
|
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
|
||||||
* (eigene Wahl → allgemeines Preset → Empfehlung → Default). */
|
* (eigene Wahl → allgemeines Preset → Empfehlung → Default). */
|
||||||
function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
|
function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
|
||||||
@@ -446,6 +458,9 @@ function LaufwerkKachel({
|
|||||||
ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'}
|
ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{info !== undefined && discHerkunft(info).length > 0 && (
|
||||||
|
<Badge text={discHerkunft(info)} ton="blau" />
|
||||||
|
)}
|
||||||
{info !== undefined && info.schonGerippt !== null && (
|
{info !== undefined && info.schonGerippt !== null && (
|
||||||
<Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />
|
<Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ export interface DiscInfoStand {
|
|||||||
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
/** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am
|
||||||
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
|
||||||
schonGerippt: string | null
|
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). */
|
/** Ein Eintrag der Bibliothek (§ 6.6). */
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
listOptischeLaufwerke,
|
listOptischeLaufwerke,
|
||||||
} from './laufwerk/disc'
|
} from './laufwerk/disc'
|
||||||
import { DiscWache } from './laufwerk/wache'
|
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 { OmdbClient } from './metadaten/omdb'
|
||||||
import { TmdbClient } from './metadaten/tmdb'
|
import { TmdbClient } from './metadaten/tmdb'
|
||||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||||
@@ -94,15 +94,21 @@ let wache: DiscWache | null = null
|
|||||||
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
|
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
|
||||||
const discZuordnungen = new Map<string, Zuordnung>()
|
const discZuordnungen = new Map<string, Zuordnung>()
|
||||||
const discFingerprints = new Map<string, string>()
|
const discFingerprints = new Map<string, string>()
|
||||||
|
// 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<string, DiscTitel>()
|
||||||
|
|
||||||
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const gefunden = discTitel(geraetepfad)
|
const gefunden = discTitel(geraetepfad)
|
||||||
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
||||||
discFingerprints.set(id, fingerprint)
|
discFingerprints.set(id, fingerprint)
|
||||||
|
discMerkmale.set(id, gefunden)
|
||||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||||
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
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)
|
discZuordnungen.set(id, zuordnung)
|
||||||
anHauptUndFenster({
|
anHauptUndFenster({
|
||||||
art: 'disc-info',
|
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
|
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||||
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
||||||
|
discNr: gefunden.discNr,
|
||||||
|
staffel: gefunden.staffel,
|
||||||
|
folgen: gefunden.folgen,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
@@ -203,6 +212,10 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
|
|||||||
backdropPfad: zuordnung.meta.backdropPfad,
|
backdropPfad: zuordnung.meta.backdropPfad,
|
||||||
beschreibung: zuordnung.meta.beschreibung,
|
beschreibung: zuordnung.meta.beschreibung,
|
||||||
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
||||||
|
// Eigene Titelwahl ändert nichts daran, welche Disc der Ausgabe hier liegt.
|
||||||
|
discNr: discMerkmale.get(id)?.discNr ?? null,
|
||||||
|
staffel: discMerkmale.get(id)?.staffel ?? null,
|
||||||
|
folgen: discMerkmale.get(id)?.folgen ?? 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
@@ -227,6 +240,7 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
|||||||
} else if (ereignis.art === 'disc-entnommen') {
|
} else if (ereignis.art === 'disc-entnommen') {
|
||||||
discZuordnungen.delete(ereignis.geraet.id)
|
discZuordnungen.delete(ereignis.geraet.id)
|
||||||
discFingerprints.delete(ereignis.geraet.id)
|
discFingerprints.delete(ereignis.geraet.id)
|
||||||
|
discMerkmale.delete(ereignis.geraet.id)
|
||||||
anHauptUndFenster({
|
anHauptUndFenster({
|
||||||
art: 'disc-info',
|
art: 'disc-info',
|
||||||
info: {
|
info: {
|
||||||
@@ -239,6 +253,9 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
|||||||
backdropPfad: '',
|
backdropPfad: '',
|
||||||
beschreibung: '',
|
beschreibung: '',
|
||||||
schonGerippt: null,
|
schonGerippt: null,
|
||||||
|
discNr: null,
|
||||||
|
staffel: null,
|
||||||
|
folgen: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,29 +105,118 @@ export function udfVolumeLabel(geraetepfad: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — bdmt_eng bevorzugt,
|
/** Was in BDMV/META/DL/bdmt_*.xml steht. Bis zum 01.09.2026 nahm Rippy von
|
||||||
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
|
* dieser Datei NUR den Titel — das Inhaltsverzeichnis daneben ist der
|
||||||
export function titelAusBdmt(wurzel: string): string {
|
* stärkere Hinweis und lag ungelesen da. An SPARTACUS_GOTA_D2 gemessen:
|
||||||
|
*
|
||||||
|
* <di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>
|
||||||
|
* <di:titleName titleNumber="1">EP 1</di:titleName>
|
||||||
|
* <di:titleName titleNumber="2">EP 2</di:titleName>
|
||||||
|
* <di:titleName titleNumber="51">DUB GER 1</di:titleName> … 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 {
|
||||||
|
/** <di:name> — der Studio-Titel im Klartext; '' wenn keiner dasteht. */
|
||||||
|
titel: string
|
||||||
|
/** <di:titleName> — 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')
|
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
|
||||||
if (!existsSync(metaOrdner)) return ''
|
if (!existsSync(metaOrdner)) return leer
|
||||||
let kandidaten: string[]
|
let kandidaten: string[]
|
||||||
try {
|
try {
|
||||||
kandidaten = readdirSync(metaOrdner).sort()
|
kandidaten = readdirSync(metaOrdner).sort()
|
||||||
} catch {
|
} catch {
|
||||||
return ''
|
return leer
|
||||||
}
|
}
|
||||||
const bdmt =
|
const bdmt =
|
||||||
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
||||||
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||||
if (bdmt === undefined) return ''
|
if (bdmt === undefined) return leer
|
||||||
let inhalt: string
|
let inhalt: string
|
||||||
try {
|
try {
|
||||||
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||||
} catch {
|
} catch {
|
||||||
return ''
|
return leer
|
||||||
}
|
}
|
||||||
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||||
return treffer === null ? '' : treffer[1].trim()
|
const eintraege: string[] = []
|
||||||
|
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{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. */
|
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
|
||||||
@@ -138,22 +227,50 @@ export function discWurzel(geraetepfad: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscTitel {
|
export interface DiscTitel {
|
||||||
|
/** Der Titel, wie die Disc ihn nennt — MIT Zusätzen, ungekürzt. */
|
||||||
titel: string
|
titel: string
|
||||||
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
|
||||||
quelle: 'bdmt' | 'label' | ''
|
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
|
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als
|
||||||
* BDMT-Titel wird NICHT normalisiert („2.22" würde zu „2 22"). */
|
* 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 {
|
export function discTitel(geraetepfad: string): DiscTitel {
|
||||||
const wurzel = discWurzel(geraetepfad)
|
const wurzel = discWurzel(geraetepfad)
|
||||||
|
let folgen = 0
|
||||||
if (wurzel.length > 0) {
|
if (wurzel.length > 0) {
|
||||||
const bdmt = titelAusBdmt(wurzel)
|
const bdmt = inhaltAusBdmt(wurzel)
|
||||||
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
|
folgen = folgenZahl(bdmt.eintraege)
|
||||||
|
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen)
|
||||||
}
|
}
|
||||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||||
if (label.length > 0) return { titel: normalizeDiscLabel(label), quelle: 'label' }
|
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
|
||||||
return { titel: '', quelle: '' }
|
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
||||||
@@ -163,25 +280,42 @@ export function discFingerprint(geraetepfad: string, groesseBytes: number): stri
|
|||||||
return `${label}|${groesseBytes}`
|
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,
|
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
|
||||||
* ARM-Lehre): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
|
* ARM-Lehre) — von SPEZIFISCH nach vage:
|
||||||
* Klammern, dann hintere Worte strippen. */
|
*
|
||||||
|
* 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[] {
|
export function titelKandidaten(titel: string): string[] {
|
||||||
const kandidaten: string[] = []
|
const kandidaten: string[] = []
|
||||||
|
const nachlaufend = new RegExp(`${TRENNER}+$`)
|
||||||
const merke = (t: string): void => {
|
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)
|
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
|
||||||
}
|
}
|
||||||
merke(titel)
|
merke(titel)
|
||||||
if (titel.includes(':')) {
|
// Ohne den Verpackungs-Zusatz ist der Titel erst suchbar — und das ist
|
||||||
merke(titel.split(':', 1)[0])
|
// der Kandidat, der die Serie findet, nicht der gestutzte Vorspann.
|
||||||
merke(titel.replaceAll(':', ''))
|
const ohneZusatz = discZusatzAbtrennen(titel).titel
|
||||||
}
|
merke(ohneZusatz)
|
||||||
merke(titel.replace(/\([^)]*\)/g, ''))
|
// Volume-Labels können keinen Doppelpunkt tragen, TMDb schreibt ihn:
|
||||||
let worte = titel.split(/\s+/).filter(Boolean)
|
// beide Schreibweisen fragen.
|
||||||
while (worte.length > 1 && kandidaten.length < 6) {
|
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)
|
worte = worte.slice(0, -1)
|
||||||
merke(worte.join(' '))
|
merke(worte.join(' '))
|
||||||
}
|
}
|
||||||
|
if (ohneZusatz.includes(':')) merke(ohneZusatz.split(':', 1)[0])
|
||||||
return kandidaten
|
return kandidaten
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//
|
//
|
||||||
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
||||||
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
|
// (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 { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
|
||||||
import type { OmdbClient, OmdbTreffer } from './omdb'
|
import type { OmdbClient, OmdbTreffer } from './omdb'
|
||||||
|
|
||||||
@@ -94,17 +94,21 @@ const UNBEKANNT = (titel: string): Zuordnung => ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Wort-Überlappung zweier Titel (Jaccard, 0–1) — das Gate gegen
|
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
||||||
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
|
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
||||||
* aus dem Label „Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur „BD"). */
|
function worte(t: string): Set<string> {
|
||||||
export function wortUeberlappung(a: string, b: string): number {
|
return new Set(
|
||||||
const worte = (t: string): Set<string> =>
|
|
||||||
new Set(
|
|
||||||
t
|
t
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/[^\p{L}\p{N}]+/u)
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
.filter((w) => w.length > 0),
|
.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 wa = worte(a)
|
const wa = worte(a)
|
||||||
const wb = worte(b)
|
const wb = worte(b)
|
||||||
if (wa.size === 0 || wb.size === 0) return 0
|
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)
|
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.
|
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
|
||||||
*
|
*
|
||||||
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → (nur beim
|
* Reihenfolge JE KANDIDAT: wörtlich Film → wörtlich Serie → spezifisch
|
||||||
* vollen Titel) spezifisch Film → spezifisch Serie. Erst dann der nächste,
|
* Film → spezifisch Serie. Erst dann der nächste, ärmere Kandidat.
|
||||||
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026:
|
*
|
||||||
* Beim Disc-Titel „Spartacus Gods Of The Arena" erschlug der stark
|
* Drei Sicherungen, alle an echten Discs bezahlt:
|
||||||
* gekürzte Kandidat „Spartacus" mit einem wörtlichen Treffer (Film von
|
*
|
||||||
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */
|
* * `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(
|
export async function zuordnen(
|
||||||
discTitel: string,
|
discTitel: string,
|
||||||
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
|
||||||
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
|
||||||
|
hinweis: DiscHinweis = {},
|
||||||
): Promise<Zuordnung> {
|
): Promise<Zuordnung> {
|
||||||
const titel = discTitel.trim()
|
const titel = discTitel.trim()
|
||||||
if (titel.length === 0) return UNBEKANNT('')
|
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)
|
const kandidaten = titelKandidaten(titel)
|
||||||
if (kandidaten.length === 0) kandidaten.push(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++) {
|
for (const kandidat of kandidaten) {
|
||||||
const kandidat = kandidaten[i]
|
const deckung = abdeckung(massstab, kandidat)
|
||||||
const filme = (await tmdb.searchMovie(kandidat)) ?? []
|
const vollstaendig = deckung >= 1
|
||||||
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme
|
/** 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)
|
// Wörtlicher Filmtreffer (0,95)
|
||||||
for (const film of filme) {
|
for (const film of filme) {
|
||||||
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
if (gleichBedeutend(film.title ?? '', kandidat)) {
|
||||||
const details = await tmdb.movieDetails(film.id)
|
const details = await tmdb.movieDetails(film.id)
|
||||||
if (details !== null) {
|
if (details !== null) {
|
||||||
const meta = ausDetails('movie', film.id, details)
|
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)) ?? []
|
const serien = (await tmdb.searchTv(kandidat)) ?? []
|
||||||
|
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
||||||
|
|
||||||
// Wörtlicher Serientreffer (0,90)
|
// Wörtlicher Serientreffer (0,90)
|
||||||
for (const serie of serien) {
|
for (const serie of serien) {
|
||||||
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
|
if (gleichBedeutend(serie.name ?? '', kandidat)) {
|
||||||
const details = await tmdb.tvDetails(serie.id)
|
const details = await tmdb.tvDetails(serie.id)
|
||||||
if (details !== null) {
|
if (details !== null) {
|
||||||
const meta = ausDetails('tv', serie.id, details)
|
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):
|
// Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
|
||||||
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie
|
// Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
|
||||||
// jemand, der weiß, was er sucht (der Evangelion-Fund).
|
// (der Evangelion-Fund).
|
||||||
if (i === 0) {
|
if (vollstaendig) {
|
||||||
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
||||||
const details = await tmdb.movieDetails(filme[0].id)
|
const details = await tmdb.movieDetails(filme[0].id)
|
||||||
if (details !== null) {
|
if (details !== null) {
|
||||||
@@ -189,6 +264,9 @@ export async function zuordnen(
|
|||||||
for (const kandidat of kandidaten) {
|
for (const kandidat of kandidaten) {
|
||||||
const treffer = await omdb.lookup(kandidat)
|
const treffer = await omdb.lookup(kandidat)
|
||||||
if (treffer !== null) {
|
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
|
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
|
||||||
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
||||||
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
||||||
@@ -208,17 +286,28 @@ export async function zuordnen(
|
|||||||
if (deutsch['type'] === 'tv') meta.typ = 'tv'
|
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.
|
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
|
||||||
if (ersteTreffer.length > 0) {
|
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
|
||||||
const details = await tmdb.movieDetails(ersteTreffer[0].id)
|
if (ersteFilme.length > 0) {
|
||||||
|
const details = await tmdb.movieDetails(ersteFilme[0].id)
|
||||||
if (details !== null) {
|
if (details !== null) {
|
||||||
const meta = ausDetails('movie', ersteTreffer[0].id, details)
|
const meta = ausDetails('movie', ersteFilme[0].id, details)
|
||||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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=<Laufwerksbuchstabe> unter Windows', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,6 +16,9 @@ const FAELLE = [
|
|||||||
'Akira',
|
'Akira',
|
||||||
'Evangelion 2.22',
|
'Evangelion 2.22',
|
||||||
'Spartacus Gods Of The Arena',
|
'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',
|
'Bd Evg D2',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,23 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
discZusatzAbtrennen,
|
||||||
|
folgenZahl,
|
||||||
|
inhaltAusBdmt,
|
||||||
|
istFolgenName,
|
||||||
normalizeDiscLabel,
|
normalizeDiscLabel,
|
||||||
parseUdfDstring,
|
parseUdfDstring,
|
||||||
titelAusBdmt,
|
titelAusBdmt,
|
||||||
titelKandidaten,
|
titelKandidaten,
|
||||||
} from '../src/kern/metadaten/discmerkmale'
|
} 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'
|
import { posterUrl } from '../src/kern/ablage/poster'
|
||||||
|
|
||||||
let wegwerf: string[] = []
|
let wegwerf: string[] = []
|
||||||
@@ -169,10 +180,13 @@ describe('zuordnen — die Confidence-Kaskade', () => {
|
|||||||
expect(z.meta.quelle).toBe('omdb')
|
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
|
// Live gemessen (30.08.2026): 'Spartacus Gods Of The Arena' wurde zum
|
||||||
// Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf,
|
// Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf,
|
||||||
// BEVOR die volle Anfrage als spezifische Serie gewertet wurde.
|
// 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(
|
const z = await zuordnen(
|
||||||
'Spartacus Gods Of The Arena',
|
'Spartacus Gods Of The Arena',
|
||||||
fakeTmdb(
|
fakeTmdb(
|
||||||
@@ -181,7 +195,7 @@ describe('zuordnen — die Confidence-Kaskade', () => {
|
|||||||
),
|
),
|
||||||
keinOmdb,
|
keinOmdb,
|
||||||
)
|
)
|
||||||
expect(z.confidence).toBe(0.8)
|
expect(z.confidence).toBe(0.9)
|
||||||
expect(z.meta.typ).toBe('tv')
|
expect(z.meta.typ).toBe('tv')
|
||||||
expect(z.titel).toBe('Spartacus: Gods of the Arena')
|
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 =
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><disclib xmlns="urn:BDA:bdmv;disclib">' +
|
||||||
|
'<di:discinfo xmlns:di="urn:BDA:bdmv;discinfo"><di:title>' +
|
||||||
|
'<di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>' +
|
||||||
|
'<di:numSets>1</di:numSets><di:setNumber>1</di:setNumber></di:title>' +
|
||||||
|
'<di:description><di:tableOfContents>' +
|
||||||
|
'<di:titleName titleNumber="1">EP 1</di:titleName>' +
|
||||||
|
'<di:titleName titleNumber="2">EP 2</di:titleName>' +
|
||||||
|
'<di:titleName titleNumber="20">MM ITA</di:titleName>' +
|
||||||
|
'<di:titleName titleNumber="50">ILSM</di:titleName>' +
|
||||||
|
'<di:titleName titleNumber="51">DUB GER 1</di:titleName>' +
|
||||||
|
'<di:titleName titleNumber="52">DUB GER 2</di:titleName>' +
|
||||||
|
'<di:titleName titleNumber="101">101 LOGO</di:titleName>' +
|
||||||
|
'<di:titleName titleNumber="104">104 GER FSK</di:titleName>' +
|
||||||
|
'<di:titleName titleNumber="105">105 WARNINGS</di:titleName>' +
|
||||||
|
'</di:tableOfContents></di:description></di:discinfo></disclib>'
|
||||||
|
|
||||||
|
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', () => {
|
describe('posterUrl', () => {
|
||||||
it('relative TMDb-Pfade bekommen das w500-Präfix', () => {
|
it('relative TMDb-Pfade bekommen das w500-Präfix', () => {
|
||||||
expect(posterUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg')
|
expect(posterUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg')
|
||||||
|
|||||||
Reference in New Issue
Block a user