Files
rippy/rippy-windows/src/kern/metadaten/zuordnung.ts
T
HitonabiandClaude Fable 5.1 3f7f962dff feat(v5): Kern 5.4.0 — Vorgaenge, Kompressions-Warteschlange, Roh-Aufraeumen, Sprachen je Titel, Erkennung v2
WAS: Der Rip endet, sobald die Roh-Dateien liegen; die Kompression laeuft
danach in einer Warteschlange (eine zur Zeit), das Laufwerk ist frei fuer
die naechste Disc. Jeder Rip ist jetzt ein Datensatz (Tabelle vorgaenge,
Schema 4): Roh-Ordner, Zielordner, Phase, Auftrag als JSON. Darauf bauen:
Roh-Aufraeumen nach Regel (Vorgabe 7 Tage, nur bei gelungener Kompression
und nur wenn jede fertige Datei da und nicht leer ist), Wiederholen ab
Kompression, Aufraeumen halber Dateien nach Abbruch/Fehler, Wiederaufnahme
nach Neustart. Unkomprimierte Rips (4K, kein HandBrake) werden in den
Zielordner VERSCHOBEN statt unter roh\ liegen zu bleiben.
Sprachen je Titel reisen vom Info-Lauf (SINFO) bis in HandBrake; fremder
Ton schaltet den deutschen Untertitel vor (--subtitle-default=1). Vor der
Kompression prueft ein HandBrake-Scan die Roh-Datei. MakeMKVs
Auswahlregel wird gesetzt, wenn keine steht, damit alle Sprachen in die
Roh-Datei kommen. Staffel und erste Folge von Hand schlagen die
Laufzeit-Raterei. Durchgehender Fortschritt, nach Groesse gewichtet.
Erkennung v2: leere TMDb-Eintraege (Stubs) zaehlen nicht, Laufzeit wird
nach dem Info-Lauf gegen TMDb gehalten, Titel-Struktur als Fingerabdruck,
bdmt_deu als zweite Titel-Variante. TMDb liefert Logo, Tagline,
Bewertung, FSK und Besetzung in derselben Anfrage. Laufwerks-Gesundheit
aus dem Windows-Ereignisprotokoll (wevtutil): Disc oder Hardware.

WARUM: Uebergabe-Punkte 1-5, 8-12 des SAVEPOINT vom 01.09.2026 — vom
Commander gutgeheissen (7 Tage / Untertitel vorgewaehlt / entkoppelte
Kompression waren seine Wahl).

GEMESSEN (Regel D): HandBrake 1.11 Sprach-Schalter an einer ffmpeg-
Testdatei (jpn/deu/deu/eng + deu/eng), HandBrake --scan --json Format,
TMDb movie/149 + tv/46296 mit append_to_response, wevtutil /f:xml an
diesem PC (338x cdrom 7, 8x cdrom 11 in 30 Tagen), makemkvcon --minlength,
MakeMKV-Auswahlregel laut forum.makemkv.com t=4386.

348 Tests gruen (vorher 261), Typpruefung und Bau gruen. Oberflaeche
folgt im naechsten Schritt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:02:03 +02:00

