feat(v5): Etappe W-4 — Metadaten-Automatik mit Sicherheitsgrad
Ampel / ampel (push) Failing after 1m20s

metadaten/: discmerkmale.ts (BDMT-Studio-Titel, ISO-9660- und
UDF-Volume-Label direkt vom Medium — OHNE koffi, ueber fs auf \.\G: —,
Label-Normalisierung, Fingerabdruck, progressive Titel-Kandidaten),
tmdb.ts (v3-Key UND v4-Token, de-DE, /find fuer deutsche Texte zu
OMDb-Treffern), omdb.ts, zuordnung.ts (die Confidence-Kaskade
0,95/0,90/0,80/0,60/0,30 aus prescan.py). ablage/poster.ts. Kern meldet
'disc-info' beim Einlegen; die Pipeline benennt den fertigen Ordner
'<Titel> (Jahr)', schreibt movie.nfo/tvshow.nfo mit echten Metadaten
und laedt das Poster. Fenster zeigt 'sehr wahrscheinlich: … · 95 %'.

ZWEI Kaskaden-Schwaechen LIVE GEFUNDEN und gehaertet (gegen die echten
APIs gemessen, Keys aus den Docker-Settings der VM, nicht persistiert):
1. 'Spartacus Gods Of The Arena' wurde zum FILM 'Spartacus' (1960) —
   der stark gekuerzte Kandidat traf woertlich, bevor die volle Anfrage
   als spezifische SERIE galt. Kaskade jetzt JE KANDIDAT
   woertlich→spezifisch (spezifisch nur fuer die volle Anfrage, auch
   fuer Serien). Nachgemessen: jetzt Serie, 90 %.
2. OMDbs t=-Suche machte aus dem Label 'Bd Evg D2' selbstbewusst
   'BD Scream 5' — jetzt Wort-Jaccard-Gate >= 0,5. Nachgemessen: der
   Fall ist jetzt ein ehrlicher 60-%-Vorschlag, den das UI nachfragt.

Messwerte: Akira→95% · Evangelion 2.22→80% (der dokumentierte Fund) ·
Spartacus GotA→Serie 90% · Bd Evg D2→Vorschlag 60%.

Dazu ein neuer Quelltext-Hygiene-Waechter (keine rohen Steuerbytes —
zweimal beim Schreiben passiert, einmal haette ein Test dadurch anders
gemessen als gelesen). 165 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 18:39:09 +02:00
co-authored by Claude Fable 5
parent e840b7f327
commit 5f386e5406
10 changed files with 709 additions and 8 deletions
@@ -0,0 +1,225 @@
// Treffer + Sicherheitsgrad (KONZEPT § 5, Schritt 3) — die Kaskade aus
// prescan.py mit ihren gemessenen Begründungen:
//
// 0,95 wörtlicher TMDb-Filmtreffer auf eine Suchvariante
// 0,90 wörtlicher TMDb-Serientreffer
// 0,80 SPEZIFISCHER Treffer: voller Disc-Titel, höchstens 3 Treffer
// (der Evangelion-Fund: 'Evangelion 2.22' → 1 Treffer = der
// richtige Film; 'Evangelion' → 20 beliebige. Die Spezifität der
// Anfrage sagt mehr als jede Titel-Ähnlichkeit.) — auch OMDb
// 0,60 Vorschlag (bester Rohtreffer) — das UI fragt nach
// 0,30 unbekannt
//
// 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 type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
import type { OmdbClient, OmdbTreffer } from './omdb'
export const WENIGE_TREFFER = 3
export interface MetaTreffer {
typ: 'movie' | 'tv' | 'unknown'
id: number | string | null
titel: string
jahr: number | null
beschreibung: string
posterPfad: string
laufzeitMinuten: number
genres: string[]
quelle: 'tmdb' | 'omdb' | ''
}
export interface Zuordnung {
titel: string
jahr: number | null
confidence: number
meta: MetaTreffer
}
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
return {
typ,
id,
titel: (typ === 'movie' ? details.title : details.name) ?? '',
jahr: jahrAus(typ === 'movie' ? details.release_date : details.first_air_date),
beschreibung: details.overview ?? '',
posterPfad: details.poster_path ?? '',
laufzeitMinuten: details.runtime ?? 0,
genres: (details.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
}
}
function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
return {
typ: treffer.type,
id: treffer.id,
titel: treffer.title,
jahr: treffer.year,
beschreibung: treffer.overview,
posterPfad: treffer.poster_path,
laufzeitMinuten: treffer.runtime,
genres: treffer.genres,
quelle: 'omdb',
}
}
const UNBEKANNT = (titel: string): Zuordnung => ({
titel,
jahr: null,
confidence: 0.3,
meta: {
typ: 'unknown',
id: null,
titel,
jahr: null,
beschreibung: '',
posterPfad: '',
laufzeitMinuten: 0,
genres: [],
quelle: '',
},
})
/** Wort-Überlappung zweier Titel (Jaccard, 01) — 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<string> =>
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
let gemeinsam = 0
for (const w of wa) if (wb.has(w)) gemeinsam += 1
return gemeinsam / (wa.size + wb.size - gemeinsam)
}
/** 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. */
export async function zuordnen(
discTitel: string,
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
): Promise<Zuordnung> {
const titel = discTitel.trim()
if (titel.length === 0) return UNBEKANNT('')
const kandidaten = titelKandidaten(titel)
if (kandidaten.length === 0) kandidaten.push(titel)
let ersteTreffer: 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
// Wörtlicher Filmtreffer (0,95)
for (const film of filme) {
if ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
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 }
}
}
}
const serien = (await tmdb.searchTv(kandidat)) ?? []
// Wörtlicher Serientreffer (0,90)
for (const serie of serien) {
if ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
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 }
}
}
}
// 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) {
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
const details = await tmdb.movieDetails(filme[0].id)
if (details !== null) {
const meta = ausDetails('movie', filme[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
const details = await tmdb.tvDetails(serien[0].id)
if (details !== null) {
const meta = ausDetails('tv', serien[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
}
// 4. OMDb (0,80) — findet oft, was TMDb nicht exakt trifft; deutscher
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
if (omdb.verfuegbar) {
for (const kandidat of kandidaten) {
const treffer = await omdb.lookup(kandidat)
if (treffer !== null) {
// Ä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.
if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue
const meta = ausOmdb(treffer)
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
const deutsch = await tmdb.findByImdb(meta.id)
if (deutsch !== null) {
if (typeof deutsch['title'] === 'string' && deutsch['title'].length > 0) meta.titel = deutsch['title']
if (typeof deutsch['overview'] === 'string' && deutsch['overview'].length > 0) {
meta.beschreibung = deutsch['overview']
}
if (typeof deutsch['poster_path'] === 'string' && deutsch['poster_path'].length > 0) {
meta.posterPfad = deutsch['poster_path']
}
if (typeof deutsch['year'] === 'number') meta.jahr = deutsch['year']
if (deutsch['type'] === 'tv') meta.typ = 'tv'
}
}
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach.
if (ersteTreffer.length > 0) {
const details = await tmdb.movieDetails(ersteTreffer[0].id)
if (details !== null) {
const meta = ausDetails('movie', ersteTreffer[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
}
}
return UNBEKANNT(titel)
}
/** § 5 Schritt 4: „Sicher genug?" — die Schwelle ist einstellbar. */
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
return zuordnung.confidence >= schwelle
}