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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d70a81d194
commit
3f7f962dff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
|||||||
|
// Roh-Dateien aufräumen (5.4.0) — PUR bis auf injizierte Dateizugriffe.
|
||||||
|
//
|
||||||
|
// Der Befund: Jede Blu-ray ließ 30+ GB unter roh\ liegen, niemand räumte
|
||||||
|
// auf. Seit W-3 stand im Code „Löschen bekommt erst mit der Einstellung im
|
||||||
|
// UI eine Stimme". Das ist diese Stimme. Die Regel des Commanders
|
||||||
|
// (01.09.2026): „Nach 7 Tagen löschen, nur bei gelungener Kompression".
|
||||||
|
//
|
||||||
|
// Drei Sicherungen, alle bewusst:
|
||||||
|
// 1. Gelöscht wird NUR ein Roh-Ordner, dessen Vorgang FERTIG und
|
||||||
|
// KOMPRIMIERT ist. Unkomprimierte Rips (4K verlustfrei) sind verschoben
|
||||||
|
// worden, ihr Roh-Ordner ist leer — es gibt dort nichts zu löschen.
|
||||||
|
// 2. Vorher wird nachgesehen, ob JEDE fertige Datei noch da ist und nicht
|
||||||
|
// leer — eine gelöschte Roh-Datei ohne heiles Ergebnis wäre der einzige
|
||||||
|
// Weg, wie Rippy Daten verlieren könnte.
|
||||||
|
// 3. Ordner unter roh\, die KEIN Vorgang kennt (aus der Zeit vor 5.4.0),
|
||||||
|
// werden nur gezeigt — löschen darf sie nur ein Klick des Nutzers.
|
||||||
|
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||||
|
// Plattform-Pfade: Laufzeit-Wurzeln (Ablage-Einstellung), CI testet auf Linux.
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { FremderOrdner } from '../../gemeinsam/nachrichten'
|
||||||
|
|
||||||
|
export type AufbewahrungsRegel = { art: 'behalten' } | { art: 'sofort' } | { art: 'tage'; tage: number }
|
||||||
|
|
||||||
|
/** Einstellungs-Schlüssel und Vorgabe (Commander-Wahl 01.09.2026). */
|
||||||
|
export const AUFBEWAHRUNG_SCHLUESSEL = 'rohAufbewahrung'
|
||||||
|
export const STANDARD_AUFBEWAHRUNG = '7'
|
||||||
|
|
||||||
|
/** 'behalten' | 'sofort' | '<Tage>' → Regel. Unbekanntes = Vorgabe. */
|
||||||
|
export function regelLesen(wert: string | null | undefined): AufbewahrungsRegel {
|
||||||
|
const text = (wert ?? '').trim().toLowerCase()
|
||||||
|
if (text === 'behalten' || text === 'nie') return { art: 'behalten' }
|
||||||
|
if (text === 'sofort' || text === '0') return { art: 'sofort' }
|
||||||
|
const tage = Number(text.length > 0 ? text : STANDARD_AUFBEWAHRUNG)
|
||||||
|
if (Number.isInteger(tage) && tage > 0 && tage <= 3650) return { art: 'tage', tage }
|
||||||
|
return { art: 'tage', tage: Number(STANDARD_AUFBEWAHRUNG) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regelText(regel: AufbewahrungsRegel): string {
|
||||||
|
if (regel.art === 'behalten') return 'Roh-Dateien werden behalten — aufgeräumt wird nur von Hand.'
|
||||||
|
if (regel.art === 'sofort') return 'Roh-Dateien werden sofort nach gelungener Kompression gelöscht.'
|
||||||
|
return `Roh-Dateien werden ${regel.tage} Tage nach gelungener Kompression gelöscht.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wann die Frist eines fertigen, komprimierten Vorgangs abläuft — '' wenn nie. */
|
||||||
|
export function faelligAm(beendetIso: string | null, regel: AufbewahrungsRegel): string {
|
||||||
|
if (beendetIso === null || beendetIso.length === 0 || regel.art === 'behalten') return ''
|
||||||
|
const beendet = new Date(beendetIso)
|
||||||
|
if (Number.isNaN(beendet.getTime())) return ''
|
||||||
|
if (regel.art === 'sofort') return beendet.toISOString()
|
||||||
|
return new Date(beendet.getTime() + regel.tage * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das, was das Aufräumen über einen Vorgang wissen muss. */
|
||||||
|
export interface AufraeumKandidat {
|
||||||
|
id: string
|
||||||
|
phase: string
|
||||||
|
komprimiert: boolean
|
||||||
|
rohOrdner: string
|
||||||
|
rohGeloeschtAm: string | null
|
||||||
|
beendet: string | null
|
||||||
|
fertigeDateien: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AufraeumZugriff {
|
||||||
|
existiert: (pfad: string) => boolean
|
||||||
|
/** Dateigröße in Bytes; 0 wenn nicht lesbar. */
|
||||||
|
groesse: (pfad: string) => number
|
||||||
|
ordnerLoeschen: (pfad: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ECHTER_ZUGRIFF: AufraeumZugriff = {
|
||||||
|
existiert: existsSync,
|
||||||
|
groesse: (pfad) => {
|
||||||
|
try {
|
||||||
|
return statSync(pfad).size
|
||||||
|
} catch {
|
||||||
|
// Nicht lesbar zählt wie „nicht da" — und dann wird NICHT gelöscht.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ordnerLoeschen: (pfad) => rmSync(pfad, { recursive: true, force: true }),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Warum ein Vorgang (noch) nicht gelöscht wird — '' heißt: löschbar. */
|
||||||
|
export function loeschHindernis(
|
||||||
|
v: AufraeumKandidat,
|
||||||
|
regel: AufbewahrungsRegel,
|
||||||
|
jetzt: Date,
|
||||||
|
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
|
||||||
|
): string {
|
||||||
|
if (v.rohGeloeschtAm !== null) return 'schon gelöscht'
|
||||||
|
if (v.phase !== 'fertig') return `nicht fertig (${v.phase})`
|
||||||
|
if (!v.komprimiert) return 'nicht komprimiert — die Roh-Dateien SIND das Ergebnis'
|
||||||
|
if (!zugriff.existiert(v.rohOrdner)) return 'Roh-Ordner ist schon weg'
|
||||||
|
if (v.fertigeDateien.length === 0) return 'keine fertige Datei bekannt'
|
||||||
|
for (const datei of v.fertigeDateien) {
|
||||||
|
if (!zugriff.existiert(datei)) return `fertige Datei fehlt: ${datei}`
|
||||||
|
if (zugriff.groesse(datei) <= 0) return `fertige Datei ist leer: ${datei}`
|
||||||
|
}
|
||||||
|
if (regel.art === 'behalten') return 'Regel: behalten'
|
||||||
|
const frist = faelligAm(v.beendet, regel)
|
||||||
|
if (frist.length === 0) return 'kein Ende-Zeitpunkt bekannt'
|
||||||
|
if (new Date(frist).getTime() > jetzt.getTime()) return `Frist läuft bis ${frist.slice(0, 16).replace('T', ' ')}`
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AufraeumErgebnis {
|
||||||
|
geloescht: string[]
|
||||||
|
uebersprungen: Array<{ id: string; grund: string }>
|
||||||
|
fehler: Array<{ id: string; grund: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wendet die Regel an. Liefert, was gelöscht wurde und warum der Rest blieb. */
|
||||||
|
export function aufraeumen(
|
||||||
|
vorgaenge: readonly AufraeumKandidat[],
|
||||||
|
regel: AufbewahrungsRegel,
|
||||||
|
jetzt: Date,
|
||||||
|
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
|
||||||
|
): AufraeumErgebnis {
|
||||||
|
const ergebnis: AufraeumErgebnis = { geloescht: [], uebersprungen: [], fehler: [] }
|
||||||
|
for (const v of vorgaenge) {
|
||||||
|
const hindernis = loeschHindernis(v, regel, jetzt, zugriff)
|
||||||
|
if (hindernis.length > 0) {
|
||||||
|
ergebnis.uebersprungen.push({ id: v.id, grund: hindernis })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
zugriff.ordnerLoeschen(v.rohOrdner)
|
||||||
|
ergebnis.geloescht.push(v.id)
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Ein Ordner, der sich nicht löschen lässt (offene Datei?), wird
|
||||||
|
// gesagt — beim nächsten Takt kommt er wieder dran.
|
||||||
|
ergebnis.fehler.push({ id: v.id, grund: String(fehler) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Belegung eines Ordners in Bytes (rekursiv). 0, wenn es ihn nicht gibt. */
|
||||||
|
export function ordnerGroesse(pfad: string): number {
|
||||||
|
let summe = 0
|
||||||
|
const gehen = (ordner: string): void => {
|
||||||
|
let eintraege: import('node:fs').Dirent[]
|
||||||
|
try {
|
||||||
|
eintraege = readdirSync(ordner, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
// Nicht lesbar zählt nicht mit — die Anzeige ist dann zu klein, nie
|
||||||
|
// erfunden.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const e of eintraege) {
|
||||||
|
const voll = join(ordner, e.name)
|
||||||
|
if (e.isDirectory()) gehen(voll)
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
summe += statSync(voll).size
|
||||||
|
} catch {
|
||||||
|
// siehe oben
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existsSync(pfad)) gehen(pfad)
|
||||||
|
return summe
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
|
||||||
|
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>): FremderOrdner[] {
|
||||||
|
if (!existsSync(rohWurzel)) return []
|
||||||
|
const liste: FremderOrdner[] = []
|
||||||
|
let namen: string[]
|
||||||
|
try {
|
||||||
|
namen = readdirSync(rohWurzel, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory())
|
||||||
|
.map((e) => e.name)
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Ein nicht lesbarer Roh-Ordner ist eine laute Zeile — die Liste
|
||||||
|
// bleibt dann leer, weil wirklich nichts gesehen wurde.
|
||||||
|
console.error(`[roh] ${rohWurzel} nicht lesbar: ${String(fehler)}`)
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
for (const name of namen) {
|
||||||
|
const voll = join(rohWurzel, name)
|
||||||
|
if (bekannte.has(voll) || bekannte.has(name)) continue
|
||||||
|
liste.push({ ordner: voll, bytes: ordnerGroesse(voll) })
|
||||||
|
}
|
||||||
|
return liste.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist dieser Pfad ein Ordner DIREKT unter der Roh-Wurzel? Nur solche
|
||||||
|
* darf „fremden Ordner löschen" anfassen — nie etwas anderes. */
|
||||||
|
export function istUnterRohWurzel(rohWurzel: string, pfad: string): boolean {
|
||||||
|
const wurzel = rohWurzel.replace(/[\\/]+$/, '')
|
||||||
|
const kandidat = pfad.replace(/[\\/]+$/, '')
|
||||||
|
if (!kandidat.toLowerCase().startsWith(wurzel.toLowerCase())) return false
|
||||||
|
const rest = kandidat.slice(wurzel.length)
|
||||||
|
return /^[\\/][^\\/]+$/.test(rest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
// Kompression + Ablage EINES Vorgangs (5.4.0) — die zweite Hälfte der
|
||||||
|
// Kette aus pipeline.ts, herausgelöst, damit sie OHNE Laufwerk läuft:
|
||||||
|
// * in der Warteschlange, während das Laufwerk schon die nächste Disc
|
||||||
|
// liest (Commander-Wunsch: „Nächste Disc rippen, während die vorige
|
||||||
|
// noch komprimiert"),
|
||||||
|
// * und Tage später noch einmal — „Wiederholen ab Kompression": Die
|
||||||
|
// Roh-Dateien liegen genau dafür da, ein Preset-Wechsel kostet kein
|
||||||
|
// Neulesen mehr.
|
||||||
|
//
|
||||||
|
// Was hier NEU ist gegenüber 5.3.x:
|
||||||
|
// * Ohne Kompression (4K verlustfrei, kein HandBrake) werden die Roh-
|
||||||
|
// Dateien in den Zielordner VERSCHOBEN — mit NFO, Poster, Bibliothek.
|
||||||
|
// Vorher blieben sie unter roh\ liegen, und nichts wusste von ihnen.
|
||||||
|
// * Sprachen JE DATEI (auftrag.ts sprachwahl) statt einer globalen Vorgabe.
|
||||||
|
// * Vor der Kompression wird die Roh-Datei GEFRAGT (HandBrake-Scan), ob
|
||||||
|
// die gewünschten Sprachen drin sind — fehlt eine, steht es im Protokoll.
|
||||||
|
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
|
||||||
|
// TMDb gibt dann nur noch eine Warnung.
|
||||||
|
// * Durchgehender Fortschritt, nach Dateigröße gewichtet.
|
||||||
|
import {
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
renameSync,
|
||||||
|
statSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs'
|
||||||
|
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||||
|
import { basename, join } from 'node:path'
|
||||||
|
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
|
||||||
|
import { baueNfo } from '../ablage/nfo'
|
||||||
|
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||||
|
import { posterSpeichern } from '../ablage/poster'
|
||||||
|
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
|
||||||
|
import { komprimieren } from '../komprimieren/handbrake'
|
||||||
|
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||||
|
import { fehlendeSprachen, spurenScannen, type HbSpuren } from '../komprimieren/scan'
|
||||||
|
import {
|
||||||
|
folgenNummern,
|
||||||
|
folgenPlausibel,
|
||||||
|
gewichteterFortschritt,
|
||||||
|
sprachwahl,
|
||||||
|
type KompressionsAuftrag,
|
||||||
|
} from './auftrag'
|
||||||
|
|
||||||
|
export interface AblageUmgebung {
|
||||||
|
/** Pfad zu HandBrakeCLI — oder '' (dann werden die Roh-Dateien verschoben). */
|
||||||
|
handbrake: () => string
|
||||||
|
/** Wurzel der Ablage. */
|
||||||
|
ablage: () => string
|
||||||
|
einstellungen: () => Einstellungen
|
||||||
|
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
|
||||||
|
presetEmpfehlung?: (discTyp: string) => string
|
||||||
|
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden]. */
|
||||||
|
episodenLaufzeiten?: (
|
||||||
|
tmdbId: number,
|
||||||
|
staffel: number,
|
||||||
|
) => Promise<ReadonlyArray<readonly [number, number]>>
|
||||||
|
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||||
|
jetzt?: () => Date
|
||||||
|
/** Roh-Datei ins Ziel verschieben — injizierbar für Tests. */
|
||||||
|
verschieben?: (von: string, nach: string) => void
|
||||||
|
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
|
||||||
|
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AblageErgebnis {
|
||||||
|
status: 'fertig' | 'fehler' | 'abgebrochen'
|
||||||
|
zielOrdner: string
|
||||||
|
dateien: string[]
|
||||||
|
text: string
|
||||||
|
komprimiert: boolean
|
||||||
|
preset: string
|
||||||
|
fertigBytes: number
|
||||||
|
hinweise: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text'>) => void
|
||||||
|
|
||||||
|
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
|
||||||
|
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
|
||||||
|
export function dateiVerschieben(von: string, nach: string): void {
|
||||||
|
try {
|
||||||
|
renameSync(von, nach)
|
||||||
|
} catch (fehler) {
|
||||||
|
const code = (fehler as NodeJS.ErrnoException).code
|
||||||
|
if (code !== 'EXDEV') throw fehler
|
||||||
|
copyFileSync(von, nach)
|
||||||
|
unlinkSync(von)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groesseVon(pfad: string): number {
|
||||||
|
try {
|
||||||
|
return statSync(pfad).size
|
||||||
|
} catch {
|
||||||
|
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts am
|
||||||
|
// Ergebnis selbst.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function komprimierenUndAblegen(
|
||||||
|
auftrag: KompressionsAuftrag,
|
||||||
|
umgebung: AblageUmgebung,
|
||||||
|
melden: AblageMeldung,
|
||||||
|
abbruch: AbortSignal,
|
||||||
|
): Promise<AblageErgebnis> {
|
||||||
|
const einstellungen = umgebung.einstellungen()
|
||||||
|
const handbrake = umgebung.handbrake()
|
||||||
|
const soll = komprimierenFuer(auftrag.discTyp, einstellungen)
|
||||||
|
const komprimiert = soll && handbrake.length > 0
|
||||||
|
const preset =
|
||||||
|
auftrag.preset.length > 0
|
||||||
|
? auftrag.preset
|
||||||
|
: presetFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
|
||||||
|
const zuordnung = auftrag.zuordnung
|
||||||
|
const hinweise: string[] = [...auftrag.hinweise]
|
||||||
|
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad))
|
||||||
|
|
||||||
|
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
|
||||||
|
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
|
||||||
|
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
|
||||||
|
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
|
||||||
|
const alsSerie = dateien.some((d) => d.rolle === 'folge')
|
||||||
|
const staffel = auftrag.staffel ?? 1
|
||||||
|
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
|
||||||
|
const fertigOrdner =
|
||||||
|
auftrag.zielOrdner.length > 0
|
||||||
|
? auftrag.zielOrdner
|
||||||
|
: alsSerie
|
||||||
|
? serienOrdner(join(umgebung.ablage(), 'Serien'), serienTitel, staffel)
|
||||||
|
: zuordnung !== null && zuordnung.titel.length > 0
|
||||||
|
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
|
||||||
|
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
|
||||||
|
mkdirSync(fertigOrdner, { recursive: true })
|
||||||
|
|
||||||
|
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
|
||||||
|
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
|
||||||
|
const extrasOrdner = join(fertigOrdner, 'extras')
|
||||||
|
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
|
||||||
|
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true })
|
||||||
|
|
||||||
|
const global = {
|
||||||
|
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
|
||||||
|
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
|
||||||
|
}
|
||||||
|
const gewichte = dateien.map((d) => d.groesseBytes)
|
||||||
|
const scannen = umgebung.spurenScannen ?? spurenScannen
|
||||||
|
const verschieben = umgebung.verschieben ?? dateiVerschieben
|
||||||
|
|
||||||
|
const fertigeDateien: string[] = []
|
||||||
|
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
||||||
|
// Episoden-Zuordnung weiter unten.
|
||||||
|
const folgenSekunden: number[] = []
|
||||||
|
for (let i = 0; i < dateien.length; i++) {
|
||||||
|
const datei = dateien[i]
|
||||||
|
const ausgabe = join(zielFuer(datei.rolle), basename(datei.pfad))
|
||||||
|
if (abbruch.aborted) {
|
||||||
|
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||||
|
}
|
||||||
|
if (!komprimiert) {
|
||||||
|
melden({
|
||||||
|
phase: 'legt-ab',
|
||||||
|
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||||
|
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
verschieben(datei.pfad, ausgabe)
|
||||||
|
} catch (fehler) {
|
||||||
|
return {
|
||||||
|
status: 'fehler',
|
||||||
|
zielOrdner: fertigOrdner,
|
||||||
|
dateien: fertigeDateien,
|
||||||
|
text: `Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
|
||||||
|
komprimiert: false,
|
||||||
|
preset: '',
|
||||||
|
fertigBytes: 0,
|
||||||
|
hinweise,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fertigeDateien.push(ausgabe)
|
||||||
|
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const wahl = sprachwahl(datei, global)
|
||||||
|
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
|
||||||
|
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
|
||||||
|
// globale Vorgabe war schon immer „was da ist".
|
||||||
|
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
|
||||||
|
const spuren = await scannen(handbrake, datei.pfad)
|
||||||
|
if (spuren !== null) {
|
||||||
|
const fehlt = fehlendeSprachen(spuren, wahl)
|
||||||
|
if (fehlt.audio.length > 0) {
|
||||||
|
hinweise.push(
|
||||||
|
`${basename(datei.pfad)}: Tonspur ${fehlt.audio.join(', ')} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (fehlt.untertitel.length > 0) {
|
||||||
|
hinweise.push(
|
||||||
|
`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
melden({
|
||||||
|
phase: 'komprimiert',
|
||||||
|
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||||
|
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
|
||||||
|
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
|
||||||
|
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} …`,
|
||||||
|
})
|
||||||
|
const encode = await komprimieren({
|
||||||
|
programm: handbrake,
|
||||||
|
eingabe: datei.pfad,
|
||||||
|
ausgabe,
|
||||||
|
preset,
|
||||||
|
audioSprachen: wahl.audio,
|
||||||
|
untertitelSprachen: wahl.untertitel,
|
||||||
|
untertitelStandard: wahl.untertitelStandard,
|
||||||
|
abbruch,
|
||||||
|
aufFortschritt: (prozent) => {
|
||||||
|
melden({
|
||||||
|
phase: 'komprimiert',
|
||||||
|
prozent: gewichteterFortschritt(gewichte, i, prozent),
|
||||||
|
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (encode.status === 'cancelled') {
|
||||||
|
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||||
|
}
|
||||||
|
if (encode.status === 'error') {
|
||||||
|
return {
|
||||||
|
status: 'fehler',
|
||||||
|
zielOrdner: fertigOrdner,
|
||||||
|
dateien: fertigeDateien,
|
||||||
|
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
|
||||||
|
komprimiert: true,
|
||||||
|
preset,
|
||||||
|
fertigBytes: 0,
|
||||||
|
hinweise,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fertigeDateien.push(encode.ausgabe)
|
||||||
|
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
|
||||||
|
// Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit-
|
||||||
|
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
|
||||||
|
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
|
||||||
|
// (die offene Wunde #395 von ARM war genau dieses Ratespiel).
|
||||||
|
if (alsSerie && folgenSekunden.length > 0) {
|
||||||
|
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
|
||||||
|
const laufzeiten =
|
||||||
|
tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
|
||||||
|
let episoden: number[] | null = null
|
||||||
|
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
|
||||||
|
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
|
||||||
|
hinweise.push(`Folgen von Hand: Staffel ${staffel}, ab Folge ${auftrag.ersteFolge}.`)
|
||||||
|
hinweise.push(...folgenPlausibel(folgenSekunden, episoden, laufzeiten))
|
||||||
|
} else {
|
||||||
|
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
|
||||||
|
}
|
||||||
|
if (episoden !== null) {
|
||||||
|
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
|
||||||
|
} else {
|
||||||
|
hinweise.push(
|
||||||
|
laufzeiten.length === 0
|
||||||
|
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen. Tipp: „Staffel und Folge von Hand" im Auswahl-Dialog.'
|
||||||
|
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ablage: NFO und Poster daneben ───────────────────────────────────
|
||||||
|
melden({ phase: 'legt-ab', prozent: 100, text: 'Lege ab …' })
|
||||||
|
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
|
||||||
|
try {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
anzeigeTitel,
|
||||||
|
zuordnung?.jahr ?? null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (fehler) {
|
||||||
|
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
|
||||||
|
// wird GESAGT (R4).
|
||||||
|
hinweise.push(`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'] ?? '')
|
||||||
|
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
|
||||||
|
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
|
||||||
|
|
||||||
|
// Umbenannte Folgen: Die Liste der fertigen Dateien muss den NEUEN
|
||||||
|
// Namen tragen — sonst prüft das Aufräumen später auf Dateien, die es
|
||||||
|
// unter dem alten Namen nicht mehr gibt.
|
||||||
|
const fertigJetzt = fertigeDateien.map((d) => (existsSync(d) ? d : '')).filter((d) => d.length > 0)
|
||||||
|
const endgueltig = fertigJetzt.length === fertigeDateien.length ? fertigeDateien : vorhandeneMkv(fertigOrdner, extrasOrdner)
|
||||||
|
let fertigBytes = 0
|
||||||
|
for (const datei of endgueltig) fertigBytes += groesseVon(datei)
|
||||||
|
|
||||||
|
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
|
||||||
|
const jetzt = umgebung.jetzt?.() ?? new Date()
|
||||||
|
if (auftrag.fingerprint !== null && umgebung.bibliothekEintragen !== undefined) {
|
||||||
|
const begonnen = auftrag.begonnen.length > 0 ? new Date(auftrag.begonnen) : jetzt
|
||||||
|
umgebung.bibliothekEintragen({
|
||||||
|
fingerprint: auftrag.fingerprint,
|
||||||
|
titel: anzeigeTitel,
|
||||||
|
jahr: zuordnung?.jahr ?? null,
|
||||||
|
typ: auftrag.discTyp,
|
||||||
|
groesseBytes: fertigBytes,
|
||||||
|
ablageort: fertigOrdner,
|
||||||
|
datum: begonnen.toISOString(),
|
||||||
|
dauerS: Math.max(0, Math.round((jetzt.getTime() - begonnen.getTime()) / 1000)),
|
||||||
|
posterPfad: zuordnung?.meta.posterPfad ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const text =
|
||||||
|
(komprimiert ? `Fertig — komprimiert nach ${fertigOrdner}.` : `Fertig — verlustfrei abgelegt unter ${fertigOrdner} (ohne Kompression).`) +
|
||||||
|
(refresh.length > 0 ? ` ${refresh}` : '') +
|
||||||
|
(auftrag.lesefehler
|
||||||
|
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||||
|
: '')
|
||||||
|
return {
|
||||||
|
status: 'fertig',
|
||||||
|
zielOrdner: fertigOrdner,
|
||||||
|
dateien: endgueltig,
|
||||||
|
text,
|
||||||
|
komprimiert,
|
||||||
|
preset: komprimiert ? preset : '',
|
||||||
|
fertigBytes,
|
||||||
|
hinweise,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function abgebrochen(
|
||||||
|
zielOrdner: string,
|
||||||
|
dateien: string[],
|
||||||
|
preset: string,
|
||||||
|
komprimiert: boolean,
|
||||||
|
hinweise: string[],
|
||||||
|
): AblageErgebnis {
|
||||||
|
return {
|
||||||
|
status: 'abgebrochen',
|
||||||
|
zielOrdner,
|
||||||
|
dateien,
|
||||||
|
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
|
||||||
|
komprimiert,
|
||||||
|
preset,
|
||||||
|
fertigBytes: 0,
|
||||||
|
hinweise,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle MKVs im Zielordner (und in extras\) — nach dem Umbenennen der Folgen
|
||||||
|
* ist das die einzige verlässliche Liste. */
|
||||||
|
function vorhandeneMkv(ordner: string, extras: string): string[] {
|
||||||
|
const liste: string[] = []
|
||||||
|
for (const o of [ordner, extras]) {
|
||||||
|
if (!existsSync(o)) continue
|
||||||
|
try {
|
||||||
|
for (const name of readdirSync(o)) if (name.toLowerCase().endsWith('.mkv')) liste.push(join(o, name))
|
||||||
|
} catch {
|
||||||
|
// Ein nicht lesbarer Ordner liefert keine Dateien — die Bibliothek
|
||||||
|
// zeigt dann 0 Bytes, das Ergebnis selbst bleibt ein Erfolg.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return liste.sort()
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Der Kompressions-Auftrag (5.4.0) — PUR, ohne Laufwerk und ohne Netz.
|
||||||
|
//
|
||||||
|
// Er ist das Bindeglied zwischen zwei Phasen, die seit 5.4.0 getrennt
|
||||||
|
// laufen: Der RIP belegt das Laufwerk und endet, sobald die Roh-Dateien
|
||||||
|
// liegen; die KOMPRESSION läuft danach in einer Warteschlange und braucht
|
||||||
|
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
|
||||||
|
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
|
||||||
|
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
|
||||||
|
import type { TitelRolle } from '../../gemeinsam/nachrichten'
|
||||||
|
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||||
|
|
||||||
|
export interface RohDatei {
|
||||||
|
pfad: string
|
||||||
|
titelNr: number
|
||||||
|
rolle: TitelRolle
|
||||||
|
dauerS: number
|
||||||
|
/** Größe der Roh-Datei — gewichtet den Fortschrittsbalken. */
|
||||||
|
groesseBytes: number
|
||||||
|
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
|
||||||
|
audioSprachen: string[]
|
||||||
|
untertitelSprachen: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KompressionsAuftrag {
|
||||||
|
vorgangId: string
|
||||||
|
laufwerk: string
|
||||||
|
discTyp: string
|
||||||
|
rohOrdner: string
|
||||||
|
dateien: RohDatei[]
|
||||||
|
/** Schnappschuss der Metadaten-Zuordnung zum Zeitpunkt des Rips. */
|
||||||
|
zuordnung: Zuordnung | null
|
||||||
|
/** Staffel (Nutzer > Disc > 1) und erste Folge (nur vom Nutzer). */
|
||||||
|
staffel: number | null
|
||||||
|
ersteFolge: number | null
|
||||||
|
discNr: number | null
|
||||||
|
fingerprint: string | null
|
||||||
|
lesefehler: boolean
|
||||||
|
/** Klartext aus dem Rip („Übersprungen: …", „Nicht gesichert: …"). */
|
||||||
|
hinweise: string[]
|
||||||
|
/** Beginn des Rips (ISO) — die Bibliothek merkt sich diesen Zeitpunkt. */
|
||||||
|
begonnen: string
|
||||||
|
/** Preset-Vorgabe beim Wiederholen; '' = die Einstellung gilt. */
|
||||||
|
preset: string
|
||||||
|
/** Beim Wiederholen: der Zielordner des ERSTEN Durchlaufs — die neue
|
||||||
|
* Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen.
|
||||||
|
* '' = Zielordner wird frisch bestimmt. */
|
||||||
|
zielOrdner: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auftragZuJson(auftrag: KompressionsAuftrag): string {
|
||||||
|
return JSON.stringify(auftrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest einen gemerkten Auftrag zurück — null, wenn er nicht die Form hat
|
||||||
|
* (eine Datenbank aus einer späteren Fassung, ein Fremdeintrag). */
|
||||||
|
export function auftragAusJson(text: string): KompressionsAuftrag | null {
|
||||||
|
let roh: unknown
|
||||||
|
try {
|
||||||
|
roh = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof roh !== 'object' || roh === null) return null
|
||||||
|
const a = roh as Record<string, unknown>
|
||||||
|
if (typeof a.vorgangId !== 'string' || typeof a.rohOrdner !== 'string' || !Array.isArray(a.dateien)) return null
|
||||||
|
const dateien: RohDatei[] = []
|
||||||
|
for (const d of a.dateien) {
|
||||||
|
if (typeof d !== 'object' || d === null) return null
|
||||||
|
const e = d as Record<string, unknown>
|
||||||
|
if (typeof e.pfad !== 'string') return null
|
||||||
|
dateien.push({
|
||||||
|
pfad: e.pfad,
|
||||||
|
titelNr: typeof e.titelNr === 'number' ? e.titelNr : 0,
|
||||||
|
rolle: e.rolle === 'extra' || e.rolle === 'folge' ? e.rolle : 'hauptfilm',
|
||||||
|
dauerS: typeof e.dauerS === 'number' ? e.dauerS : 0,
|
||||||
|
groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0,
|
||||||
|
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
|
||||||
|
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
vorgangId: a.vorgangId,
|
||||||
|
laufwerk: typeof a.laufwerk === 'string' ? a.laufwerk : '',
|
||||||
|
discTyp: typeof a.discTyp === 'string' ? a.discTyp : 'bluray',
|
||||||
|
rohOrdner: a.rohOrdner,
|
||||||
|
dateien,
|
||||||
|
zuordnung: typeof a.zuordnung === 'object' && a.zuordnung !== null ? (a.zuordnung as Zuordnung) : null,
|
||||||
|
staffel: typeof a.staffel === 'number' ? a.staffel : null,
|
||||||
|
ersteFolge: typeof a.ersteFolge === 'number' ? a.ersteFolge : null,
|
||||||
|
discNr: typeof a.discNr === 'number' ? a.discNr : null,
|
||||||
|
fingerprint: typeof a.fingerprint === 'string' ? a.fingerprint : null,
|
||||||
|
lesefehler: a.lesefehler === true,
|
||||||
|
hinweise: Array.isArray(a.hinweise) ? a.hinweise.map(String) : [],
|
||||||
|
begonnen: typeof a.begonnen === 'string' ? a.begonnen : '',
|
||||||
|
preset: typeof a.preset === 'string' ? a.preset : '',
|
||||||
|
zielOrdner: typeof a.zielOrdner === 'string' ? a.zielOrdner : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was HandBrake für EINE Datei bekommt. */
|
||||||
|
export interface SprachWahl {
|
||||||
|
audio: string[]
|
||||||
|
untertitel: string[]
|
||||||
|
/** Die erste Untertitelspur vorwählen — weil der Ton nicht in der
|
||||||
|
* Wunschsprache ist. */
|
||||||
|
untertitelStandard: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprachwahl je Datei (pur): Was der Nutzer am Titel gewählt hat, schlägt
|
||||||
|
* die globale Einstellung. Und die Regel des Commanders (01.09.2026):
|
||||||
|
* „japanischer Ton und braucht dringend die deutschen untertitel" —
|
||||||
|
* spielt die ERSTE Tonspur nicht in der Wunschsprache (der ersten
|
||||||
|
* globalen Tonsprache), wird die Untertitelspur der Wunschsprache nach
|
||||||
|
* vorn gestellt und als Standard markiert. HandBrake ordnet die Spuren in
|
||||||
|
* Listen-Reihenfolge und `--subtitle-default=1` trifft die erste (gemessen,
|
||||||
|
* komprimieren/handbrake.ts).
|
||||||
|
*/
|
||||||
|
export function sprachwahl(
|
||||||
|
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[] },
|
||||||
|
global: { audio: readonly string[]; untertitel: readonly string[] },
|
||||||
|
): SprachWahl {
|
||||||
|
const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio]
|
||||||
|
let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel]
|
||||||
|
const wunsch = global.audio[0] ?? ''
|
||||||
|
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
|
||||||
|
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
|
||||||
|
if (untertitelStandard) untertitel = [wunsch, ...untertitel.filter((u) => u !== wunsch)]
|
||||||
|
return { audio, untertitel, untertitelStandard }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
|
||||||
|
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
|
||||||
|
const start = Math.max(1, Math.trunc(ersteFolge))
|
||||||
|
return Array.from({ length: Math.max(0, anzahl) }, (_, i) => start + i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durchgehender Fortschritt über mehrere Teile, nach Gewicht (pur).
|
||||||
|
* Vorher sprang der Balken je Titel auf null zurück — bei 2 × 16 GB stand
|
||||||
|
* er nach der Hälfte wieder bei 0 %. Fehlt ein Gewicht (0), zählen alle
|
||||||
|
* Teile gleich. `prozent` < 0 heißt „vom laufenden Teil noch keine Angabe".
|
||||||
|
*/
|
||||||
|
export function gewichteterFortschritt(gewichte: readonly number[], index: number, prozent: number): number {
|
||||||
|
if (gewichte.length === 0) return -1
|
||||||
|
const gleich = gewichte.some((g) => !(g > 0))
|
||||||
|
const g = gleich ? gewichte.map(() => 1) : [...gewichte]
|
||||||
|
const gesamt = g.reduce((s, w) => s + w, 0)
|
||||||
|
if (gesamt <= 0) return -1
|
||||||
|
const i = Math.min(Math.max(0, index), g.length - 1)
|
||||||
|
const erledigt = g.slice(0, i).reduce((s, w) => s + w, 0)
|
||||||
|
const laufend = prozent < 0 ? 0 : (g[i] * Math.min(100, prozent)) / 100
|
||||||
|
return Math.max(0, Math.min(100, Math.floor(((erledigt + laufend) / gesamt) * 100)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passen die von Hand gesetzten Folgen zu den TMDb-Laufzeiten? Nur eine
|
||||||
|
* Warnung — der Nutzer weiß, was auf seiner Disc ist. Rückgabe: Klartext
|
||||||
|
* je Abweichung (leer = alles plausibel oder nichts zum Vergleichen). */
|
||||||
|
export function folgenPlausibel(
|
||||||
|
dateiSekunden: readonly number[],
|
||||||
|
nummern: readonly number[],
|
||||||
|
laufzeiten: ReadonlyArray<readonly [nummer: number, sekunden: number]>,
|
||||||
|
toleranz = 0.15,
|
||||||
|
): string[] {
|
||||||
|
const warnungen: string[] = []
|
||||||
|
for (let i = 0; i < nummern.length; i++) {
|
||||||
|
const soll = laufzeiten.find(([nr]) => nr === nummern[i])
|
||||||
|
const ist = dateiSekunden[i] ?? 0
|
||||||
|
if (soll === undefined || soll[1] <= 0 || ist <= 0) continue
|
||||||
|
if (Math.abs(ist - soll[1]) / soll[1] > toleranz) {
|
||||||
|
warnungen.push(
|
||||||
|
`Folge ${nummern[i]}: Datei ${Math.round(ist / 60)} min, TMDb ${Math.round(soll[1] / 60)} min — bitte nachsehen.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return warnungen
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
|||||||
|
// Die Kompressions-Warteschlange (5.4.0) — KONZEPT § 4.2 nannte sie
|
||||||
|
// `auftraege.ts`: Warteschlange, Slots. Seit 5.4.0 sind Rip und Kompression
|
||||||
|
// entkoppelt (Commander-Wahl 01.09.2026: „Nächste Disc rippen, während
|
||||||
|
// die vorige noch komprimiert"). Das Laufwerk ist frei, sobald die
|
||||||
|
// Roh-Dateien liegen; die Kompressionen laufen hier der Reihe nach —
|
||||||
|
// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und
|
||||||
|
// werden zusammen nicht schneller.
|
||||||
|
//
|
||||||
|
// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel
|
||||||
|
// prüft das Verhalten der Schlange auf Linux ohne HandBrake.
|
||||||
|
import type { KompressionStand } from '../../gemeinsam/nachrichten'
|
||||||
|
import type { KompressionsAuftrag } from './auftrag'
|
||||||
|
import type { AblageErgebnis, AblageMeldung } from './ablegen'
|
||||||
|
|
||||||
|
export type Ausfuehren = (
|
||||||
|
auftrag: KompressionsAuftrag,
|
||||||
|
melden: AblageMeldung,
|
||||||
|
abbruch: AbortSignal,
|
||||||
|
) => Promise<AblageErgebnis>
|
||||||
|
|
||||||
|
interface Eintrag {
|
||||||
|
auftrag: KompressionsAuftrag
|
||||||
|
titel: string
|
||||||
|
abbruch: AbortController
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarteschlangenStand {
|
||||||
|
laeuft: KompressionStand | null
|
||||||
|
wartend: KompressionStand[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KompressionsWarteschlange {
|
||||||
|
private wartend: Eintrag[] = []
|
||||||
|
private laufend: Eintrag | null = null
|
||||||
|
private laufendStand: KompressionStand | null = null
|
||||||
|
private arbeitet = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ausfuehren: Ausfuehren,
|
||||||
|
/** Jede Änderung — Einreihen, Fortschritt, Ende — geht hier hinaus. */
|
||||||
|
private readonly beiStand: (stand: WarteschlangenStand) => void,
|
||||||
|
/** Ein Auftrag ist zu Ende (auch abgebrochen/gescheitert). */
|
||||||
|
private readonly beiErgebnis: (auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis) => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Reiht ein und liefert den Platz (1 = läuft jetzt oder als Nächstes).
|
||||||
|
* Ein Vorgang, der schon drin ist, wird nicht doppelt eingereiht. */
|
||||||
|
einreihen(auftrag: KompressionsAuftrag, titel: string): number {
|
||||||
|
if (this.laufend?.auftrag.vorgangId === auftrag.vorgangId) return 1
|
||||||
|
const schon = this.wartend.findIndex((e) => e.auftrag.vorgangId === auftrag.vorgangId)
|
||||||
|
if (schon >= 0) return schon + (this.laufend === null ? 1 : 2)
|
||||||
|
this.wartend.push({ auftrag, titel, abbruch: new AbortController() })
|
||||||
|
const platz = this.wartend.length + (this.laufend === null ? 0 : 1)
|
||||||
|
this.beiStand(this.stand())
|
||||||
|
void this.schleife()
|
||||||
|
return platz
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bricht einen wartenden (entfernt) oder laufenden (Signal) Auftrag ab. */
|
||||||
|
abbrechen(vorgangId: string): boolean {
|
||||||
|
const i = this.wartend.findIndex((e) => e.auftrag.vorgangId === vorgangId)
|
||||||
|
if (i >= 0) {
|
||||||
|
const [weg] = this.wartend.splice(i, 1)
|
||||||
|
this.beiStand(this.stand())
|
||||||
|
void this.beiErgebnis(weg.auftrag, {
|
||||||
|
status: 'abgebrochen',
|
||||||
|
zielOrdner: '',
|
||||||
|
dateien: [],
|
||||||
|
text: 'Aus der Warteschlange genommen — die Roh-Dateien bleiben stehen.',
|
||||||
|
komprimiert: false,
|
||||||
|
preset: '',
|
||||||
|
fertigBytes: 0,
|
||||||
|
hinweise: [],
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (this.laufend?.auftrag.vorgangId === vorgangId) {
|
||||||
|
this.laufend.abbruch.abort()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
stand(): WarteschlangenStand {
|
||||||
|
return {
|
||||||
|
laeuft: this.laufendStand === null ? null : { ...this.laufendStand },
|
||||||
|
wartend: this.wartend.map((e) => ({
|
||||||
|
vorgangId: e.auftrag.vorgangId,
|
||||||
|
titel: e.titel,
|
||||||
|
prozent: -1,
|
||||||
|
text: 'wartet',
|
||||||
|
phase: 'wartet',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
laeuftEtwas(): boolean {
|
||||||
|
return this.laufend !== null || this.wartend.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liegt dieser Vorgang gerade in der Schlange (wartend oder laufend)? */
|
||||||
|
kennt(vorgangId: string): boolean {
|
||||||
|
return this.laufend?.auftrag.vorgangId === vorgangId || this.wartend.some((e) => e.auftrag.vorgangId === vorgangId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async schleife(): Promise<void> {
|
||||||
|
if (this.arbeitet) return
|
||||||
|
this.arbeitet = true
|
||||||
|
try {
|
||||||
|
while (this.wartend.length > 0) {
|
||||||
|
const eintrag = this.wartend.shift()!
|
||||||
|
this.laufend = eintrag
|
||||||
|
this.laufendStand = {
|
||||||
|
vorgangId: eintrag.auftrag.vorgangId,
|
||||||
|
titel: eintrag.titel,
|
||||||
|
prozent: -1,
|
||||||
|
text: 'Beginne …',
|
||||||
|
phase: 'komprimiert',
|
||||||
|
}
|
||||||
|
this.beiStand(this.stand())
|
||||||
|
let ergebnis: AblageErgebnis
|
||||||
|
try {
|
||||||
|
ergebnis = await this.ausfuehren(
|
||||||
|
eintrag.auftrag,
|
||||||
|
(stand) => {
|
||||||
|
if (this.laufendStand === null) return
|
||||||
|
this.laufendStand = { ...this.laufendStand, ...stand }
|
||||||
|
this.beiStand(this.stand())
|
||||||
|
},
|
||||||
|
eintrag.abbruch.signal,
|
||||||
|
)
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Eine Ausnahme mitten im Encode ist ein lauter Fehlschlag,
|
||||||
|
// kein stilles Ende der Schlange.
|
||||||
|
ergebnis = {
|
||||||
|
status: 'fehler',
|
||||||
|
zielOrdner: '',
|
||||||
|
dateien: [],
|
||||||
|
text: `Kompression abgestürzt: ${String(fehler)} — die Roh-Dateien bleiben stehen.`,
|
||||||
|
komprimiert: false,
|
||||||
|
preset: '',
|
||||||
|
fertigBytes: 0,
|
||||||
|
hinweise: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.laufend = null
|
||||||
|
this.laufendStand = null
|
||||||
|
try {
|
||||||
|
await this.beiErgebnis(eintrag.auftrag, ergebnis)
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error(`[warteschlange] Ergebnis-Verarbeitung: ${String(fehler)}`)
|
||||||
|
}
|
||||||
|
this.beiStand(this.stand())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.arbeitet = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+538
-101
@@ -3,19 +3,42 @@
|
|||||||
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
|
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
|
||||||
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
|
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
|
||||||
//
|
//
|
||||||
|
// Seit 5.4.0 dazu: Vorgänge als Datensätze (welcher Roh-Ordner gehört zu
|
||||||
|
// welchem Film), die Kompressions-Warteschlange (das Laufwerk ist frei,
|
||||||
|
// sobald die Roh-Dateien liegen), das Roh-Aufräumen im Takt, die
|
||||||
|
// Wiederaufnahme nach einem Neustart, die Laufwerks-Prüfung aus dem
|
||||||
|
// Windows-Protokoll und die Nachprüfung der Erkennung nach dem Info-Lauf.
|
||||||
|
//
|
||||||
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
|
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
|
||||||
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
|
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
|
||||||
// Beweis beweise/sqlite-main.js.
|
// Beweis beweise/sqlite-main.js.
|
||||||
import type { MessagePortMain } from 'electron'
|
import type { MessagePortMain } from 'electron'
|
||||||
|
import { existsSync, rmSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
istFensterNachricht,
|
istFensterNachricht,
|
||||||
istHauptNachricht,
|
istHauptNachricht,
|
||||||
type DbStatus,
|
type DbStatus,
|
||||||
|
type DiscInfoStand,
|
||||||
type KernNachricht,
|
type KernNachricht,
|
||||||
type MetaVorschlag,
|
type MetaVorschlag,
|
||||||
|
type VorgaengeStand,
|
||||||
|
type VorgangEintrag,
|
||||||
} from '../gemeinsam/nachrichten'
|
} from '../gemeinsam/nachrichten'
|
||||||
|
import { komprimierenUndAblegen, type AblageUmgebung } from './ablauf/ablegen'
|
||||||
|
import { auftragAusJson, auftragZuJson } from './ablauf/auftrag'
|
||||||
import { Pipeline } from './ablauf/pipeline'
|
import { Pipeline } from './ablauf/pipeline'
|
||||||
|
import { KompressionsWarteschlange } from './ablauf/warteschlange'
|
||||||
|
import {
|
||||||
|
AUFBEWAHRUNG_SCHLUESSEL,
|
||||||
|
aufraeumen,
|
||||||
|
faelligAm,
|
||||||
|
fremdeOrdner,
|
||||||
|
istUnterRohWurzel,
|
||||||
|
ordnerGroesse,
|
||||||
|
regelLesen,
|
||||||
|
regelText,
|
||||||
|
} from './ablage/rohaufraeumen'
|
||||||
import type { LaufwerkApi } from './laufwerk/codes'
|
import type { LaufwerkApi } from './laufwerk/codes'
|
||||||
import {
|
import {
|
||||||
audioSpurLesen,
|
audioSpurLesen,
|
||||||
@@ -25,15 +48,17 @@ import {
|
|||||||
kennung,
|
kennung,
|
||||||
listOptischeLaufwerke,
|
listOptischeLaufwerke,
|
||||||
} from './laufwerk/disc'
|
} from './laufwerk/disc'
|
||||||
|
import { beurteilen, ereignisseLesen } from './laufwerk/ereignisse'
|
||||||
import { DiscWache } from './laufwerk/wache'
|
import { DiscWache } from './laufwerk/wache'
|
||||||
import { discFingerprint, discTitel, type DiscTitel } from './metadaten/discmerkmale'
|
import { discFingerprint, discTitel, type DiscTitel } from './metadaten/discmerkmale'
|
||||||
import { OmdbClient } from './metadaten/omdb'
|
import { OmdbClient } from './metadaten/omdb'
|
||||||
import { TmdbClient } from './metadaten/tmdb'
|
import { TmdbClient } from './metadaten/tmdb'
|
||||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||||
import { Datenbank } from './speicher/db'
|
import { Datenbank, type Vorgang } from './speicher/db'
|
||||||
import { titelInfoLesen, ABGELOEST } from './rip/makemkv'
|
import { titelInfoLesen, ABGELOEST } from './rip/makemkv'
|
||||||
import { titelVorauswahl } from './rip/titelwahl'
|
import { sprachenFuerTitel } from './rip/parser'
|
||||||
|
import { strukturHinweis, titelVorauswahl } from './rip/titelwahl'
|
||||||
import { finden } from './werkzeuge/katalog'
|
import { finden } from './werkzeuge/katalog'
|
||||||
|
|
||||||
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
||||||
@@ -72,6 +97,8 @@ if (pfad === null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const smokeKern = process.argv.includes('--smoke-kern')
|
||||||
|
|
||||||
let fensterPort: MessagePortMain | null = null
|
let fensterPort: MessagePortMain | null = null
|
||||||
|
|
||||||
function anFenster(nachricht: KernNachricht): void {
|
function anFenster(nachricht: KernNachricht): void {
|
||||||
@@ -83,6 +110,11 @@ function anHauptUndFenster(nachricht: KernNachricht): void {
|
|||||||
anFenster(nachricht)
|
anFenster(nachricht)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hinweis(text: string): void {
|
||||||
|
console.log(`[kern] ${text}`)
|
||||||
|
anFenster({ art: 'kern-hinweis', text })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
|
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
|
||||||
// koffi wird erst HIER geladen, über einen echten dynamischen import():
|
// koffi wird erst HIER geladen, über einen echten dynamischen import():
|
||||||
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
|
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
|
||||||
@@ -100,6 +132,39 @@ const discFingerprints = new Map<string, string>()
|
|||||||
// das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt.
|
// das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt.
|
||||||
const discMerkmale = new Map<string, DiscTitel>()
|
const discMerkmale = new Map<string, DiscTitel>()
|
||||||
|
|
||||||
|
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
|
||||||
|
* baut (5.4.0) — vorher stand dieselbe Struktur dreimal im Code, und jedes
|
||||||
|
* neue Feld hätte dreimal nachgezogen werden müssen. */
|
||||||
|
function discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
|
||||||
|
const m = discMerkmale.get(id)
|
||||||
|
const meta = zuordnung?.meta
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
titel: zuordnung?.titel ?? '',
|
||||||
|
jahr: zuordnung?.jahr ?? null,
|
||||||
|
confidence: zuordnung?.confidence ?? 0,
|
||||||
|
quelle: quelle ?? meta?.quelle ?? '',
|
||||||
|
posterPfad: meta?.posterPfad ?? '',
|
||||||
|
backdropPfad: meta?.backdropPfad ?? '',
|
||||||
|
beschreibung: meta?.beschreibung ?? '',
|
||||||
|
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||||
|
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||||
|
schonGerippt: zuordnung === null ? null : (db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null),
|
||||||
|
discNr: m?.discNr ?? null,
|
||||||
|
staffel: m?.staffel ?? null,
|
||||||
|
folgen: m?.folgen ?? 0,
|
||||||
|
typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '',
|
||||||
|
logoPfad: meta?.logoPfad ?? '',
|
||||||
|
tagline: meta?.tagline ?? '',
|
||||||
|
bewertung: meta?.bewertung ?? 0,
|
||||||
|
fsk: meta?.fsk ?? '',
|
||||||
|
laufzeitMinuten: meta?.laufzeitMinuten ?? 0,
|
||||||
|
genres: meta?.genres ?? [],
|
||||||
|
besetzung: meta?.besetzung ?? [],
|
||||||
|
hinweise: zuordnung?.hinweise ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const gefunden = discTitel(geraetepfad)
|
const gefunden = discTitel(geraetepfad)
|
||||||
@@ -109,28 +174,14 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb
|
|||||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||||
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
||||||
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
|
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
|
||||||
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen.
|
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede
|
||||||
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, { istSerie: gefunden.istSerie })
|
// Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0).
|
||||||
discZuordnungen.set(id, zuordnung)
|
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, {
|
||||||
anHauptUndFenster({
|
istSerie: gefunden.istSerie,
|
||||||
art: 'disc-info',
|
alternativen: gefunden.alternativen,
|
||||||
info: {
|
|
||||||
id,
|
|
||||||
titel: zuordnung.titel,
|
|
||||||
jahr: zuordnung.jahr,
|
|
||||||
confidence: zuordnung.confidence,
|
|
||||||
quelle: zuordnung.meta.quelle,
|
|
||||||
posterPfad: zuordnung.meta.posterPfad,
|
|
||||||
backdropPfad: zuordnung.meta.backdropPfad,
|
|
||||||
beschreibung: zuordnung.meta.beschreibung,
|
|
||||||
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
|
||||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
|
||||||
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
|
||||||
discNr: gefunden.discNr,
|
|
||||||
staffel: gefunden.staffel,
|
|
||||||
folgen: gefunden.folgen,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
discZuordnungen.set(id, zuordnung)
|
||||||
|
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung) })
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
|
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
|
||||||
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
|
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
|
||||||
@@ -183,43 +234,27 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
|
|||||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||||
const details =
|
const details =
|
||||||
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
|
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
|
||||||
|
const meta =
|
||||||
|
details !== null
|
||||||
|
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
|
||||||
|
: {
|
||||||
|
...LEERER_TREFFER,
|
||||||
|
typ: vorschlag.typ,
|
||||||
|
id: vorschlag.tmdbId,
|
||||||
|
titel: vorschlag.titel,
|
||||||
|
jahr: vorschlag.jahr,
|
||||||
|
posterPfad: vorschlag.posterPfad,
|
||||||
|
quelle: 'tmdb' as const,
|
||||||
|
}
|
||||||
const zuordnung: Zuordnung = {
|
const zuordnung: Zuordnung = {
|
||||||
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
|
||||||
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
jahr: meta.jahr ?? vorschlag.jahr,
|
||||||
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
|
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
|
||||||
confidence: 1,
|
confidence: 1,
|
||||||
meta: {
|
meta,
|
||||||
typ: vorschlag.typ,
|
|
||||||
id: vorschlag.tmdbId,
|
|
||||||
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
|
||||||
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
|
||||||
beschreibung: details?.overview ?? '',
|
|
||||||
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
|
|
||||||
backdropPfad: details?.backdrop_path ?? '',
|
|
||||||
laufzeitMinuten: details?.runtime ?? 0,
|
|
||||||
genres: (details?.genres ?? []).map((g) => g.name),
|
|
||||||
quelle: 'tmdb',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
discZuordnungen.set(id, zuordnung)
|
discZuordnungen.set(id, zuordnung)
|
||||||
anHauptUndFenster({
|
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung, 'deine Wahl') })
|
||||||
art: 'disc-info',
|
|
||||||
info: {
|
|
||||||
id,
|
|
||||||
titel: zuordnung.titel,
|
|
||||||
jahr: zuordnung.jahr,
|
|
||||||
confidence: 1,
|
|
||||||
quelle: 'deine Wahl',
|
|
||||||
posterPfad: zuordnung.meta.posterPfad,
|
|
||||||
backdropPfad: zuordnung.meta.backdropPfad,
|
|
||||||
beschreibung: zuordnung.meta.beschreibung,
|
|
||||||
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
|
||||||
// Eigene Titelwahl ändert nichts daran, welche Disc der Ausgabe hier liegt.
|
|
||||||
discNr: discMerkmale.get(id)?.discNr ?? null,
|
|
||||||
staffel: discMerkmale.get(id)?.staffel ?? null,
|
|
||||||
folgen: discMerkmale.get(id)?.folgen ?? 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
|
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
|
||||||
}
|
}
|
||||||
@@ -243,23 +278,7 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
|||||||
discZuordnungen.delete(ereignis.geraet.id)
|
discZuordnungen.delete(ereignis.geraet.id)
|
||||||
discFingerprints.delete(ereignis.geraet.id)
|
discFingerprints.delete(ereignis.geraet.id)
|
||||||
discMerkmale.delete(ereignis.geraet.id)
|
discMerkmale.delete(ereignis.geraet.id)
|
||||||
anHauptUndFenster({
|
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(ereignis.geraet.id, null) })
|
||||||
art: 'disc-info',
|
|
||||||
info: {
|
|
||||||
id: ereignis.geraet.id,
|
|
||||||
titel: '',
|
|
||||||
jahr: null,
|
|
||||||
confidence: 0,
|
|
||||||
quelle: '',
|
|
||||||
posterPfad: '',
|
|
||||||
backdropPfad: '',
|
|
||||||
beschreibung: '',
|
|
||||||
schonGerippt: null,
|
|
||||||
discNr: null,
|
|
||||||
staffel: null,
|
|
||||||
folgen: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -277,9 +296,6 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
// ── Rippen (W-2) ────────────────────────────────────────────────────────
|
|
||||||
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
|
|
||||||
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
|
|
||||||
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
|
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
|
||||||
// Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und
|
// Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und
|
||||||
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
|
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
|
||||||
@@ -343,12 +359,16 @@ async function titelLesen(id: string): Promise<void> {
|
|||||||
stand: {
|
stand: {
|
||||||
id,
|
id,
|
||||||
laeuft: false,
|
laeuft: false,
|
||||||
titel: wahl.titel,
|
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie
|
||||||
|
// zum Anhaken an.
|
||||||
|
titel: wahl.titel.map((t) => ({ ...t, sprachen: sprachenFuerTitel(info.streams.get(t.nr)) })),
|
||||||
einordnung: wahl.einordnung,
|
einordnung: wahl.einordnung,
|
||||||
unsicher: wahl.unsicher,
|
unsicher: wahl.unsicher,
|
||||||
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
|
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen.
|
||||||
|
if (info.titel.length > 0) await erkennungNachpruefen(id, wahl, info.titel)
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
|
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
|
||||||
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
|
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
|
||||||
@@ -369,14 +389,59 @@ async function titelLesen(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
|
||||||
|
* Nach dem Info-Lauf wird noch einmal gerechnet — Laufzeit gegen TMDb,
|
||||||
|
* Titel-Struktur gegen den Treffer-Typ. Aus „wahrscheinlich" wird
|
||||||
|
* „bestätigt", oder Rippy merkt selbst, dass es danebenlag. */
|
||||||
|
async function erkennungNachpruefen(
|
||||||
|
id: string,
|
||||||
|
wahl: ReturnType<typeof titelVorauswahl>,
|
||||||
|
titel: Parameters<typeof strukturHinweis>[0],
|
||||||
|
): Promise<void> {
|
||||||
|
const bisher = discZuordnungen.get(id)
|
||||||
|
if (bisher === undefined) return
|
||||||
|
const gewaehlt = wahl.titel.filter((t) => t.gewaehlt)
|
||||||
|
const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS)
|
||||||
|
const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0
|
||||||
|
const messung = {
|
||||||
|
hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster),
|
||||||
|
folgenS: folgen,
|
||||||
|
struktur: strukturHinweis(titel),
|
||||||
|
}
|
||||||
|
let zuordnung = bisher
|
||||||
|
// Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat
|
||||||
|
// nicht selbst gewählt): einmal als Serie fragen — das ist der
|
||||||
|
// Fingerabdruck, der Serien auch ohne bdmt erkennt.
|
||||||
|
if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) {
|
||||||
|
const merkmale = discMerkmale.get(id)
|
||||||
|
if (merkmale !== undefined && merkmale.titel.length > 0) {
|
||||||
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||||
|
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
||||||
|
const nochmal = await zuordnen(merkmale.titel, tmdb, omdb, { istSerie: true, alternativen: merkmale.alternativen })
|
||||||
|
if (nochmal.meta.typ === 'tv') {
|
||||||
|
zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const geprueft = nachpruefen(zuordnung, messung)
|
||||||
|
const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])]
|
||||||
|
const neu = { ...geprueft, hinweise }
|
||||||
|
discZuordnungen.set(id, neu)
|
||||||
|
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, neu) })
|
||||||
|
}
|
||||||
|
|
||||||
function ablageOrdner(): string {
|
function ablageOrdner(): string {
|
||||||
const eingestellt = db?.einstellungLesen('ablage')
|
const eingestellt = db?.einstellungLesen('ablage')
|
||||||
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
|
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
|
||||||
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
|
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rohWurzel(): string {
|
||||||
|
return join(ablageOrdner(), 'roh')
|
||||||
|
}
|
||||||
|
|
||||||
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
|
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
|
||||||
// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults.
|
// Quelle (§ 6.7).
|
||||||
const PIPELINE_EINSTELLUNGEN = [
|
const PIPELINE_EINSTELLUNGEN = [
|
||||||
'transcodeEnabled',
|
'transcodeEnabled',
|
||||||
'transcodePreset',
|
'transcodePreset',
|
||||||
@@ -404,6 +469,274 @@ function geraetVon(id: string): string | null {
|
|||||||
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
|
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Vorgänge (5.4.0): Datensatz je Rip, Warteschlange, Aufräumen ────────
|
||||||
|
function dateienAusJson(text: string): string[] {
|
||||||
|
const liste: string[] = []
|
||||||
|
try {
|
||||||
|
const roh: unknown = JSON.parse(text)
|
||||||
|
if (Array.isArray(roh)) liste.push(...roh.map(String))
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine
|
||||||
|
// fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst.
|
||||||
|
console.error(`[kern] fertige Dateien nicht lesbar: ${String(fehler)}`)
|
||||||
|
}
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
|
||||||
|
const ablageUmgebung: AblageUmgebung = {
|
||||||
|
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
|
||||||
|
ablage: ablageOrdner,
|
||||||
|
einstellungen: einstellungenLaden,
|
||||||
|
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
||||||
|
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
|
||||||
|
episodenLaufzeiten: async (tmdbId, staffel) => {
|
||||||
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||||
|
if (!tmdb.verfuegbar) return []
|
||||||
|
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
|
||||||
|
const paare: Array<[number, number]> = []
|
||||||
|
for (const ep of antwort?.episodes ?? []) {
|
||||||
|
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
|
||||||
|
}
|
||||||
|
return paare
|
||||||
|
},
|
||||||
|
bibliothekEintragen: (eintrag) => {
|
||||||
|
try {
|
||||||
|
db?.bibliothekEintragen(eintrag)
|
||||||
|
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||||
|
} catch (fehler) {
|
||||||
|
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
|
||||||
|
// gesagt.
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem
|
||||||
|
* Prozent-Tick. */
|
||||||
|
const letztePhase = new Map<string, string>()
|
||||||
|
|
||||||
|
const warteschlange = new KompressionsWarteschlange(
|
||||||
|
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, ablageUmgebung, melden, abbruch),
|
||||||
|
(stand) => {
|
||||||
|
const l = stand.laeuft
|
||||||
|
if (l !== null && letztePhase.get(l.vorgangId) !== l.phase) {
|
||||||
|
letztePhase.set(l.vorgangId, l.phase)
|
||||||
|
db?.vorgangAktualisieren(l.vorgangId, { phase: l.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
|
||||||
|
}
|
||||||
|
vorgaengeSenden()
|
||||||
|
},
|
||||||
|
(auftrag, ergebnis) => {
|
||||||
|
letztePhase.delete(auftrag.vorgangId)
|
||||||
|
const jetzt = new Date().toISOString()
|
||||||
|
db?.vorgangAktualisieren(auftrag.vorgangId, {
|
||||||
|
phase: ergebnis.status,
|
||||||
|
beendet: jetzt,
|
||||||
|
zielOrdner: ergebnis.zielOrdner,
|
||||||
|
fertigBytes: ergebnis.fertigBytes,
|
||||||
|
komprimiert: ergebnis.komprimiert,
|
||||||
|
preset: ergebnis.preset,
|
||||||
|
fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text,
|
||||||
|
fertigeDateien: JSON.stringify(ergebnis.dateien),
|
||||||
|
})
|
||||||
|
const vorgang = db?.vorgangLesen(auftrag.vorgangId)
|
||||||
|
const titel = vorgang?.titel ?? auftrag.vorgangId
|
||||||
|
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
|
||||||
|
anHauptUndFenster({
|
||||||
|
art: 'kompression-ergebnis',
|
||||||
|
vorgangId: auftrag.vorgangId,
|
||||||
|
titel,
|
||||||
|
ok: ergebnis.status === 'fertig',
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
|
||||||
|
if (ergebnis.status === 'fertig') rohAufraeumenLauf()
|
||||||
|
else vorgaengeSenden()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function vorgaengeStand(): VorgaengeStand {
|
||||||
|
const regel = regelLesen(db?.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL))
|
||||||
|
const liste = db?.vorgangListe() ?? []
|
||||||
|
let rohGesamtBytes = 0
|
||||||
|
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
|
||||||
|
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
|
||||||
|
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
|
||||||
|
rohGesamtBytes += rohBytes
|
||||||
|
const auftrag = auftragAusJson(v.auftrag)
|
||||||
|
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
laufwerk: v.laufwerk,
|
||||||
|
typ: v.typ,
|
||||||
|
titel: v.titel,
|
||||||
|
jahr: v.jahr,
|
||||||
|
phase: v.phase,
|
||||||
|
begonnen: v.begonnen,
|
||||||
|
beendet: v.beendet,
|
||||||
|
rohOrdner: v.rohOrdner,
|
||||||
|
rohBytes,
|
||||||
|
rohGeloescht: !rohDa,
|
||||||
|
zielOrdner: v.zielOrdner,
|
||||||
|
fertigBytes: v.fertigBytes,
|
||||||
|
komprimiert: v.komprimiert,
|
||||||
|
preset: v.preset,
|
||||||
|
fehler: v.fehler,
|
||||||
|
titelZahl: auftrag?.dateien.length ?? 0,
|
||||||
|
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !warteschlange.kennt(v.id),
|
||||||
|
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const bekannte = new Set(liste.map((v) => v.rohOrdner))
|
||||||
|
const fremde = fremdeOrdner(rohWurzel(), bekannte)
|
||||||
|
for (const f of fremde) rohGesamtBytes += f.bytes
|
||||||
|
return { vorgaenge, rohGesamtBytes, fremde, regel: regelText(regel), kompression: warteschlange.stand() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function vorgaengeSenden(): void {
|
||||||
|
try {
|
||||||
|
anHauptUndFenster({ art: 'vorgaenge', stand: vorgaengeStand() })
|
||||||
|
} catch (fehler) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Vorgänge: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rohAufraeumenLauf(): void {
|
||||||
|
if (db === null) return
|
||||||
|
try {
|
||||||
|
const regel = regelLesen(db.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL))
|
||||||
|
const kandidaten = db.vorgangListe().map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
phase: v.phase,
|
||||||
|
komprimiert: v.komprimiert,
|
||||||
|
rohOrdner: v.rohOrdner,
|
||||||
|
rohGeloeschtAm: v.rohGeloeschtAm,
|
||||||
|
beendet: v.beendet,
|
||||||
|
fertigeDateien: dateienAusJson(v.fertigeDateien),
|
||||||
|
}))
|
||||||
|
const ergebnis = aufraeumen(kandidaten, regel, new Date())
|
||||||
|
const jetzt = new Date().toISOString()
|
||||||
|
for (const id of ergebnis.geloescht) {
|
||||||
|
db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: 0 })
|
||||||
|
hinweis(`Roh-Dateien von „${db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
|
||||||
|
}
|
||||||
|
for (const f of ergebnis.fehler) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}` })
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Roh-Aufräumen: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
vorgaengeSenden()
|
||||||
|
}
|
||||||
|
|
||||||
|
function vorgangRohLoeschen(vorgangId: string): void {
|
||||||
|
const v = db?.vorgangLesen(vorgangId)
|
||||||
|
if (v === null || v === undefined) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || warteschlange.kennt(vorgangId)) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `„${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!istUnterRohWurzel(rohWurzel(), v.rohOrdner)) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" liegt nicht unter ${rohWurzel()} — nicht angefasst.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rmSync(v.rohOrdner, { recursive: true, force: true })
|
||||||
|
db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString(), rohBytes: 0 })
|
||||||
|
hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
|
||||||
|
} catch (fehler) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
vorgaengeSenden()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fremdenOrdnerLoeschen(ordner: string): void {
|
||||||
|
if (!istUnterRohWurzel(rohWurzel(), ordner)) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `„${ordner}" liegt nicht direkt unter ${rohWurzel()} — nicht angefasst.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const bekannt = (db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
|
||||||
|
if (bekannt) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `„${ordner}" gehört zu einem Vorgang — dort löschen.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rmSync(ordner, { recursive: true, force: true })
|
||||||
|
hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
|
||||||
|
} catch (fehler) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `„${ordner}" nicht gelöscht: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
vorgaengeSenden()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
|
||||||
|
function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
|
||||||
|
const v = db?.vorgangLesen(vorgangId)
|
||||||
|
if (v === null || v === undefined) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (warteschlange.kennt(vorgangId)) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `„${v.titel}" ist schon in der Warteschlange.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const auftrag = auftragAusJson(v.auftrag)
|
||||||
|
if (auftrag === null) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `„${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad))
|
||||||
|
if (vorhandene.length === 0) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `„${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (vorhandene.length < auftrag.dateien.length) {
|
||||||
|
hinweis(`„${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
|
||||||
|
}
|
||||||
|
const neu = {
|
||||||
|
...auftrag,
|
||||||
|
dateien: vorhandene,
|
||||||
|
preset,
|
||||||
|
// Die neue Fassung ersetzt die alte im selben Ordner.
|
||||||
|
zielOrdner: v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner) ? v.zielOrdner : '',
|
||||||
|
}
|
||||||
|
db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
|
||||||
|
const platz = warteschlange.einreihen(neu, v.titel)
|
||||||
|
hinweis(`„${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
|
||||||
|
vorgaengeSenden()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
|
||||||
|
function vorgaengeWiederaufnehmen(): void {
|
||||||
|
if (db === null) return
|
||||||
|
const jetzt = new Date().toISOString()
|
||||||
|
for (const v of db.vorgangListe()) {
|
||||||
|
if (v.phase === 'rippt') {
|
||||||
|
db.vorgangAktualisieren(v.id, {
|
||||||
|
phase: 'abgebrochen',
|
||||||
|
beendet: jetzt,
|
||||||
|
fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') {
|
||||||
|
const auftrag = auftragAusJson(v.auftrag)
|
||||||
|
if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) {
|
||||||
|
warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
|
||||||
|
hinweis(`„${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
|
||||||
|
} else {
|
||||||
|
db.vorgangAktualisieren(v.id, {
|
||||||
|
phase: 'fehler',
|
||||||
|
beendet: jetzt,
|
||||||
|
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ripVerwaltung = new Pipeline({
|
const ripVerwaltung = new Pipeline({
|
||||||
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
|
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
|
||||||
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
|
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
|
||||||
@@ -425,37 +758,93 @@ const ripVerwaltung = new Pipeline({
|
|||||||
},
|
},
|
||||||
ablage: ablageOrdner,
|
ablage: ablageOrdner,
|
||||||
einstellungen: einstellungenLaden,
|
einstellungen: einstellungenLaden,
|
||||||
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
melden: (status) => {
|
||||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
anHauptUndFenster({ art: 'rip-status', status })
|
||||||
|
// Nach einem gescheiterten Rip (oder Lesefehlern) gleich das Windows-
|
||||||
|
// Protokoll fragen: Disc oder Hardware? (5.4.0, Punkt 8)
|
||||||
|
if (status.phase === 'fehler' || (status.phase === 'fertig' && status.lesefehler)) void laufwerkPruefen(status.id)
|
||||||
|
},
|
||||||
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
||||||
discAngaben: (id) => {
|
discAngaben: (id) => {
|
||||||
const m = discMerkmale.get(id)
|
const m = discMerkmale.get(id)
|
||||||
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
|
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
|
||||||
},
|
},
|
||||||
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
|
|
||||||
episodenLaufzeiten: async (tmdbId, staffel) => {
|
|
||||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
|
||||||
if (!tmdb.verfuegbar) return []
|
|
||||||
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
|
|
||||||
const paare: Array<[number, number]> = []
|
|
||||||
for (const ep of antwort?.episodes ?? []) {
|
|
||||||
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
|
|
||||||
}
|
|
||||||
return paare
|
|
||||||
},
|
|
||||||
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
||||||
bibliothekEintragen: (eintrag) => {
|
bibliothekEintragen: ablageUmgebung.bibliothekEintragen,
|
||||||
try {
|
vorgangBeginnt: (v) => {
|
||||||
db?.bibliothekEintragen(eintrag)
|
const vorgang: Vorgang = {
|
||||||
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
id: v.id,
|
||||||
} catch (fehler) {
|
laufwerk: v.laufwerk,
|
||||||
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
|
typ: v.typ,
|
||||||
// gesagt.
|
titel: v.titel,
|
||||||
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
|
jahr: v.jahr,
|
||||||
|
phase: 'rippt',
|
||||||
|
begonnen: v.begonnen,
|
||||||
|
beendet: null,
|
||||||
|
rohOrdner: v.rohOrdner,
|
||||||
|
rohBytes: 0,
|
||||||
|
rohGeloeschtAm: null,
|
||||||
|
zielOrdner: '',
|
||||||
|
fertigBytes: 0,
|
||||||
|
komprimiert: false,
|
||||||
|
preset: '',
|
||||||
|
fehler: '',
|
||||||
|
auftrag: '{}',
|
||||||
|
lesefehler: false,
|
||||||
|
fertigeDateien: '[]',
|
||||||
}
|
}
|
||||||
|
db?.vorgangAnlegen(vorgang)
|
||||||
|
vorgaengeSenden()
|
||||||
|
},
|
||||||
|
vorgangEinreihen: (auftrag, titel) => {
|
||||||
|
db?.vorgangAktualisieren(auftrag.vorgangId, {
|
||||||
|
phase: 'wartet',
|
||||||
|
titel,
|
||||||
|
jahr: auftrag.zuordnung?.jahr ?? null,
|
||||||
|
rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0),
|
||||||
|
lesefehler: auftrag.lesefehler,
|
||||||
|
auftrag: auftragZuJson(auftrag),
|
||||||
|
})
|
||||||
|
const platz = warteschlange.einreihen(auftrag, titel)
|
||||||
|
vorgaengeSenden()
|
||||||
|
return platz
|
||||||
|
},
|
||||||
|
vorgangEndet: (id, phase, text) => {
|
||||||
|
db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
|
||||||
|
vorgaengeSenden()
|
||||||
|
},
|
||||||
|
nachRipAuswerfen: (id) => {
|
||||||
|
// Vorgabe: auswerfen — die Warteschlange will ein freies Laufwerk.
|
||||||
|
if (db?.einstellungLesen('autoAuswurf') === 'false') return
|
||||||
|
void auswerfen(id)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ───────────────────────────────
|
||||||
|
const GESUNDHEIT_STUNDEN = 24
|
||||||
|
|
||||||
|
async function laufwerkPruefen(id: string): Promise<void> {
|
||||||
|
if (process.platform !== 'win32') return
|
||||||
|
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
|
||||||
|
if ('fehler' in ergebnis) {
|
||||||
|
anFenster({
|
||||||
|
art: 'laufwerk-gesundheit',
|
||||||
|
id,
|
||||||
|
bericht: {
|
||||||
|
urteil: 'nicht-lesbar',
|
||||||
|
text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`,
|
||||||
|
fehlerhafteBloecke: 0,
|
||||||
|
controllerfehler: 0,
|
||||||
|
sonstige: 0,
|
||||||
|
letzte: [],
|
||||||
|
stunden: GESUNDHEIT_STUNDEN,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
anFenster({ art: 'laufwerk-gesundheit', id, bericht: beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN) })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
|
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
|
||||||
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
|
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
|
||||||
const ERLAUBTE_EINSTELLUNGEN = new Set([
|
const ERLAUBTE_EINSTELLUNGEN = new Set([
|
||||||
@@ -479,6 +868,7 @@ const ERLAUBTE_EINSTELLUNGEN = new Set([
|
|||||||
'werkzeug.makemkv',
|
'werkzeug.makemkv',
|
||||||
'werkzeug.handbrake',
|
'werkzeug.handbrake',
|
||||||
'werkzeug.flac',
|
'werkzeug.flac',
|
||||||
|
AUFBEWAHRUNG_SCHLUESSEL,
|
||||||
])
|
])
|
||||||
|
|
||||||
function einstellungenSenden(): void {
|
function einstellungenSenden(): void {
|
||||||
@@ -515,6 +905,25 @@ async function schluesselLauf(): Promise<void> {
|
|||||||
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
||||||
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
|
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
|
||||||
|
|
||||||
|
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) — sonst fehlen
|
||||||
|
* Sprachen in der Roh-Datei, die der Dialog anbot. */
|
||||||
|
async function spurauswahlLauf(): Promise<void> {
|
||||||
|
if (process.platform !== 'win32') return
|
||||||
|
try {
|
||||||
|
const { spurauswahlSicherstellen } = await import('./werkzeuge/spurauswahl')
|
||||||
|
const stand = spurauswahlSicherstellen()
|
||||||
|
if (stand.stand === 'eigen' || stand.stand === 'fehler') {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${stand.text}` })
|
||||||
|
} else if (stand.stand === 'gesetzt') {
|
||||||
|
hinweis(stand.text)
|
||||||
|
} else {
|
||||||
|
console.log(`[kern] ${stand.text}`)
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${String(fehler)}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||||
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
|
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
|
||||||
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
|
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
|
||||||
@@ -702,7 +1111,7 @@ process.parentPort.on('message', (ereignis) => {
|
|||||||
} else if (frage.art === 'titel-lesen') {
|
} else if (frage.art === 'titel-lesen') {
|
||||||
void titelLesen(frage.id)
|
void titelLesen(frage.id)
|
||||||
} else if (frage.art === 'rip-start') {
|
} else if (frage.art === 'rip-start') {
|
||||||
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? []))
|
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? [], frage.optionen ?? {}))
|
||||||
} else if (frage.art === 'rip-abbruch') {
|
} else if (frage.art === 'rip-abbruch') {
|
||||||
ripVerwaltung.abbrechen(frage.id)
|
ripVerwaltung.abbrechen(frage.id)
|
||||||
} else if (frage.art === 'rip-ueberspringen') {
|
} else if (frage.art === 'rip-ueberspringen') {
|
||||||
@@ -719,6 +1128,8 @@ process.parentPort.on('message', (ereignis) => {
|
|||||||
} else {
|
} else {
|
||||||
db.einstellungSchreiben(frage.schluessel, frage.wert)
|
db.einstellungSchreiben(frage.schluessel, frage.wert)
|
||||||
einstellungenSenden()
|
einstellungenSenden()
|
||||||
|
// Eine neue Aufbewahrungs-Regel wirkt sofort auf die Anzeige.
|
||||||
|
if (frage.schluessel === AUFBEWAHRUNG_SCHLUESSEL) vorgaengeSenden()
|
||||||
}
|
}
|
||||||
} else if (frage.art === 'einstellungen-laden') {
|
} else if (frage.art === 'einstellungen-laden') {
|
||||||
einstellungenSenden()
|
einstellungenSenden()
|
||||||
@@ -730,6 +1141,22 @@ process.parentPort.on('message', (ereignis) => {
|
|||||||
void makemkvUpdateLauf()
|
void makemkvUpdateLauf()
|
||||||
} else if (frage.art === 'schluessel-pruefen') {
|
} else if (frage.art === 'schluessel-pruefen') {
|
||||||
void schluesselLauf().then(() => werkzeugeSenden())
|
void schluesselLauf().then(() => werkzeugeSenden())
|
||||||
|
} else if (frage.art === 'vorgaenge-laden') {
|
||||||
|
vorgaengeSenden()
|
||||||
|
} else if (frage.art === 'vorgang-roh-loeschen') {
|
||||||
|
vorgangRohLoeschen(frage.vorgangId)
|
||||||
|
} else if (frage.art === 'vorgang-neu-komprimieren') {
|
||||||
|
vorgangNeuKomprimieren(frage.vorgangId, frage.preset ?? '')
|
||||||
|
} else if (frage.art === 'kompression-abbrechen') {
|
||||||
|
if (!warteschlange.abbrechen(frage.vorgangId)) {
|
||||||
|
anFenster({ art: 'kern-fehler', text: `Vorgang ${frage.vorgangId} ist nicht in der Warteschlange.` })
|
||||||
|
}
|
||||||
|
} else if (frage.art === 'roh-aufraeumen') {
|
||||||
|
rohAufraeumenLauf()
|
||||||
|
} else if (frage.art === 'fremden-ordner-loeschen') {
|
||||||
|
fremdenOrdnerLoeschen(frage.ordner)
|
||||||
|
} else if (frage.art === 'laufwerk-pruefen') {
|
||||||
|
void laufwerkPruefen(frage.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
fensterPort.start()
|
fensterPort.start()
|
||||||
@@ -744,6 +1171,7 @@ process.parentPort.on('message', (ereignis) => {
|
|||||||
anFenster({ art: 'schluessel-status', stand: schluesselStand })
|
anFenster({ art: 'schluessel-status', stand: schluesselStand })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
vorgaengeSenden()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -759,11 +1187,20 @@ function bereitNachricht(): KernNachricht {
|
|||||||
process.parentPort.postMessage(bereitNachricht())
|
process.parentPort.postMessage(bereitNachricht())
|
||||||
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
|
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
|
||||||
einstellungenSenden()
|
einstellungenSenden()
|
||||||
|
// Was nach dem letzten Beenden noch offen war (5.4.0).
|
||||||
|
vorgaengeWiederaufnehmen()
|
||||||
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
|
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
|
||||||
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
|
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
|
||||||
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
|
if (!smokeKern && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
|
||||||
void schluesselLauf()
|
void schluesselLauf()
|
||||||
|
void spurauswahlLauf()
|
||||||
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
|
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
|
||||||
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
|
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
|
||||||
void werkzeugeSenden()
|
void werkzeugeSenden()
|
||||||
}
|
}
|
||||||
|
// Roh-Aufräumen (5.4.0): eine Minute nach dem Start, dann täglich — und
|
||||||
|
// nach jeder gelungenen Kompression (siehe Warteschlange).
|
||||||
|
if (!smokeKern) {
|
||||||
|
setTimeout(() => rohAufraeumenLauf(), 60_000).unref()
|
||||||
|
setInterval(() => rohAufraeumenLauf(), 24 * 60 * 60 * 1000).unref()
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,8 +30,23 @@ export interface HandbrakeArgsOptionen {
|
|||||||
preset: string
|
preset: string
|
||||||
audioSprachen?: readonly string[]
|
audioSprachen?: readonly string[]
|
||||||
untertitelSprachen?: readonly string[]
|
untertitelSprachen?: readonly string[]
|
||||||
|
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
|
||||||
|
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
|
||||||
|
untertitelStandard?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
|
||||||
|
// Testdatei mit jpn/deu/deu/eng-Ton und deu/eng-Untertiteln, Ausgabe per
|
||||||
|
// ffprobe geprüft — Regel D):
|
||||||
|
// --audio-lang-list deu,eng → je Sprache die ERSTE Spur (2 Spuren)
|
||||||
|
// --audio-lang-list deu,eng --first-audio → dasselbe (je Sprache eine)
|
||||||
|
// --audio-lang-list deu,eng --all-audio → ALLE passenden Spuren (3)
|
||||||
|
// --subtitle-lang-list eng,deu --all-subtitles → Ausgabe-Reihenfolge = LISTEN-
|
||||||
|
// Reihenfolge (eng zuerst), NICHT Quell-Reihenfolge
|
||||||
|
// --subtitle-default=1 → die erste Untertitelspur trägt default=1 — und
|
||||||
|
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
|
||||||
|
// sie damit von selbst)
|
||||||
|
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
|
||||||
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
||||||
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
|
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
|
||||||
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
|
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
|
||||||
@@ -45,6 +60,9 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
|||||||
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
|
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
|
||||||
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
|
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
|
||||||
args.push('--all-subtitles')
|
args.push('--all-subtitles')
|
||||||
|
// Nur mit einer Sprachliste ist klar, WELCHE Spur die erste ist — die
|
||||||
|
// Liste bestimmt die Reihenfolge (gemessen, siehe oben).
|
||||||
|
if (optionen.untertitelStandard === true && untertitel.length > 0) args.push('--subtitle-default=1')
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +242,7 @@ export interface HbLaufOptionen {
|
|||||||
preset: string
|
preset: string
|
||||||
audioSprachen?: readonly string[]
|
audioSprachen?: readonly string[]
|
||||||
untertitelSprachen?: readonly string[]
|
untertitelSprachen?: readonly string[]
|
||||||
|
untertitelStandard?: boolean
|
||||||
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
||||||
extraArgs?: readonly string[]
|
extraArgs?: readonly string[]
|
||||||
aufFortschritt?: (prozent: number) => void
|
aufFortschritt?: (prozent: number) => void
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// HandBrakes Scan einer Datei — welche Ton- und Untertitelspuren stecken
|
||||||
|
// WIRKLICH in der Roh-Datei? (5.4.0)
|
||||||
|
//
|
||||||
|
// Warum das nötig ist: Der Auswahl-Dialog zeigt die Sprachen, die MakeMKV
|
||||||
|
// im Info-Lauf an der DISC gemessen hat. Ob sie alle in der MKV landen,
|
||||||
|
// entscheidet MakeMKVs Auswahlregel (siehe werkzeuge/spurauswahl.ts). Vor
|
||||||
|
// der Kompression wird deshalb die Datei selbst gefragt — fehlt eine
|
||||||
|
// gewünschte Sprache, steht das im Protokoll, statt dass ein leerer
|
||||||
|
// Wunsch still durchrutscht.
|
||||||
|
//
|
||||||
|
// Ausgabeformat am mitgelieferten HandBrake 1.11 gemessen (01.09.2026,
|
||||||
|
// Regel D): `HandBrakeCLI --scan --json -i <datei> -t 1` schreibt ein
|
||||||
|
// Textprotokoll, darin EINMAL „JSON Title Set:" gefolgt von einem
|
||||||
|
// JSON-Objekt, und danach weitere Zeilen („HandBrake has exited."). Das
|
||||||
|
// Objekt endet also NICHT am Textende — es wird über die Klammern
|
||||||
|
// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2,
|
||||||
|
// z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}.
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { textVon } from '../rip/parser'
|
||||||
|
|
||||||
|
export interface HbSpuren {
|
||||||
|
audio: Array<{ nr: number; lang: string; beschreibung: string }>
|
||||||
|
untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
|
||||||
|
dauerS: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildScanArgs(datei: string): string[] {
|
||||||
|
return ['--scan', '--json', '--input', datei, '--title', '1']
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das JSON-Objekt hinter „JSON Title Set:" ausschneiden (pur, testbar). */
|
||||||
|
export function spurenAusScan(ausgabe: string): HbSpuren | null {
|
||||||
|
const marke = ausgabe.indexOf('JSON Title Set:')
|
||||||
|
if (marke < 0) return null
|
||||||
|
const start = ausgabe.indexOf('{', marke)
|
||||||
|
if (start < 0) return null
|
||||||
|
let tiefe = 0
|
||||||
|
let ende = -1
|
||||||
|
for (let i = start; i < ausgabe.length; i++) {
|
||||||
|
const zeichen = ausgabe[i]
|
||||||
|
if (zeichen === '{') tiefe += 1
|
||||||
|
else if (zeichen === '}') {
|
||||||
|
tiefe -= 1
|
||||||
|
if (tiefe === 0) {
|
||||||
|
ende = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ende < 0) return null
|
||||||
|
let daten: unknown
|
||||||
|
try {
|
||||||
|
daten = JSON.parse(ausgabe.slice(start, ende + 1))
|
||||||
|
} catch {
|
||||||
|
// Kein gültiges JSON heißt „nicht auswertbar" — das ist ein null, kein
|
||||||
|
// erfundener leerer Befund (R2).
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof daten !== 'object' || daten === null) return null
|
||||||
|
const liste = (daten as { TitleList?: unknown }).TitleList
|
||||||
|
if (!Array.isArray(liste) || liste.length === 0) return null
|
||||||
|
const titel = liste[0] as Record<string, unknown>
|
||||||
|
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
|
||||||
|
if (!Array.isArray(feld)) return []
|
||||||
|
return feld.map((s, i) => {
|
||||||
|
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
nr: i + 1,
|
||||||
|
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
|
||||||
|
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const dauer = (titel.Duration ?? {}) as Record<string, unknown>
|
||||||
|
const zahl = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
||||||
|
return {
|
||||||
|
audio: spuren(titel.AudioList),
|
||||||
|
untertitel: spuren(titel.SubtitleList),
|
||||||
|
dauerS: zahl(dauer.Hours) * 3600 + zahl(dauer.Minutes) * 60 + zahl(dauer.Seconds),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fragt die Datei — null heißt „konnte nicht nachsehen", nie „keine Spuren". */
|
||||||
|
export function spurenScannen(programm: string, datei: string, timeoutMs = 120_000): Promise<HbSpuren | null> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
if (programm.length === 0) {
|
||||||
|
fertig(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const kind = spawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
const stuecke: Buffer[] = []
|
||||||
|
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||||
|
kind.on('error', () => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
fertig(null)
|
||||||
|
})
|
||||||
|
kind.on('close', () => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
fertig(spurenAusScan(textVon(Buffer.concat(stuecke))))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche gewünschten Sprachen FEHLEN in der Datei? (pur) */
|
||||||
|
export function fehlendeSprachen(
|
||||||
|
spuren: HbSpuren,
|
||||||
|
gewuenscht: { audio: readonly string[]; untertitel: readonly string[] },
|
||||||
|
): { audio: string[]; untertitel: string[] } {
|
||||||
|
const da = (liste: Array<{ lang: string }>): Set<string> => new Set(liste.map((s) => s.lang))
|
||||||
|
const audioDa = da(spuren.audio)
|
||||||
|
const untertitelDa = da(spuren.untertitel)
|
||||||
|
return {
|
||||||
|
audio: gewuenscht.audio.filter((l) => !audioDa.has(l)),
|
||||||
|
untertitel: gewuenscht.untertitel.filter((l) => !untertitelDa.has(l)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (5.4.0).
|
||||||
|
//
|
||||||
|
// Der Anlass (01.09.2026): Beim Spartacus-Defekt und beim 5042-Zustand
|
||||||
|
// des BU40N war die entscheidende Frage „Disc oder Hardware?" — und die
|
||||||
|
// Antwort stand im System-Protokoll, von Hand gelesen. Jetzt liest Rippy
|
||||||
|
// sie selbst.
|
||||||
|
//
|
||||||
|
// GEMESSEN (01.09.2026, Regel D, an diesem PC):
|
||||||
|
// wevtutil qe System "/q:<XPath>" /f:xml /c:200 /rd:true
|
||||||
|
// Provider 'cdrom', EventID 7 → „Fehlerhafter Block bei Gerät \Device\CdRom0."
|
||||||
|
// EventID 11 → „Der Treiber hat einen Controllerfehler auf \Device\CdRom0 gefunden."
|
||||||
|
// EventID 51 → Auslagerungs-/E/A-Fehler (bei 7 dabei)
|
||||||
|
// In 30 Tagen: 338 × 7, 8 × 11, 46 × 51 — die 7er häuften sich exakt um
|
||||||
|
// den Spartacus-Rip (20:32), die 11er beim USB-Aussetzer (20:17).
|
||||||
|
// TimeCreated SystemTime ist UTC. Die XML-Ereignisse stehen OHNE
|
||||||
|
// Zeilenumbruch hintereinander; ein Regex je <Event>…</Event> reicht.
|
||||||
|
// Die Argumente gehen ohne Shell an wevtutil — ein „/q:"-Argument mit
|
||||||
|
// Leerzeichen bleibt so EIN Argument (über Git Bash zerlegt es der
|
||||||
|
// Pfad-Konverter, über spawn nicht).
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import type { LaufwerkBericht } from '../../gemeinsam/nachrichten'
|
||||||
|
import { textVon } from '../rip/parser'
|
||||||
|
|
||||||
|
export interface LaufwerkEreignis {
|
||||||
|
/** ISO-Zeit (UTC). */
|
||||||
|
zeit: string
|
||||||
|
provider: string
|
||||||
|
id: number
|
||||||
|
geraet: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Ereignis-IDs des cdrom-/disk-Treibers, wie am PC gemessen. */
|
||||||
|
export const EREIGNIS_TEXTE: Record<number, string> = {
|
||||||
|
7: 'fehlerhafter Block',
|
||||||
|
11: 'Controllerfehler',
|
||||||
|
15: 'Gerät nicht bereit',
|
||||||
|
51: 'E/A-Fehler beim Lesen',
|
||||||
|
153: 'E/A-Vorgang wiederholt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xpathFuer(stunden: number): string {
|
||||||
|
const ms = Math.max(1, Math.round(stunden)) * 60 * 60 * 1000
|
||||||
|
return `*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= ${ms}]]]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWevtutilArgs(stunden: number, hoechstens = 200): string[] {
|
||||||
|
return ['qe', 'System', `/q:${xpathFuer(stunden)}`, '/f:xml', `/c:${hoechstens}`, '/rd:true']
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die XML-Ausgabe von wevtutil → Ereignisse (pur, testbar). */
|
||||||
|
export function ereignisseAusXml(xml: string): LaufwerkEreignis[] {
|
||||||
|
const liste: LaufwerkEreignis[] = []
|
||||||
|
for (const treffer of xml.matchAll(/<Event\b[\s\S]*?<\/Event>/g)) {
|
||||||
|
const block = treffer[0]
|
||||||
|
const provider = /<Provider Name='([^']*)'/.exec(block)?.[1] ?? ''
|
||||||
|
const id = Number(/<EventID[^>]*>(\d+)<\/EventID>/.exec(block)?.[1] ?? '')
|
||||||
|
const zeit = /<TimeCreated SystemTime='([^']*)'/.exec(block)?.[1] ?? ''
|
||||||
|
const geraet = /<Data>([^<]*)<\/Data>/.exec(block)?.[1] ?? ''
|
||||||
|
if (!Number.isInteger(id) || provider.length === 0) continue
|
||||||
|
liste.push({ zeit, provider, id, geraet })
|
||||||
|
}
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
|
||||||
|
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */
|
||||||
|
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht {
|
||||||
|
const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime())
|
||||||
|
let fehlerhafteBloecke = 0
|
||||||
|
let controllerfehler = 0
|
||||||
|
let sonstige = 0
|
||||||
|
for (const e of relevant) {
|
||||||
|
if (e.id === 7) fehlerhafteBloecke += 1
|
||||||
|
else if (e.id === 11 || e.id === 15) controllerfehler += 1
|
||||||
|
else sonstige += 1
|
||||||
|
}
|
||||||
|
const letzte = relevant.slice(0, 8).map((e) => {
|
||||||
|
const lokal = e.zeit.length > 0 ? new Date(e.zeit) : null
|
||||||
|
const zeit =
|
||||||
|
lokal !== null && !Number.isNaN(lokal.getTime())
|
||||||
|
? `${lokal.getFullYear()}-${String(lokal.getMonth() + 1).padStart(2, '0')}-${String(lokal.getDate()).padStart(2, '0')} ${String(lokal.getHours()).padStart(2, '0')}:${String(lokal.getMinutes()).padStart(2, '0')}`
|
||||||
|
: '?'
|
||||||
|
return `${zeit} · ${e.provider} ${e.id} · ${EREIGNIS_TEXTE[e.id] ?? 'Ereignis'} (${e.geraet})`
|
||||||
|
})
|
||||||
|
const zeitraum = seit === undefined ? `in den letzten ${stunden} Stunden` : `seit ${seit.toISOString().slice(11, 16)} UTC`
|
||||||
|
|
||||||
|
if (relevant.length === 0) {
|
||||||
|
return {
|
||||||
|
urteil: 'ruhig',
|
||||||
|
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`,
|
||||||
|
fehlerhafteBloecke,
|
||||||
|
controllerfehler,
|
||||||
|
sonstige,
|
||||||
|
letzte,
|
||||||
|
stunden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (controllerfehler > 0) {
|
||||||
|
return {
|
||||||
|
urteil: 'hardware',
|
||||||
|
text:
|
||||||
|
`${controllerfehler} Controllerfehler ${zeitraum}` +
|
||||||
|
(fehlerhafteBloecke > 0 ? ` und ${fehlerhafteBloecke} fehlerhafte Blöcke` : '') +
|
||||||
|
'. Ein Controllerfehler kommt vom Laufwerk oder seiner Verbindung, nicht von der Disc: ' +
|
||||||
|
'Bei einem USB-Laufwerk zuerst einen Port direkt am Mainboard probieren, Kabel und Netzteil prüfen.',
|
||||||
|
fehlerhafteBloecke,
|
||||||
|
controllerfehler,
|
||||||
|
sonstige,
|
||||||
|
letzte,
|
||||||
|
stunden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fehlerhafteBloecke > 0) {
|
||||||
|
return {
|
||||||
|
urteil: 'disc',
|
||||||
|
text:
|
||||||
|
`${fehlerhafteBloecke} fehlerhafte Blöcke ${zeitraum}, keine Controllerfehler. ` +
|
||||||
|
'Das spricht für die DISC (Kratzer, Fertigungsfehler), nicht für das Laufwerk: ' +
|
||||||
|
'Disc reinigen, den betroffenen Titel überspringen oder eine andere Ausgabe versuchen.',
|
||||||
|
fehlerhafteBloecke,
|
||||||
|
controllerfehler,
|
||||||
|
sonstige,
|
||||||
|
letzte,
|
||||||
|
stunden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
urteil: 'unklar',
|
||||||
|
text: `${sonstige} Laufwerks-Ereignisse ${zeitraum}, aber weder fehlerhafte Blöcke noch Controllerfehler — kein klares Bild.`,
|
||||||
|
fehlerhafteBloecke,
|
||||||
|
controllerfehler,
|
||||||
|
sonstige,
|
||||||
|
letzte,
|
||||||
|
stunden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest das Protokoll — null heißt „konnte nicht lesen", mit Grund. */
|
||||||
|
export function ereignisseLesen(
|
||||||
|
stunden: number,
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
): Promise<{ ereignisse: LaufwerkEreignis[] } | { fehler: string }> {
|
||||||
|
return new Promise((fertig) => {
|
||||||
|
let kind: ReturnType<typeof spawn>
|
||||||
|
try {
|
||||||
|
kind = spawn('wevtutil', buildWevtutilArgs(stunden), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
} catch (fehler) {
|
||||||
|
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const stuecke: Buffer[] = []
|
||||||
|
const fehlerStuecke: Buffer[] = []
|
||||||
|
kind.stdout?.on('data', (s: Buffer) => stuecke.push(s))
|
||||||
|
kind.stderr?.on('data', (s: Buffer) => fehlerStuecke.push(s))
|
||||||
|
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||||
|
kind.on('error', (fehler) => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
|
||||||
|
})
|
||||||
|
kind.on('close', (code) => {
|
||||||
|
clearTimeout(zeiger)
|
||||||
|
const xml = textVon(Buffer.concat(stuecke))
|
||||||
|
// Ohne Treffer endet wevtutil mit 0 und leerer Ausgabe; ein Fehler
|
||||||
|
// steht auf stderr mit Code ≠ 0.
|
||||||
|
if (code !== 0 && xml.trim().length === 0) {
|
||||||
|
fertig({ fehler: `wevtutil endete mit Code ${code}: ${textVon(Buffer.concat(fehlerStuecke)).trim().slice(0, 200)}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fertig({ ereignisse: ereignisseAusXml(xml) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -121,14 +121,30 @@ export interface BdmtInhalt {
|
|||||||
titel: string
|
titel: string
|
||||||
/** <di:titleName> — was die Disc trägt: Folgen, Tonspuren, Trailer. */
|
/** <di:titleName> — was die Disc trägt: Folgen, Tonspuren, Trailer. */
|
||||||
eintraege: string[]
|
eintraege: string[]
|
||||||
|
/** Weitere Titel derselben Disc aus den anderen bdmt-Sprachdateien
|
||||||
|
* (bdmt_deu neben bdmt_eng, 5.4.0) — ohne den Haupttitel, ohne Doppel. */
|
||||||
|
alternativen: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liest die bdmt-Datei EINMAL — bdmt_eng bevorzugt. Regex statt
|
/** Den Studio-Titel und das Inhaltsverzeichnis aus EINER bdmt-Datei ziehen
|
||||||
* XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
|
* (pur, testbar). Regex statt XML-Parser (Namespaces variieren je
|
||||||
|
* Authoring-Werkzeug). */
|
||||||
|
export function bdmtZerlegen(inhalt: string): { titel: string; eintraege: string[] } {
|
||||||
|
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
||||||
|
const eintraege: string[] = []
|
||||||
|
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
|
||||||
|
const text = t[1].trim()
|
||||||
|
if (text.length > 0) eintraege.push(text)
|
||||||
|
}
|
||||||
|
return { titel: name === null ? '' : name[1].trim(), eintraege }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die bdmt-Dateien — bdmt_eng zuerst, die anderen Sprachen liefern
|
||||||
|
* Titel-Varianten (5.4.0). */
|
||||||
export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
|
export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
|
||||||
// Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine
|
// Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine
|
||||||
// Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer.
|
// Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer.
|
||||||
const leer: BdmtInhalt = { titel: '', eintraege: [] }
|
const leer: BdmtInhalt = { titel: '', eintraege: [], alternativen: [] }
|
||||||
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
|
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
|
||||||
if (!existsSync(metaOrdner)) return leer
|
if (!existsSync(metaOrdner)) return leer
|
||||||
let kandidaten: string[]
|
let kandidaten: string[]
|
||||||
@@ -137,23 +153,38 @@ export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
|
|||||||
} catch {
|
} catch {
|
||||||
return leer
|
return leer
|
||||||
}
|
}
|
||||||
const bdmt =
|
const bdmts = kandidaten.filter((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
||||||
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
|
if (bdmts.length === 0) return leer
|
||||||
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
|
// Englisch zuerst (das ist der Titel, den die Suche bisher kannte), dann
|
||||||
if (bdmt === undefined) return leer
|
// Deutsch, dann der Rest in Dateireihenfolge.
|
||||||
let inhalt: string
|
const rang = (k: string): number => (k === 'bdmt_eng.xml' ? 0 : k === 'bdmt_deu.xml' ? 1 : 2)
|
||||||
try {
|
bdmts.sort((a, b) => rang(a) - rang(b) || a.localeCompare(b))
|
||||||
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
let titel = ''
|
||||||
} catch {
|
let eintraege: string[] = []
|
||||||
return leer
|
const alternativen: string[] = []
|
||||||
|
for (const bdmt of bdmts) {
|
||||||
|
let inhalt: string
|
||||||
|
try {
|
||||||
|
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const zerlegt = bdmtZerlegen(inhalt)
|
||||||
|
if (titel.length === 0 && zerlegt.titel.length > 0) {
|
||||||
|
titel = zerlegt.titel
|
||||||
|
eintraege = zerlegt.eintraege
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (eintraege.length === 0 && zerlegt.eintraege.length > 0) eintraege = zerlegt.eintraege
|
||||||
|
if (
|
||||||
|
zerlegt.titel.length > 0 &&
|
||||||
|
zerlegt.titel.toLowerCase() !== titel.toLowerCase() &&
|
||||||
|
!alternativen.some((a) => a.toLowerCase() === zerlegt.titel.toLowerCase())
|
||||||
|
) {
|
||||||
|
alternativen.push(zerlegt.titel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
|
return { titel, eintraege, alternativen }
|
||||||
const eintraege: string[] = []
|
|
||||||
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
|
|
||||||
const text = t[1].trim()
|
|
||||||
if (text.length > 0) eintraege.push(text)
|
|
||||||
}
|
|
||||||
return { titel: name === null ? '' : name[1].trim(), eintraege }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */
|
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */
|
||||||
@@ -240,13 +271,15 @@ export interface DiscTitel {
|
|||||||
/** Die Disc bezeugt selbst, dass sie Folgen trägt — ab zwei ist es
|
/** Die Disc bezeugt selbst, dass sie Folgen trägt — ab zwei ist es
|
||||||
* keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */
|
* keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */
|
||||||
istSerie: boolean
|
istSerie: boolean
|
||||||
|
/** Weitere Titel-Varianten (bdmt_deu, 5.4.0) — die Suche fragt jede. */
|
||||||
|
alternativen: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als
|
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit „Serie" als
|
||||||
* bewiesen gilt — eine einzelne könnte auch eine Zugabe sein. */
|
* bewiesen gilt — eine einzelne könnte auch eine Zugabe sein. */
|
||||||
export const FOLGEN_FUER_SERIE = 2
|
export const FOLGEN_FUER_SERIE = 2
|
||||||
|
|
||||||
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number): DiscTitel {
|
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number, alternativen: string[] = []): DiscTitel {
|
||||||
const zusatz = discZusatzAbtrennen(titel)
|
const zusatz = discZusatzAbtrennen(titel)
|
||||||
return {
|
return {
|
||||||
titel,
|
titel,
|
||||||
@@ -255,6 +288,7 @@ function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number): Dis
|
|||||||
staffel: zusatz.staffel,
|
staffel: zusatz.staffel,
|
||||||
folgen,
|
folgen,
|
||||||
istSerie: folgen >= FOLGEN_FUER_SERIE,
|
istSerie: folgen >= FOLGEN_FUER_SERIE,
|
||||||
|
alternativen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,11 +300,11 @@ export function discTitel(geraetepfad: string): DiscTitel {
|
|||||||
if (wurzel.length > 0) {
|
if (wurzel.length > 0) {
|
||||||
const bdmt = inhaltAusBdmt(wurzel)
|
const bdmt = inhaltAusBdmt(wurzel)
|
||||||
folgen = folgenZahl(bdmt.eintraege)
|
folgen = folgenZahl(bdmt.eintraege)
|
||||||
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen)
|
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen, bdmt.alternativen)
|
||||||
}
|
}
|
||||||
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
|
||||||
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
|
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
|
||||||
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE }
|
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE, alternativen: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) — Basis der
|
||||||
|
|||||||
@@ -2,14 +2,50 @@
|
|||||||
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
|
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
|
||||||
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
|
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
|
||||||
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
|
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
|
||||||
|
//
|
||||||
|
// 5.4.0 — Kino-Banner v2: Details holen in DERSELBEN Anfrage per
|
||||||
|
// `append_to_response` Logo, Besetzung und Freigabe mit. Die Feldnamen
|
||||||
|
// sind GEMESSEN (01.09.2026 mit dem hinterlegten v4-Token, Regel D):
|
||||||
|
// movie/149?append_to_response=images,credits,release_dates
|
||||||
|
// &include_image_language=de,en,null
|
||||||
|
// → tagline „Neo-Tokyo ist am EXPLODIEREN", runtime 124,
|
||||||
|
// vote_average 7.94, images.logos[{iso_639_1:'de'|'en'|null,
|
||||||
|
// file_path:'/….png'|'.svg', width, height, vote_average}],
|
||||||
|
// credits.cast[{name, character, profile_path, order}],
|
||||||
|
// release_dates.results[{iso_3166_1:'DE', release_dates:[{certification:'16', type:3|5|6}]}]
|
||||||
|
// tv/46296?append_to_response=images,credits,content_ratings
|
||||||
|
// → episode_run_time [53], content_ratings.results[{iso_3166_1:'DE', rating:'18'}],
|
||||||
|
// seasons[{season_number, name, episode_count}], number_of_episodes
|
||||||
|
// configuration.images: logo_sizes w45…w500, profile_sizes w45,w185,h632
|
||||||
|
// Und der Stub-Fund: „Spartacus: Gods of the Arena" gibt es als drei
|
||||||
|
// leere Serien-Einträge (popularity 1, kein Backdrop, 1 Folge) — der
|
||||||
|
// echte Inhalt ist die Extras-Staffel von „Spartacus" (46296).
|
||||||
|
|
||||||
export interface FilmTreffer {
|
export interface FilmTreffer {
|
||||||
id: number
|
id: number
|
||||||
title?: string
|
title?: string
|
||||||
name?: string
|
name?: string
|
||||||
poster_path?: string
|
poster_path?: string | null
|
||||||
|
backdrop_path?: string | null
|
||||||
release_date?: string
|
release_date?: string
|
||||||
first_air_date?: string
|
first_air_date?: string
|
||||||
|
overview?: string
|
||||||
|
popularity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbLogo {
|
||||||
|
iso_639_1: string | null
|
||||||
|
file_path: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
vote_average?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmdbCast {
|
||||||
|
name: string
|
||||||
|
character?: string
|
||||||
|
profile_path?: string | null
|
||||||
|
order?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilmDetails {
|
export interface FilmDetails {
|
||||||
@@ -19,10 +55,22 @@ export interface FilmDetails {
|
|||||||
release_date?: string
|
release_date?: string
|
||||||
first_air_date?: string
|
first_air_date?: string
|
||||||
overview?: string
|
overview?: string
|
||||||
poster_path?: string
|
poster_path?: string | null
|
||||||
backdrop_path?: string
|
backdrop_path?: string | null
|
||||||
runtime?: number
|
runtime?: number
|
||||||
genres?: Array<{ name: string }>
|
genres?: Array<{ name: string }>
|
||||||
|
tagline?: string
|
||||||
|
vote_average?: number
|
||||||
|
vote_count?: number
|
||||||
|
popularity?: number
|
||||||
|
episode_run_time?: number[]
|
||||||
|
number_of_episodes?: number
|
||||||
|
number_of_seasons?: number
|
||||||
|
seasons?: Array<{ season_number: number; name?: string; episode_count?: number }>
|
||||||
|
images?: { logos?: TmdbLogo[] }
|
||||||
|
credits?: { cast?: TmdbCast[] }
|
||||||
|
release_dates?: { results?: Array<{ iso_3166_1: string; release_dates?: Array<{ certification?: string; type?: number }> }> }
|
||||||
|
content_ratings?: { results?: Array<{ iso_3166_1: string; rating?: string }> }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function istV4Token(key: string): boolean {
|
export function istV4Token(key: string): boolean {
|
||||||
@@ -31,6 +79,44 @@ export function istV4Token(key: string): boolean {
|
|||||||
|
|
||||||
const BASIS = 'https://api.themoviedb.org/3'
|
const BASIS = 'https://api.themoviedb.org/3'
|
||||||
|
|
||||||
|
/** Das beste Logo (pur): deutsch vor englisch vor sprachlos, darin das
|
||||||
|
* bestbewertete. '' wenn keins. */
|
||||||
|
export function logoAus(logos: readonly TmdbLogo[] | undefined): string {
|
||||||
|
if (logos === undefined || logos.length === 0) return ''
|
||||||
|
const rang = (l: TmdbLogo): number => (l.iso_639_1 === 'de' ? 0 : l.iso_639_1 === 'en' ? 1 : l.iso_639_1 === null ? 2 : 3)
|
||||||
|
const sortiert = [...logos]
|
||||||
|
.filter((l) => typeof l.file_path === 'string' && l.file_path.length > 0)
|
||||||
|
.sort((a, b) => rang(a) - rang(b) || (b.vote_average ?? 0) - (a.vote_average ?? 0))
|
||||||
|
return sortiert[0]?.file_path ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die deutsche Freigabe (pur): Film aus release_dates (Kino vor
|
||||||
|
* Heimvideo vor TV), Serie aus content_ratings. '' wenn unbekannt. */
|
||||||
|
export function fskAus(details: FilmDetails, typ: 'movie' | 'tv'): string {
|
||||||
|
if (typ === 'tv') {
|
||||||
|
const de = details.content_ratings?.results?.find((r) => r.iso_3166_1 === 'DE')
|
||||||
|
return (de?.rating ?? '').trim()
|
||||||
|
}
|
||||||
|
const de = details.release_dates?.results?.find((r) => r.iso_3166_1 === 'DE')
|
||||||
|
const eintraege = (de?.release_dates ?? []).filter((e) => (e.certification ?? '').trim().length > 0)
|
||||||
|
if (eintraege.length === 0) return ''
|
||||||
|
const rang = (typ2: number | undefined): number => (typ2 === 3 ? 0 : typ2 === 5 ? 1 : typ2 === 4 ? 2 : typ2 === 6 ? 3 : 4)
|
||||||
|
return [...eintraege].sort((a, b) => rang(a.type) - rang(b.type))[0].certification!.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die ersten Darsteller mit Bild (pur). */
|
||||||
|
export function besetzungAus(
|
||||||
|
cast: readonly TmdbCast[] | undefined,
|
||||||
|
hoechstens = 6,
|
||||||
|
): Array<{ name: string; rolle: string; bildPfad: string }> {
|
||||||
|
if (cast === undefined) return []
|
||||||
|
return [...cast]
|
||||||
|
.filter((c) => typeof c.name === 'string' && c.name.length > 0)
|
||||||
|
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
|
||||||
|
.slice(0, hoechstens)
|
||||||
|
.map((c) => ({ name: c.name, rolle: (c.character ?? '').trim(), bildPfad: c.profile_path ?? '' }))
|
||||||
|
}
|
||||||
|
|
||||||
export class TmdbClient {
|
export class TmdbClient {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly apiKey: string,
|
private readonly apiKey: string,
|
||||||
@@ -84,11 +170,19 @@ export class TmdbClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async movieDetails(id: number): Promise<FilmDetails | null> {
|
async movieDetails(id: number): Promise<FilmDetails | null> {
|
||||||
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
return (await this.anfrage(`movie/${id}`, {
|
||||||
|
language: 'de-DE',
|
||||||
|
append_to_response: 'images,credits,release_dates',
|
||||||
|
include_image_language: 'de,en,null',
|
||||||
|
})) as FilmDetails | null
|
||||||
}
|
}
|
||||||
|
|
||||||
async tvDetails(id: number): Promise<FilmDetails | null> {
|
async tvDetails(id: number): Promise<FilmDetails | null> {
|
||||||
return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null
|
return (await this.anfrage(`tv/${id}`, {
|
||||||
|
language: 'de-DE',
|
||||||
|
append_to_response: 'images,credits,content_ratings',
|
||||||
|
include_image_language: 'de,en,null',
|
||||||
|
})) as FilmDetails | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
|
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
|
||||||
|
|||||||
@@ -12,9 +12,24 @@
|
|||||||
//
|
//
|
||||||
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
|
||||||
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
|
// (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 { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
|
||||||
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
|
import { besetzungAus, fskAus, logoAus, type FilmDetails, type FilmTreffer, type TmdbClient } from './tmdb'
|
||||||
import type { OmdbClient, OmdbTreffer } from './omdb'
|
import type { OmdbClient, OmdbTreffer } from './omdb'
|
||||||
|
import type { Darsteller } from '../../gemeinsam/nachrichten'
|
||||||
|
|
||||||
export const WENIGE_TREFFER = 3
|
export const WENIGE_TREFFER = 3
|
||||||
|
|
||||||
@@ -30,6 +45,14 @@ export interface MetaTreffer {
|
|||||||
laufzeitMinuten: number
|
laufzeitMinuten: number
|
||||||
genres: string[]
|
genres: string[]
|
||||||
quelle: 'tmdb' | 'omdb' | ''
|
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 {
|
export interface Zuordnung {
|
||||||
@@ -37,6 +60,8 @@ export interface Zuordnung {
|
|||||||
jahr: number | null
|
jahr: number | null
|
||||||
confidence: number
|
confidence: number
|
||||||
meta: MetaTreffer
|
meta: MetaTreffer
|
||||||
|
/** Was Rippy nach dem Info-Lauf noch dazugelernt hat (nachpruefen). */
|
||||||
|
hinweise?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function jahrAus(datum: string | undefined): number | null {
|
function jahrAus(datum: string | undefined): number | null {
|
||||||
@@ -45,7 +70,11 @@ function jahrAus(datum: string | undefined): number | null {
|
|||||||
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
|
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 {
|
return {
|
||||||
typ,
|
typ,
|
||||||
id,
|
id,
|
||||||
@@ -54,9 +83,15 @@ function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): Meta
|
|||||||
beschreibung: details.overview ?? '',
|
beschreibung: details.overview ?? '',
|
||||||
posterPfad: details.poster_path ?? '',
|
posterPfad: details.poster_path ?? '',
|
||||||
backdropPfad: details.backdrop_path ?? '',
|
backdropPfad: details.backdrop_path ?? '',
|
||||||
laufzeitMinuten: details.runtime ?? 0,
|
laufzeitMinuten: laufzeit,
|
||||||
genres: (details.genres ?? []).map((g) => g.name),
|
genres: (details.genres ?? []).map((g) => g.name),
|
||||||
quelle: 'tmdb',
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,27 +108,51 @@ function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
|
|||||||
laufzeitMinuten: treffer.runtime,
|
laufzeitMinuten: treffer.runtime,
|
||||||
genres: treffer.genres,
|
genres: treffer.genres,
|
||||||
quelle: 'omdb',
|
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 => ({
|
const UNBEKANNT = (titel: string): Zuordnung => ({
|
||||||
titel,
|
titel,
|
||||||
jahr: null,
|
jahr: null,
|
||||||
confidence: 0.3,
|
confidence: 0.3,
|
||||||
meta: {
|
meta: { ...LEERER_TREFFER, titel },
|
||||||
typ: 'unknown',
|
|
||||||
id: null,
|
|
||||||
titel,
|
|
||||||
jahr: null,
|
|
||||||
beschreibung: '',
|
|
||||||
posterPfad: '',
|
|
||||||
backdropPfad: '',
|
|
||||||
laufzeitMinuten: 0,
|
|
||||||
genres: [],
|
|
||||||
quelle: '',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 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,
|
/** Titel → Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
|
||||||
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
* Abdeckung und Titelvergleich — alle drei sollen dasselbe „Wort" meinen. */
|
||||||
function worte(t: string): Set<string> {
|
function worte(t: string): Set<string> {
|
||||||
@@ -163,6 +222,35 @@ export interface DiscHinweis {
|
|||||||
* trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige
|
* trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige
|
||||||
* aus: Was bewiesen ist, muss nicht mehr geraten werden. */
|
* aus: Was bewiesen ist, muss nicht mehr geraten werden. */
|
||||||
istSerie?: boolean
|
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.
|
/** Die Kaskade — Clients injiziert, damit sie ohne Netz prüfbar ist.
|
||||||
@@ -183,6 +271,8 @@ export interface DiscHinweis {
|
|||||||
* '- Disc 2' zählen nicht mit). Vorher hing das an „Kandidat Nummer
|
* '- Disc 2' zählen nicht mit). Vorher hing das an „Kandidat Nummer
|
||||||
* 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat
|
* 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat
|
||||||
* Nummer 1 und fiel durch dieses Raster.
|
* 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(
|
export async function zuordnen(
|
||||||
discTitel: string,
|
discTitel: string,
|
||||||
@@ -194,43 +284,39 @@ export async function zuordnen(
|
|||||||
if (titel.length === 0) return UNBEKANNT('')
|
if (titel.length === 0) return UNBEKANNT('')
|
||||||
|
|
||||||
const nurSerie = hinweis.istSerie === true
|
const nurSerie = hinweis.istSerie === true
|
||||||
// Maßstab für die Abdeckung ist der Titel OHNE Verpackungs-Zusatz:
|
const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? [])
|
||||||
// '- Disc 2' ist Rauschen und darf einer guten Frage nicht angelastet werden.
|
|
||||||
const massstab = discZusatzAbtrennen(titel).titel || titel
|
|
||||||
const kandidaten = titelKandidaten(titel)
|
|
||||||
if (kandidaten.length === 0) kandidaten.push(titel)
|
|
||||||
|
|
||||||
let ersteFilme: FilmTreffer[] = []
|
let ersteFilme: FilmTreffer[] = []
|
||||||
let ersteSerien: FilmTreffer[] = []
|
let ersteSerien: FilmTreffer[] = []
|
||||||
|
|
||||||
for (const kandidat of kandidaten) {
|
for (const kandidat of kandidaten) {
|
||||||
const deckung = abdeckung(massstab, kandidat)
|
const deckung = abdeckung(kandidat.massstab, kandidat.text)
|
||||||
const vollstaendig = deckung >= 1
|
const vollstaendig = deckung >= 1
|
||||||
/** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */
|
/** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */
|
||||||
const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG))
|
const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG))
|
||||||
|
|
||||||
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat)) ?? [])
|
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
|
||||||
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
|
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
|
||||||
|
|
||||||
// Wörtlicher Filmtreffer (0,95)
|
// Wörtlicher Filmtreffer (0,95)
|
||||||
for (const film of filme) {
|
for (const film of filme) {
|
||||||
if (gleichBedeutend(film.title ?? '', kandidat)) {
|
if (gleichBedeutend(film.title ?? '', kandidat.text)) {
|
||||||
const details = await tmdb.movieDetails(film.id)
|
const details = await tmdb.movieDetails(film.id)
|
||||||
if (details !== null) {
|
if (details !== null && !istStub(details)) {
|
||||||
const meta = ausDetails('movie', film.id, details)
|
const meta = ausDetails('movie', film.id, details)
|
||||||
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
|
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const serien = (await tmdb.searchTv(kandidat)) ?? []
|
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
|
||||||
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
|
||||||
|
|
||||||
// Wörtlicher Serientreffer (0,90)
|
// Wörtlicher Serientreffer (0,90)
|
||||||
for (const serie of serien) {
|
for (const serie of serien) {
|
||||||
if (gleichBedeutend(serie.name ?? '', kandidat)) {
|
if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
|
||||||
const details = await tmdb.tvDetails(serie.id)
|
const details = await tmdb.tvDetails(serie.id)
|
||||||
if (details !== null) {
|
if (details !== null && !istStub(details)) {
|
||||||
const meta = ausDetails('tv', serie.id, details)
|
const meta = ausDetails('tv', serie.id, details)
|
||||||
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
|
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
|
||||||
}
|
}
|
||||||
@@ -239,20 +325,24 @@ export async function zuordnen(
|
|||||||
|
|
||||||
// Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
|
// Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
|
||||||
// Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
|
// Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
|
||||||
// (der Evangelion-Fund).
|
// (der Evangelion-Fund). Stubs zählen dabei nicht mit.
|
||||||
if (vollstaendig) {
|
if (vollstaendig) {
|
||||||
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
|
||||||
const details = await tmdb.movieDetails(filme[0].id)
|
for (const film of filme) {
|
||||||
if (details !== null) {
|
const details = await tmdb.movieDetails(film.id)
|
||||||
const meta = ausDetails('movie', filme[0].id, details)
|
if (details !== null && !istStub(details)) {
|
||||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
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) {
|
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
|
||||||
const details = await tmdb.tvDetails(serien[0].id)
|
for (const serie of serien) {
|
||||||
if (details !== null) {
|
const details = await tmdb.tvDetails(serie.id)
|
||||||
const meta = ausDetails('tv', serien[0].id, details)
|
if (details !== null && !istStub(details)) {
|
||||||
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
const meta = ausDetails('tv', serie.id, details)
|
||||||
|
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +352,7 @@ export async function zuordnen(
|
|||||||
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
|
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
|
||||||
if (omdb.verfuegbar) {
|
if (omdb.verfuegbar) {
|
||||||
for (const kandidat of kandidaten) {
|
for (const kandidat of kandidaten) {
|
||||||
const treffer = await omdb.lookup(kandidat)
|
const treffer = await omdb.lookup(kandidat.text)
|
||||||
if (treffer !== null) {
|
if (treffer !== null) {
|
||||||
// Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen,
|
// Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen,
|
||||||
// was sie ist.
|
// was sie ist.
|
||||||
@@ -270,7 +360,7 @@ export async function zuordnen(
|
|||||||
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
|
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
|
||||||
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
|
||||||
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
|
||||||
if (wortUeberlappung(kandidat, treffer.title) < 0.5) continue
|
if (wortUeberlappung(kandidat.text, treffer.title) < 0.5) continue
|
||||||
const meta = ausOmdb(treffer)
|
const meta = ausOmdb(treffer)
|
||||||
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
|
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
|
||||||
const deutsch = await tmdb.findByImdb(meta.id)
|
const deutsch = await tmdb.findByImdb(meta.id)
|
||||||
@@ -288,7 +378,7 @@ export async function zuordnen(
|
|||||||
}
|
}
|
||||||
// Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage
|
// Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage
|
||||||
// ist ein Vorschlag, egal von welchem Dienst er kommt.
|
// ist ein Vorschlag, egal von welchem Dienst er kommt.
|
||||||
const sicher = abdeckung(massstab, kandidat) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG
|
const sicher = abdeckung(kandidat.massstab, kandidat.text) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG
|
||||||
return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta }
|
return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,20 +386,26 @@ export async function zuordnen(
|
|||||||
|
|
||||||
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
|
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
|
||||||
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
|
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
|
||||||
if (ersteFilme.length > 0) {
|
// Einträge MIT Inhalt zuerst; ist alles leer, bleibt der erste Treffer
|
||||||
const details = await tmdb.movieDetails(ersteFilme[0].id)
|
// trotzdem ein Vorschlag — besser als gar keiner (es wird ja gefragt).
|
||||||
if (details !== null) {
|
let notnagel: Zuordnung | null = null
|
||||||
const meta = ausDetails('movie', ersteFilme[0].id, details)
|
for (const film of ersteFilme.slice(0, WENIGE_TREFFER)) {
|
||||||
return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
|
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
|
||||||
}
|
}
|
||||||
if (ersteSerien.length > 0) {
|
for (const serie of ersteSerien.slice(0, WENIGE_TREFFER)) {
|
||||||
const details = await tmdb.tvDetails(ersteSerien[0].id)
|
const details = await tmdb.tvDetails(serie.id)
|
||||||
if (details !== null) {
|
if (details === null) continue
|
||||||
const meta = ausDetails('tv', ersteSerien[0].id, details)
|
const meta = ausDetails('tv', serie.id, details)
|
||||||
return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
|
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)
|
return UNBEKANNT(titel)
|
||||||
}
|
}
|
||||||
@@ -318,3 +414,71 @@ export async function zuordnen(
|
|||||||
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
|
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
|
||||||
return zuordnung.confidence >= schwelle
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -170,11 +170,10 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SprachEintrag {
|
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
|
||||||
lang: string
|
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
|
||||||
sprache: string
|
import type { SprachEintrag } from '../../gemeinsam/nachrichten'
|
||||||
spuren: number
|
export type { SprachEintrag }
|
||||||
}
|
|
||||||
|
|
||||||
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
|
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
|
||||||
* Reihenfolge stabil (wie auf der Disc). */
|
* Reihenfolge stabil (wie auf der Disc). */
|
||||||
@@ -200,6 +199,17 @@ export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>
|
|||||||
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
|
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe
|
||||||
|
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
|
||||||
|
* (Titel unbekannt) leer, nicht erfunden. */
|
||||||
|
export function sprachenFuerTitel(streams: TitelStreams | undefined): {
|
||||||
|
audio: SprachEintrag[]
|
||||||
|
untertitel: SprachEintrag[]
|
||||||
|
} {
|
||||||
|
if (streams === undefined) return { audio: [], untertitel: [] }
|
||||||
|
return sprachenZusammenfassen(new Map([[0, streams]]))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Titelwahl-Heuristiken ───────────────────────────────────────────────
|
// ── Titelwahl-Heuristiken ───────────────────────────────────────────────
|
||||||
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
|
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
|
||||||
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) — das
|
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) — das
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ export interface DiscHinweis {
|
|||||||
folgen: number
|
folgen: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Titel-Struktur als Fingerabdruck (5.4.0, KONZEPT § 5: „1 langer +
|
||||||
|
* viele kurze = Film; 6–13 gleich lange = Serienstaffel"). Bewusst
|
||||||
|
* ZURÜCKHALTEND — 'unklar' ist die häufigste und ehrlichste Antwort:
|
||||||
|
* * 'serie': mindestens drei ähnlich lange Titel zwischen 15 und 75 min
|
||||||
|
* * 'film': ein Titel ab 60 min, der den zweitlängsten deutlich überragt
|
||||||
|
* Zwei gleich lange Fassungen (Seamless Branching) bleiben 'unklar', eine
|
||||||
|
* Zwei-Folgen-Disc auch (dafür ist das bdmt-Inhaltsverzeichnis da). */
|
||||||
|
export function strukturHinweis(alle: readonly TitelInfo[]): 'film' | 'serie' | 'unklar' {
|
||||||
|
const lang = alle.filter((t) => t.dauerS >= KURZ_S).sort((a, b) => b.dauerS - a.dauerS)
|
||||||
|
if (lang.length === 0) return 'unklar'
|
||||||
|
const laengster = lang[0].dauerS
|
||||||
|
const aehnlich = lang.filter((t) => Math.abs(t.dauerS - laengster) / laengster <= AEHNLICH)
|
||||||
|
if (aehnlich.length >= 3 && laengster >= 15 * 60 && laengster <= 75 * 60) return 'serie'
|
||||||
|
const zweiter = lang[1]?.dauerS ?? 0
|
||||||
|
if (laengster >= 60 * 60 && zweiter < laengster * 0.6) return 'film'
|
||||||
|
return 'unklar'
|
||||||
|
}
|
||||||
|
|
||||||
function dauerText(s: number): string {
|
function dauerText(s: number): string {
|
||||||
const m = Math.round(s / 60)
|
const m = Math.round(s / 60)
|
||||||
return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
|
return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
|
||||||
|
|||||||
@@ -1,18 +1,53 @@
|
|||||||
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
|
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
|
||||||
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
|
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
|
||||||
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
|
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
|
||||||
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus
|
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las
|
||||||
// einer Konfigurationsdatei, die niemand schrieb.
|
// aus einer Konfigurationsdatei, die niemand schrieb.
|
||||||
//
|
//
|
||||||
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
|
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
|
||||||
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
|
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
|
||||||
// keine native Kompilierung, kein node-gyp in der Bau-Kette.
|
// keine native Kompilierung, kein node-gyp in der Bau-Kette.
|
||||||
|
//
|
||||||
|
// Schema 4 (5.4.0): die Tabelle `vorgaenge`. Bis dahin wusste Rippy nach
|
||||||
|
// einem Rip NICHT mehr, welcher Roh-Ordner zu welchem Film gehört — und
|
||||||
|
// genau deshalb konnte niemand aufräumen (jede Blu-ray ließ 30+ GB liegen),
|
||||||
|
// niemand „ab Kompression wiederholen" und niemand nach einem Abbruch
|
||||||
|
// nachsehen, was halb fertig herumlag. Der Vorgang ist jetzt ein Datensatz.
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
import { mkdirSync } from 'node:fs'
|
import { mkdirSync } from 'node:fs'
|
||||||
import { dirname } from 'node:path'
|
import { dirname } from 'node:path'
|
||||||
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
|
import type { BibliothekEintrag, VorgangPhase } from '../../gemeinsam/nachrichten'
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 3
|
export const SCHEMA_VERSION = 4
|
||||||
|
|
||||||
|
/** Ein Vorgang, wie die Datenbank ihn kennt (§ 6.7 / 5.4.0). */
|
||||||
|
export interface Vorgang {
|
||||||
|
/** Der Vorgangsname — zugleich der Name des Roh-Ordners. */
|
||||||
|
id: string
|
||||||
|
laufwerk: string
|
||||||
|
typ: string
|
||||||
|
titel: string
|
||||||
|
jahr: number | null
|
||||||
|
phase: VorgangPhase
|
||||||
|
begonnen: string
|
||||||
|
beendet: string | null
|
||||||
|
rohOrdner: string
|
||||||
|
rohBytes: number
|
||||||
|
rohGeloeschtAm: string | null
|
||||||
|
zielOrdner: string
|
||||||
|
fertigBytes: number
|
||||||
|
/** 0 = die Roh-Dateien SIND das Ergebnis (verschoben, nicht komprimiert). */
|
||||||
|
komprimiert: boolean
|
||||||
|
preset: string
|
||||||
|
fehler: string
|
||||||
|
/** Der Kompressions-Auftrag als JSON — Grundlage für „ab Kompression
|
||||||
|
* wiederholen" (kern/ablauf/auftrag.ts). */
|
||||||
|
auftrag: string
|
||||||
|
lesefehler: boolean
|
||||||
|
/** Die fertigen Dateien als JSON-Liste — das Aufräumen prüft, dass sie
|
||||||
|
* wirklich da sind, BEVOR es den Roh-Ordner löscht. */
|
||||||
|
fertigeDateien: string
|
||||||
|
}
|
||||||
|
|
||||||
export class Datenbank {
|
export class Datenbank {
|
||||||
readonly pfad: string
|
readonly pfad: string
|
||||||
@@ -48,6 +83,27 @@ export class Datenbank {
|
|||||||
datum TEXT NOT NULL,
|
datum TEXT NOT NULL,
|
||||||
dauer_s INTEGER NOT NULL,
|
dauer_s INTEGER NOT NULL,
|
||||||
poster_pfad TEXT NOT NULL DEFAULT ''
|
poster_pfad TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS vorgaenge (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
laufwerk TEXT NOT NULL,
|
||||||
|
typ TEXT NOT NULL,
|
||||||
|
titel TEXT NOT NULL,
|
||||||
|
jahr INTEGER,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
begonnen TEXT NOT NULL,
|
||||||
|
beendet TEXT,
|
||||||
|
roh_ordner TEXT NOT NULL,
|
||||||
|
roh_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
roh_geloescht_am TEXT,
|
||||||
|
ziel_ordner TEXT NOT NULL DEFAULT '',
|
||||||
|
fertig_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
komprimiert INTEGER NOT NULL DEFAULT 0,
|
||||||
|
preset TEXT NOT NULL DEFAULT '',
|
||||||
|
fehler TEXT NOT NULL DEFAULT '',
|
||||||
|
auftrag TEXT NOT NULL DEFAULT '{}',
|
||||||
|
lesefehler INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fertige_dateien TEXT NOT NULL DEFAULT '[]'
|
||||||
);`,
|
);`,
|
||||||
)
|
)
|
||||||
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
|
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
|
||||||
@@ -129,7 +185,91 @@ export class Datenbank {
|
|||||||
return zeile === undefined ? null : zeile.datum
|
return zeile === undefined ? null : zeile.datum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
|
||||||
|
vorgangAnlegen(v: Vorgang): void {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR REPLACE INTO vorgaenge
|
||||||
|
(id, laufwerk, typ, titel, jahr, phase, begonnen, beendet, roh_ordner, roh_bytes,
|
||||||
|
roh_geloescht_am, ziel_ordner, fertig_bytes, komprimiert, preset, fehler, auftrag,
|
||||||
|
lesefehler, fertige_dateien)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
v.id,
|
||||||
|
v.laufwerk,
|
||||||
|
v.typ,
|
||||||
|
v.titel,
|
||||||
|
v.jahr,
|
||||||
|
v.phase,
|
||||||
|
v.begonnen,
|
||||||
|
v.beendet,
|
||||||
|
v.rohOrdner,
|
||||||
|
v.rohBytes,
|
||||||
|
v.rohGeloeschtAm,
|
||||||
|
v.zielOrdner,
|
||||||
|
v.fertigBytes,
|
||||||
|
v.komprimiert ? 1 : 0,
|
||||||
|
v.preset,
|
||||||
|
v.fehler,
|
||||||
|
v.auftrag,
|
||||||
|
v.lesefehler ? 1 : 0,
|
||||||
|
v.fertigeDateien,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ändert nur die genannten Felder — der Rest bleibt, wie er war. */
|
||||||
|
vorgangAktualisieren(id: string, teil: Partial<Omit<Vorgang, 'id'>>): void {
|
||||||
|
const alt = this.vorgangLesen(id)
|
||||||
|
if (alt === null) return
|
||||||
|
this.vorgangAnlegen({ ...alt, ...teil, id })
|
||||||
|
}
|
||||||
|
|
||||||
|
vorgangLesen(id: string): Vorgang | null {
|
||||||
|
const zeile = this.db.prepare(`SELECT * FROM vorgaenge WHERE id = ?`).get(id) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined
|
||||||
|
return zeile === undefined ? null : vorgangAusZeile(zeile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Vorgänge, neueste zuerst. */
|
||||||
|
vorgangListe(): Vorgang[] {
|
||||||
|
const zeilen = this.db
|
||||||
|
.prepare(`SELECT * FROM vorgaenge ORDER BY begonnen DESC`)
|
||||||
|
.all() as Array<Record<string, unknown>>
|
||||||
|
return zeilen.map(vorgangAusZeile)
|
||||||
|
}
|
||||||
|
|
||||||
|
vorgangLoeschen(id: string): void {
|
||||||
|
this.db.prepare(`DELETE FROM vorgaenge WHERE id = ?`).run(id)
|
||||||
|
}
|
||||||
|
|
||||||
schliessen(): void {
|
schliessen(): void {
|
||||||
this.db.close()
|
this.db.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function vorgangAusZeile(z: Record<string, unknown>): Vorgang {
|
||||||
|
return {
|
||||||
|
id: String(z.id),
|
||||||
|
laufwerk: String(z.laufwerk),
|
||||||
|
typ: String(z.typ),
|
||||||
|
titel: String(z.titel),
|
||||||
|
jahr: z.jahr === null || z.jahr === undefined ? null : Number(z.jahr),
|
||||||
|
phase: String(z.phase) as VorgangPhase,
|
||||||
|
begonnen: String(z.begonnen),
|
||||||
|
beendet: z.beendet === null || z.beendet === undefined ? null : String(z.beendet),
|
||||||
|
rohOrdner: String(z.roh_ordner),
|
||||||
|
rohBytes: Number(z.roh_bytes ?? 0),
|
||||||
|
rohGeloeschtAm:
|
||||||
|
z.roh_geloescht_am === null || z.roh_geloescht_am === undefined ? null : String(z.roh_geloescht_am),
|
||||||
|
zielOrdner: String(z.ziel_ordner ?? ''),
|
||||||
|
fertigBytes: Number(z.fertig_bytes ?? 0),
|
||||||
|
komprimiert: Number(z.komprimiert ?? 0) === 1,
|
||||||
|
preset: String(z.preset ?? ''),
|
||||||
|
fehler: String(z.fehler ?? ''),
|
||||||
|
auftrag: String(z.auftrag ?? '{}'),
|
||||||
|
lesefehler: Number(z.lesefehler ?? 0) === 1,
|
||||||
|
fertigeDateien: String(z.fertige_dateien ?? '[]'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// MakeMKVs Spur-Auswahlregel (5.4.0) — damit ALLE Sprachen in der
|
||||||
|
// Roh-Datei landen, die der Auswahl-Dialog anbietet.
|
||||||
|
//
|
||||||
|
// Der Befund: Der Info-Lauf zeigt jede Tonspur der Disc — aber ob sie in
|
||||||
|
// die MKV kommt, entscheidet MakeMKVs „default selection rule". Die Doku
|
||||||
|
// (forum.makemkv.com, Thema 4386, am 01.09.2026 nachgelesen — Regel D)
|
||||||
|
// nennt als Regel des Standard-Profils
|
||||||
|
//
|
||||||
|
// -sel:all,+sel:(favlang|nolang),-sel:(havemulti|havecore),=100:all,-10:favlang
|
||||||
|
//
|
||||||
|
// mit: favlang = „matches favorite languages, always matches if no
|
||||||
|
// favorite language is set" · havemulti = „track is mono/stereo and there
|
||||||
|
// is a multi-channel track in same language" · havecore = „this track is
|
||||||
|
// hd track with core audio" · +sel/-sel = wählen/abwählen · =N = Gewicht.
|
||||||
|
//
|
||||||
|
// Solange KEINE Vorzugssprache gesetzt ist, nimmt MakeMKV also alles
|
||||||
|
// (bis auf die Stereo-Doppel und die DTS-Kerne). Sobald jemand in MakeMKVs
|
||||||
|
// Einstellungen „Deutsch" wählt, fielen japanischer und englischer Ton
|
||||||
|
// STILL weg — und der Dialog böte Sprachen an, die nicht in der Datei
|
||||||
|
// sind. Rippy setzt deshalb seine eigene Regel, die sprachunabhängig
|
||||||
|
// alles behält, und zwar NUR, wenn noch keine gesetzt ist (wer eine
|
||||||
|
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
|
||||||
|
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
|
||||||
|
// dort, die settings.conf ist der Linux-Weg).
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
|
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
|
||||||
|
export const REGEL_NAME = 'app_DefaultSelectionString'
|
||||||
|
/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */
|
||||||
|
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all'
|
||||||
|
|
||||||
|
export interface SpurauswahlStand {
|
||||||
|
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
|
||||||
|
* 'rippy' (Rippys Regel steht schon) | 'fehler' */
|
||||||
|
stand: 'gesetzt' | 'eigen' | 'rippy' | 'fehler'
|
||||||
|
regel: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function reg(args: string[]): { ok: boolean; ausgabe: string } {
|
||||||
|
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
|
||||||
|
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Den Wert aus `reg query`-Ausgabe lesen (pur, testbar):
|
||||||
|
* „ app_DefaultSelectionString REG_SZ -sel:all,…" */
|
||||||
|
export function regelAusQuery(ausgabe: string): string | null {
|
||||||
|
const treffer = new RegExp(`${REGEL_NAME}\\s+REG_SZ\\s+(.*)$`, 'm').exec(ausgabe)
|
||||||
|
return treffer === null ? null : treffer[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sicherstellen, dass eine Regel steht, die alle Sprachen behält. */
|
||||||
|
export function spurauswahlSicherstellen(): SpurauswahlStand {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return { stand: 'fehler', regel: '', text: 'Nur unter Windows (Registry).' }
|
||||||
|
}
|
||||||
|
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
|
||||||
|
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
|
||||||
|
if (bisher !== null && bisher.length > 0) {
|
||||||
|
if (bisher === RIPPY_REGEL) {
|
||||||
|
return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
stand: 'eigen',
|
||||||
|
regel: bisher,
|
||||||
|
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
|
||||||
|
if (!gesetzt.ok) {
|
||||||
|
return { stand: 'fehler', regel: '', text: `Auswahlregel nicht setzbar: ${gesetzt.ausgabe.trim().slice(0, 160)}` }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
stand: 'gesetzt',
|
||||||
|
regel: RIPPY_REGEL,
|
||||||
|
text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
// Tests von Kompression + Ablage (kern/ablauf/ablegen.ts, 5.4.0) — mit
|
||||||
|
// nachgebildetem HandBrake, das die Ausgabedatei wirklich SCHREIBT (die
|
||||||
|
// Serien-Umbenennung braucht sie). Die Regeln: Kompression nur wenn
|
||||||
|
// gewollt UND HandBrake da, sonst VERSCHIEBEN; ein Kompressions-Fehler
|
||||||
|
// lässt den verlustfreien Rip STEHEN und sagt wo er liegt; Sprachen je
|
||||||
|
// Datei erreichen HandBrake; Folgen von Hand schlagen die Laufzeit-Raterei.
|
||||||
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { KompressionsAuftrag, RohDatei } from '../src/kern/ablauf/auftrag'
|
||||||
|
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
|
||||||
|
import { LEERER_TREFFER } from '../src/kern/metadaten/zuordnung'
|
||||||
|
|
||||||
|
const hb = vi.hoisted(() => ({
|
||||||
|
aufrufe: [] as Array<Record<string, unknown>>,
|
||||||
|
ergebnis: 'success' as 'success' | 'error' | 'cancelled',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../src/kern/komprimieren/handbrake', async () => {
|
||||||
|
const fs = await import('node:fs')
|
||||||
|
return {
|
||||||
|
komprimieren: vi.fn(async (o: { ausgabe: string; aufFortschritt?: (p: number) => void }) => {
|
||||||
|
hb.aufrufe.push({ ...o })
|
||||||
|
o.aufFortschritt?.(50)
|
||||||
|
if (hb.ergebnis === 'cancelled') return { status: 'cancelled' }
|
||||||
|
if (hb.ergebnis === 'error') return { status: 'error', fehler: 'HandBrake endete mit Code 3' }
|
||||||
|
fs.writeFileSync(o.ausgabe, 'encoded')
|
||||||
|
return { status: 'success', ausgabe: o.ausgabe }
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { komprimierenUndAblegen, type AblageUmgebung } from '../src/kern/ablauf/ablegen'
|
||||||
|
|
||||||
|
let wegwerf: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
hb.aufrufe = []
|
||||||
|
hb.ergebnis = 'success'
|
||||||
|
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function zuordnung(typ: 'movie' | 'tv', titel: string, jahr: number | null): Zuordnung {
|
||||||
|
return {
|
||||||
|
titel,
|
||||||
|
jahr,
|
||||||
|
confidence: 0.95,
|
||||||
|
meta: { ...LEERER_TREFFER, typ, id: 149, titel, jahr, quelle: 'tmdb', laufzeitMinuten: 124 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aufbau(dateinamen: string[], rollen: Array<RohDatei['rolle']> = []): {
|
||||||
|
ablage: string
|
||||||
|
roh: string
|
||||||
|
dateien: RohDatei[]
|
||||||
|
} {
|
||||||
|
const ablage = mkdtempSync(join(tmpdir(), 'rippy-ablegen-'))
|
||||||
|
wegwerf.push(ablage)
|
||||||
|
const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')
|
||||||
|
mkdirSync(roh, { recursive: true })
|
||||||
|
const dateien = dateinamen.map((name, i) => {
|
||||||
|
const pfad = join(roh, name)
|
||||||
|
writeFileSync(pfad, 'roh'.repeat(10))
|
||||||
|
return {
|
||||||
|
pfad,
|
||||||
|
titelNr: i,
|
||||||
|
rolle: rollen[i] ?? 'hauptfilm',
|
||||||
|
dauerS: 3167,
|
||||||
|
groesseBytes: 30,
|
||||||
|
audioSprachen: [],
|
||||||
|
untertitelSprachen: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { ablage, roh, dateien }
|
||||||
|
}
|
||||||
|
|
||||||
|
function auftrag(teil: Partial<KompressionsAuftrag> & { rohOrdner: string; dateien: RohDatei[] }): KompressionsAuftrag {
|
||||||
|
return {
|
||||||
|
vorgangId: 'bluray-2026-08-30T12-00-00',
|
||||||
|
laufwerk: 'G',
|
||||||
|
discTyp: 'bluray',
|
||||||
|
zuordnung: null,
|
||||||
|
staffel: null,
|
||||||
|
ersteFolge: null,
|
||||||
|
discNr: null,
|
||||||
|
fingerprint: 'LABEL|33',
|
||||||
|
lesefehler: false,
|
||||||
|
hinweise: [],
|
||||||
|
begonnen: '2026-08-30T12:00:00Z',
|
||||||
|
preset: '',
|
||||||
|
zielOrdner: '',
|
||||||
|
...teil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function umgebung(ablage: string, überschreiben: Partial<AblageUmgebung> = {}): AblageUmgebung {
|
||||||
|
return {
|
||||||
|
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
|
||||||
|
ablage: () => ablage,
|
||||||
|
einstellungen: () => ({ audioSprachen: 'deu,eng', untertitelSprachen: 'deu' }),
|
||||||
|
jetzt: () => new Date('2026-08-30T13:00:00Z'),
|
||||||
|
spurenScannen: async () => null,
|
||||||
|
...überschreiben,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEIN_ABBRUCH = new AbortController().signal
|
||||||
|
|
||||||
|
describe('komprimierenUndAblegen', () => {
|
||||||
|
it('komprimiert in Filme/<Titel (Jahr)> mit NFO und meldet die Phasen', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const bibliothek: string[] = []
|
||||||
|
const phasen: string[] = []
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
|
||||||
|
umgebung(ablage, { bibliothekEintragen: (e) => bibliothek.push(e.titel) }),
|
||||||
|
(s) => phasen.push(s.phase),
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(ergebnis.komprimiert).toBe(true)
|
||||||
|
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'Akira (1988)'))
|
||||||
|
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
|
||||||
|
expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'title_t00.mkv')])
|
||||||
|
expect(phasen).toContain('komprimiert')
|
||||||
|
expect(phasen.at(-1)).toBe('legt-ab')
|
||||||
|
expect(bibliothek).toEqual(['Akira'])
|
||||||
|
expect(ergebnis.text).toContain('komprimiert nach')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Kompression abgewählt: Roh-Dateien werden VERSCHOBEN, mit NFO — der Roh-Ordner bleibt leer zurück', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien, discTyp: 'uhd', zuordnung: zuordnung('movie', 'Akira', 1988) }),
|
||||||
|
umgebung(ablage, { einstellungen: () => ({ transcodePresetUhd: 'keine' }) }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(ergebnis.komprimiert).toBe(false)
|
||||||
|
expect(ergebnis.text).toContain('ohne Kompression')
|
||||||
|
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true)
|
||||||
|
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
|
||||||
|
expect(readdirSync(roh)).toEqual([])
|
||||||
|
expect(hb.aufrufe).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ohne HandBrake: ebenfalls verschieben, nicht liegen lassen', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, { handbrake: () => '' }),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.komprimiert).toBe(false)
|
||||||
|
expect(readdirSync(roh)).toEqual([])
|
||||||
|
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'bluray-2026-08-30T12-00-00'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
|
||||||
|
hb.ergebnis = 'error'
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
|
||||||
|
expect(ergebnis.status).toBe('fehler')
|
||||||
|
expect(ergebnis.text).toContain('bleibt stehen')
|
||||||
|
expect(ergebnis.text).toContain(roh)
|
||||||
|
expect(existsSync(dateien[0].pfad)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Abbruch mittendrin: abgebrochen, Roh bleibt', async () => {
|
||||||
|
hb.ergebnis = 'cancelled'
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
|
||||||
|
expect(ergebnis.status).toBe('abgebrochen')
|
||||||
|
expect(existsSync(dateien[0].pfad)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Sprachen je Datei erreichen HandBrake — fremder Ton schaltet die deutschen Untertitel vor', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
|
||||||
|
dateien[0].audioSprachen = ['jpn']
|
||||||
|
dateien[0].untertitelSprachen = ['eng', 'deu']
|
||||||
|
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(hb.aufrufe).toHaveLength(2)
|
||||||
|
// Datei 0: eigene Wahl — deu nach vorn, Vorwahl an.
|
||||||
|
expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn'])
|
||||||
|
expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu', 'eng'])
|
||||||
|
expect(hb.aufrufe[0].untertitelStandard).toBe(true)
|
||||||
|
// Datei 1: nichts gewählt — die globale Einstellung, keine Vorwahl.
|
||||||
|
expect(hb.aufrufe[1].audioSprachen).toEqual(['deu', 'eng'])
|
||||||
|
expect(hb.aufrufe[1].untertitelSprachen).toEqual(['deu'])
|
||||||
|
expect(hb.aufrufe[1].untertitelStandard).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('der Scan der Roh-Datei meldet fehlende Sprachen als Hinweis', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
dateien[0].audioSprachen = ['jpn']
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien }),
|
||||||
|
umgebung(ablage, {
|
||||||
|
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }),
|
||||||
|
}),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.hinweise.join(' ')).toContain('Tonspur jpn ist NICHT in der Roh-Datei')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Serie von Hand: Staffel 1, ab Folge 3 → S01E03 und S01E04, Warnung bei TMDb-Abweichung', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
|
||||||
|
dateien[1].dauerS = 3249
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({
|
||||||
|
rohOrdner: roh,
|
||||||
|
dateien,
|
||||||
|
zuordnung: zuordnung('tv', 'Spartacus', 2010),
|
||||||
|
staffel: 1,
|
||||||
|
ersteFolge: 3,
|
||||||
|
}),
|
||||||
|
umgebung(ablage, {
|
||||||
|
// Folge 4 ist bei TMDb 20 min kürzer — das gibt eine Warnung, aber
|
||||||
|
// die Hand des Nutzers gilt.
|
||||||
|
episodenLaufzeiten: async () => [
|
||||||
|
[3, 3200],
|
||||||
|
[4, 2000],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.status).toBe('fertig')
|
||||||
|
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Serien', 'Spartacus', 'Season 01'))
|
||||||
|
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
|
||||||
|
'Spartacus S01E03.mkv',
|
||||||
|
'Spartacus S01E04.mkv',
|
||||||
|
])
|
||||||
|
expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv'])
|
||||||
|
expect(ergebnis.hinweise.join(' ')).toContain('Folge 4: Datei 54 min, TMDb 33 min')
|
||||||
|
expect(existsSync(join(ergebnis.zielOrdner, 'tvshow.nfo'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Serie ohne Vorgabe: Laufzeit-Zuordnung wie bisher — nur bei eindeutigem Treffer', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
|
||||||
|
dateien[0].dauerS = 3167
|
||||||
|
dateien[1].dauerS = 3249
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 2 }),
|
||||||
|
umgebung(ablage, {
|
||||||
|
episodenLaufzeiten: async () => [
|
||||||
|
[1, 3180],
|
||||||
|
[2, 3240],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
|
||||||
|
'Spartacus S02E01.mkv',
|
||||||
|
'Spartacus S02E02.mkv',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Extras liegen im Unterordner extras\\ neben dem Hauptfilm', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t05.mkv'], ['hauptfilm', 'extra'])
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
|
||||||
|
umgebung(ablage),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true)
|
||||||
|
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'title_t05.mkv'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
||||||
|
const ziel = join(ablage, 'Filme', 'Akira (1988)')
|
||||||
|
mkdirSync(ziel, { recursive: true })
|
||||||
|
const ergebnis = await komprimierenUndAblegen(
|
||||||
|
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, preset: 'Fast 1080p30' }),
|
||||||
|
umgebung(ablage),
|
||||||
|
() => {},
|
||||||
|
KEIN_ABBRUCH,
|
||||||
|
)
|
||||||
|
expect(ergebnis.zielOrdner).toBe(ziel)
|
||||||
|
expect(ergebnis.preset).toBe('Fast 1080p30')
|
||||||
|
expect(readdirSync(join(ablage, 'Filme'))).toEqual(['Akira (1988)'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('der Fortschritt ist über beide Dateien gewichtet', async () => {
|
||||||
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
|
||||||
|
dateien[0].groesseBytes = 3
|
||||||
|
dateien[1].groesseBytes = 1
|
||||||
|
const prozente: number[] = []
|
||||||
|
await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), (s) => prozente.push(s.prozent), KEIN_ABBRUCH)
|
||||||
|
// 50 % der ersten (3/4) = 37, 50 % der zweiten = 87.
|
||||||
|
expect(prozente).toContain(37)
|
||||||
|
expect(prozente).toContain(87)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Tests des Kompressions-Auftrags (kern/ablauf/auftrag.ts, 5.4.0) — pur:
|
||||||
|
// Sprachwahl je Datei, Folgen von Hand, gewichteter Fortschritt, JSON.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
auftragAusJson,
|
||||||
|
auftragZuJson,
|
||||||
|
folgenNummern,
|
||||||
|
folgenPlausibel,
|
||||||
|
gewichteterFortschritt,
|
||||||
|
sprachwahl,
|
||||||
|
type KompressionsAuftrag,
|
||||||
|
} from '../src/kern/ablauf/auftrag'
|
||||||
|
|
||||||
|
describe('sprachwahl — die Regel des Commanders', () => {
|
||||||
|
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
|
||||||
|
|
||||||
|
it('nichts gewählt: die globale Einstellung, keine Vorwahl', () => {
|
||||||
|
expect(sprachwahl({ audioSprachen: [], untertitelSprachen: [] }, global)).toEqual({
|
||||||
|
audio: ['deu', 'eng'],
|
||||||
|
untertitel: ['deu'],
|
||||||
|
untertitelStandard: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('japanischer Ton + deutsche Untertitel: Untertitel nach vorn und vorgewählt', () => {
|
||||||
|
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng', 'deu'] }, global)).toEqual({
|
||||||
|
audio: ['jpn'],
|
||||||
|
untertitel: ['deu', 'eng'],
|
||||||
|
untertitelStandard: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deutscher Ton zuerst: keine Vorwahl, auch wenn Untertitel gewählt sind', () => {
|
||||||
|
expect(sprachwahl({ audioSprachen: ['deu', 'jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fremder Ton, aber kein deutscher Untertitel gewählt: nichts vorzuwählen', () => {
|
||||||
|
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'] }, global)).toEqual({
|
||||||
|
audio: ['jpn'],
|
||||||
|
untertitel: ['eng'],
|
||||||
|
untertitelStandard: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ohne globale Wunschsprache gibt es keine Vorwahl', () => {
|
||||||
|
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, { audio: [], untertitel: [] }).untertitelStandard).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('folgenNummern und folgenPlausibel', () => {
|
||||||
|
it('fortlaufend ab der ersten Folge', () => {
|
||||||
|
expect(folgenNummern(2, 3)).toEqual([3, 4])
|
||||||
|
expect(folgenNummern(0, 3)).toEqual([])
|
||||||
|
expect(folgenNummern(2, 0)).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warnt nur bei deutlicher Abweichung von TMDb', () => {
|
||||||
|
const laufzeiten: Array<[number, number]> = [
|
||||||
|
[3, 3200],
|
||||||
|
[4, 2000],
|
||||||
|
]
|
||||||
|
const w = folgenPlausibel([3167, 3249], [3, 4], laufzeiten)
|
||||||
|
expect(w).toHaveLength(1)
|
||||||
|
expect(w[0]).toContain('Folge 4')
|
||||||
|
expect(folgenPlausibel([3167], [9], laufzeiten)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gewichteterFortschritt — nie zurück auf null', () => {
|
||||||
|
it('gewichtet nach Größe', () => {
|
||||||
|
expect(gewichteterFortschritt([3, 1], 0, 50)).toBe(37)
|
||||||
|
expect(gewichteterFortschritt([3, 1], 0, 100)).toBe(75)
|
||||||
|
expect(gewichteterFortschritt([3, 1], 1, 50)).toBe(87)
|
||||||
|
expect(gewichteterFortschritt([3, 1], 1, 100)).toBe(100)
|
||||||
|
})
|
||||||
|
it('ohne Gewichte zählen alle gleich; -1 heißt „noch keine Angabe" vom laufenden Teil', () => {
|
||||||
|
expect(gewichteterFortschritt([0, 0], 1, 50)).toBe(75)
|
||||||
|
expect(gewichteterFortschritt([1, 1], 1, -1)).toBe(50)
|
||||||
|
expect(gewichteterFortschritt([], 0, 50)).toBe(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Auftrag als JSON — und wieder zurück', () => {
|
||||||
|
const a: KompressionsAuftrag = {
|
||||||
|
vorgangId: 'bluray-2026-08-30T12-00-00',
|
||||||
|
laufwerk: 'G',
|
||||||
|
discTyp: 'bluray',
|
||||||
|
rohOrdner: 'E:\\Rippy\\roh\\bluray-2026-08-30T12-00-00',
|
||||||
|
dateien: [
|
||||||
|
{ pfad: 'E:\\Rippy\\roh\\x\\title_t00.mkv', titelNr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
|
||||||
|
],
|
||||||
|
zuordnung: null,
|
||||||
|
staffel: 1,
|
||||||
|
ersteFolge: 3,
|
||||||
|
discNr: 2,
|
||||||
|
fingerprint: 'SPARTACUS_GOTA_D2|33759100928',
|
||||||
|
lesefehler: false,
|
||||||
|
hinweise: ['Übersprungen: Titel 5.'],
|
||||||
|
begonnen: '2026-08-30T12:00:00.000Z',
|
||||||
|
preset: '',
|
||||||
|
zielOrdner: '',
|
||||||
|
}
|
||||||
|
it('kommt unverändert zurück', () => {
|
||||||
|
expect(auftragAusJson(auftragZuJson(a))).toEqual(a)
|
||||||
|
})
|
||||||
|
it('Unsinn ist null, kein Absturz', () => {
|
||||||
|
expect(auftragAusJson('{')).toBeNull()
|
||||||
|
expect(auftragAusJson('{}')).toBeNull()
|
||||||
|
expect(auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{}]}')).toBeNull()
|
||||||
|
})
|
||||||
|
it('fehlende Felder bekommen Vorgaben', () => {
|
||||||
|
const b = auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{"pfad":"p"}]}')!
|
||||||
|
expect(b.dateien[0].rolle).toBe('hauptfilm')
|
||||||
|
expect(b.dateien[0].audioSprachen).toEqual([])
|
||||||
|
expect(b.staffel).toBeNull()
|
||||||
|
expect(b.preset).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -71,3 +71,59 @@ describe('Datenbank', () => {
|
|||||||
expect(() => new Datenbank(kaputt)).toThrow()
|
expect(() => new Datenbank(kaputt)).toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
|
||||||
|
describe('Datenbank — Vorgänge', () => {
|
||||||
|
const vorgang = {
|
||||||
|
id: 'bluray-2026-09-01T20-00-00',
|
||||||
|
laufwerk: 'G',
|
||||||
|
typ: 'bluray',
|
||||||
|
titel: 'Spartacus',
|
||||||
|
jahr: 2010,
|
||||||
|
phase: 'rippt' as const,
|
||||||
|
begonnen: '2026-09-01T20:00:00.000Z',
|
||||||
|
beendet: null,
|
||||||
|
rohOrdner: 'E:\Rippy\roh\bluray-2026-09-01T20-00-00',
|
||||||
|
rohBytes: 0,
|
||||||
|
rohGeloeschtAm: null,
|
||||||
|
zielOrdner: '',
|
||||||
|
fertigBytes: 0,
|
||||||
|
komprimiert: false,
|
||||||
|
preset: '',
|
||||||
|
fehler: '',
|
||||||
|
auftrag: '{}',
|
||||||
|
lesefehler: false,
|
||||||
|
fertigeDateien: '[]',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('anlegen, lesen, aktualisieren (nur die genannten Felder), listen, löschen', () => {
|
||||||
|
const db = new Datenbank(':memory:')
|
||||||
|
aufraeumen.push(() => db.schliessen())
|
||||||
|
db.vorgangAnlegen(vorgang)
|
||||||
|
expect(db.vorgangLesen(vorgang.id)).toEqual(vorgang)
|
||||||
|
db.vorgangAktualisieren(vorgang.id, { phase: 'fertig', komprimiert: true, fertigBytes: 4800, beendet: '2026-09-01T21:00:00.000Z' })
|
||||||
|
const neu = db.vorgangLesen(vorgang.id)!
|
||||||
|
expect(neu.phase).toBe('fertig')
|
||||||
|
expect(neu.komprimiert).toBe(true)
|
||||||
|
expect(neu.fertigBytes).toBe(4800)
|
||||||
|
expect(neu.titel).toBe('Spartacus')
|
||||||
|
expect(neu.rohGeloeschtAm).toBeNull()
|
||||||
|
db.vorgangAnlegen({ ...vorgang, id: 'dvd-2026-09-02T10-00-00', begonnen: '2026-09-02T10:00:00.000Z' })
|
||||||
|
expect(db.vorgangListe().map((v) => v.id)).toEqual(['dvd-2026-09-02T10-00-00', vorgang.id])
|
||||||
|
db.vorgangLoeschen(vorgang.id)
|
||||||
|
expect(db.vorgangLesen(vorgang.id)).toBeNull()
|
||||||
|
expect(db.vorgangAktualisieren('gibt-es-nicht', { phase: 'fertig' })).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Schema 3 → 4: eine Bestands-Datenbank bekommt die Tabelle ohne Datenverlust', () => {
|
||||||
|
const pfad = frischerPfad()
|
||||||
|
const alt = new Datenbank(pfad)
|
||||||
|
alt.einstellungSchreiben('ablage', 'E:\Rippy')
|
||||||
|
alt.schliessen()
|
||||||
|
const neu = new Datenbank(pfad)
|
||||||
|
aufraeumen.push(() => neu.schliessen())
|
||||||
|
expect(neu.einstellungLesen('ablage')).toBe('E:\Rippy')
|
||||||
|
expect(neu.vorgangListe()).toEqual([])
|
||||||
|
expect(neu.schemaVersion()).toBe(SCHEMA_VERSION)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Tests der Laufwerks-Gesundheit (kern/laufwerk/ereignisse.ts, 5.4.0) —
|
||||||
|
// an der ECHTEN wevtutil-Ausgabe dieses PCs (01.09.2026): cdrom 7
|
||||||
|
// (fehlerhafter Block, der Spartacus-Defekt), cdrom 11 (Controllerfehler,
|
||||||
|
// der USB-Aussetzer).
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { beurteilen, buildWevtutilArgs, ereignisseAusXml, xpathFuer } from '../src/kern/laufwerk/ereignisse'
|
||||||
|
|
||||||
|
/** Drei Ereignisse, so wie wevtutil /f:xml sie schreibt — ohne Zeilenumbrüche. */
|
||||||
|
const XML =
|
||||||
|
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><Version>0</Version><Level>2</Level><TimeCreated SystemTime='2026-09-01T18:32:42.3868580Z'/><EventRecordID>135751</EventRecordID><Channel>System</Channel><Computer>TobisNicerPC</Computer></System><EventData><Data>\\Device\\CdRom0</Data><Binary>0300</Binary></EventData></Event>" +
|
||||||
|
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><TimeCreated SystemTime='2026-09-01T18:32:39.6629681Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>" +
|
||||||
|
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>11</EventID><TimeCreated SystemTime='2026-09-01T18:17:39.6632548Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>"
|
||||||
|
|
||||||
|
describe('ereignisseAusXml', () => {
|
||||||
|
it('liest Provider, ID, Zeit und Gerät je Ereignis', () => {
|
||||||
|
const e = ereignisseAusXml(XML)
|
||||||
|
expect(e).toHaveLength(3)
|
||||||
|
expect(e[0]).toEqual({ zeit: '2026-09-01T18:32:42.3868580Z', provider: 'cdrom', id: 7, geraet: '\\Device\\CdRom0' })
|
||||||
|
expect(e[2].id).toBe(11)
|
||||||
|
})
|
||||||
|
it('leere Ausgabe = keine Ereignisse', () => {
|
||||||
|
expect(ereignisseAusXml('')).toEqual([])
|
||||||
|
expect(ereignisseAusXml('\r\n')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('beurteilen — Disc oder Hardware?', () => {
|
||||||
|
const alle = ereignisseAusXml(XML)
|
||||||
|
|
||||||
|
it('ein Controllerfehler dabei: Hardware — Port am Mainboard probieren', () => {
|
||||||
|
const b = beurteilen(alle, 24)
|
||||||
|
expect(b.urteil).toBe('hardware')
|
||||||
|
expect(b.controllerfehler).toBe(1)
|
||||||
|
expect(b.fehlerhafteBloecke).toBe(2)
|
||||||
|
expect(b.text).toContain('Mainboard')
|
||||||
|
expect(b.letzte).toHaveLength(3)
|
||||||
|
expect(b.letzte[0]).toContain('fehlerhafter Block')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nur fehlerhafte Blöcke: die Disc', () => {
|
||||||
|
const b = beurteilen(alle.filter((e) => e.id === 7), 24)
|
||||||
|
expect(b.urteil).toBe('disc')
|
||||||
|
expect(b.text).toContain('DISC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nichts: ruhig', () => {
|
||||||
|
const b = beurteilen([], 24)
|
||||||
|
expect(b.urteil).toBe('ruhig')
|
||||||
|
expect(b.text).toContain('nichts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seit einem Zeitpunkt: nur die jüngeren zählen', () => {
|
||||||
|
const b = beurteilen(alle, 24, new Date('2026-09-01T18:30:00Z'))
|
||||||
|
expect(b.controllerfehler).toBe(0)
|
||||||
|
expect(b.urteil).toBe('disc')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('die wevtutil-Argumente (Regel D: so gemessen)', () => {
|
||||||
|
it('XPath auf cdrom/disk mit Zeitfenster, XML, neueste zuerst', () => {
|
||||||
|
expect(xpathFuer(24)).toBe("*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= 86400000]]]")
|
||||||
|
const args = buildWevtutilArgs(24, 50)
|
||||||
|
expect(args[0]).toBe('qe')
|
||||||
|
expect(args[1]).toBe('System')
|
||||||
|
expect(args[2]).toBe(`/q:${xpathFuer(24)}`)
|
||||||
|
expect(args).toContain('/f:xml')
|
||||||
|
expect(args).toContain('/c:50')
|
||||||
|
expect(args).toContain('/rd:true')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -182,3 +182,22 @@ describe('presets', () => {
|
|||||||
expect(sprachliste(null)).toEqual([])
|
expect(sprachliste(null)).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('buildHandbrakeArgs — Untertitel vorwählen (5.4.0, am HandBrake 1.11 gemessen)', () => {
|
||||||
|
it('mit Sprachliste und untertitelStandard kommt --subtitle-default=1 dazu', () => {
|
||||||
|
const args = buildHandbrakeArgs({
|
||||||
|
eingabe: 'a.mkv',
|
||||||
|
ausgabe: 'b.mkv',
|
||||||
|
preset: 'X',
|
||||||
|
audioSprachen: ['jpn'],
|
||||||
|
untertitelSprachen: ['deu', 'eng'],
|
||||||
|
untertitelStandard: true,
|
||||||
|
})
|
||||||
|
expect(args).toContain('--subtitle-default=1')
|
||||||
|
expect(args[args.indexOf('--subtitle-lang-list') + 1]).toBe('deu,eng')
|
||||||
|
})
|
||||||
|
it('ohne Sprachliste KEINE Vorwahl — die Reihenfolge wäre nicht bestimmt', () => {
|
||||||
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'X', untertitelStandard: true })
|
||||||
|
expect(args).not.toContain('--subtitle-default=1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// Erkennung v2 + Kino-Banner v2 (5.4.0) — alles an GEMESSENEN Antworten
|
||||||
|
// (01.09.2026, TMDb mit dem hinterlegten v4-Token):
|
||||||
|
// movie/149 (Akira): tagline, runtime 124, vote_average 7.94,
|
||||||
|
// images.logos mit iso_639_1 'en'/'de', credits.cast, release_dates DE
|
||||||
|
// mit certification '16' (type 3 Kino, 5 Heimvideo, 6 TV).
|
||||||
|
// tv/46296 (Spartacus): episode_run_time [53], content_ratings DE '18'.
|
||||||
|
// Die drei „Spartacus: Gods of the Arena"-Stubs: overview '', backdrop
|
||||||
|
// null, 1 Folge, Popularität 1.
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { bdmtZerlegen, inhaltAusBdmt } from '../src/kern/metadaten/discmerkmale'
|
||||||
|
import { besetzungAus, fskAus, logoAus, type FilmDetails } from '../src/kern/metadaten/tmdb'
|
||||||
|
import {
|
||||||
|
LEERER_TREFFER,
|
||||||
|
VORSCHLAG,
|
||||||
|
ausDetails,
|
||||||
|
istStub,
|
||||||
|
kandidatenAus,
|
||||||
|
nachpruefen,
|
||||||
|
zuordnen,
|
||||||
|
type Zuordnung,
|
||||||
|
} from '../src/kern/metadaten/zuordnung'
|
||||||
|
import { strukturHinweis } from '../src/kern/rip/titelwahl'
|
||||||
|
|
||||||
|
let wegwerf: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Die gemessene Akira-Antwort, auf die genutzten Felder gekürzt. */
|
||||||
|
const AKIRA: FilmDetails = {
|
||||||
|
id: 149,
|
||||||
|
title: 'Akira',
|
||||||
|
release_date: '1988-06-10',
|
||||||
|
overview: 'Tokio 1988. In einem Geheimlabor der Armee …',
|
||||||
|
poster_path: '/fDRZmPUPUHTxULsuSjPIqPHKjuE.jpg',
|
||||||
|
backdrop_path: '/fK40VGYIm7hmKrLJ26fgPQU0qRG.jpg',
|
||||||
|
runtime: 124,
|
||||||
|
genres: [{ name: 'Animation' }, { name: 'Science Fiction' }, { name: 'Action' }],
|
||||||
|
tagline: 'Neo-Tokyo ist am EXPLODIEREN',
|
||||||
|
vote_average: 7.94,
|
||||||
|
vote_count: 4944,
|
||||||
|
images: {
|
||||||
|
logos: [
|
||||||
|
{ iso_639_1: 'en', file_path: '/AbcUfIbGRwrlPqdmTSi2WzArjQh.png', width: 664, height: 387, vote_average: 1.75 },
|
||||||
|
{ iso_639_1: 'en', file_path: '/cHIsyx3xAW314SijTvOyPozWwDV.png', width: 472, height: 290, vote_average: 1.75 },
|
||||||
|
{ iso_639_1: 'de', file_path: '/lHBQKVTxGUUqCMVo7rzTkYICHjm.png', width: 867, height: 404, vote_average: 0 },
|
||||||
|
{ iso_639_1: null, file_path: '/sprachlos.png', width: 100, height: 50, vote_average: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
credits: {
|
||||||
|
cast: [
|
||||||
|
{ name: 'Nozomu Sasaki', character: 'Tetsuo Shima (voice)', profile_path: '/7usoPlvc9Xa1rDA2vxmVi0OvLDb.jpg', order: 1 },
|
||||||
|
{ name: 'Mitsuo Iwata', character: 'Shotaro Kaneda (voice)', profile_path: '/xCAsuzc5OBQZWhG5WSZIyhBZ7xj.jpg', order: 0 },
|
||||||
|
{ name: 'Mami Koyama', character: 'Kei (voice)', profile_path: null, order: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
release_dates: {
|
||||||
|
results: [
|
||||||
|
{ iso_3166_1: 'US', release_dates: [{ certification: 'R', type: 3 }] },
|
||||||
|
{
|
||||||
|
iso_3166_1: 'DE',
|
||||||
|
release_dates: [
|
||||||
|
{ certification: '16', type: 5 },
|
||||||
|
{ certification: '16', type: 3 },
|
||||||
|
{ certification: '', type: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPARTACUS_TV: FilmDetails = {
|
||||||
|
id: 46296,
|
||||||
|
name: 'Spartacus',
|
||||||
|
first_air_date: '2010-01-22',
|
||||||
|
overview: 'Der Thraker Spartacus …',
|
||||||
|
backdrop_path: '/x.jpg',
|
||||||
|
vote_average: 8,
|
||||||
|
vote_count: 1000,
|
||||||
|
episode_run_time: [53],
|
||||||
|
number_of_episodes: 39,
|
||||||
|
content_ratings: { results: [{ iso_3166_1: 'US', rating: 'TV-MA' }, { iso_3166_1: 'DE', rating: '18' }] },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einer der drei gemessenen Stubs. */
|
||||||
|
const STUB: FilmDetails = {
|
||||||
|
id: 331850,
|
||||||
|
name: 'Spartacus: Gods of the Arena',
|
||||||
|
first_air_date: '',
|
||||||
|
overview: '',
|
||||||
|
backdrop_path: null,
|
||||||
|
vote_average: 8,
|
||||||
|
vote_count: 0,
|
||||||
|
number_of_episodes: 1,
|
||||||
|
episode_run_time: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Kino-Banner v2 — die Felder aus der gemessenen Antwort', () => {
|
||||||
|
it('logoAus: deutsch vor englisch, darin das bestbewertete', () => {
|
||||||
|
expect(logoAus(AKIRA.images?.logos)).toBe('/lHBQKVTxGUUqCMVo7rzTkYICHjm.png')
|
||||||
|
expect(logoAus(AKIRA.images?.logos?.filter((l) => l.iso_639_1 !== 'de'))).toBe('/AbcUfIbGRwrlPqdmTSi2WzArjQh.png')
|
||||||
|
expect(logoAus([])).toBe('')
|
||||||
|
expect(logoAus(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fskAus: Film aus den deutschen Freigaben (Kino vor Heimvideo), Serie aus content_ratings', () => {
|
||||||
|
expect(fskAus(AKIRA, 'movie')).toBe('16')
|
||||||
|
expect(fskAus(SPARTACUS_TV, 'tv')).toBe('18')
|
||||||
|
expect(fskAus({ id: 1 }, 'movie')).toBe('')
|
||||||
|
expect(fskAus(STUB, 'tv')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('besetzungAus: nach order sortiert, höchstens sechs, ohne Bild bleibt der Name', () => {
|
||||||
|
const b = besetzungAus(AKIRA.credits?.cast)
|
||||||
|
expect(b.map((d) => d.name)).toEqual(['Mitsuo Iwata', 'Nozomu Sasaki', 'Mami Koyama'])
|
||||||
|
expect(b[0].rolle).toBe('Shotaro Kaneda (voice)')
|
||||||
|
expect(b[2].bildPfad).toBe('')
|
||||||
|
expect(besetzungAus(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ausDetails trägt alles in den Treffer — Film und Serie', () => {
|
||||||
|
const film = ausDetails('movie', 149, AKIRA)
|
||||||
|
expect(film.tagline).toBe('Neo-Tokyo ist am EXPLODIEREN')
|
||||||
|
expect(film.bewertung).toBe(7.9)
|
||||||
|
expect(film.fsk).toBe('16')
|
||||||
|
expect(film.logoPfad).toContain('.png')
|
||||||
|
expect(film.besetzung).toHaveLength(3)
|
||||||
|
expect(film.laufzeitMinuten).toBe(124)
|
||||||
|
expect(film.genres).toEqual(['Animation', 'Science Fiction', 'Action'])
|
||||||
|
const serie = ausDetails('tv', 46296, SPARTACUS_TV)
|
||||||
|
expect(serie.laufzeitMinuten).toBe(53)
|
||||||
|
expect(serie.folgenGesamt).toBe(39)
|
||||||
|
expect(serie.fsk).toBe('18')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('istStub erkennt die leeren Einträge — und nichts anderes', () => {
|
||||||
|
expect(istStub(STUB)).toBe(true)
|
||||||
|
expect(istStub(AKIRA)).toBe(false)
|
||||||
|
expect(istStub(SPARTACUS_TV)).toBe(false)
|
||||||
|
// Nur die Beschreibung fehlt (deutsche Übersetzung nicht da) — kein Stub.
|
||||||
|
expect(istStub({ ...SPARTACUS_TV, overview: '' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zuordnen — leere Einträge zählen nicht (der Stub-Fund)', () => {
|
||||||
|
const tmdb = {
|
||||||
|
verfuegbar: true,
|
||||||
|
searchMovie: async () => [],
|
||||||
|
searchTv: async (t: string) =>
|
||||||
|
t === 'Spartacus: Gods of the Arena'
|
||||||
|
? [
|
||||||
|
{ id: 331850, name: 'Spartacus: Gods of the Arena' },
|
||||||
|
{ id: 331915, name: 'Spartacus Gods of the Arena' },
|
||||||
|
]
|
||||||
|
: t === 'Spartacus'
|
||||||
|
? [{ id: 46296, name: 'Spartacus' }]
|
||||||
|
: [],
|
||||||
|
movieDetails: async () => null,
|
||||||
|
tvDetails: async (id: number) => (id === 46296 ? SPARTACUS_TV : { ...STUB, id }),
|
||||||
|
findByImdb: async () => null,
|
||||||
|
}
|
||||||
|
const keinOmdb = { verfuegbar: false, lookup: async () => null }
|
||||||
|
|
||||||
|
it('der wörtliche Stub-Treffer gewinnt nicht mehr — die echte Serie wird ein Vorschlag', async () => {
|
||||||
|
const z = await zuordnen('Spartacus: Gods of the Arena', tmdb, keinOmdb, { istSerie: true })
|
||||||
|
// Über den Doppelpunkt-Vorspann 'Spartacus' (Abdeckung 1/5 → gedeckelt).
|
||||||
|
expect(z.titel).toBe('Spartacus')
|
||||||
|
expect(z.confidence).toBe(VORSCHLAG)
|
||||||
|
expect(z.meta.laufzeitMinuten).toBe(53)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt es NUR Stubs, bleibt der erste ein Vorschlag — es wird ja gefragt', async () => {
|
||||||
|
const nurStubs = { ...tmdb, searchTv: async (t: string) => (t.startsWith('Spartacus: Gods') ? [{ id: 331850, name: 'Spartacus: Gods of the Arena' }] : []) }
|
||||||
|
const z = await zuordnen('Spartacus: Gods of the Arena', nurStubs, keinOmdb, { istSerie: true })
|
||||||
|
expect(z.confidence).toBe(VORSCHLAG)
|
||||||
|
expect(z.titel).toBe('Spartacus: Gods of the Arena')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('kandidatenAus — jede Titel-Variante an ihrer eigenen Vorlage gemessen', () => {
|
||||||
|
it('volle Titel zuerst, dann die gestutzten, ohne Doppel', () => {
|
||||||
|
const k = kandidatenAus('Spartacus: Gods Of The Arena - Disc 2', ['Spartacus: Götter der Arena - Disc 2'])
|
||||||
|
expect(k[0].text).toBe('Spartacus: Gods Of The Arena - Disc 2')
|
||||||
|
expect(k[1].text).toBe('Spartacus: Götter der Arena - Disc 2')
|
||||||
|
expect(k[0].massstab).toBe('Spartacus: Gods Of The Arena')
|
||||||
|
expect(k[1].massstab).toBe('Spartacus: Götter der Arena')
|
||||||
|
expect(new Set(k.map((x) => x.text.toLowerCase())).size).toBe(k.length)
|
||||||
|
})
|
||||||
|
it('ohne Alternativen wie bisher', () => {
|
||||||
|
expect(kandidatenAus('Akira').map((x) => x.text)).toEqual(['Akira'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('nachpruefen — die Laufzeit nach dem Info-Lauf', () => {
|
||||||
|
const akira: Zuordnung = { titel: 'Akira', jahr: 1988, confidence: 0.8, meta: ausDetails('movie', 149, AKIRA) }
|
||||||
|
|
||||||
|
it('passt die Laufzeit, wird aus 0,80 ein bestätigtes 0,95', () => {
|
||||||
|
const z = nachpruefen(akira, { hauptS: 124 * 60 + 30, folgenS: [], struktur: 'film' })
|
||||||
|
expect(z.confidence).toBe(0.95)
|
||||||
|
expect(z.hinweise?.[0]).toContain('Laufzeit bestätigt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passt sie gar nicht (95 vs 158 min), fällt der Treffer auf den Vorschlag zurück', () => {
|
||||||
|
const falsch = { ...akira, meta: { ...akira.meta, laufzeitMinuten: 158 } }
|
||||||
|
const z = nachpruefen(falsch, { hauptS: 95 * 60, folgenS: [], struktur: 'film' })
|
||||||
|
expect(z.confidence).toBe(VORSCHLAG)
|
||||||
|
expect(z.hinweise?.[0]).toContain('passt NICHT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('die Wahl des Nutzers (1,0) wird nie herabgestuft, nur kommentiert', () => {
|
||||||
|
const eigene = { ...akira, confidence: 1 }
|
||||||
|
const z = nachpruefen(eigene, { hauptS: 95 * 60, folgenS: [], struktur: 'film' })
|
||||||
|
expect(z.confidence).toBe(1)
|
||||||
|
expect(z.hinweise).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Serie: Folgenlänge gegen episode_run_time', () => {
|
||||||
|
const serie: Zuordnung = { titel: 'Spartacus', jahr: 2010, confidence: 0.6, meta: ausDetails('tv', 46296, SPARTACUS_TV) }
|
||||||
|
const z = nachpruefen(serie, { hauptS: 0, folgenS: [3167, 3249], struktur: 'unklar' })
|
||||||
|
expect(z.confidence).toBe(0.8)
|
||||||
|
expect(z.hinweise?.[0]).toContain('Folgenlänge passt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Struktur sagt Serie, Treffer sagt Film: Vorschlag mit Hinweis', () => {
|
||||||
|
const z = nachpruefen({ ...akira, meta: { ...akira.meta, laufzeitMinuten: 0 } }, { hauptS: 0, folgenS: [], struktur: 'serie' })
|
||||||
|
expect(z.confidence).toBe(VORSCHLAG)
|
||||||
|
expect(z.hinweise?.[0]).toContain('Serienstaffel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ohne Laufzeit bei TMDb bleibt alles, wie es war', () => {
|
||||||
|
const ohne = { ...akira, meta: { ...LEERER_TREFFER, typ: 'movie' as const } }
|
||||||
|
const z = nachpruefen(ohne, { hauptS: 7000, folgenS: [], struktur: 'unklar' })
|
||||||
|
expect(z.confidence).toBe(0.8)
|
||||||
|
expect(z.hinweise).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('strukturHinweis — der Fingerabdruck der Titel-Struktur', () => {
|
||||||
|
const t = (nr: number, min: number) => ({ nr, dauerS: Math.round(min * 60), groesseBytes: 1e9, kapitel: 8 })
|
||||||
|
it('ein langer + viele kurze = Film', () => {
|
||||||
|
expect(strukturHinweis([t(0, 124), t(1, 3), t(2, 1.5), t(3, 20)])).toBe('film')
|
||||||
|
})
|
||||||
|
it('sechs gleich lange = Serie', () => {
|
||||||
|
expect(strukturHinweis([t(0, 44), t(1, 43), t(2, 45), t(3, 44), t(4, 42), t(5, 44), t(6, 2)])).toBe('serie')
|
||||||
|
})
|
||||||
|
it('zwei gleich lange Fassungen bleiben unklar (Seamless Branching)', () => {
|
||||||
|
expect(strukturHinweis([t(0, 120), t(1, 118)])).toBe('unklar')
|
||||||
|
})
|
||||||
|
it('zwei Folgen bleiben unklar — dafür ist das bdmt-Inhaltsverzeichnis da', () => {
|
||||||
|
expect(strukturHinweis([t(0, 52), t(1, 54), t(2, 1)])).toBe('unklar')
|
||||||
|
})
|
||||||
|
it('nichts Langes: unklar', () => {
|
||||||
|
expect(strukturHinweis([t(0, 1), t(1, 0.5)])).toBe('unklar')
|
||||||
|
expect(strukturHinweis([])).toBe('unklar')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bdmt_deu als zweite Titel-Variante', () => {
|
||||||
|
it('bdmtZerlegen liest Titel und Inhaltsverzeichnis (pur)', () => {
|
||||||
|
const z = bdmtZerlegen('<x><di:name>Titel</di:name><di:titleName titleNumber="1">EP 1</di:titleName></x>')
|
||||||
|
expect(z).toEqual({ titel: 'Titel', eintraege: ['EP 1'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('englisch bleibt der Haupttitel, deutsch wird Alternative — Doppel fallen weg', () => {
|
||||||
|
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt2-'))
|
||||||
|
wegwerf.push(wurzel)
|
||||||
|
const meta = join(wurzel, 'BDMV', 'META', 'DL')
|
||||||
|
mkdirSync(meta, { recursive: true })
|
||||||
|
writeFileSync(join(meta, 'bdmt_deu.xml'), '<x><di:name>Spartacus: Götter der Arena</di:name></x>')
|
||||||
|
writeFileSync(join(meta, 'bdmt_eng.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name><di:titleName titleNumber="1">EP 1</di:titleName><di:titleName titleNumber="2">EP 2</di:titleName></x>')
|
||||||
|
writeFileSync(join(meta, 'bdmt_fra.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name></x>')
|
||||||
|
const inhalt = inhaltAusBdmt(wurzel)
|
||||||
|
expect(inhalt.titel).toBe('Spartacus: Gods of the Arena')
|
||||||
|
expect(inhalt.eintraege).toEqual(['EP 1', 'EP 2'])
|
||||||
|
expect(inhalt.alternativen).toEqual(['Spartacus: Götter der Arena'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nur bdmt_deu: dann ist der deutsche Titel der Haupttitel', () => {
|
||||||
|
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt3-'))
|
||||||
|
wegwerf.push(wurzel)
|
||||||
|
const meta = join(wurzel, 'BDMV', 'META', 'DL')
|
||||||
|
mkdirSync(meta, { recursive: true })
|
||||||
|
writeFileSync(join(meta, 'bdmt_deu.xml'), '<x><di:name>Nur Deutsch</di:name></x>')
|
||||||
|
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: 'Nur Deutsch', eintraege: [], alternativen: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -109,7 +109,9 @@ function fakeTmdb(filme: Record<string, FilmEintrag[]>, serien: Record<string, F
|
|||||||
tvDetails: async (id: number) => {
|
tvDetails: async (id: number) => {
|
||||||
for (const liste of Object.values(serien)) {
|
for (const liste of Object.values(serien)) {
|
||||||
const treffer = liste.find((f) => f.id === id)
|
const treffer = liste.find((f) => f.id === id)
|
||||||
if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10' }
|
// Eine ECHTE Serie hat eine Beschreibung — ohne sie wäre es ein
|
||||||
|
// leerer Eintrag (istStub, 5.4.0), und der zählt nicht als Treffer.
|
||||||
|
if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10', overview: 'Ü', ...treffer.details }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
@@ -292,7 +294,7 @@ describe('inhaltAusBdmt — die Disc sagt selbst, was sie ist', () => {
|
|||||||
it('ohne bdmt-Datei bleibt alles leer, ohne Absturz', () => {
|
it('ohne bdmt-Datei bleibt alles leer, ohne Absturz', () => {
|
||||||
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
|
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
|
||||||
wegwerf.push(wurzel)
|
wegwerf.push(wurzel)
|
||||||
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [] })
|
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [], alternativen: [] })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -71,3 +71,47 @@ describe('istHauptNachricht', () => {
|
|||||||
expect(istHauptNachricht(undefined)).toBe(false)
|
expect(istHauptNachricht(undefined)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 5.4.0: Auftrag mit Sprachen und Optionen, neue Nachrichten ──────────
|
||||||
|
describe('istFensterNachricht — rip-start mit Sprachen und Optionen (5.4.0)', () => {
|
||||||
|
it('nimmt Sprachlisten und Staffel/Folge an', () => {
|
||||||
|
expect(
|
||||||
|
istFensterNachricht({
|
||||||
|
art: 'rip-start',
|
||||||
|
id: 'G',
|
||||||
|
auftrag: [{ nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }],
|
||||||
|
optionen: { staffel: 1, ersteFolge: 3 },
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: null, ersteFolge: null } })).toBe(true)
|
||||||
|
})
|
||||||
|
it('lehnt kaputte Sprachlisten, Rollen und Optionen ab', () => {
|
||||||
|
expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'folge', dauerS: 1, audioSprachen: 'jpn' }] })).toBe(false)
|
||||||
|
expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'egal', dauerS: 1 }] })).toBe(false)
|
||||||
|
expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: 'eins' } })).toBe(false)
|
||||||
|
})
|
||||||
|
it('kennt die neuen Nachrichten', () => {
|
||||||
|
expect(istFensterNachricht({ art: 'vorgaenge-laden' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: 'x' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: '' })).toBe(false)
|
||||||
|
expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 'Fast' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 5 })).toBe(false)
|
||||||
|
expect(istFensterNachricht({ art: 'kompression-abbrechen', vorgangId: 'x' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'roh-aufraeumen' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'fremden-ordner-loeschen', ordner: 'E:\Rippy\roh\alt' })).toBe(true)
|
||||||
|
expect(istFensterNachricht({ art: 'laufwerk-pruefen', id: 'G' })).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istKernNachricht — Vorgänge, Kompression, Gesundheit (5.4.0)', () => {
|
||||||
|
it('vorgaenge braucht Liste, Belegung und Warteschlange', () => {
|
||||||
|
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [], rohGesamtBytes: 0, fremde: [], regel: '', kompression: { laeuft: null, wartend: [] } } })).toBe(true)
|
||||||
|
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [] } })).toBe(false)
|
||||||
|
})
|
||||||
|
it('kompression-ergebnis und laufwerk-gesundheit', () => {
|
||||||
|
expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', titel: 'T', ok: true, text: 'fertig' })).toBe(true)
|
||||||
|
expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', ok: true })).toBe(false)
|
||||||
|
expect(istKernNachricht({ art: 'laufwerk-gesundheit', id: 'G', bericht: { urteil: 'disc', text: 't', fehlerhafteBloecke: 1, controllerfehler: 0, sonstige: 0, letzte: [], stunden: 24 } })).toBe(true)
|
||||||
|
expect(istKernNachricht({ art: 'kern-hinweis', text: 'Roh-Ordner gelöscht' })).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+208
-117
@@ -1,170 +1,261 @@
|
|||||||
// Tests der Verkettung Rip → Kompression → Ablage — mit nachgebildeten
|
// Tests der Rip-Phase (5.4.0): Die Pipeline führt den RIP und übergibt
|
||||||
// Werkzeug-Läufen. Die Regeln: Kompression nur wenn gewollt UND HandBrake
|
// danach einen Kompressions-Auftrag an die Warteschlange — das Laufwerk
|
||||||
// da; ein Kompressions-Fehler lässt den verlustfreien Rip STEHEN und sagt
|
// ist frei, sobald die Roh-Dateien liegen. Die Kompression selbst prüft
|
||||||
// wo er liegt; Statusmeldungen tragen die Phase.
|
// test/ablegen.test.ts.
|
||||||
import { mkdtempSync, rmSync } from 'node:fs'
|
//
|
||||||
|
// makemkvcon ist nachgebildet und SCHREIBT Dateien in den Roh-Ordner —
|
||||||
|
// nur so lässt sich prüfen, dass halbe Dateien nach Abbruch und Fehler
|
||||||
|
// wirklich verschwinden.
|
||||||
|
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { RipStatus } from '../src/gemeinsam/nachrichten'
|
import type { RipAuftragTitel, RipStatus } from '../src/gemeinsam/nachrichten'
|
||||||
|
import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag'
|
||||||
|
|
||||||
const ripErgebnis = vi.hoisted(() => ({
|
interface Schritt {
|
||||||
wert: { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } as unknown,
|
status: 'success' | 'error' | 'cancelled'
|
||||||
}))
|
/** Diese Datei entsteht im Roh-Ordner (auch bei Abbruch/Fehler: die halbe). */
|
||||||
const encodeErgebnis = vi.hoisted(() => ({
|
datei?: string
|
||||||
wert: { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' } as unknown,
|
bytes?: number
|
||||||
}))
|
lesefehler?: boolean
|
||||||
|
fehler?: string
|
||||||
|
/** Fortschritts-Ticks, die der Lauf meldet. */
|
||||||
|
ticks?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('../src/kern/rip/makemkv', () => ({
|
const plan = vi.hoisted(() => ({ schritte: [] as Schritt[] }))
|
||||||
rippen: vi.fn(async () => ripErgebnis.wert),
|
|
||||||
}))
|
|
||||||
vi.mock('../src/kern/komprimieren/handbrake', () => ({
|
|
||||||
komprimieren: vi.fn(async () => encodeErgebnis.wert),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { Pipeline, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
|
vi.mock('../src/kern/rip/makemkv', async () => {
|
||||||
|
const fs = await import('node:fs')
|
||||||
|
const pfad = await import('node:path')
|
||||||
|
return {
|
||||||
|
rippen: vi.fn(
|
||||||
|
async (o: {
|
||||||
|
zielOrdner: string
|
||||||
|
aufFortschritt?: (p: number) => void
|
||||||
|
}) => {
|
||||||
|
const schritt = plan.schritte.shift() ?? { status: 'success' as const, datei: 'title_t00.mkv' }
|
||||||
|
for (const p of schritt.ticks ?? []) o.aufFortschritt?.(p)
|
||||||
|
if (schritt.datei !== undefined) {
|
||||||
|
fs.writeFileSync(pfad.join(o.zielOrdner, schritt.datei), 'x'.repeat(schritt.bytes ?? 10))
|
||||||
|
}
|
||||||
|
if (schritt.status === 'cancelled') return { status: 'cancelled' }
|
||||||
|
if (schritt.status === 'error') {
|
||||||
|
return { status: 'error', fehler: schritt.fehler ?? 'MakeMKV endete mit Code 1', lesefehler: schritt.lesefehler ?? false }
|
||||||
|
}
|
||||||
|
const dateien = fs
|
||||||
|
.readdirSync(o.zielOrdner)
|
||||||
|
.filter((n: string) => n.endsWith('.mkv'))
|
||||||
|
.sort()
|
||||||
|
.map((n: string) => pfad.join(o.zielOrdner, n))
|
||||||
|
return { status: 'success', dateien, lesefehler: schritt.lesefehler ?? false }
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { Pipeline, halbeDateienEntfernen, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
|
||||||
|
|
||||||
let wegwerf: string[] = []
|
let wegwerf: string[] = []
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
plan.schritte = []
|
||||||
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
|
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const GERAET = {
|
||||||
|
id: 'G',
|
||||||
|
name: 'Laufwerk G:',
|
||||||
|
typ: 'bluray' as const,
|
||||||
|
pfad: '\\\\.\\G:',
|
||||||
|
status: 'ready' as const,
|
||||||
|
modell: '',
|
||||||
|
seriennummer: '',
|
||||||
|
groesseBytes: 33e9,
|
||||||
|
grund: '',
|
||||||
|
}
|
||||||
|
|
||||||
function umgebung(überschreiben: Partial<PipelineUmgebung> = {}): {
|
function umgebung(überschreiben: Partial<PipelineUmgebung> = {}): {
|
||||||
pipeline: Pipeline
|
pipeline: Pipeline
|
||||||
meldungen: RipStatus[]
|
meldungen: RipStatus[]
|
||||||
|
ablage: string
|
||||||
|
eingereiht: KompressionsAuftrag[]
|
||||||
|
endet: Array<{ id: string; phase: string; text: string }>
|
||||||
|
ausgeworfen: string[]
|
||||||
} {
|
} {
|
||||||
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
|
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
|
||||||
wegwerf.push(ablage)
|
wegwerf.push(ablage)
|
||||||
const meldungen: RipStatus[] = []
|
const meldungen: RipStatus[] = []
|
||||||
|
const eingereiht: KompressionsAuftrag[] = []
|
||||||
|
const endet: Array<{ id: string; phase: string; text: string }> = []
|
||||||
|
const ausgeworfen: string[] = []
|
||||||
const pipeline = new Pipeline({
|
const pipeline = new Pipeline({
|
||||||
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
|
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
|
||||||
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
|
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
|
||||||
flac: () => '',
|
flac: () => '',
|
||||||
geraet: () => ({
|
geraet: () => GERAET,
|
||||||
id: 'G',
|
|
||||||
name: 'Laufwerk G:',
|
|
||||||
typ: 'bluray',
|
|
||||||
pfad: '\\\\.\\G:',
|
|
||||||
status: 'ready',
|
|
||||||
modell: '',
|
|
||||||
seriennummer: '',
|
|
||||||
groesseBytes: 33e9,
|
|
||||||
grund: '',
|
|
||||||
}),
|
|
||||||
ablage: () => ablage,
|
ablage: () => ablage,
|
||||||
einstellungen: () => ({}),
|
einstellungen: () => ({}),
|
||||||
melden: (s) => meldungen.push(s),
|
melden: (s) => meldungen.push(s),
|
||||||
jetzt: () => new Date('2026-08-30T12:00:00Z'),
|
jetzt: () => new Date('2026-08-30T12:00:00Z'),
|
||||||
|
vorgangEinreihen: (auftrag) => {
|
||||||
|
eingereiht.push(auftrag)
|
||||||
|
return eingereiht.length
|
||||||
|
},
|
||||||
|
vorgangEndet: (id, phase, text) => endet.push({ id, phase, text }),
|
||||||
|
nachRipAuswerfen: (id) => ausgeworfen.push(id),
|
||||||
...überschreiben,
|
...überschreiben,
|
||||||
})
|
})
|
||||||
return { pipeline, meldungen }
|
return { pipeline, meldungen, ablage, eingereiht, endet, ausgeworfen }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Pipeline', () => {
|
const AUFTRAG: RipAuftragTitel[] = [
|
||||||
it('kettet Rip → Kompression → Ablage und meldet die Phasen', async () => {
|
{ nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 3e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
|
||||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
{ nr: 1, rolle: 'folge', dauerS: 3249, groesseBytes: 1e9 },
|
||||||
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
|
]
|
||||||
|
|
||||||
|
describe('Pipeline — die Rip-Phase (5.4.0)', () => {
|
||||||
|
it('rippt Titel für Titel, übergibt den Auftrag und gibt das Laufwerk frei', async () => {
|
||||||
|
plan.schritte = [
|
||||||
|
{ status: 'success', datei: 'title_t00.mkv', bytes: 300 },
|
||||||
|
{ status: 'success', datei: 'title_t01.mkv', bytes: 100 },
|
||||||
|
]
|
||||||
|
const { pipeline, meldungen, ablage, eingereiht, ausgeworfen } = umgebung()
|
||||||
|
const beginn: string[] = []
|
||||||
|
;(pipeline as unknown as { umgebung: PipelineUmgebung }).umgebung.vorgangBeginnt = (v) => beginn.push(v.rohOrdner)
|
||||||
|
await pipeline.starten('G', AUFTRAG, { staffel: 2, ersteFolge: 3 })
|
||||||
|
|
||||||
|
expect(meldungen[0].phase).toBe('rippt')
|
||||||
|
const letzte = meldungen.at(-1)!
|
||||||
|
expect(letzte.phase).toBe('fertig')
|
||||||
|
expect(letzte.text).toContain('eingereiht')
|
||||||
|
expect(letzte.text).toContain('Laufwerk ist frei')
|
||||||
|
expect(letzte.vorgangId).toBe('bluray-2026-08-30T12-00-00')
|
||||||
|
expect(pipeline.laeuft('G')).toBe(false)
|
||||||
|
expect(ausgeworfen).toEqual(['G'])
|
||||||
|
expect(beginn).toEqual([join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')])
|
||||||
|
|
||||||
|
expect(eingereiht).toHaveLength(1)
|
||||||
|
const auftrag = eingereiht[0]
|
||||||
|
expect(auftrag.vorgangId).toBe('bluray-2026-08-30T12-00-00')
|
||||||
|
expect(auftrag.dateien.map((d) => d.rolle)).toEqual(['folge', 'folge'])
|
||||||
|
expect(auftrag.dateien[0].audioSprachen).toEqual(['jpn'])
|
||||||
|
expect(auftrag.dateien[0].untertitelSprachen).toEqual(['deu'])
|
||||||
|
expect(auftrag.dateien[1].audioSprachen).toEqual([])
|
||||||
|
expect(auftrag.dateien.map((d) => d.groesseBytes)).toEqual([300, 100])
|
||||||
|
expect(auftrag.staffel).toBe(2)
|
||||||
|
expect(auftrag.ersteFolge).toBe(3)
|
||||||
|
expect(auftrag.preset).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('der Fortschritt läuft durch — nach Dateigröße gewichtet, ohne Rücksprung', async () => {
|
||||||
|
plan.schritte = [
|
||||||
|
{ status: 'success', datei: 'title_t00.mkv', ticks: [50, 100] },
|
||||||
|
{ status: 'success', datei: 'title_t01.mkv', ticks: [50] },
|
||||||
|
]
|
||||||
const { pipeline, meldungen } = umgebung()
|
const { pipeline, meldungen } = umgebung()
|
||||||
await pipeline.starten('G')
|
await pipeline.starten('G', AUFTRAG)
|
||||||
const phasen = meldungen.map((m) => m.phase)
|
const prozente = meldungen.filter((m) => m.phase === 'rippt').map((m) => m.prozent)
|
||||||
expect(phasen[0]).toBe('rippt')
|
// Titel 0 wiegt 3 GB, Titel 1 wiegt 1 GB: 50 % vom ersten = 37 %,
|
||||||
expect(phasen).toContain('komprimiert')
|
// erster fertig = 75 %, 50 % vom zweiten = 87 %. Nie zurück auf 0.
|
||||||
expect(phasen).toContain('legt-ab')
|
expect(prozente).toContain(37)
|
||||||
expect(phasen.at(-1)).toBe('fertig')
|
expect(prozente).toContain(75)
|
||||||
expect(meldungen.at(-1)?.dateien).toEqual(['R:\\fertig\\a.mkv'])
|
expect(prozente).toContain(87)
|
||||||
|
for (let i = 1; i < prozente.length; i++) expect(prozente[i]).toBeGreaterThanOrEqual(prozente[i - 1])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Kompression abgewählt: Roh-Rip ist das Ergebnis, mit Begründung', async () => {
|
it('Abbruch: die halbe Datei verschwindet, der leere Roh-Ordner auch', async () => {
|
||||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
plan.schritte = [{ status: 'cancelled', datei: 'title_t00.mkv' }]
|
||||||
const { pipeline, meldungen } = umgebung({
|
const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung()
|
||||||
einstellungen: () => ({ transcodePresetBluray: 'keine' }),
|
await pipeline.starten('G', AUFTRAG)
|
||||||
})
|
const letzte = meldungen.at(-1)!
|
||||||
await pipeline.starten('G')
|
expect(letzte.phase).toBe('abgebrochen')
|
||||||
const letzte = meldungen.at(-1)
|
expect(letzte.text).toContain('Halbe Datei entfernt: title_t00.mkv')
|
||||||
expect(letzte?.phase).toBe('fertig')
|
expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false)
|
||||||
expect(letzte?.text).toContain('abgewählt')
|
expect(eingereiht).toHaveLength(0)
|
||||||
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
|
expect(endet).toEqual([{ id: 'bluray-2026-08-30T12-00-00', phase: 'abgebrochen', text: letzte.text }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ohne HandBrake bleibt es beim Roh-Rip — mit klarem Satz', async () => {
|
it('Abbruch nach einem fertigen Titel: der fertige bleibt, die halbe geht', async () => {
|
||||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
plan.schritte = [
|
||||||
const { pipeline, meldungen } = umgebung({ handbrake: () => '' })
|
{ status: 'success', datei: 'title_t00.mkv' },
|
||||||
await pipeline.starten('G')
|
{ status: 'cancelled', datei: 'title_t01.mkv' },
|
||||||
expect(meldungen.at(-1)?.text).toContain('HandBrakeCLI nicht gefunden')
|
]
|
||||||
|
const { pipeline, meldungen, ablage } = umgebung()
|
||||||
|
await pipeline.starten('G', AUFTRAG)
|
||||||
|
expect(meldungen.at(-1)!.phase).toBe('abgebrochen')
|
||||||
|
const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')
|
||||||
|
expect(readdirSync(roh)).toEqual(['title_t00.mkv'])
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('1 fertige Datei(en) bleiben')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
|
it('EIN kaputter Titel beendet den Auftrag nicht — seine halbe Datei wird entfernt', async () => {
|
||||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
plan.schritte = [
|
||||||
encodeErgebnis.wert = { status: 'error', fehler: 'HandBrake endete mit Code 3' }
|
{ status: 'error', datei: 'title_t00.mkv', fehler: 'Lesefehler an Byte 56881152' },
|
||||||
const { pipeline, meldungen } = umgebung()
|
{ status: 'success', datei: 'title_t01.mkv' },
|
||||||
await pipeline.starten('G')
|
]
|
||||||
const letzte = meldungen.at(-1)
|
const { pipeline, meldungen, eingereiht } = umgebung()
|
||||||
expect(letzte?.phase).toBe('fehler')
|
await pipeline.starten('G', AUFTRAG)
|
||||||
expect(letzte?.text).toContain('bleibt stehen')
|
const letzte = meldungen.at(-1)!
|
||||||
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
|
expect(letzte.phase).toBe('fertig')
|
||||||
|
expect(eingereiht[0].dateien.map((d) => d.pfad.split(/[\\/]/).pop())).toEqual(['title_t01.mkv'])
|
||||||
|
expect(eingereiht[0].hinweise.join(' ')).toContain('Nicht gesichert')
|
||||||
|
expect(eingereiht[0].hinweise.join(' ')).toContain('Halbe Dateien entfernt: title_t00.mkv')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Lesefehler überleben bis in die Fertig-Meldung (Rückgabewert-0-Falle)', async () => {
|
it('kein Titel gesichert: Fehler, kein Auftrag, Roh-Ordner weg', async () => {
|
||||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: true }
|
plan.schritte = [{ status: 'error', fehler: 'kaputt' }, { status: 'error', fehler: 'auch kaputt' }]
|
||||||
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
|
const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung()
|
||||||
const { pipeline, meldungen } = umgebung()
|
await pipeline.starten('G', AUFTRAG)
|
||||||
|
expect(meldungen.at(-1)!.phase).toBe('fehler')
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('kaputt')
|
||||||
|
expect(eingereiht).toHaveLength(0)
|
||||||
|
expect(endet[0].phase).toBe('fehler')
|
||||||
|
expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Lesefehler reisen bis in den Auftrag und die Fertig-Meldung', async () => {
|
||||||
|
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv', lesefehler: true }]
|
||||||
|
const { pipeline, meldungen, eingereiht } = umgebung()
|
||||||
|
await pipeline.starten('G', [AUFTRAG[0]])
|
||||||
|
expect(meldungen.at(-1)!.lesefehler).toBe(true)
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('nicht lesen')
|
||||||
|
expect(eingereiht[0].lesefehler).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ohne Auftrag (alles rippen): eine Datei, Rolle Hauptfilm, Staffel von der Disc', async () => {
|
||||||
|
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
|
||||||
|
const { pipeline, eingereiht } = umgebung({ discAngaben: () => ({ staffel: 3, discNr: 2 }) })
|
||||||
await pipeline.starten('G')
|
await pipeline.starten('G')
|
||||||
const letzte = meldungen.at(-1)
|
expect(eingereiht[0].dateien).toHaveLength(1)
|
||||||
expect(letzte?.lesefehler).toBe(true)
|
expect(eingereiht[0].dateien[0].rolle).toBe('hauptfilm')
|
||||||
expect(letzte?.text).toContain('nicht lesen')
|
expect(eingereiht[0].staffel).toBe(3)
|
||||||
|
expect(eingereiht[0].discNr).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ohne Empfänger für den Auftrag sagt die Meldung, wo die Roh-Dateien liegen', async () => {
|
||||||
|
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
|
||||||
|
const { pipeline, meldungen } = umgebung({ vorgangEinreihen: undefined })
|
||||||
|
await pipeline.starten('G')
|
||||||
|
expect(meldungen.at(-1)!.text).toContain('nicht eingereiht')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('leeres Laufwerk: ein Satz statt makemkvcon (aber unknown rippt!)', async () => {
|
it('leeres Laufwerk: ein Satz statt makemkvcon (aber unknown rippt!)', async () => {
|
||||||
const { pipeline, meldungen } = umgebung({
|
const { pipeline, meldungen } = umgebung({
|
||||||
geraet: () => ({
|
geraet: () => ({ ...GERAET, typ: 'unknown', status: 'empty', groesseBytes: 0 }),
|
||||||
id: 'G',
|
|
||||||
name: 'Laufwerk G:',
|
|
||||||
typ: 'unknown',
|
|
||||||
pfad: '\\\\.\\G:',
|
|
||||||
status: 'empty',
|
|
||||||
modell: '',
|
|
||||||
seriennummer: '',
|
|
||||||
groesseBytes: 0,
|
|
||||||
grund: '',
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
await pipeline.starten('G')
|
await pipeline.starten('G')
|
||||||
expect(meldungen[0].phase).toBe('fehler')
|
expect(meldungen[0].phase).toBe('fehler')
|
||||||
expect(meldungen[0].text).toContain('Kein Datenträger')
|
expect(meldungen[0].text).toContain('Kein Datenträger')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('NFO landet im fertigen Ordner', async () => {
|
it('halbeDateienEntfernen: nur, was NEU dazukam', () => {
|
||||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
const ordner = mkdtempSync(join(tmpdir(), 'rippy-halb-'))
|
||||||
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
|
wegwerf.push(ordner)
|
||||||
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
|
writeFileSync(join(ordner, 'alt.mkv'), 'a')
|
||||||
wegwerf.push(ablage)
|
const vorher = new Set(readdirSync(ordner))
|
||||||
const meldungen: RipStatus[] = []
|
writeFileSync(join(ordner, 'neu.mkv'), 'b')
|
||||||
const pipeline = new Pipeline({
|
expect(halbeDateienEntfernen(ordner, vorher)).toEqual(['neu.mkv'])
|
||||||
makemkvcon: () => 'x',
|
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
|
||||||
handbrake: () => 'y',
|
|
||||||
flac: () => '',
|
|
||||||
geraet: () => ({
|
|
||||||
id: 'G',
|
|
||||||
name: 'Laufwerk G:',
|
|
||||||
typ: 'dvd',
|
|
||||||
pfad: '\\\\.\\G:',
|
|
||||||
status: 'ready',
|
|
||||||
modell: '',
|
|
||||||
seriennummer: '',
|
|
||||||
groesseBytes: 4e9,
|
|
||||||
grund: '',
|
|
||||||
}),
|
|
||||||
ablage: () => ablage,
|
|
||||||
einstellungen: () => ({}),
|
|
||||||
melden: (s) => meldungen.push(s),
|
|
||||||
jetzt: () => new Date('2026-08-30T12:00:00Z'),
|
|
||||||
})
|
|
||||||
await pipeline.starten('G')
|
|
||||||
expect(meldungen.at(-1)?.phase).toBe('fertig')
|
|
||||||
const { existsSync, readdirSync } = await import('node:fs')
|
|
||||||
const filme = join(ablage, 'Filme')
|
|
||||||
const ordner = readdirSync(filme)
|
|
||||||
expect(ordner).toHaveLength(1)
|
|
||||||
expect(existsSync(join(filme, ordner[0], 'movie.nfo'))).toBe(true)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Tests des Roh-Aufräumens (kern/ablage/rohaufraeumen.ts, 5.4.0): die
|
||||||
|
// Regel, die Frist, die drei Sicherungen — und dass fremde Ordner nur
|
||||||
|
// gezeigt, nie von selbst gelöscht werden.
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
aufraeumen,
|
||||||
|
faelligAm,
|
||||||
|
fremdeOrdner,
|
||||||
|
istUnterRohWurzel,
|
||||||
|
loeschHindernis,
|
||||||
|
ordnerGroesse,
|
||||||
|
regelLesen,
|
||||||
|
regelText,
|
||||||
|
type AufraeumKandidat,
|
||||||
|
type AufraeumZugriff,
|
||||||
|
} from '../src/kern/ablage/rohaufraeumen'
|
||||||
|
|
||||||
|
let wegwerf: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('regelLesen', () => {
|
||||||
|
it('kennt behalten, sofort und Tage — und die Vorgabe 7', () => {
|
||||||
|
expect(regelLesen('behalten')).toEqual({ art: 'behalten' })
|
||||||
|
expect(regelLesen('sofort')).toEqual({ art: 'sofort' })
|
||||||
|
expect(regelLesen('30')).toEqual({ art: 'tage', tage: 30 })
|
||||||
|
expect(regelLesen(null)).toEqual({ art: 'tage', tage: 7 })
|
||||||
|
expect(regelLesen('')).toEqual({ art: 'tage', tage: 7 })
|
||||||
|
expect(regelLesen('quatsch')).toEqual({ art: 'tage', tage: 7 })
|
||||||
|
expect(regelLesen('-3')).toEqual({ art: 'tage', tage: 7 })
|
||||||
|
expect(regelText(regelLesen('7'))).toContain('7 Tage')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('faelligAm', () => {
|
||||||
|
it('Tage nach dem Ende; sofort = am Ende; behalten = nie', () => {
|
||||||
|
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'tage', tage: 7 })).toBe('2026-09-08T20:00:00.000Z')
|
||||||
|
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'sofort' })).toBe('2026-09-01T20:00:00.000Z')
|
||||||
|
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'behalten' })).toBe('')
|
||||||
|
expect(faelligAm(null, { art: 'sofort' })).toBe('')
|
||||||
|
expect(faelligAm('kaputt', { art: 'sofort' })).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const fertigeDateien = ['E:\\Rippy\\Filme\\Akira (1988)\\title_t00.mkv']
|
||||||
|
const kandidat = (teil: Partial<AufraeumKandidat> = {}): AufraeumKandidat => ({
|
||||||
|
id: 'bluray-1',
|
||||||
|
phase: 'fertig',
|
||||||
|
komprimiert: true,
|
||||||
|
rohOrdner: 'E:\\Rippy\\roh\\bluray-1',
|
||||||
|
rohGeloeschtAm: null,
|
||||||
|
beendet: '2026-09-01T20:00:00.000Z',
|
||||||
|
fertigeDateien,
|
||||||
|
...teil,
|
||||||
|
})
|
||||||
|
const zugriff = (überschreiben: Partial<AufraeumZugriff> = {}): AufraeumZugriff & { geloescht: string[] } => {
|
||||||
|
const geloescht: string[] = []
|
||||||
|
return {
|
||||||
|
existiert: () => true,
|
||||||
|
groesse: () => 1000,
|
||||||
|
ordnerLoeschen: (p) => {
|
||||||
|
geloescht.push(p)
|
||||||
|
},
|
||||||
|
geloescht,
|
||||||
|
...überschreiben,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const regel7 = { art: 'tage' as const, tage: 7 }
|
||||||
|
const spaeter = new Date('2026-09-10T00:00:00Z')
|
||||||
|
const frueher = new Date('2026-09-03T00:00:00Z')
|
||||||
|
|
||||||
|
describe('loeschHindernis — die drei Sicherungen', () => {
|
||||||
|
it('fertig, komprimiert, Ergebnis da, Frist um: löschbar', () => {
|
||||||
|
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff())).toBe('')
|
||||||
|
})
|
||||||
|
it('vor der Frist: nicht löschbar, mit Datum', () => {
|
||||||
|
expect(loeschHindernis(kandidat(), regel7, frueher, zugriff())).toContain('Frist läuft bis 2026-09-08')
|
||||||
|
})
|
||||||
|
it('nicht fertig / nicht komprimiert / schon gelöscht / Regel behalten', () => {
|
||||||
|
expect(loeschHindernis(kandidat({ phase: 'fehler' }), regel7, spaeter, zugriff())).toContain('nicht fertig')
|
||||||
|
expect(loeschHindernis(kandidat({ komprimiert: false }), regel7, spaeter, zugriff())).toContain('nicht komprimiert')
|
||||||
|
expect(loeschHindernis(kandidat({ rohGeloeschtAm: 'x' }), regel7, spaeter, zugriff())).toBe('schon gelöscht')
|
||||||
|
expect(loeschHindernis(kandidat(), { art: 'behalten' }, spaeter, zugriff())).toBe('Regel: behalten')
|
||||||
|
})
|
||||||
|
it('die fertige Datei fehlt oder ist leer: NIE löschen', () => {
|
||||||
|
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ existiert: (p) => p.endsWith('bluray-1') }))).toContain('fertige Datei fehlt')
|
||||||
|
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ groesse: () => 0 }))).toContain('ist leer')
|
||||||
|
expect(loeschHindernis(kandidat({ fertigeDateien: [] }), regel7, spaeter, zugriff())).toContain('keine fertige Datei')
|
||||||
|
})
|
||||||
|
it('sofort: gleich nach dem Ende löschbar', () => {
|
||||||
|
expect(loeschHindernis(kandidat(), { art: 'sofort' }, new Date('2026-09-01T20:00:01Z'), zugriff())).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aufraeumen', () => {
|
||||||
|
it('löscht nur die Fälligen und sagt, warum der Rest blieb', () => {
|
||||||
|
const z = zugriff()
|
||||||
|
const ergebnis = aufraeumen(
|
||||||
|
[kandidat(), kandidat({ id: 'jung', beendet: '2026-09-09T00:00:00.000Z' }), kandidat({ id: 'roh', komprimiert: false })],
|
||||||
|
regel7,
|
||||||
|
spaeter,
|
||||||
|
z,
|
||||||
|
)
|
||||||
|
expect(ergebnis.geloescht).toEqual(['bluray-1'])
|
||||||
|
expect(z.geloescht).toEqual(['E:\\Rippy\\roh\\bluray-1'])
|
||||||
|
expect(ergebnis.uebersprungen.map((u) => u.id)).toEqual(['jung', 'roh'])
|
||||||
|
expect(ergebnis.fehler).toEqual([])
|
||||||
|
})
|
||||||
|
it('ein Ordner, der sich nicht löschen lässt, ist ein lauter Fehler, kein stilles Nichts', () => {
|
||||||
|
const ergebnis = aufraeumen([kandidat()], regel7, spaeter, zugriff({ ordnerLoeschen: () => { throw new Error('EBUSY') } }))
|
||||||
|
expect(ergebnis.geloescht).toEqual([])
|
||||||
|
expect(ergebnis.fehler[0].grund).toContain('EBUSY')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Belegung und fremde Ordner — am echten Dateisystem', () => {
|
||||||
|
it('ordnerGroesse zählt rekursiv; fremdeOrdner kennt die Vorgänge und lässt sie aus', () => {
|
||||||
|
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-roh-'))
|
||||||
|
wegwerf.push(wurzel)
|
||||||
|
const roh = join(wurzel, 'roh')
|
||||||
|
mkdirSync(join(roh, 'bluray-1', 'tief'), { recursive: true })
|
||||||
|
mkdirSync(join(roh, 'alt-vor-540'), { recursive: true })
|
||||||
|
writeFileSync(join(roh, 'bluray-1', 'a.mkv'), 'x'.repeat(100))
|
||||||
|
writeFileSync(join(roh, 'bluray-1', 'tief', 'b.mkv'), 'x'.repeat(50))
|
||||||
|
writeFileSync(join(roh, 'alt-vor-540', 'c.mkv'), 'x'.repeat(7))
|
||||||
|
writeFileSync(join(roh, 'lose.txt'), 'x')
|
||||||
|
expect(ordnerGroesse(join(roh, 'bluray-1'))).toBe(150)
|
||||||
|
expect(ordnerGroesse(join(roh, 'gibt-es-nicht'))).toBe(0)
|
||||||
|
const fremde = fremdeOrdner(roh, new Set([join(roh, 'bluray-1')]))
|
||||||
|
expect(fremde).toEqual([{ ordner: join(roh, 'alt-vor-540'), bytes: 7 }])
|
||||||
|
expect(fremdeOrdner(join(wurzel, 'nix'), new Set())).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istUnterRohWurzel — nur Ordner DIREKT unter roh\\', () => {
|
||||||
|
it('erlaubt genau eine Ebene', () => {
|
||||||
|
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\bluray-1')).toBe(true)
|
||||||
|
expect(istUnterRohWurzel('E:\\Rippy\\roh\\', 'e:\\rippy\\roh\\bluray-1\\')).toBe(true)
|
||||||
|
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh')).toBe(false)
|
||||||
|
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\a\\b')).toBe(false)
|
||||||
|
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\Filme\\Akira')).toBe(false)
|
||||||
|
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\rohling\\x')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Tests des HandBrake-Scans (kern/komprimieren/scan.ts, 5.4.0) — am
|
||||||
|
// gemessenen Format: „JSON Title Set:" + Objekt + weiterer Text.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildScanArgs, fehlendeSprachen, spurenAusScan } from '../src/kern/komprimieren/scan'
|
||||||
|
import { regelAusQuery, RIPPY_REGEL } from '../src/kern/werkzeuge/spurauswahl'
|
||||||
|
|
||||||
|
const AUSGABE = `[21:27:54] hb_init: starting libhb thread
|
||||||
|
Scanning title 1 of 1, preview 1, 25.00 %
|
||||||
|
JSON Title Set: {
|
||||||
|
"MainFeature": 0,
|
||||||
|
"TitleList": [
|
||||||
|
{
|
||||||
|
"AudioList": [
|
||||||
|
{ "Description": "\u65e5\u672c\u8a9e (AAC LC, 2.0 ch)", "Language": "\u65e5\u672c\u8a9e", "LanguageCode": "jpn", "TrackNumber": 1 },
|
||||||
|
{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 },
|
||||||
|
{ "Description": "English (AAC LC, 2.0 ch)", "Language": "English", "LanguageCode": "eng", "TrackNumber": 3 }
|
||||||
|
],
|
||||||
|
"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },
|
||||||
|
"Index": 1,
|
||||||
|
"Name": "quelle",
|
||||||
|
"SubtitleList": [
|
||||||
|
{ "Format": "text", "Language": "Deutsch (UTF-8)", "LanguageCode": "deu", "SourceName": "UTF-8", "TrackNumber": 1 },
|
||||||
|
{ "Format": "text", "Language": "English (UTF-8)", "LanguageCode": "eng", "SourceName": "UTF-8", "TrackNumber": 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
HandBrake has exited.
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('spurenAusScan', () => {
|
||||||
|
it('schneidet das Objekt aus dem Textprotokoll und liest Spuren + Dauer', () => {
|
||||||
|
const s = spurenAusScan(AUSGABE)!
|
||||||
|
expect(s.audio.map((a) => `${a.nr}:${a.lang}`)).toEqual(['1:jpn', '2:deu', '3:eng'])
|
||||||
|
expect(s.untertitel.map((u) => u.lang)).toEqual(['deu', 'eng'])
|
||||||
|
expect(s.dauerS).toBe(52 * 60 + 47)
|
||||||
|
})
|
||||||
|
it('ohne Marke oder mit kaputtem JSON: null, nie ein leerer Befund', () => {
|
||||||
|
expect(spurenAusScan('nichts')).toBeNull()
|
||||||
|
expect(spurenAusScan('JSON Title Set: { "TitleList": [ ')).toBeNull()
|
||||||
|
expect(spurenAusScan('JSON Title Set: { "TitleList": [] }')).toBeNull()
|
||||||
|
})
|
||||||
|
it('buildScanArgs: --scan --json, Titel 1', () => {
|
||||||
|
expect(buildScanArgs('R:\\roh\\a.mkv')).toEqual(['--scan', '--json', '--input', 'R:\\roh\\a.mkv', '--title', '1'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fehlendeSprachen', () => {
|
||||||
|
it('nennt, was gewünscht ist und nicht drin', () => {
|
||||||
|
const s = spurenAusScan(AUSGABE)!
|
||||||
|
expect(fehlendeSprachen(s, { audio: ['jpn', 'fra'], untertitel: ['deu', 'jpn'] })).toEqual({ audio: ['fra'], untertitel: ['jpn'] })
|
||||||
|
expect(fehlendeSprachen(s, { audio: [], untertitel: [] })).toEqual({ audio: [], untertitel: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MakeMKV-Auswahlregel — reg query lesen', () => {
|
||||||
|
it('findet den Wert in der Ausgabe', () => {
|
||||||
|
const ausgabe = `\r\nHKEY_CURRENT_USER\\Software\\MakeMKV\r\n app_DefaultSelectionString REG_SZ ${RIPPY_REGEL}\r\n\r\n`
|
||||||
|
expect(regelAusQuery(ausgabe)).toBe(RIPPY_REGEL)
|
||||||
|
expect(regelAusQuery('FEHLER: Der angegebene Registrierungsschlüssel oder Wert wurde nicht gefunden.')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Tests der Kompressions-Warteschlange (kern/ablauf/warteschlange.ts,
|
||||||
|
// 5.4.0): eine zur Zeit, der Reihe nach; abbrechen wartend und laufend;
|
||||||
|
// eine Ausnahme im Encode beendet nicht die Schlange; kein Doppel.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag'
|
||||||
|
import type { AblageErgebnis } from '../src/kern/ablauf/ablegen'
|
||||||
|
import { KompressionsWarteschlange, type WarteschlangenStand } from '../src/kern/ablauf/warteschlange'
|
||||||
|
|
||||||
|
function auftrag(id: string): KompressionsAuftrag {
|
||||||
|
return {
|
||||||
|
vorgangId: id,
|
||||||
|
laufwerk: 'G',
|
||||||
|
discTyp: 'bluray',
|
||||||
|
rohOrdner: `R:\\roh\\${id}`,
|
||||||
|
dateien: [],
|
||||||
|
zuordnung: null,
|
||||||
|
staffel: null,
|
||||||
|
ersteFolge: null,
|
||||||
|
discNr: null,
|
||||||
|
fingerprint: null,
|
||||||
|
lesefehler: false,
|
||||||
|
hinweise: [],
|
||||||
|
begonnen: '2026-08-30T12:00:00Z',
|
||||||
|
preset: '',
|
||||||
|
zielOrdner: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fertig(text = 'ok'): AblageErgebnis {
|
||||||
|
return { status: 'fertig', zielOrdner: 'Z', dateien: [], text, komprimiert: true, preset: 'p', fertigBytes: 1, hinweise: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Encode, den der Test von außen beendet. */
|
||||||
|
function steuerbar() {
|
||||||
|
const freigaben: Array<() => void> = []
|
||||||
|
const gestartet: string[] = []
|
||||||
|
const ausfuehren = (a: KompressionsAuftrag, melden: (s: { phase: 'komprimiert'; prozent: number; text: string }) => void, abbruch: AbortSignal) =>
|
||||||
|
new Promise<AblageErgebnis>((loesen) => {
|
||||||
|
gestartet.push(a.vorgangId)
|
||||||
|
melden({ phase: 'komprimiert', prozent: 10, text: 'läuft' })
|
||||||
|
abbruch.addEventListener('abort', () => loesen({ ...fertig('abgebrochen'), status: 'abgebrochen' }), { once: true })
|
||||||
|
freigaben.push(() => loesen(fertig()))
|
||||||
|
})
|
||||||
|
return { ausfuehren, freigaben, gestartet }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
|
describe('KompressionsWarteschlange', () => {
|
||||||
|
it('arbeitet der Reihe nach, meldet Stand und Ergebnis, vergibt Plätze', async () => {
|
||||||
|
const { ausfuehren, freigaben, gestartet } = steuerbar()
|
||||||
|
const staende: WarteschlangenStand[] = []
|
||||||
|
const ergebnisse: string[] = []
|
||||||
|
const q = new KompressionsWarteschlange(ausfuehren, (s) => staende.push(s), (a, e) => {
|
||||||
|
ergebnisse.push(`${a.vorgangId}:${e.status}`)
|
||||||
|
})
|
||||||
|
expect(q.einreihen(auftrag('a'), 'A')).toBe(1)
|
||||||
|
expect(q.einreihen(auftrag('b'), 'B')).toBe(2)
|
||||||
|
expect(q.einreihen(auftrag('c'), 'C')).toBe(3)
|
||||||
|
await tick()
|
||||||
|
expect(gestartet).toEqual(['a'])
|
||||||
|
expect(q.stand().laeuft?.vorgangId).toBe('a')
|
||||||
|
expect(q.stand().laeuft?.prozent).toBe(10)
|
||||||
|
expect(q.stand().wartend.map((w) => w.vorgangId)).toEqual(['b', 'c'])
|
||||||
|
expect(q.laeuftEtwas()).toBe(true)
|
||||||
|
expect(q.kennt('b')).toBe(true)
|
||||||
|
|
||||||
|
freigaben[0]()
|
||||||
|
await tick()
|
||||||
|
expect(gestartet).toEqual(['a', 'b'])
|
||||||
|
expect(ergebnisse).toEqual(['a:fertig'])
|
||||||
|
freigaben[1]()
|
||||||
|
await tick()
|
||||||
|
freigaben[2]()
|
||||||
|
await tick()
|
||||||
|
expect(ergebnisse).toEqual(['a:fertig', 'b:fertig', 'c:fertig'])
|
||||||
|
expect(q.laeuftEtwas()).toBe(false)
|
||||||
|
expect(q.stand().laeuft).toBeNull()
|
||||||
|
expect(staende.length).toBeGreaterThan(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kein Doppel: derselbe Vorgang bekommt seinen alten Platz zurück', async () => {
|
||||||
|
const { ausfuehren } = steuerbar()
|
||||||
|
const q = new KompressionsWarteschlange(ausfuehren, () => {}, () => {})
|
||||||
|
q.einreihen(auftrag('a'), 'A')
|
||||||
|
q.einreihen(auftrag('b'), 'B')
|
||||||
|
await tick()
|
||||||
|
expect(q.einreihen(auftrag('a'), 'A')).toBe(1)
|
||||||
|
expect(q.einreihen(auftrag('b'), 'B')).toBe(2)
|
||||||
|
expect(q.stand().wartend).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('abbrechen: wartend wird entfernt (mit Ergebnis), laufend bekommt das Signal', async () => {
|
||||||
|
const { ausfuehren, freigaben } = steuerbar()
|
||||||
|
const ergebnisse: string[] = []
|
||||||
|
const q = new KompressionsWarteschlange(ausfuehren, () => {}, (a, e) => {
|
||||||
|
ergebnisse.push(`${a.vorgangId}:${e.status}`)
|
||||||
|
})
|
||||||
|
q.einreihen(auftrag('a'), 'A')
|
||||||
|
q.einreihen(auftrag('b'), 'B')
|
||||||
|
await tick()
|
||||||
|
expect(q.abbrechen('b')).toBe(true)
|
||||||
|
expect(ergebnisse).toEqual(['b:abgebrochen'])
|
||||||
|
expect(q.stand().wartend).toHaveLength(0)
|
||||||
|
expect(q.abbrechen('a')).toBe(true)
|
||||||
|
await tick()
|
||||||
|
expect(ergebnisse).toEqual(['b:abgebrochen', 'a:abgebrochen'])
|
||||||
|
expect(q.abbrechen('gibt-es-nicht')).toBe(false)
|
||||||
|
expect(freigaben).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eine Ausnahme im Encode ist ein lautes Fehler-Ergebnis — die Schlange läuft weiter', async () => {
|
||||||
|
let n = 0
|
||||||
|
const ergebnisse: string[] = []
|
||||||
|
const q = new KompressionsWarteschlange(
|
||||||
|
async () => {
|
||||||
|
n += 1
|
||||||
|
if (n === 1) throw new Error('HandBrake explodiert')
|
||||||
|
return fertig()
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
(a, e) => {
|
||||||
|
ergebnisse.push(`${a.vorgangId}:${e.status}:${e.text.includes('explodiert')}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
q.einreihen(auftrag('a'), 'A')
|
||||||
|
q.einreihen(auftrag('b'), 'B')
|
||||||
|
await tick()
|
||||||
|
await tick()
|
||||||
|
expect(ergebnisse).toEqual(['a:fehler:true', 'b:fertig:false'])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user