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
@@ -2,7 +2,7 @@
|
||||
// Disc-Wache. Die große Oberfläche (Dashboard, Bibliothek, Einstellungen)
|
||||
// ist Etappe W-5 — hier wird das Gerüst SICHTBAR bewiesen.
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
||||
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
||||
import {
|
||||
aufVerbindung,
|
||||
auswerfen,
|
||||
@@ -60,7 +60,22 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
|
||||
|
||||
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
||||
|
||||
function LaufwerkKachel({ geraet, rip }: { geraet: GeraeteInfo; rip: RipStatus | undefined }) {
|
||||
function sicherheitsWort(confidence: number): string {
|
||||
if (confidence >= 0.9) return 'sehr wahrscheinlich'
|
||||
if (confidence >= 0.8) return 'wahrscheinlich'
|
||||
if (confidence >= 0.6) return 'Vorschlag'
|
||||
return 'unsicher'
|
||||
}
|
||||
|
||||
function LaufwerkKachel({
|
||||
geraet,
|
||||
rip,
|
||||
info,
|
||||
}: {
|
||||
geraet: GeraeteInfo
|
||||
rip: RipStatus | undefined
|
||||
info: DiscInfoStand | undefined
|
||||
}) {
|
||||
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
||||
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
|
||||
const punktFarbe = ripLaeuft
|
||||
@@ -113,6 +128,13 @@ function LaufwerkKachel({ geraet, rip }: { geraet: GeraeteInfo; rip: RipStatus |
|
||||
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'ready' && info !== undefined && info.titel.length > 0 && (
|
||||
<p className={info.confidence >= 0.8 ? 'text-emerald-300' : 'text-amber-300'}>
|
||||
{sicherheitsWort(info.confidence)}: {info.titel}
|
||||
{info.jahr !== null ? ` (${info.jahr})` : ''} · {Math.round(info.confidence * 100)} %
|
||||
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
|
||||
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
|
||||
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
|
||||
@@ -162,6 +184,7 @@ export default function App() {
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
meldungen: [],
|
||||
})
|
||||
|
||||
@@ -201,7 +224,12 @@ export default function App() {
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{verbindung.laufwerke.map((geraet) => (
|
||||
<LaufwerkKachel key={geraet.id} geraet={geraet} rip={verbindung.rips[geraet.id]} />
|
||||
<LaufwerkKachel
|
||||
key={geraet.id}
|
||||
geraet={geraet}
|
||||
rip={verbindung.rips[geraet.id]}
|
||||
info={verbindung.discInfos[geraet.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
|
||||
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import { istKernNachricht, type GeraeteInfo, type KernNachricht, type RipStatus } from '../gemeinsam/nachrichten'
|
||||
import {
|
||||
istKernNachricht,
|
||||
type DiscInfoStand,
|
||||
type GeraeteInfo,
|
||||
type KernNachricht,
|
||||
type RipStatus,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface Verbindung {
|
||||
verbunden: boolean
|
||||
@@ -16,6 +22,8 @@ export interface Verbindung {
|
||||
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
|
||||
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
|
||||
rips: Record<string, RipStatus>
|
||||
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
|
||||
discInfos: Record<string, DiscInfoStand>
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
@@ -30,6 +38,7 @@ const stand: Verbindung = {
|
||||
pongLatenzMs: null,
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
meldungen: [],
|
||||
}
|
||||
const horcher = new Set<Horcher>()
|
||||
@@ -38,7 +47,13 @@ let port: MessagePort | null = null
|
||||
|
||||
function melden(): void {
|
||||
for (const h of horcher) {
|
||||
h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, meldungen: [...stand.meldungen] })
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +94,15 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`)
|
||||
break
|
||||
}
|
||||
case 'disc-info': {
|
||||
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
|
||||
if (nachricht.info.titel.length > 0) {
|
||||
meldung(
|
||||
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
@@ -130,7 +154,13 @@ export function verbindungStarten(): void {
|
||||
|
||||
export function aufVerbindung(h: Horcher): () => void {
|
||||
horcher.add(h)
|
||||
h({ ...stand, laufwerke: [...stand.laufwerke], rips: { ...stand.rips }, meldungen: [...stand.meldungen] })
|
||||
h({
|
||||
...stand,
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
return () => horcher.delete(h)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,17 @@ export interface RipStatus {
|
||||
lesefehler: boolean
|
||||
}
|
||||
|
||||
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
|
||||
export interface DiscInfoStand {
|
||||
id: string
|
||||
titel: string
|
||||
jahr: number | null
|
||||
/** 0–1; die Schwelle für „sicher genug" ist einstellbar. */
|
||||
confidence: number
|
||||
quelle: string
|
||||
posterPfad: string
|
||||
}
|
||||
|
||||
/** Nachrichten, die der Kern verschickt. */
|
||||
export type KernNachricht =
|
||||
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
|
||||
@@ -67,6 +78,7 @@ export type KernNachricht =
|
||||
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
|
||||
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
|
||||
| { art: 'rip-status'; status: RipStatus }
|
||||
| { art: 'disc-info'; info: DiscInfoStand }
|
||||
|
||||
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
|
||||
export type FensterNachricht =
|
||||
@@ -113,6 +125,13 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
|
||||
)
|
||||
case 'rip-status':
|
||||
return istRipStatus(wert.status)
|
||||
case 'disc-info':
|
||||
return (
|
||||
istObjekt(wert.info) &&
|
||||
typeof (wert.info as Record<string, unknown>).id === 'string' &&
|
||||
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
|
||||
typeof (wert.info as Record<string, unknown>).confidence === 'number'
|
||||
)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Poster/Fanart neben den Film legen (KONZEPT § 6.10) — Regeln aus
|
||||
// medien.py: relative TMDb-Pfade bekommen das w500-Präfix; ein fehlendes
|
||||
// Poster darf einen gelungenen Rip NIE kippen (best effort, aber die
|
||||
// Meldung sagt was passiert ist).
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
export function posterUrl(posterPfad: string): string {
|
||||
if (posterPfad.length === 0) return ''
|
||||
if (posterPfad.startsWith('http')) return posterPfad
|
||||
return `https://image.tmdb.org/t/p/w500${posterPfad}`
|
||||
}
|
||||
|
||||
export async function posterSpeichern(
|
||||
posterPfad: string,
|
||||
zielDatei: string,
|
||||
holen: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
const url = posterUrl(posterPfad)
|
||||
if (url.length === 0) return ''
|
||||
try {
|
||||
const antwort = await holen(url, { signal: AbortSignal.timeout(15_000) })
|
||||
if (antwort.status !== 200) return `Poster: HTTP ${antwort.status}`
|
||||
const daten = Buffer.from(await antwort.arrayBuffer())
|
||||
if (daten.length === 0) return 'Poster: leere Antwort'
|
||||
writeFileSync(zielDatei, daten)
|
||||
return 'poster.jpg gespeichert'
|
||||
} catch (fehler) {
|
||||
return `Poster fehlgeschlagen: ${String(fehler)}`
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,11 @@ import { basename, join } from 'node:path'
|
||||
import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
import { posterSpeichern } from '../ablage/poster'
|
||||
import { zielordner } from '../ablage/struktur'
|
||||
import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
import { rippen } from '../rip/makemkv'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
@@ -34,6 +36,8 @@ export interface PipelineUmgebung {
|
||||
ablage: () => string
|
||||
einstellungen: () => Einstellungen
|
||||
melden: (status: RipStatus) => void
|
||||
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
|
||||
zuordnung?: (id: string) => Zuordnung | null
|
||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||
jetzt?: () => Date
|
||||
}
|
||||
@@ -163,7 +167,13 @@ export class Pipeline {
|
||||
}
|
||||
|
||||
const preset = presetFuer(geraet.typ, einstellungen)
|
||||
const fertigOrdner = zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
||||
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
|
||||
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||
const fertigOrdner =
|
||||
zuordnung !== null && zuordnung.titel.length > 0
|
||||
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
|
||||
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
||||
mkdirSync(fertigOrdner, { recursive: true })
|
||||
|
||||
const fertigeDateien: string[] = []
|
||||
@@ -227,13 +237,30 @@ export class Pipeline {
|
||||
this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien })
|
||||
}
|
||||
try {
|
||||
writeFileSync(join(fertigOrdner, 'movie.nfo'), baueNfo({ quelle: 'Rippy v5' }, vorgangsName, null))
|
||||
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
|
||||
writeFileSync(
|
||||
join(fertigOrdner, nfoName),
|
||||
baueNfo(
|
||||
{
|
||||
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
|
||||
beschreibung: zuordnung?.meta.beschreibung,
|
||||
genres: zuordnung?.meta.genres,
|
||||
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
|
||||
quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
|
||||
},
|
||||
zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
|
||||
zuordnung?.jahr ?? null,
|
||||
),
|
||||
)
|
||||
} catch (fehler) {
|
||||
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
|
||||
// wird GESAGT (R4).
|
||||
const stand = this.laufend.get(id)?.status
|
||||
if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` })
|
||||
}
|
||||
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
|
||||
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
|
||||
}
|
||||
|
||||
const server = String(einstellungen['mediaServer'] ?? '')
|
||||
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
|
||||
|
||||
@@ -18,6 +18,10 @@ import { Pipeline } from './ablauf/pipeline'
|
||||
import type { LaufwerkApi } from './laufwerk/codes'
|
||||
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
|
||||
import { DiscWache } from './laufwerk/wache'
|
||||
import { discTitel } from './metadaten/discmerkmale'
|
||||
import { OmdbClient } from './metadaten/omdb'
|
||||
import { TmdbClient } from './metadaten/tmdb'
|
||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||
import { Datenbank } from './speicher/db'
|
||||
import { finden } from './werkzeuge/katalog'
|
||||
|
||||
@@ -78,6 +82,35 @@ let laufwerkApi: LaufwerkApi | null = null
|
||||
let laufwerkFehler: string | null = null
|
||||
let wache: DiscWache | null = null
|
||||
|
||||
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
|
||||
const discZuordnungen = new Map<string, Zuordnung>()
|
||||
|
||||
async function discErmitteln(id: string, geraetepfad: string): Promise<void> {
|
||||
try {
|
||||
const gefunden = discTitel(geraetepfad)
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
||||
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
|
||||
discZuordnungen.set(id, zuordnung)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: {
|
||||
id,
|
||||
titel: zuordnung.titel,
|
||||
jahr: zuordnung.jahr,
|
||||
confidence: zuordnung.confidence,
|
||||
quelle: zuordnung.meta.quelle,
|
||||
posterPfad: zuordnung.meta.posterPfad,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
|
||||
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
|
||||
console.error(`[kern] Disc-Erkennung ${id}: ${String(fehler)}`)
|
||||
anFenster({ art: 'kern-fehler', text: `Disc-Erkennung ${id}: ${String(fehler)}` })
|
||||
}
|
||||
}
|
||||
|
||||
const laufwerkeBereit: Promise<void> = (async () => {
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -89,6 +122,17 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
||||
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
|
||||
(meldung) => {
|
||||
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
|
||||
for (const ereignis of meldung.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
discZuordnungen.delete(ereignis.geraet.id)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: { id: ereignis.geraet.id, titel: '', jahr: null, confidence: 0, quelle: '', posterPfad: '' },
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
(fehler) => {
|
||||
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
|
||||
@@ -148,6 +192,7 @@ const ripVerwaltung = new Pipeline({
|
||||
ablage: ablageOrdner,
|
||||
einstellungen: einstellungenLaden,
|
||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
||||
})
|
||||
|
||||
async function auswerfen(id: string): Promise<void> {
|
||||
|
||||
@@ -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