feat(v5): 5.6.0 — Vorgangs-Leiste, mehrere Filme je Disc, Filmnamen für Ordner und Dateien, Untertitel-Vorwahl je Extra, Restzeit, Hell-Modus

Die sechs Punkte des Commanders vom 02.09.2026 abends und seine Entscheide:

* Restzeit der Kompression aus HandBrakes eigener ETA-Klammer (Formatstring
  aus dem Binary 1.11.2 gelesen), KompressionStand.restS.
* Kachel zeigt nach dem Rip nur noch eine Zeile — Balken, Prozent, Ziel in
  der Kompressions-Karte und im Verlauf ("nur in der Kompressions-Karte").
* Kopfzeile bleibt beim Scrollen stehen (sticky), Sektionswahl darunter.
* Untertitel-Vorwahl je Extra ("NUR für Extras, IMMER OPTIONAL"): welche
  Untertitel-Spur beim Abspielen an ist — weiche Spur, nie eingebrannt;
  RipAuftragTitel/RohDatei.untertitelVorwahl, sprachwahl.
* Mehrere Filme je Disc: "anderer Film …" je Titel mit TMDb-Suche;
  filme.ts filmGruppen trennt am Rip-Ende in eigene Roh-Ordner, Vorgänge
  und Kompressions-Aufträge mit voller Zuordnung (erkennung.zuordnungAus).
* Roh-Ordner und Dateien tragen den Filmnamen (filme.ts rohOrdnerName,
  ablage/dateinamen.ts: Akira (1988).mkv, - Fassung n, - Extra nn).
* Hell-Modus: Einstellung design (dunkel/hell/system), data-design auf
  <html>, Token --color-tinte statt white/-Alpha-Klassen.
