// 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 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 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, 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 }