485 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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).
//
// 5.4.0 — Erkennung v2, drei Ergänzungen, alle an echten Daten bezahlt:
// * STUBS: TMDb hat „Spartacus: Gods of the Arena" dreimal als leeren
// Serien-Eintrag (kein Backdrop, keine Beschreibung, 1 Folge,
// Popularität 1). Ein wörtlicher Treffer darauf sah 5.1.1 als 90 %
// sicher an — es war ein Eintrag ohne Inhalt. Leere Einträge zählen
// nicht mehr als Treffer.
// * LAUFZEIT: Nach dem Info-Lauf wird die gemessene Länge des Hauptinhalts
// gegen TMDb gehalten (nachpruefen). Passt sie, wird aus „wahrscheinlich"
// „bestätigt"; passt sie gar nicht (95 vs. 158 min), fällt der Treffer
// auf einen Vorschlag zurück.
// * VARIANTEN: Die Disc trägt oft zwei Titel (bdmt_eng UND bdmt_deu) —
// jede Variante wird gefragt, und die Abdeckung wird an IHRER Vorlage
// gemessen, nicht an der englischen.
import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
import { besetzungAus, fskAus, logoAus, type FilmDetails, type FilmTreffer, type TmdbClient } from './tmdb'
import type { OmdbClient, OmdbTreffer } from './omdb'
import type { Darsteller } from '../../gemeinsam/nachrichten'
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
/** TMDb-Hintergrundbild (Querformat) — fürs Kachel-Panorama; '' wenn keins. */
backdropPfad: string
laufzeitMinuten: number
genres: string[]
quelle: 'tmdb' | 'omdb' | ''
// ── Kino-Banner v2 (5.4.0) ──
logoPfad: string
tagline: string
bewertung: number
fsk: string
besetzung: Darsteller[]
/** Folgen laut TMDb (Serie) — 0 wenn unbekannt/Film. */
folgenGesamt: number
}
export interface Zuordnung {
titel: string
jahr: number | null
confidence: number
meta: MetaTreffer
/** Was Rippy nach dem Info-Lauf noch dazugelernt hat (nachpruefen). */
hinweise?: string[]
}
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
}
export function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
const laufzeit =
typ === 'movie'
? (details.runtime ?? 0)
: ((details.episode_run_time ?? []).find((m) => typeof m === 'number' && m > 0) ?? 0)
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 ?? '',
backdropPfad: details.backdrop_path ?? '',
laufzeitMinuten: laufzeit,
genres: (details.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
logoPfad: logoAus(details.images?.logos),
tagline: (details.tagline ?? '').trim(),
bewertung: typeof details.vote_average === 'number' ? Math.round(details.vote_average * 10) / 10 : 0,
fsk: fskAus(details, typ),
besetzung: besetzungAus(details.credits?.cast),
folgenGesamt: typ === 'tv' ? (details.number_of_episodes ?? 0) : 0,
}
}
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,
// OMDb kennt keine Querformat-Bilder — die Kachel fällt aufs Poster zurück.
backdropPfad: '',
laufzeitMinuten: treffer.runtime,
genres: treffer.genres,
quelle: 'omdb',
logoPfad: '',
tagline: '',
bewertung: 0,
fsk: '',
besetzung: [],
folgenGesamt: 0,
}
}
export const LEERER_TREFFER: MetaTreffer = {
typ: 'unknown',
id: null,
titel: '',
jahr: null,
beschreibung: '',
posterPfad: '',
backdropPfad: '',
laufzeitMinuten: 0,
genres: [],
quelle: '',
logoPfad: '',
tagline: '',
bewertung: 0,
fsk: '',
besetzung: [],
folgenGesamt: 0,
}
const UNBEKANNT = (titel: string): Zuordnung => ({
titel,
jahr: null,
confidence: 0.3,
meta: { ...LEERER_TREFFER, titel },
})
/** Ein Eintrag ohne Inhalt ist kein Treffer (pur). Gemessen an den drei
* „Spartacus: Gods of the Arena"-Stubs: kein Backdrop, keine Beschreibung,
* keine Bewertung — TMDb kennt dort nur den Namen. */
export function istStub(details: FilmDetails): boolean {
const beschreibung = (details.overview ?? '').trim().length > 0
const bild = (details.backdrop_path ?? '') !== '' && details.backdrop_path !== null
const bewertet = (details.vote_count ?? 0) > 0
return !beschreibung && !bild && !bewertet
}
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
function worte(t: string): Set<string> {
return new Set(
t
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0),
)
}
/** 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 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)
}
/** 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 (01)? */
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
/** Weitere Titel derselben Disc (bdmt_deu neben bdmt_eng, 5.4.0). */
alternativen?: readonly string[]
}
interface Kandidat {
text: string
/** Die Vorlage, an der die Abdeckung gemessen wird (Titel ohne Zusatz). */
massstab: string
}
/** Suchvarianten aller Titel-Varianten: erst die vollen Titel, dann die
* gestutzten — je Variante an ihrer eigenen Vorlage gemessen (pur). */
export function kandidatenAus(titel: string, alternativen: readonly string[] = []): Kandidat[] {
const quellen = [titel, ...alternativen].map((t) => t.trim()).filter((t) => t.length > 0)
const gesehen = new Set<string>()
const voll: Kandidat[] = []
const rest: Kandidat[] = []
for (const quelle of quellen) {
const massstab = discZusatzAbtrennen(quelle).titel || quelle
const varianten = titelKandidaten(quelle)
if (varianten.length === 0) varianten.push(quelle)
varianten.forEach((text, i) => {
const schluessel = text.toLowerCase()
if (gesehen.has(schluessel)) return
gesehen.add(schluessel)
;(i === 0 ? voll : rest).push({ text, massstab })
})
}
return [...voll, ...rest]
}
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
*
* 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.
* * (5.4.0) Ein Eintrag ohne Inhalt (istStub) ist kein Treffer — der
* nächste wörtliche Treffer bekommt die Chance.
*/
export async function zuordnen(
discTitel: string,
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
hinweis: DiscHinweis = {},
): Promise<Zuordnung> {
const titel = discTitel.trim()
if (titel.length === 0) return UNBEKANNT('')
const nurSerie = hinweis.istSerie === true
const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? [])
let ersteFilme: FilmTreffer[] = []
let ersteSerien: FilmTreffer[] = []
for (const kandidat of kandidaten) {
const deckung = abdeckung(kandidat.massstab, kandidat.text)
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.text)) ?? [])
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
// Wörtlicher Filmtreffer (0,95)
for (const film of filme) {
if (gleichBedeutend(film.title ?? '', kandidat.text)) {
const details = await tmdb.movieDetails(film.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
}
}
}
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
// Wörtlicher Serientreffer (0,90)
for (const serie of serien) {
if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
}
}
}
// 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). Stubs zählen dabei nicht mit.
if (vollstaendig) {
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
for (const film of filme) {
const details = await tmdb.movieDetails(film.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
for (const serie of serien) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('tv', serie.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.text)
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.
if (wortUeberlappung(kandidat.text, 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'
}
}
// Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage
// ist ein Vorschlag, egal von welchem Dienst er kommt.
const sicher = abdeckung(kandidat.massstab, kandidat.text) >= 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. Bei einer
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
// Einträge MIT Inhalt zuerst; ist alles leer, bleibt der erste Treffer
// trotzdem ein Vorschlag — besser als gar keiner (es wird ja gefragt).
let notnagel: Zuordnung | null = null
for (const film of ersteFilme.slice(0, WENIGE_TREFFER)) {
const details = await tmdb.movieDetails(film.id)
if (details === null) continue
const meta = ausDetails('movie', film.id, details)
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
if (!istStub(details)) return vorschlag
notnagel ??= vorschlag
}
for (const serie of ersteSerien.slice(0, WENIGE_TREFFER)) {
const details = await tmdb.tvDetails(serie.id)
if (details === null) continue
const meta = ausDetails('tv', serie.id, details)
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
if (!istStub(details)) return vorschlag
notnagel ??= vorschlag
}
if (notnagel !== null) return notnagel
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
}
/** Laufzeit passt, wenn die gemessene Länge des Hauptinhalts höchstens so
* weit von TMDb abweicht (Kino- gegen Heimvideo-Fassung, Abspann). */
export const LAUFZEIT_PASST = 0.06
/** Darüber passt sie NICHT — der Treffer wird zum Vorschlag (95 vs. 158 min). */
export const LAUFZEIT_FALSCH = 0.25
/** Was der Info-Lauf über die Disc gemessen hat (rip/titelwahl.ts). */
export interface Messung {
/** Länge des Hauptinhalts in Sekunden (Film) — 0 wenn keiner erkennbar. */
hauptS: number
/** Längen der Folgen-Kandidaten in Sekunden (Serie). */
folgenS: readonly number[]
/** Was die Titel-Struktur sagt: 'film' | 'serie' | 'unklar'. */
struktur: 'film' | 'serie' | 'unklar'
}
/**
* Nachprüfen NACH dem Info-Lauf (pur, 5.4.0): Erkannt wird beim EINLEGEN,
* also vor jeder Messung. Jetzt liegt die stärkste Evidenz vor, die zu
* haben ist — die Laufzeit. Aus „wahrscheinlich" wird „bestätigt", oder
* Rippy merkt selbst, dass es danebenlag. Eine Wahl des Nutzers
* (confidence 1) wird nie herabgestuft, nur kommentiert.
*/
export function nachpruefen(zuordnung: Zuordnung, messung: Messung): Zuordnung {
const hinweise: string[] = []
let confidence = zuordnung.confidence
const meta = zuordnung.meta
const vomNutzer = zuordnung.confidence >= 1
const soll = meta.laufzeitMinuten * 60
if (meta.typ === 'movie' && soll > 0 && messung.hauptS > 0) {
const abweichung = Math.abs(messung.hauptS - soll) / soll
const ist = Math.round(messung.hauptS / 60)
if (abweichung <= LAUFZEIT_PASST) {
hinweise.push(`Laufzeit bestätigt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.max(confidence, 0.95)
} else if (abweichung >= LAUFZEIT_FALSCH) {
hinweise.push(
`Laufzeit passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb — bitte über „Ändern …" nachsehen.`,
)
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
} else {
hinweise.push(`Laufzeit ähnlich: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb (andere Fassung?).`)
}
} else if (meta.typ === 'tv' && soll > 0 && messung.folgenS.length > 0) {
const mittel = messung.folgenS.reduce((s, f) => s + f, 0) / messung.folgenS.length
const abweichung = Math.abs(mittel - soll) / soll
const ist = Math.round(mittel / 60)
if (abweichung <= 0.2) {
hinweise.push(`Folgenlänge passt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.max(confidence, 0.8)
} else if (abweichung >= 0.5) {
hinweise.push(`Folgenlänge passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
}
}
if (messung.struktur === 'serie' && meta.typ === 'movie') {
hinweise.push('Die Titel-Struktur sieht nach einer Serienstaffel aus (mehrere gleich lange Titel), TMDb-Treffer ist ein Film.')
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
} else if (messung.struktur === 'film' && meta.typ === 'tv') {
hinweise.push('Die Titel-Struktur sieht nach einem Film aus (ein langer Titel), TMDb-Treffer ist eine Serie.')
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
}
return { ...zuordnung, confidence: Math.round(confidence * 100) / 100, hinweise }
}