feat(v5): Etappe W-3 — Komprimieren + Ablegen, mit Vollbeweis
Ampel / ampel (push) Failing after 1m18s
Ampel / ampel (push) Failing after 1m18s
komprimieren/: handbrake.ts traegt ALLE bezahlten Fallen aus dem Docker-Zweig — --aencoder statt --audio-codec (mit Anti-Regressions- Wächter im Test: der alte Test hatte den Fehler FESTGESCHRIEBEN), --format aus der Endung (Container kommt sonst aus dem Preset), Fortschritt NUR aus der Encoding-Zeile (Scan-99%-Falle), letzte 12 Zeilen fuer den Fehlerfall, unknown-option-Erkennung VOR allem anderen (kommt mit Rueckgabewert 0!), Datei-daneben-Suche, CR-Zeilentrennung mit 0x81-Kodierungsfalle. encoder.ts misst statt behauptet (--help, --preset-list, Familien statt Sammelbegriff). presets.ts: je Disc-Typ, PRESET_KEINE, Sprachlisten. ablage/: struktur.ts (sichere Namen, '<Titel> (Jahr)', Season NN, Episoden-Zuordnung NUR bei Eindeutigkeit), nfo.ts (Kodi-Schema), medienserver.ts (Jellyfin/Emby/Kodi-Refresh, wirft nie). ablauf/pipeline.ts ERSETZT rippen.ts: Rip → Kompression → Ablage als EINE Kette; Kompressions-Fehler laesst den verlustfreien Rip STEHEN und nennt den Ort; Lesefehler ueberleben bis in die Fertig-Meldung. Roh wird in dieser Etappe grundsaetzlich behalten (Loeschen erst mit W-5-UI). GEMESSEN am echten System (30.08.2026): - Encoder: 22 Stueck, Backends cpu-x264/x265/av1 + vce + vce-av1 (RX 9070 XT als VCE-Familie — nicht unter Intel-Namen), 107 Presets. - Probe-Encode 5 s Material: success in 45,5 s. - VOLLBEWEIS: Spartacus-Rohschnitt (17,71 GB — die Datei, die in rc11 am falschen Schalter starb) mit Preset 'H.265 VCN 1080p' in 2,1 min auf 1,17 GB komprimiert und nach Jellyfin-Schema abgelegt: E:\Rippy-v5-Beweis\Filme\Spartacus - Gods of the Arena - Disc 2 (2011)\ mit movie.nfo. - Nebenbefund: Der Evangelion-Roh-Rip vom 29.08. traegt 'This file was not properly finalized' im Kopf — 23,5 GB, die wie fertig aussehen und es nicht sind (der abgebrochene rc11-Rip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ca338ad467
commit
3dc296f071
@@ -58,8 +58,10 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
||||
|
||||
function LaufwerkKachel({ geraet, rip }: { geraet: GeraeteInfo; rip: RipStatus | undefined }) {
|
||||
const ripLaeuft = rip !== undefined && rip.phase === 'rippt'
|
||||
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
|
||||
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
|
||||
const punktFarbe = ripLaeuft
|
||||
? 'animate-pulse bg-sky-400'
|
||||
|
||||
@@ -39,7 +39,14 @@ export type WacheEreignis =
|
||||
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
|
||||
|
||||
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
|
||||
export type RipPhase = 'liest-info' | 'rippt' | 'fertig' | 'fehler' | 'abgebrochen'
|
||||
export type RipPhase =
|
||||
| 'liest-info'
|
||||
| 'rippt'
|
||||
| 'komprimiert'
|
||||
| 'legt-ab'
|
||||
| 'fertig'
|
||||
| 'fehler'
|
||||
| 'abgebrochen'
|
||||
|
||||
export interface RipStatus {
|
||||
/** Laufwerks-Kennung, auf der der Rip läuft. */
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Bibliotheks-Refresh des Medienservers anstoßen (KONZEPT § 6.10) —
|
||||
// Wege aus medien.py: Jellyfin/Emby per POST /Library/Refresh mit
|
||||
// X-Emby-Token; Kodi per JSON-RPC VideoLibrary.Scan mit Basic-Auth
|
||||
// (api_key = "user:passwort"). Wirft NIE — der Refresh ist Komfort,
|
||||
// nicht Teil des Rips (R4: das Ergebnis wird trotzdem GEMELDET).
|
||||
|
||||
export type MedienServer = 'jellyfin' | 'emby' | 'kodi' | 'plex' | 'none'
|
||||
|
||||
export async function bibliothekRefresh(
|
||||
server: string,
|
||||
url: string,
|
||||
apiKey: string,
|
||||
holen: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
if (url.length === 0 || !['jellyfin', 'emby', 'kodi'].includes(server)) return ''
|
||||
try {
|
||||
if ((server === 'jellyfin' || server === 'emby') && apiKey.length > 0) {
|
||||
const antwort = await holen(url.replace(/\/+$/, '') + '/Library/Refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Emby-Token': apiKey },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (antwort.status < 300) {
|
||||
return `${server[0].toUpperCase()}${server.slice(1)}-Bibliothek aktualisiert (${url})`
|
||||
}
|
||||
return `Bibliotheks-Refresh: HTTP ${antwort.status} von ${url}`
|
||||
}
|
||||
if (server === 'kodi') {
|
||||
const kopf: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey.includes(':')) {
|
||||
kopf['Authorization'] = 'Basic ' + Buffer.from(apiKey).toString('base64')
|
||||
}
|
||||
const antwort = await holen(url.replace(/\/+$/, '') + '/jsonrpc', {
|
||||
method: 'POST',
|
||||
headers: kopf,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1 }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (antwort.status < 300) return `Kodi-Bibliothek aktualisiert (${url})`
|
||||
return `Kodi-Refresh: HTTP ${antwort.status} von ${url}`
|
||||
}
|
||||
} catch (fehler) {
|
||||
return `Bibliotheks-Refresh fehlgeschlagen: ${String(fehler)}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// movie.nfo/tvshow.nfo im Kodi-Schema (kodi.wiki/view/NFO_files) —
|
||||
// Jellyfin, Emby und Kodi lesen es nativ; Plex bekommt nur die Benennung
|
||||
// (liest NFO ohne Zusatz-Agent nicht). Regeln aus medien.py.
|
||||
|
||||
export const NFO_SERVER = ['jellyfin', 'emby', 'kodi']
|
||||
|
||||
export interface NfoMeta {
|
||||
typ?: 'film' | 'serie'
|
||||
beschreibung?: string
|
||||
genres?: readonly string[]
|
||||
laufzeitMinuten?: number
|
||||
quelle?: string
|
||||
}
|
||||
|
||||
function xml(wert: string): string {
|
||||
return wert
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
|
||||
/** Minimales NFO (pur, testbar). */
|
||||
export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | null): string {
|
||||
const wurzel = meta.typ === 'serie' ? 'tvshow' : 'movie'
|
||||
const zeilen = [
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||
`<${wurzel}>`,
|
||||
` <title>${xml(titel ?? '')}</title>`,
|
||||
]
|
||||
if (jahr !== undefined && jahr !== null && String(jahr).length > 0) {
|
||||
zeilen.push(` <year>${xml(String(jahr))}</year>`)
|
||||
}
|
||||
if (meta.beschreibung !== undefined && meta.beschreibung.length > 0) {
|
||||
zeilen.push(` <plot>${xml(meta.beschreibung)}</plot>`)
|
||||
}
|
||||
for (const genre of meta.genres ?? []) {
|
||||
zeilen.push(` <genre>${xml(String(genre))}</genre>`)
|
||||
}
|
||||
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
|
||||
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
|
||||
}
|
||||
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
|
||||
zeilen.push(`</${wurzel}>`)
|
||||
return zeilen.join('\n') + '\n'
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus
|
||||
// medien.py: Ordner heißt „<Titel> (Jahr)" (daran erkennen die Server den
|
||||
// Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien
|
||||
// liegen als <Serie>/Season NN. Episoden werden NUR bei EINDEUTIGER
|
||||
// Zuordnung umbenannt — lieber gar nicht als falsch (ARMs offene Wunde
|
||||
// #395 war genau das Ratespiel).
|
||||
import { existsSync, readdirSync, renameSync } from 'node:fs'
|
||||
import { win32 as pfad } from 'node:path'
|
||||
|
||||
const { join } = pfad
|
||||
|
||||
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
|
||||
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
|
||||
|
||||
/** Titel → dateisystem-tauglicher Ordnername (pur). */
|
||||
export function sichererName(titel: string, jahr?: number | string | null): string {
|
||||
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
|
||||
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
|
||||
if (name.length === 0) return ''
|
||||
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
|
||||
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
|
||||
name = `${name} (${jahr})`
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
|
||||
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
|
||||
export function zielordner(
|
||||
basis: string,
|
||||
titel: string,
|
||||
jahr: number | string | null,
|
||||
jobId: string,
|
||||
existiert: (p: string) => boolean = existsSync,
|
||||
): string {
|
||||
const name = sichererName(titel, jahr)
|
||||
if (name.length === 0) return join(basis, jobId)
|
||||
const ziel = join(basis, name)
|
||||
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`)
|
||||
return ziel
|
||||
}
|
||||
|
||||
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
|
||||
export function serienOrdner(basis: string, serie: string, staffel: number): string {
|
||||
const name = sichererName(serie) || 'Unbekannte Serie'
|
||||
return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
/** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.
|
||||
* Rückgabe: Episoden-Nummern je Datei, oder null wenn NICHT eindeutig.
|
||||
*
|
||||
* Stufe 1: komplette Staffel (Dateizahl == Episodenzahl) → 1:1 in
|
||||
* Reihenfolge. Stufe 2: Einzel-Zuordnung über distinkte Laufzeiten;
|
||||
* passt eine Datei auf mehrere Episoden ähnlich gut → null. */
|
||||
export function matcheEpisoden(
|
||||
dateiSekunden: readonly number[],
|
||||
episoden: ReadonlyArray<readonly [nummer: number, laufzeitS: number]>,
|
||||
toleranzProzent = 8.0,
|
||||
toleranzMinS = 180,
|
||||
): number[] | null {
|
||||
if (dateiSekunden.length === 0 || episoden.length === 0) return null
|
||||
|
||||
const toleranz = (dauer: number): number => Math.max(toleranzMinS, (dauer * toleranzProzent) / 100)
|
||||
|
||||
if (dateiSekunden.length === episoden.length) {
|
||||
const passt = dateiSekunden.every((dauer, i) => {
|
||||
const epDauer = episoden[i][1]
|
||||
return epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer)
|
||||
})
|
||||
if (passt) return episoden.map(([nr]) => nr)
|
||||
}
|
||||
|
||||
const zuordnung: number[] = []
|
||||
let start = 0
|
||||
for (const dauer of dateiSekunden) {
|
||||
const kandidaten = episoden
|
||||
.slice(start)
|
||||
.filter(([, epDauer]) => epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer))
|
||||
if (kandidaten.length === 0) return null
|
||||
let gewaehlt = kandidaten[0]
|
||||
if (kandidaten.length > 1) {
|
||||
const sortiert = [...kandidaten].sort((a, b) => Math.abs(dauer - a[1]) - Math.abs(dauer - b[1]))
|
||||
// Nur eindeutig, wenn der beste KLAR besser passt (doppelt so nah).
|
||||
if (!(Math.abs(dauer - sortiert[0][1]) * 2 < Math.abs(dauer - sortiert[1][1]))) return null
|
||||
gewaehlt = sortiert[0]
|
||||
}
|
||||
zuordnung.push(gewaehlt[0])
|
||||
start = episoden.findIndex(([nr]) => nr === gewaehlt[0]) + 1
|
||||
}
|
||||
return zuordnung
|
||||
}
|
||||
|
||||
/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge =
|
||||
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */
|
||||
export function episodenUmbenennen(
|
||||
ordner: string,
|
||||
serie: string,
|
||||
staffel: number,
|
||||
zuordnung: readonly number[],
|
||||
fs: {
|
||||
listen: (o: string) => string[]
|
||||
existiert: (p: string) => boolean
|
||||
umbenennen: (von: string, nach: string) => void
|
||||
} = {
|
||||
listen: (o) => readdirSync(o),
|
||||
existiert: existsSync,
|
||||
umbenennen: renameSync,
|
||||
},
|
||||
): string[] {
|
||||
const meldungen: string[] = []
|
||||
const dateien = fs
|
||||
.listen(ordner)
|
||||
.filter((f) => f.endsWith('.mkv'))
|
||||
.sort()
|
||||
if (dateien.length !== zuordnung.length) {
|
||||
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
|
||||
}
|
||||
const serienName = sichererName(serie) || 'Serie'
|
||||
for (let i = 0; i < dateien.length; i++) {
|
||||
const alt = dateien[i]
|
||||
const neu = `${serienName} S${String(Math.trunc(staffel)).padStart(2, '0')}E${String(Math.trunc(zuordnung[i])).padStart(2, '0')}.mkv`
|
||||
if (alt === neu) continue
|
||||
const ziel = join(ordner, neu)
|
||||
if (fs.existiert(ziel)) {
|
||||
meldungen.push(`Umbenennen übersprungen (${neu} existiert schon)`)
|
||||
continue
|
||||
}
|
||||
fs.umbenennen(join(ordner, alt), ziel)
|
||||
meldungen.push(`${alt} → ${neu}`)
|
||||
}
|
||||
return meldungen
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage.
|
||||
// EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über
|
||||
// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und
|
||||
// hängen damit an der Prozess-Leine (R6).
|
||||
//
|
||||
// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein
|
||||
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in
|
||||
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
|
||||
// Einstellung im UI (W-5) eine Stimme.
|
||||
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { win32 as pfad } from 'node:path'
|
||||
import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
import { zielordner } from '../ablage/struktur'
|
||||
import { komprimieren } from '../komprimieren/handbrake'
|
||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||
import { rippen } from '../rip/makemkv'
|
||||
|
||||
const { basename, join } = pfad
|
||||
|
||||
interface LaufenderVorgang {
|
||||
abbruch: AbortController
|
||||
status: RipStatus
|
||||
}
|
||||
|
||||
export interface PipelineUmgebung {
|
||||
/** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */
|
||||
makemkvcon: () => string
|
||||
/** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */
|
||||
handbrake: () => string
|
||||
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
||||
geraet: (id: string) => GeraeteInfo | null
|
||||
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
|
||||
ablage: () => string
|
||||
einstellungen: () => Einstellungen
|
||||
melden: (status: RipStatus) => void
|
||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||
jetzt?: () => Date
|
||||
}
|
||||
|
||||
export class Pipeline {
|
||||
private laufend = new Map<string, LaufenderVorgang>()
|
||||
|
||||
constructor(private readonly umgebung: PipelineUmgebung) {}
|
||||
|
||||
laeuft(id: string): boolean {
|
||||
return this.laufend.has(id)
|
||||
}
|
||||
|
||||
private melden(status: RipStatus): void {
|
||||
const eintrag = this.laufend.get(status.id)
|
||||
if (eintrag !== undefined) eintrag.status = status
|
||||
this.umgebung.melden(status)
|
||||
}
|
||||
|
||||
private fehler(id: string, text: string): RipStatus {
|
||||
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
||||
}
|
||||
|
||||
/** Startet die Kette für die ganze Disc ('all'). */
|
||||
async starten(id: string): Promise<void> {
|
||||
if (this.laufend.has(id)) {
|
||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||
return
|
||||
}
|
||||
|
||||
const programm = this.umgebung.makemkvcon()
|
||||
if (programm.length === 0) {
|
||||
this.umgebung.melden(
|
||||
this.fehler(
|
||||
id,
|
||||
'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)',
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const geraet = this.umgebung.geraet(id)
|
||||
if (geraet === null) {
|
||||
this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`))
|
||||
return
|
||||
}
|
||||
// Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt
|
||||
// einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der
|
||||
// Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf
|
||||
// nie zu „es geht nicht" werden.
|
||||
if (geraet.status === 'empty') {
|
||||
this.umgebung.melden(
|
||||
this.fehler(
|
||||
id,
|
||||
`Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const abbruch = new AbortController()
|
||||
const zeit = (this.umgebung.jetzt?.() ?? new Date()).toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
|
||||
const vorgangsName = `${geraet.typ}-${zeit}`
|
||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||
|
||||
const start: RipStatus = {
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rippe verlustfrei nach ${rohOrdner} …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
}
|
||||
this.laufend.set(id, { abbruch, status: start })
|
||||
this.melden(start)
|
||||
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: 'all',
|
||||
abbruch: abbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, prozent })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
})
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||
return
|
||||
}
|
||||
if (rip.status === 'error') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
|
||||
return
|
||||
}
|
||||
|
||||
const lesefehlerHinweis = rip.lesefehler
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: ''
|
||||
|
||||
// ── Kompression (W-3) ────────────────────────────────────────────────
|
||||
const einstellungen = this.umgebung.einstellungen()
|
||||
const handbrake = this.umgebung.handbrake()
|
||||
const soll = komprimierenFuer(geraet.typ, einstellungen)
|
||||
|
||||
if (!soll || handbrake.length === 0) {
|
||||
const grund = !soll
|
||||
? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.`
|
||||
: 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.'
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
text: `Fertig. ${grund}${lesefehlerHinweis}`,
|
||||
dateien: rip.dateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const preset = presetFuer(geraet.typ, einstellungen)
|
||||
const fertigOrdner = zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
||||
mkdirSync(fertigOrdner, { recursive: true })
|
||||
|
||||
const fertigeDateien: string[] = []
|
||||
for (let i = 0; i < rip.dateien.length; i++) {
|
||||
const eingabe = rip.dateien[i]
|
||||
const ausgabe = join(fertigOrdner, basename(eingabe))
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) {
|
||||
this.melden({
|
||||
...alt,
|
||||
phase: 'komprimiert',
|
||||
prozent: -1,
|
||||
text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`,
|
||||
})
|
||||
}
|
||||
const encode = await komprimieren({
|
||||
programm: handbrake,
|
||||
eingabe,
|
||||
ausgabe,
|
||||
preset,
|
||||
audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
|
||||
untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
|
||||
abbruch: abbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const stand = this.laufend.get(id)?.status
|
||||
if (stand !== undefined) {
|
||||
this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) })
|
||||
}
|
||||
},
|
||||
})
|
||||
if (encode.status === 'cancelled') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'abgebrochen',
|
||||
prozent: -1,
|
||||
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
|
||||
dateien: rip.dateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (encode.status === 'error') {
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fehler',
|
||||
prozent: -1,
|
||||
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`,
|
||||
dateien: rip.dateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
return
|
||||
}
|
||||
fertigeDateien.push(encode.ausgabe)
|
||||
}
|
||||
|
||||
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) {
|
||||
this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien })
|
||||
}
|
||||
try {
|
||||
writeFileSync(join(fertigOrdner, 'movie.nfo'), baueNfo({ quelle: 'Rippy v5' }, vorgangsName, null))
|
||||
} catch (fehler) {
|
||||
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
|
||||
// wird GESAGT (R4).
|
||||
const stand = this.laufend.get(id)?.status
|
||||
if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` })
|
||||
}
|
||||
|
||||
const server = String(einstellungen['mediaServer'] ?? '')
|
||||
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
|
||||
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
|
||||
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
|
||||
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
text:
|
||||
`Fertig — komprimiert nach ${fertigOrdner}.` +
|
||||
(refresh.length > 0 ? ` ${refresh}` : '') +
|
||||
lesefehlerHinweis,
|
||||
dateien: fertigeDateien,
|
||||
lesefehler: rip.lesefehler,
|
||||
})
|
||||
}
|
||||
|
||||
abbrechen(id: string): void {
|
||||
this.laufend.get(id)?.abbruch.abort()
|
||||
}
|
||||
}
|
||||
|
||||
// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon
|
||||
// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken.
|
||||
export function kopierenStattEncode(eingabe: string, ausgabe: string): void {
|
||||
copyFileSync(eingabe, ausgabe)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// Rip-Verwaltung (W-2) — die schlanke Vorstufe der Pipeline aus § 4.2:
|
||||
// EIN Rip je Laufwerk, Status-Meldungen nach draußen, Abbruch über
|
||||
// AbortController. makemkvcon läuft als Kind dieses Kerns und hängt damit
|
||||
// automatisch an der Prozess-Leine (R6): Der Main-Prozess hat die
|
||||
// Arbeitsgruppe VOR dem Kern-Start angelegt, Kinder erben die
|
||||
// Mitgliedschaft.
|
||||
import { win32 as pfad } from 'node:path'
|
||||
import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||
|
||||
const { join } = pfad
|
||||
import { rippen } from '../rip/makemkv'
|
||||
|
||||
interface LaufenderRip {
|
||||
abbruch: AbortController
|
||||
status: RipStatus
|
||||
}
|
||||
|
||||
export interface RipUmgebung {
|
||||
/** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */
|
||||
makemkvcon: () => string
|
||||
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
|
||||
geraet: (id: string) => GeraeteInfo | null
|
||||
/** Wohin gerippt wird (Wurzel der Ablage). */
|
||||
ablage: () => string
|
||||
melden: (status: RipStatus) => void
|
||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||
jetzt?: () => Date
|
||||
}
|
||||
|
||||
export class RipVerwaltung {
|
||||
private laufend = new Map<string, LaufenderRip>()
|
||||
|
||||
constructor(private readonly umgebung: RipUmgebung) {}
|
||||
|
||||
laeuft(id: string): boolean {
|
||||
return this.laufend.has(id)
|
||||
}
|
||||
|
||||
status(id: string): RipStatus | null {
|
||||
return this.laufend.get(id)?.status ?? null
|
||||
}
|
||||
|
||||
private melden(status: RipStatus): void {
|
||||
const eintrag = this.laufend.get(status.id)
|
||||
if (eintrag !== undefined) eintrag.status = status
|
||||
this.umgebung.melden(status)
|
||||
}
|
||||
|
||||
/** Startet einen verlustfreien Rip der ganzen Disc ('all'). */
|
||||
async starten(id: string): Promise<void> {
|
||||
if (this.laufend.has(id)) {
|
||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Rip.`))
|
||||
return
|
||||
}
|
||||
|
||||
const programm = this.umgebung.makemkvcon()
|
||||
if (programm.length === 0) {
|
||||
this.umgebung.melden(
|
||||
this.fehler(id, 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)'),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const geraet = this.umgebung.geraet(id)
|
||||
if (geraet === null) {
|
||||
this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`))
|
||||
return
|
||||
}
|
||||
// Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt
|
||||
// einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der
|
||||
// Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf
|
||||
// nie zu „es geht nicht" werden.
|
||||
if (geraet.status === 'empty') {
|
||||
this.umgebung.melden(
|
||||
this.fehler(id, `Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const abbruch = new AbortController()
|
||||
const zeit = (this.umgebung.jetzt?.() ?? new Date())
|
||||
.toISOString()
|
||||
.replaceAll(/[:.]/g, '-')
|
||||
.slice(0, 19)
|
||||
const zielOrdner = join(this.umgebung.ablage(), 'roh', `${geraet.typ}-${zeit}`)
|
||||
|
||||
const start: RipStatus = {
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rippe verlustfrei nach ${zielOrdner} …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
}
|
||||
this.laufend.set(id, { abbruch, status: start })
|
||||
this.melden(start)
|
||||
|
||||
const ergebnis = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
zielOrdner,
|
||||
titel: 'all',
|
||||
abbruch: abbruch.signal,
|
||||
aufFortschritt: (prozent) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt === undefined) return
|
||||
this.melden({ ...alt, prozent })
|
||||
},
|
||||
aufMeldung: (_code, text) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt === undefined) return
|
||||
this.melden({ ...alt, text })
|
||||
},
|
||||
})
|
||||
|
||||
this.laufend.delete(id)
|
||||
if (ergebnis.status === 'success') {
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
// Lesefehler AUCH bei Erfolg nennen — genau dann gehen sie sonst
|
||||
// unter (Rückgabewert 0, ein Titel fehlt trotzdem).
|
||||
text: ergebnis.lesefehler
|
||||
? '⚠ Fertig, aber die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: 'Fertig — verlustfrei gesichert.',
|
||||
dateien: ergebnis.dateien,
|
||||
lesefehler: ergebnis.lesefehler,
|
||||
})
|
||||
} else if (ergebnis.status === 'cancelled') {
|
||||
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
|
||||
} else {
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'fehler',
|
||||
prozent: -1,
|
||||
text: ergebnis.fehler,
|
||||
dateien: [],
|
||||
lesefehler: ergebnis.lesefehler,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
abbrechen(id: string): void {
|
||||
this.laufend.get(id)?.abbruch.abort()
|
||||
}
|
||||
|
||||
private fehler(id: string, text: string): RipStatus {
|
||||
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type DbStatus,
|
||||
type KernNachricht,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
import { RipVerwaltung } from './ablauf/rippen'
|
||||
import { Pipeline } from './ablauf/pipeline'
|
||||
import type { LaufwerkApi } from './laufwerk/codes'
|
||||
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
|
||||
import { DiscWache } from './laufwerk/wache'
|
||||
@@ -113,14 +113,40 @@ function ablageOrdner(): string {
|
||||
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
|
||||
}
|
||||
|
||||
const ripVerwaltung = new RipVerwaltung({
|
||||
// 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.
|
||||
const PIPELINE_EINSTELLUNGEN = [
|
||||
'transcodeEnabled',
|
||||
'transcodePreset',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
] as const
|
||||
|
||||
function einstellungenLaden(): Record<string, string> {
|
||||
const werte: Record<string, string> = {}
|
||||
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
|
||||
const wert = db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
}
|
||||
return werte
|
||||
}
|
||||
|
||||
const ripVerwaltung = new Pipeline({
|
||||
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
|
||||
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
|
||||
geraet: (id) => {
|
||||
if (laufwerkApi === null) return null
|
||||
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
|
||||
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
|
||||
},
|
||||
ablage: ablageOrdner,
|
||||
einstellungen: einstellungenLaden,
|
||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Encoder-Fähigkeiten — GEMESSEN, nicht behauptet (KONZEPT § 4.2,
|
||||
// Regeln aus caps.py, dort am echten HandBrake abgenommen):
|
||||
//
|
||||
// - `--help`, Abschnitt `-e, --encoder`: NUR was DIESE Maschine wirklich
|
||||
// benutzen kann (HandBrake probiert Hardware beim Start selbst an).
|
||||
// - `--preset-list`: ALLE mitgelieferten Preset-NAMEN (Struktur ist die
|
||||
// Einrückung — Namen stehen mit genau vier Leerzeichen).
|
||||
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
|
||||
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
|
||||
import { spawn } from 'node:child_process'
|
||||
import { textVon } from '../rip/parser'
|
||||
|
||||
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
|
||||
|
||||
/** Video-Encoder aus `--help` — der Block endet an der nächsten Option. */
|
||||
export function parseEncoderListe(hilfeText: string): string[] {
|
||||
const encoder: string[] = []
|
||||
let imBlock = false
|
||||
for (const zeile of (hilfeText ?? '').split(/\r?\n/)) {
|
||||
const inhalt = zeile.trim()
|
||||
if (!imBlock) {
|
||||
if (HB_ENCODER_KOPF.test(inhalt)) imBlock = true
|
||||
continue
|
||||
}
|
||||
if (inhalt.length === 0) continue
|
||||
if (inhalt.startsWith('-')) break
|
||||
encoder.push(inhalt.split(/\s+/)[0])
|
||||
}
|
||||
return encoder
|
||||
}
|
||||
|
||||
// Reihenfolge = Anzeige-Reihenfolge; AV1 in Hardware sichtbar machen.
|
||||
const HW_FAMILIEN: ReadonlyArray<readonly [string, string]> = [
|
||||
['nvenc', 'nvenc'],
|
||||
['qsv', 'qsv'],
|
||||
['vce', 'vce'],
|
||||
['vaapi', 'vaapi'],
|
||||
]
|
||||
|
||||
/** HandBrake-Encoder-Namen → Backend-Kennungen fürs UI. */
|
||||
export function leiteBackendsAb(hbEncoder: readonly string[]): string[] {
|
||||
const namen = new Set(hbEncoder.map((e) => e.toLowerCase()))
|
||||
const gefunden: string[] = []
|
||||
if ([...namen].some((n) => n.startsWith('x264'))) gefunden.push('cpu-x264')
|
||||
if ([...namen].some((n) => n.startsWith('x265'))) gefunden.push('cpu-x265')
|
||||
if ([...namen].some((n) => n.startsWith('svt_av1'))) gefunden.push('cpu-av1')
|
||||
for (const [kennzeichen, backend] of HW_FAMILIEN) {
|
||||
const treffer = [...namen].filter((n) => n.includes(kennzeichen))
|
||||
if (treffer.length === 0) continue
|
||||
gefunden.push(backend)
|
||||
if (treffer.some((n) => n.includes('av1'))) gefunden.push(`${backend}-av1`)
|
||||
}
|
||||
return gefunden
|
||||
}
|
||||
|
||||
/** Preset-Namen aus `--preset-list`: genau vier Leerzeichen Einrückung.
|
||||
* Kategorien (0), Beschreibungen (8) und Logzeilen fallen so weg. */
|
||||
export function parsePresetListe(text: string): string[] {
|
||||
const presets: string[] = []
|
||||
for (const zeile of (text ?? '').split(/\r?\n/)) {
|
||||
if (zeile.trim().length === 0) continue
|
||||
const einrueckung = zeile.length - zeile.replace(/^ +/, '').length
|
||||
if (einrueckung === 4) presets.push(zeile.trim())
|
||||
}
|
||||
return presets
|
||||
}
|
||||
|
||||
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
|
||||
return new Promise((fertig) => {
|
||||
const kind = spawn(programm, args, { 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('')
|
||||
})
|
||||
kind.on('close', () => {
|
||||
clearTimeout(zeiger)
|
||||
fertig(textVon(Buffer.concat(stuecke)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export interface EncoderAuskunft {
|
||||
encoder: string[]
|
||||
backends: string[]
|
||||
presets: string[]
|
||||
}
|
||||
|
||||
/** Fragt HandBrake selbst — leer, wenn es nicht da ist (kein Raten). */
|
||||
export async function encoderMessen(programm: string): Promise<EncoderAuskunft> {
|
||||
if (programm.length === 0) return { encoder: [], backends: [], presets: [] }
|
||||
const [hilfe, presetListe] = await Promise.all([
|
||||
lauf(programm, ['--help'], 30_000),
|
||||
lauf(programm, ['--preset-list'], 30_000),
|
||||
])
|
||||
const encoder = parseEncoderListe(hilfe)
|
||||
return { encoder, backends: leiteBackendsAb(encoder), presets: parsePresetListe(presetListe) }
|
||||
}
|
||||
Reference in New Issue
Block a user