feat(v5): Etappe W-4 — Metadaten-Automatik mit Sicherheitsgrad
Ampel / ampel (push) Failing after 1m20s
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:
co-authored by
Claude Fable 5
parent
e840b7f327
commit
5f386e5406
@@ -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, 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<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
|
||||
}
|
||||
Reference in New Issue
Block a user