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>
485 lines
20 KiB
TypeScript
485 lines
20 KiB
TypeScript
// 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, 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 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 (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
|
||
/** 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 }
|
||
}
|