* Vorgangs-Leiste (Commander: "Phase 1–4 plus Komprimierung — zu einem
  machen"): gemeinsam/vorgangsleiste.ts + uebersicht/VorgangsLeiste.tsx,
  vier Stationen (Rettung als Zwischenstation), Segment-Balken in der
  aktiven Station; Kachel, Kompressions-Karte, Verlauf. PHASE-x/4-Texte weg.
* Automatik-Kasten leert sich, sobald ein Rip startet.

Beweise: 446 Tests (neu: filme, dateinamen, vorgangsleiste; erweitert:
auftrag, handbrake, automatik, ablegen), drei Typprüfungen, Klick-Beweis
33 Schritte inkl. Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit,
Hell-Modus (Bilder beweise/klick/01…11).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-03 19:17:37 +02:00
co-authored by Claude Fable 5.1
parent 76ed3b337a
commit f5dab5d1c1
59 changed files with 1284 additions and 182 deletions
@@ -0,0 +1,47 @@
// Dateinamen in der Ablage (5.6.0, Commander: „die Ordner … müssen direkt
// den Filmnamen haben" — und die Dateien darin auch): Statt MakeMKVs
// title_t00.mkv heißt der Hauptfilm „Akira (1988).mkv", zwei Fassungen
// „Akira (1988) - Fassung 1.mkv" und „… - Fassung 2.mkv", Zugaben „Akira
// (1988) - Extra 01.mkv" im Unterordner extras. Folgen behalten ihren
// Namen, bis die Episoden-Zuordnung sie umbenennt (struktur.ts) — ohne
// Zuordnung bleiben sie, wie sie sind (lieber gar nicht als falsch). Ohne
// erkannten Titel bleibt alles beim MakeMKV-Namen. PUR.
import { extname } from 'node:path'
import { sichererName } from '../../gemeinsam/ablagepfad'
import type { TitelRolle } from '../../gemeinsam/nachrichten'
export interface NamensEingabe {
titel: string
jahr: number | null
alsSerie: boolean
rolle: TitelRolle
/** 1-basierte Nummer unter den Dateien GLEICHER Rolle (Ablage-Reihenfolge). */
nummer: number
/** Wie viele Dateien dieser Rolle es gibt. */
anzahl: number
/** MakeMKVs Dateiname (mit Endung). */
original: string
}
export function zielDateiname(e: NamensEingabe): string {
const endung = extname(e.original) || '.mkv'
const name = e.alsSerie ? sichererName(e.titel) : sichererName(e.titel, e.jahr)
if (e.titel.trim().length === 0 || name.length === 0) return e.original
const nn = String(e.nummer).padStart(2, '0')
if (e.rolle === 'extra') return `${name} - Extra ${nn}${endung}`
if (e.alsSerie) return e.original
if (e.rolle === 'hauptfilm') return e.anzahl > 1 ? `${name} - Fassung ${e.nummer}${endung}` : `${name}${endung}`
return e.original
}
/** Nummern je Rolle in Ablage-Reihenfolge (pur): [nummer, anzahl] je Index. */
export function rollenNummern(rollen: readonly TitelRolle[]): Array<[number, number]> {
const zaehler = new Map<TitelRolle, number>()
const gesamt = new Map<TitelRolle, number>()
for (const r of rollen) gesamt.set(r, (gesamt.get(r) ?? 0) + 1)
return rollen.map((r) => {
const n = (zaehler.get(r) ?? 0) + 1
zaehler.set(r, n)
return [n, gesamt.get(r) ?? 1]
})
}
+21 -3
View File
@@ -34,6 +34,7 @@ import {
// 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 { rollenNummern, zielDateiname } from '../ablage/dateinamen'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
@@ -87,7 +88,7 @@ export interface AblageErgebnis {
folgenVergeben: number[]
}
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text'>) => void
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text' | 'restS'>) => void
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
@@ -188,9 +189,24 @@ export async function komprimierenUndAblegen(
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
// ihren Namen bis zur Episoden-Zuordnung.
const nummern = rollenNummern(dateien.map((d) => d.rolle))
for (let i = 0; i < dateien.length; i++) {
const datei = dateien[i]
const zielDatei = join(zielFuer(datei.rolle), basename(datei.pfad))
const [nummer, anzahl] = nummern[i]
const zielDatei = join(
zielFuer(datei.rolle),
zielDateiname({
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
alsSerie,
rolle: datei.rolle,
nummer,
anzahl,
original: basename(datei.pfad),
}),
)
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
if (!komprimiert) {
melden({
@@ -237,6 +253,7 @@ export async function komprimierenUndAblegen(
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0),
restS: -1,
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)' : ''}` : ''}`,
@@ -250,10 +267,11 @@ export async function komprimierenUndAblegen(
untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard,
abbruch,
aufFortschritt: (prozent) => {
aufFortschritt: (prozent, restS) => {
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, prozent),
restS,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
})
},
+29 -2
View File
@@ -6,7 +6,7 @@
// 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 { MetaVorschlag, TitelRolle } from '../../gemeinsam/nachrichten'
import type { Zuordnung } from '../metadaten/zuordnung'
export interface RohDatei {
@@ -19,6 +19,10 @@ export interface RohDatei {
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
audioSprachen: string[]
untertitelSprachen: string[]
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
untertitelVorwahl?: string | null
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
film?: MetaVorschlag
}
export interface KompressionsAuftrag {
@@ -81,6 +85,8 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
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) : [],
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
})
}
return {
@@ -103,6 +109,20 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
}
}
/** Ein Film-Vorschlag aus dem JSON — null, wenn er nicht die Form hat. */
export function filmAus(wert: unknown): MetaVorschlag | null {
if (typeof wert !== 'object' || wert === null) return null
const f = wert as Record<string, unknown>
if (typeof f.tmdbId !== 'number' || typeof f.titel !== 'string') return null
return {
typ: f.typ === 'tv' ? 'tv' : 'movie',
tmdbId: f.tmdbId,
titel: f.titel,
jahr: typeof f.jahr === 'number' ? f.jahr : null,
posterPfad: typeof f.posterPfad === 'string' ? f.posterPfad : '',
}
}
/** Was HandBrake für EINE Datei bekommt. */
export interface SprachWahl {
audio: string[]
@@ -123,11 +143,18 @@ export interface SprachWahl {
* komprimieren/handbrake.ts).
*/
export function sprachwahl(
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[] },
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[]; untertitelVorwahl?: string | null },
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]
// 5.6.0: Die Vorwahl des Nutzers (nur Extras, immer optional) schlägt die
// Regel — null heißt ausdrücklich „keine Vorwahl".
if (datei.untertitelVorwahl === null) return { audio, untertitel, untertitelStandard: false }
if (typeof datei.untertitelVorwahl === 'string' && datei.untertitelVorwahl.length > 0) {
const v = datei.untertitelVorwahl
return { audio, untertitel: [v, ...untertitel.filter((u) => u !== v)], untertitelStandard: true }
}
const wunsch = global.audio[0] ?? ''
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
+89
View File
@@ -0,0 +1,89 @@
// Mehrere Filme auf einer Disc und Ordner mit Filmnamen (5.6.0, Commander:
// „je Titel eigener Film im Dialog" und „die Ordner, die Rippy generiert,
// müssen direkt den Filmnamen haben, z. B. Akira (Jahr)") — PUR, damit die
// Ampel die Regeln prüft:
// * rohOrdnerName: Der Roh-Ordner heißt wie der Film (oder die Serie mit
// Staffel und Disc), bei Wiederholung mit „(2)", „(3)" …; ohne Zuordnung
// bleibt der Zeitstempel-Name des Vorgangs.
// * filmGruppen: Titel, die der Nutzer einem ANDEREN Film zugeordnet hat,
// werden zu eigenen Gruppen — jede bekommt eigenen Roh-Ordner, Vorgang
// und Zielordner. Hat kein Titel die Disc-Zuordnung behalten, wird die
// erste Gruppe zum Hauptvorgang (der Roh-Ordner ist schon da).
// * zuordnungAusVorschlag: Notnagel ohne TMDb-Details.
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
import { sichererName } from '../../gemeinsam/ablagepfad'
import { LEERER_TREFFER, type Zuordnung } from '../metadaten/zuordnung'
import type { RohDatei } from './auftrag'
export interface DiscAngaben {
staffel: number | null
discNr: number | null
}
export function rohOrdnerName(
zuordnung: Zuordnung | null,
angaben: DiscAngaben | null,
vorgangsName: string,
existiert: (name: string) => boolean,
): string {
if (zuordnung === null || zuordnung.titel.trim().length === 0) return vorgangsName
let basis: string
if (zuordnung.meta.typ === 'tv') {
basis = sichererName(zuordnung.titel)
if (basis.length > 0 && angaben?.staffel !== null && angaben?.staffel !== undefined) basis += ` - Staffel ${angaben.staffel}`
if (basis.length > 0 && angaben?.discNr !== null && angaben?.discNr !== undefined) basis += ` Disc ${angaben.discNr}`
} else {
basis = sichererName(zuordnung.titel, zuordnung.jahr)
}
if (basis.length === 0) return vorgangsName
if (!existiert(basis)) return basis
for (let n = 2; n < 100; n++) {
const kandidat = `${basis} (${n})`
if (!existiert(kandidat)) return kandidat
}
return vorgangsName
}
export interface FilmGruppe {
film: MetaVorschlag
dateien: RohDatei[]
}
export interface FilmGruppen {
/** Die Dateien des Hauptvorgangs (Disc-Zuordnung). */
primaer: RohDatei[]
/** Gesetzt, wenn KEIN Titel die Disc-Zuordnung behielt: Der Hauptvorgang
* übernimmt diesen Film. */
primaerFilm: MetaVorschlag | null
weitere: FilmGruppe[]
}
export function filmGruppen(dateien: readonly RohDatei[], discFilmId: number | null): FilmGruppen {
const primaer: RohDatei[] = []
const weitere: FilmGruppe[] = []
for (const d of dateien) {
if (d.film === undefined || (d.film.typ === 'movie' && d.film.tmdbId === discFilmId)) {
primaer.push(d)
continue
}
const gruppe = weitere.find((g) => g.film.tmdbId === d.film!.tmdbId && g.film.typ === d.film!.typ)
if (gruppe !== undefined) gruppe.dateien.push(d)
else weitere.push({ film: d.film, dateien: [d] })
}
if (primaer.length === 0 && weitere.length > 0) {
const erste = weitere.shift()!
return { primaer: erste.dateien, primaerFilm: erste.film, weitere }
}
return { primaer, primaerFilm: null, weitere }
}
/** Eine Zuordnung aus dem Vorschlag allein — wenn TMDb-Details nicht zu
* holen sind (kein Netz, kein Schlüssel). Vom Nutzer gewählt = sicher. */
export function zuordnungAusVorschlag(v: MetaVorschlag): Zuordnung {
return {
titel: v.titel,
jahr: v.jahr,
confidence: 1,
meta: { ...LEERER_TREFFER, typ: v.typ, id: v.tmdbId, titel: v.titel, jahr: v.jahr, posterPfad: v.posterPfad, quelle: 'tmdb' },
}
}
+90 -7
View File
@@ -16,9 +16,9 @@
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
// aus dem Abbild (iso:), das nicht mehr hängen kann.
import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { join } from 'node:path'
import { basename, join } from 'node:path'
import type {
BibliothekEintrag,
GeraeteInfo,
@@ -38,6 +38,7 @@ import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdtempSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os'
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
interface LaufenderVorgang {
/** Beendet den GANZEN Auftrag. */
@@ -99,6 +100,9 @@ export interface PipelineUmgebung {
/** 5.4.0: Der Rip ist durch — der Kern merkt sich den Vorgang und reiht
* die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */
vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number
/** 5.6.0: die volle Zuordnung zu einem TMDb-Vorschlag (zweiter Film einer
* Disc) — null, wenn nichts zu holen ist. */
zuordnungFuer?: (vorschlag: NonNullable<RohDatei['film']>) => Promise<Zuordnung | null>
/** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */
vorgangBeginnt?: (vorgang: VorgangBeginn) => void
/** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */
@@ -237,9 +241,21 @@ export class Pipeline {
}
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
const rohOrdner = join(this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh'), vorgangsName)
mkdirSync(rohOrdner, { recursive: true })
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
// behält seinen Zeitstempel-Namen als Kennung.
const rohWurzel = this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh')
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const startAngaben = this.umgebung.discAngaben?.(id) ?? null
const rohOrdner = join(
rohWurzel,
rohOrdnerName(
zuordnung,
{ staffel: optionen.staffel ?? startAngaben?.staffel ?? null, discNr: startAngaben?.discNr ?? null },
vorgangsName,
(name) => existsSync(join(rohWurzel, name)),
),
)
mkdirSync(rohOrdner, { recursive: true })
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
this.umgebung.vorgangBeginnt?.({
id: vorgangsName,
@@ -476,16 +492,77 @@ export class Pipeline {
groesseBytes: groesseVon(pfad),
audioSprachen: a?.audioSprachen ?? [],
untertitelSprachen: a?.untertitelSprachen ?? [],
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
...(a?.film !== undefined ? { film: a.film } : {}),
}
})
const angaben = this.umgebung.discAngaben?.(id) ?? null
// ── 5.6.0: Titel eines ANDEREN Films bekommen eigenen Roh-Ordner und
// Vorgang — jeder Film seinen Ordner (Commander, Double Feature). ──
const gruppen = filmGruppen(dateien, zuordnung !== null && zuordnung.meta.typ === 'movie' ? Number(zuordnung.meta.id) : null)
let hauptDateien = gruppen.primaer
let hauptZuordnung = zuordnung
if (gruppen.primaerFilm !== null) {
hauptZuordnung = (await this.umgebung.zuordnungFuer?.(gruppen.primaerFilm)) ?? zuordnungAusVorschlag(gruppen.primaerFilm)
}
const weitereAuftraege: Array<{ auftrag: Omit<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'> & Pick<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'>; titel: string }> = []
for (let n = 0; n < gruppen.weitere.length; n++) {
const gruppe = gruppen.weitere[n]
const gruppenZuordnung = (await this.umgebung.zuordnungFuer?.(gruppe.film)) ?? zuordnungAusVorschlag(gruppe.film)
const gruppenId = `${vorgangsName}-${n + 2}`
const gruppenOrdner = join(rohWurzel, rohOrdnerName(gruppenZuordnung, null, gruppenId, (name) => existsSync(join(rohWurzel, name))))
try {
mkdirSync(gruppenOrdner, { recursive: true })
for (const d of gruppe.dateien) {
const neu = join(gruppenOrdner, basename(d.pfad))
renameSync(d.pfad, neu)
d.pfad = neu
}
} catch (fehler) {
hinweise.push(`${gruppe.film.titel}" bekam keinen eigenen Roh-Ordner (${String(fehler)}) — die Titel bleiben beim ersten Film.`)
hauptDateien = [...hauptDateien, ...gruppe.dateien]
continue
}
this.umgebung.vorgangBeginnt?.({
id: gruppenId,
laufwerk: id,
typ: geraet.typ,
titel: gruppenZuordnung.titel,
jahr: gruppenZuordnung.jahr,
rohOrdner: gruppenOrdner,
begonnen: startZeit.toISOString(),
})
weitereAuftraege.push({
titel: gruppenZuordnung.titel,
auftrag: {
vorgangId: gruppenId,
laufwerk: id,
discTyp: geraet.typ,
rohOrdner: gruppenOrdner,
dateien: gruppe.dateien,
zuordnung: gruppenZuordnung,
staffel: null,
ersteFolge: null,
discNr: angaben?.discNr ?? null,
fingerprint: null,
lesefehler: lesefehlerGesamt,
hinweise: [],
begonnen: startZeit.toISOString(),
preset: '',
zielOrdner: '',
},
})
}
const hauptTitel = hauptZuordnung !== null && hauptZuordnung.titel.length > 0 ? hauptZuordnung.titel : anzeigeTitel
const kompression: KompressionsAuftrag = {
vorgangId: vorgangsName,
laufwerk: id,
discTyp: geraet.typ,
rohOrdner,
dateien,
zuordnung,
dateien: hauptDateien,
zuordnung: hauptZuordnung,
staffel: optionen.staffel ?? angaben?.staffel ?? null,
ersteFolge: optionen.ersteFolge ?? null,
discNr: angaben?.discNr ?? null,
@@ -496,7 +573,12 @@ export class Pipeline {
preset: '',
zielOrdner: '',
}
const platz = this.umgebung.vorgangEinreihen?.(kompression, anzeigeTitel) ?? 0
const platz = this.umgebung.vorgangEinreihen?.(kompression, hauptTitel) ?? 0
const weitereTitel: string[] = []
for (const w of weitereAuftraege) {
this.umgebung.vorgangEinreihen?.(w.auftrag, w.titel)
weitereTitel.push(w.titel)
}
const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0)
// Das Laufwerk ist FREI — die Kompression läuft ohne es weiter.
@@ -511,6 +593,7 @@ export class Pipeline {
(platz > 0
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
(weitereTitel.length > 0 ? ` Weitere${weitereTitel.length === 1 ? 'r' : ''} Film${weitereTitel.length === 1 ? '' : 'e'} mit eigenem Vorgang: ${weitereTitel.join(', ')}.` : '') +
(lesefehlerGesamt
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '') +
@@ -153,6 +153,14 @@ export class AutomatikDienst {
if (grund.length > 0) this.melden(id, false, 0, grund)
}
/** Der Kasten in der Kachel verschwindet — ein Rip hat begonnen (5.6.0):
* Countdown weg, Text weg. Ohne das stand „Automatik gestoppt — du
* entscheidest." noch unter dem fertigen Rip. */
leeren(id: string): void {
this.stopp(id, '')
if (this.staende.has(id)) this.melden(id, false, 0, '')
}
/** „Jetzt": den Countdown überspringen. */
jetzt(id: string): void {
const stand = this.staende.get(id)
@@ -36,6 +36,7 @@ export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
'discordWebhook',
'telegramToken',
'telegramChatId',
'design',
])
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
+34 -23
View File
@@ -238,32 +238,43 @@ export class ErkennungDienst {
}
}
/** Aus einem TMDb-Vorschlag die volle Zuordnung (Details holen) — für
* „Ändern …" und seit 5.6.0 für den zweiten Film einer Disc. Vom Nutzer
* gewählt = sicher (confidence 1). Ohne Details bleibt der Vorschlag. */
async zuordnungAus(vorschlag: MetaVorschlag): Promise<Zuordnung> {
const tmdb = this.tmdb()
let details = null
try {
details = vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
} catch (fehler) {
this.kontext.fehler(`TMDb-Details für „${vorschlag.titel}": ${String(fehler)} — Zuordnung ohne Details.`)
}
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,
}
return {
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
jahr: meta.jahr ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta,
}
}
async zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
try {
const tmdb = this.tmdb()
const details =
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 = {
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
jahr: meta.jahr ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta,
}
const zuordnung = await this.zuordnungAus(vorschlag)
this.zuordnungen.set(id, zuordnung)
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${meta.typ} ${String(meta.id)})`)
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${zuordnung.meta.typ} ${String(zuordnung.meta.id)})`)
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, zuordnung, 'deine Wahl') })
} catch (fehler) {
this.kontext.fehler(`Zuordnung übernehmen: ${String(fehler)}`)
+4 -1
View File
@@ -147,6 +147,7 @@ ripVerwaltung = new Pipeline({
zuordnung: (id) => erkennung.zuordnung(id),
discAngaben: (id) => erkennung.discAngaben(id),
fingerprint: (id) => erkennung.fingerprint(id),
zuordnungFuer: (vorschlag) => erkennung.zuordnungAus(vorschlag),
bibliothekEintragen: (eintrag) => vorgaenge.bibliothekEintragen(eintrag),
vorgangBeginnt: (v) => vorgaenge.vorgangBeginnt(v),
vorgangEinreihen: (auftrag, titel) => vorgaenge.vorgangEinreihen(auftrag, titel),
@@ -192,6 +193,8 @@ function ripMitPlatz(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftra
}
if (urteil.text.length > 0) kontext.hinweis(`Platz im Arbeitsordner: ${urteil.text}`)
}
// Der Rip beginnt — der Automatik-Kasten in der Kachel hat ausgedient.
automatik.leeren(id)
void laufwerke.bereit.then(() => ripVerwaltung.starten(id, auftrag, optionen))
}
@@ -229,7 +232,7 @@ function fensterNachricht(frage: unknown): void {
ripVerwaltung.ueberspringen(frage.id)
return
case 'rettung-start':
automatik.stopp(frage.id, '')
automatik.leeren(frage.id)
void laufwerke.bereit.then(() => ripVerwaltung.rettungStarten(frage.id))
return
case 'metadaten-suchen':
@@ -72,6 +72,20 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
// 5.6.0 ETA — Formatstring aus dem Binary gelesen (HandBrakeCLI 1.11.2,
// grep -a, 02.09.2026):
// Encoding: task %d of %d, %.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)
// Die Klammer fehlt in den ersten Sekunden (noch keine fps) — dann -1.
const HB_ETA = /ETA ([0-9]+)h([0-9]+)m([0-9]+)s/
/** Restzeit in Sekunden aus einer Encoding-Zeile, -1 ohne Angabe. */
export function etaAusZeile(zeile: string): number {
if (!HB_ENCODE.test(zeile.trim())) return -1
const t = HB_ETA.exec(zeile)
if (t === null) return -1
return Number(t[1]) * 3600 + Number(t[2]) * 60 + Number(t[3])
}
/** Encode-Fortschritt 0100 oder -1 („keine Angabe" — ein echtes 0 % muss
* von „nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
@@ -122,11 +136,12 @@ export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
export class HbAuswertung {
prozent = -1
restS = -1
falschesPreset = ''
falscherSchalter = ''
letzteZeilen: string[] = []
verarbeite(zeile: string): { prozent?: number } {
verarbeite(zeile: string): { prozent?: number; restS?: number } {
if (zeile.trim().length > 0) {
this.letzteZeilen.push(zeile)
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
@@ -134,9 +149,13 @@ export class HbAuswertung {
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
const p = progressAusZeile(zeile)
if (p >= 0 && p !== this.prozent) {
this.prozent = p
return { prozent: p }
if (p >= 0) {
const rest = etaAusZeile(zeile)
if (p !== this.prozent || (rest >= 0 && rest !== this.restS)) {
this.prozent = p
this.restS = rest
return rest >= 0 ? { prozent: p, restS: rest } : { prozent: p }
}
}
return {}
}
@@ -245,7 +264,8 @@ export interface HbLaufOptionen {
untertitelStandard?: boolean
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
extraArgs?: readonly string[]
aufFortschritt?: (prozent: number) => void
/** Fortschritt 0100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
aufFortschritt?: (prozent: number, restS: number) => void
abbruch?: AbortSignal
}
@@ -267,7 +287,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent, ergebnis.restS ?? -1)
}
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))