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>
178 lines
7.6 KiB
TypeScript
178 lines
7.6 KiB
TypeScript
// 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
|
||
}
|