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:
Hitonabi
2026-09-01 22:02:03 +02:00
co-authored by Claude Fable 5.1
parent d70a81d194
commit 3f7f962dff
29 changed files with 5102 additions and 1418 deletions
+177
View File
@@ -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
}