feat(v5): 5.5.0 — Kern und Oberfläche aufgeteilt, Übersicht und Einstellungen neu, Erst-Einrichtung mit Poster-Kulisse
Commander-Wünsche vom 02.09.2026 vor der Freigabe von 5.4.0 (die deshalb nie veröffentlicht wurde): Kern: kern/index.ts ist nur noch Bootstrap und Router; Dienste in kern/dienste/ (Einstellungen, Werkzeuge, Laufwerke, Erkennung, Vorgänge, Speicher, Benachrichtigung, Automatik), Kontext in kern/kontext.ts, Protokoll-Datei in gemeinsam/protokoll.ts, Platz-Rechnung in gemeinsam/platz.ts. Oberfläche: fenster/App.tsx ist nur noch der Rahmen; bausteine.tsx, dialoge/, uebersicht/ (LaufwerkKachel, KompressionsKarte, SpeicherKarte, VerlaufKarte), einstellungen/ (Karten je Funktion, SprachAuswahl), einrichtung/ (ErstEinrichtung, PosterHintergrund). Neu: Bibliothek weg, dafür Verlauf; Speicher-und-Bilanz-Karte (statfs); LED-Fußleiste; MakeMKV-Kopf in die Einstellungen; Sprach-Chips mit Dropdown statt Allgemeinem Preset; Karten Automatik/Benachrichtigung/Allgemein/Update; Durchsuchen bei Werkzeugen; Arbeitsordner lokal (Encode dort, dann in die Ablage); Platz-Prüfung vor dem Rip (Dialog und Kern); Automatik mit Countdown; Serien-Merker über Discs; Rettungs-Abbild mit Nullen nur nach gesehenen Lesefehlern (kern/rip/rettung.ts, Phase rettet, iso:-Quelle); Warteschlange pausieren; Discord/Telegram-Benachrichtigung; Protokoll-Datei je Tag; Fensterlage 80 % mittig, gemerkt; Erst-Einrichtung in acht Schritten vor dem Poster-Grid. Beweise: 413 Tests (neu: speicher, benachrichtigung, automatik, rettung, fensterlage, protokoll, kleinkram-550), drei Typprüfungen, Klick-Beweis mit 29 Schritten inklusive Erst-Einrichtung (zweiter Start mit RIPPY_START_BEREICH=einrichtung), Bilder beweise/klick/01…09. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6678c81592
commit
3dc6798c79
@@ -17,12 +17,16 @@
|
||||
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
|
||||
// TMDb gibt dann nur noch eine Warnung.
|
||||
// * Durchgehender Fortschritt, nach Dateigröße gewichtet.
|
||||
// * 5.5.0 Arbeitsordner: Liegt die Ablage auf dem Server, rechnet HandBrake
|
||||
// LOKAL (Arbeitsordner\fertig\<Vorgang>) und erst das Ergebnis wandert
|
||||
// über das Netz — kein Encode gegen eine SMB-Freigabe.
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
@@ -50,6 +54,9 @@ export interface AblageUmgebung {
|
||||
handbrake: () => string
|
||||
/** Wurzel der Ablage. */
|
||||
ablage: () => string
|
||||
/** 5.5.0: eigener lokaler Arbeitsordner — '' wenn keiner (dann wird
|
||||
* direkt in die Ablage gerechnet, wie bisher). */
|
||||
arbeitsordner?: () => string
|
||||
einstellungen: () => Einstellungen
|
||||
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
|
||||
presetEmpfehlung?: (discTyp: string) => string
|
||||
@@ -60,7 +67,7 @@ export interface AblageUmgebung {
|
||||
) => Promise<ReadonlyArray<readonly [number, number]>>
|
||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||
jetzt?: () => Date
|
||||
/** Roh-Datei ins Ziel verschieben — injizierbar für Tests. */
|
||||
/** Datei verschieben — injizierbar für Tests. */
|
||||
verschieben?: (von: string, nach: string) => void
|
||||
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
|
||||
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
|
||||
@@ -75,6 +82,9 @@ export interface AblageErgebnis {
|
||||
preset: string
|
||||
fertigBytes: number
|
||||
hinweise: string[]
|
||||
/** 5.5.0: Welche Folgen-Nummern vergeben wurden (leer bei Filmen oder ohne
|
||||
* Zuordnung) — der Serien-Merker zählt daran weiter. */
|
||||
folgenVergeben: number[]
|
||||
}
|
||||
|
||||
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text'>) => void
|
||||
@@ -119,6 +129,17 @@ export async function komprimierenUndAblegen(
|
||||
const zuordnung = auftrag.zuordnung
|
||||
const hinweise: string[] = [...auftrag.hinweise]
|
||||
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad))
|
||||
const fehlschlag = (text: string, statusPreset: string, ist: boolean): AblageErgebnis => ({
|
||||
status: 'fehler',
|
||||
zielOrdner: fertigOrdner,
|
||||
dateien: [],
|
||||
text,
|
||||
komprimiert: ist,
|
||||
preset: statusPreset,
|
||||
fertigBytes: 0,
|
||||
hinweise,
|
||||
folgenVergeben: [],
|
||||
})
|
||||
|
||||
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
|
||||
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
|
||||
@@ -135,7 +156,11 @@ export async function komprimierenUndAblegen(
|
||||
: zuordnung !== null && zuordnung.titel.length > 0
|
||||
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
|
||||
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
|
||||
mkdirSync(fertigOrdner, { recursive: true })
|
||||
try {
|
||||
mkdirSync(fertigOrdner, { recursive: true })
|
||||
} catch (fehler) {
|
||||
return fehlschlag(`Zielordner nicht anlegbar (${fertigOrdner}): ${String(fehler)} — ist die Ablage erreichbar?`, '', komprimiert)
|
||||
}
|
||||
|
||||
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
|
||||
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
|
||||
@@ -143,6 +168,12 @@ export async function komprimierenUndAblegen(
|
||||
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
|
||||
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true })
|
||||
|
||||
// 5.5.0: Eigener Arbeitsordner → HandBrake schreibt LOKAL, danach wird
|
||||
// verschoben. Ohne ihn wie bisher direkt ins Ziel.
|
||||
const arbeit = (umgebung.arbeitsordner?.() ?? '').trim()
|
||||
const zwischen = komprimiert && arbeit.length > 0 ? join(arbeit, 'fertig', auftrag.vorgangId) : ''
|
||||
if (zwischen.length > 0) mkdirSync(zwischen, { recursive: true })
|
||||
|
||||
const global = {
|
||||
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
|
||||
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
|
||||
@@ -152,15 +183,15 @@ export async function komprimierenUndAblegen(
|
||||
const verschieben = umgebung.verschieben ?? dateiVerschieben
|
||||
|
||||
const fertigeDateien: string[] = []
|
||||
/** Lokal gerechnet, noch nicht im Ziel: [von, nach]. */
|
||||
const nachzuziehen: Array<[string, string]> = []
|
||||
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
||||
// Episoden-Zuordnung weiter unten.
|
||||
const folgenSekunden: number[] = []
|
||||
for (let i = 0; i < dateien.length; i++) {
|
||||
const datei = dateien[i]
|
||||
const ausgabe = join(zielFuer(datei.rolle), basename(datei.pfad))
|
||||
if (abbruch.aborted) {
|
||||
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||
}
|
||||
const zielDatei = join(zielFuer(datei.rolle), basename(datei.pfad))
|
||||
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||
if (!komprimiert) {
|
||||
melden({
|
||||
phase: 'legt-ab',
|
||||
@@ -168,20 +199,15 @@ export async function komprimierenUndAblegen(
|
||||
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
|
||||
})
|
||||
try {
|
||||
verschieben(datei.pfad, ausgabe)
|
||||
verschieben(datei.pfad, zielDatei)
|
||||
} catch (fehler) {
|
||||
return {
|
||||
status: 'fehler',
|
||||
zielOrdner: fertigOrdner,
|
||||
dateien: fertigeDateien,
|
||||
text: `Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
|
||||
komprimiert: false,
|
||||
preset: '',
|
||||
fertigBytes: 0,
|
||||
hinweise,
|
||||
}
|
||||
return fehlschlag(
|
||||
`Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
|
||||
'',
|
||||
false,
|
||||
)
|
||||
}
|
||||
fertigeDateien.push(ausgabe)
|
||||
fertigeDateien.push(zielDatei)
|
||||
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
|
||||
continue
|
||||
}
|
||||
@@ -200,15 +226,14 @@ export async function komprimierenUndAblegen(
|
||||
)
|
||||
}
|
||||
if (fehlt.untertitel.length > 0) {
|
||||
hinweise.push(
|
||||
`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`,
|
||||
)
|
||||
hinweise.push(`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`)
|
||||
}
|
||||
} else {
|
||||
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`)
|
||||
}
|
||||
}
|
||||
|
||||
const ausgabe = zwischen.length > 0 ? join(zwischen, basename(datei.pfad)) : zielDatei
|
||||
melden({
|
||||
phase: 'komprimiert',
|
||||
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||
@@ -233,25 +258,44 @@ export async function komprimierenUndAblegen(
|
||||
})
|
||||
},
|
||||
})
|
||||
if (encode.status === 'cancelled') {
|
||||
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||
}
|
||||
if (encode.status === 'cancelled') return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||
if (encode.status === 'error') {
|
||||
return {
|
||||
status: 'fehler',
|
||||
zielOrdner: fertigOrdner,
|
||||
dateien: fertigeDateien,
|
||||
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
|
||||
komprimiert: true,
|
||||
return fehlschlag(
|
||||
`Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
|
||||
preset,
|
||||
fertigBytes: 0,
|
||||
hinweise,
|
||||
}
|
||||
true,
|
||||
)
|
||||
}
|
||||
fertigeDateien.push(encode.ausgabe)
|
||||
if (zwischen.length > 0) nachzuziehen.push([encode.ausgabe, zielDatei])
|
||||
fertigeDateien.push(zielDatei)
|
||||
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
|
||||
}
|
||||
|
||||
// 5.5.0: Lokal Gerechnetes auf die Ablage bringen — erst jetzt, da alle
|
||||
// Encodes gelungen sind. Ein Netzfehler hier kostet keinen Encode: Die
|
||||
// Dateien liegen im Arbeitsordner und der Vorgang sagt, wo.
|
||||
for (let i = 0; i < nachzuziehen.length; i++) {
|
||||
const [von, nach] = nachzuziehen[i]
|
||||
if (abbruch.aborted) return abgebrochen(fertigOrdner, [], preset, komprimiert, hinweise)
|
||||
melden({ phase: 'legt-ab', prozent: 100, text: `Verschiebe auf die Ablage ${i + 1}/${nachzuziehen.length}: ${basename(nach)} …` })
|
||||
try {
|
||||
verschieben(von, nach)
|
||||
} catch (fehler) {
|
||||
return fehlschlag(
|
||||
`Verschieben auf die Ablage fehlgeschlagen: ${String(fehler)} — die fertigen Dateien liegen unter ${zwischen}, die Roh-Dateien unter ${auftrag.rohOrdner}.`,
|
||||
preset,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (zwischen.length > 0) {
|
||||
try {
|
||||
rmSync(zwischen, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ein leerer Zwischenordner, der stehen bleibt, stört nicht.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiederholen: die alte Fassung weicht der neuen — JETZT erst, da alle
|
||||
// neuen Dateien da sind. Vorher hätte ein gescheiterter Lauf die
|
||||
// erste Fassung gekostet; danach stünde sie bei Serien der
|
||||
@@ -271,10 +315,10 @@ export async function komprimierenUndAblegen(
|
||||
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
|
||||
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
|
||||
// (die offene Wunde #395 von ARM war genau dieses Ratespiel).
|
||||
let folgenVergeben: number[] = []
|
||||
if (alsSerie && folgenSekunden.length > 0) {
|
||||
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
|
||||
const laufzeiten =
|
||||
tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
|
||||
const laufzeiten = tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
|
||||
let episoden: number[] | null = null
|
||||
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
|
||||
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
|
||||
@@ -285,6 +329,7 @@ export async function komprimierenUndAblegen(
|
||||
}
|
||||
if (episoden !== null) {
|
||||
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
|
||||
folgenVergeben = episoden
|
||||
} else {
|
||||
hinweise.push(
|
||||
laufzeiten.length === 0
|
||||
@@ -368,6 +413,7 @@ export async function komprimierenUndAblegen(
|
||||
preset: komprimiert ? preset : '',
|
||||
fertigBytes,
|
||||
hinweise,
|
||||
folgenVergeben,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +433,7 @@ function abgebrochen(
|
||||
preset,
|
||||
fertigBytes: 0,
|
||||
hinweise,
|
||||
folgenVergeben: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
// (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher
|
||||
// Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und
|
||||
// nach einem Abbruch nachsehen.
|
||||
//
|
||||
// 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'
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { join } from 'node:path'
|
||||
@@ -29,6 +33,7 @@ import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadate
|
||||
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
|
||||
import { isoSichern } from '../rip/iso'
|
||||
import { rippen } from '../rip/makemkv'
|
||||
import { rettungsAbbildDatei } from '../rip/rettung'
|
||||
import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { mkdtempSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -74,8 +79,10 @@ export interface PipelineUmgebung {
|
||||
) => number
|
||||
/** 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). */
|
||||
/** Wurzel der Ablage (fertige Filme, Musik, ISO liegen darunter). */
|
||||
ablage: () => string
|
||||
/** 5.5.0: Wo die Roh-Ordner liegen (Arbeitsordner\roh). Fehlt: Ablage\roh. */
|
||||
rohWurzel?: () => string
|
||||
einstellungen: () => Einstellungen
|
||||
melden: (status: RipStatus) => void
|
||||
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
|
||||
@@ -99,10 +106,22 @@ export interface PipelineUmgebung {
|
||||
/** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) — die nächste
|
||||
* Disc soll rein, während die Kompression läuft. */
|
||||
nachRipAuswerfen?: (id: string) => void
|
||||
/** 5.5.0: Das Rettungs-Abbild lesen — injizierbar für Tests. */
|
||||
rettungsAbbild?: typeof rettungsAbbildDatei
|
||||
}
|
||||
|
||||
/** Der letzte Auftrag je Laufwerk — die Rettung wiederholt genau ihn. */
|
||||
interface LetzterAuftrag {
|
||||
auftrag: RipAuftragTitel[]
|
||||
optionen: RipAuftragOptionen
|
||||
}
|
||||
|
||||
export class Pipeline {
|
||||
private laufend = new Map<string, LaufenderVorgang>()
|
||||
/** 5.5.0: Rippy hat an diesem Laufwerk Lesefehler GESEHEN — nur dann
|
||||
* wird das Rettungs-Abbild angeboten (Commander: nicht bei heilen Discs). */
|
||||
private fehlerGesehen = new Map<string, string>()
|
||||
private letzterAuftrag = new Map<string, LetzterAuftrag>()
|
||||
|
||||
constructor(private readonly umgebung: PipelineUmgebung) {}
|
||||
|
||||
@@ -110,10 +129,22 @@ export class Pipeline {
|
||||
return this.laufend.has(id)
|
||||
}
|
||||
|
||||
/** Warum eine Rettung angeboten wird — '' wenn keine Fehler gesehen wurden. */
|
||||
rettungGrund(id: string): string {
|
||||
return this.fehlerGesehen.get(id) ?? ''
|
||||
}
|
||||
|
||||
/** Beim Entnehmen der Disc: Was gesehen wurde, gilt nicht mehr. */
|
||||
discEntnommen(id: string): void {
|
||||
this.fehlerGesehen.delete(id)
|
||||
this.letzterAuftrag.delete(id)
|
||||
}
|
||||
|
||||
private melden(status: RipStatus): void {
|
||||
const eintrag = this.laufend.get(status.id)
|
||||
if (eintrag !== undefined) eintrag.status = status
|
||||
this.umgebung.melden(status)
|
||||
const mitRettung: RipStatus = { ...status, rettungMoeglich: this.fehlerGesehen.has(status.id) }
|
||||
if (eintrag !== undefined) eintrag.status = mitRettung
|
||||
this.umgebung.melden(mitRettung)
|
||||
}
|
||||
|
||||
private fehler(id: string, text: string): RipStatus {
|
||||
@@ -131,11 +162,26 @@ export class Pipeline {
|
||||
vorgang.titelAbbruch.abort()
|
||||
}
|
||||
|
||||
/** 5.5.0: Rettungs-Abbild + Rip daraus — mit dem letzten Auftrag dieses
|
||||
* Laufwerks. Nur, wenn Rippy vorher Lesefehler gesehen hat. */
|
||||
async rettungStarten(id: string): Promise<void> {
|
||||
const grund = this.fehlerGesehen.get(id)
|
||||
if (grund === undefined) {
|
||||
this.umgebung.melden(
|
||||
this.fehler(id, 'Rettungs-Abbild nur bei GESEHENEN Lesefehlern — an dieser Disc hat Rippy keine bemerkt. Erst normal rippen.'),
|
||||
)
|
||||
return
|
||||
}
|
||||
const letzter = this.letzterAuftrag.get(id)
|
||||
await this.starten(id, letzter?.auftrag ?? [], letzter?.optionen ?? {}, true)
|
||||
}
|
||||
|
||||
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
|
||||
async starten(
|
||||
id: string,
|
||||
auftrag: readonly RipAuftragTitel[] = [],
|
||||
optionen: RipAuftragOptionen = {},
|
||||
rettung = false,
|
||||
): Promise<void> {
|
||||
if (this.laufend.has(id)) {
|
||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||
@@ -190,7 +236,8 @@ export class Pipeline {
|
||||
return
|
||||
}
|
||||
|
||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
|
||||
const rohOrdner = join(this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh'), vorgangsName)
|
||||
mkdirSync(rohOrdner, { recursive: true })
|
||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
|
||||
@@ -204,25 +251,9 @@ export class Pipeline {
|
||||
begonnen: startZeit.toISOString(),
|
||||
})
|
||||
|
||||
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
|
||||
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
|
||||
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
|
||||
// Trailer, Werbung und Alterskennzeichen.
|
||||
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
||||
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
||||
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
|
||||
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
|
||||
// Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet —
|
||||
// vorher sprang er je Titel zurück auf null.
|
||||
const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1]
|
||||
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
|
||||
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
|
||||
const auftragJeDatei = new Map<string, RipAuftragTitel | undefined>()
|
||||
const alleDateien = new Set<string>()
|
||||
let lesefehlerGesamt = false
|
||||
const gescheitert: string[] = []
|
||||
const uebersprungen: string[] = []
|
||||
const aufgeraeumt: string[] = []
|
||||
const hinweise: string[] = []
|
||||
|
||||
this.laufend.set(id, {
|
||||
abbruch,
|
||||
@@ -252,6 +283,81 @@ export class Pipeline {
|
||||
})
|
||||
}
|
||||
|
||||
// ── 5.5.0: Rettungs-Abbild — die Quelle des Rips wird die ISO ────────
|
||||
let quellePfad = geraet.pfad
|
||||
if (rettung) {
|
||||
const isoPfad = join(rohOrdner, 'rettung.iso')
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'rettet',
|
||||
prozent: 0,
|
||||
text: `Rettungs-Abbild: lese die Disc Sektor für Sektor nach ${isoPfad} — Unlesbares wird mit Nullen gefüllt …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
vorgangId: vorgangsName,
|
||||
rettung: { unlesbareSektoren: 0, stellen: 0 },
|
||||
})
|
||||
const lesen = this.umgebung.rettungsAbbild ?? rettungsAbbildDatei
|
||||
const ergebnis = await lesen(
|
||||
geraet.pfad,
|
||||
isoPfad,
|
||||
geraet.groesseBytes,
|
||||
(prozent, unlesbar, stellen) => {
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) {
|
||||
this.melden({
|
||||
...alt,
|
||||
phase: 'rettet',
|
||||
prozent,
|
||||
text: `Rettungs-Abbild: ${prozent} % — ${unlesbar} unlesbare Sektoren an ${stellen} Stellen (mit Nullen gefüllt)`,
|
||||
rettung: { unlesbareSektoren: unlesbar, stellen },
|
||||
})
|
||||
}
|
||||
},
|
||||
abbruch.signal,
|
||||
)
|
||||
if (ergebnis.status === 'cancelled') {
|
||||
beenden('abgebrochen', 'Rettungs-Abbild abgebrochen.')
|
||||
return
|
||||
}
|
||||
if (ergebnis.status === 'error') {
|
||||
beenden('fehler', `Rettungs-Abbild gescheitert: ${ergebnis.fehler}`)
|
||||
return
|
||||
}
|
||||
hinweise.push(
|
||||
`Rettungs-Abbild: ${ergebnis.unlesbareSektoren} unlesbare Sektoren an ${ergebnis.stellen.length} Stellen mit Nullen gefüllt (Bericht: rettung.iso.rettung.txt).`,
|
||||
)
|
||||
quellePfad = isoPfad
|
||||
this.melden({
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: -1,
|
||||
text: `Rettungs-Abbild fertig (${ergebnis.unlesbareSektoren} Sektoren genullt) — rippe jetzt aus dem Abbild …`,
|
||||
dateien: [],
|
||||
lesefehler: false,
|
||||
vorgangId: vorgangsName,
|
||||
rettung: { unlesbareSektoren: ergebnis.unlesbareSektoren, stellen: ergebnis.stellen.length },
|
||||
})
|
||||
}
|
||||
|
||||
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
|
||||
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
|
||||
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
|
||||
// Trailer, Werbung und Alterskennzeichen.
|
||||
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
||||
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
||||
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
|
||||
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
|
||||
// Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet —
|
||||
// vorher sprang er je Titel zurück auf null.
|
||||
const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1]
|
||||
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
|
||||
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
|
||||
const auftragJeDatei = new Map<string, RipAuftragTitel | undefined>()
|
||||
const gescheitert: string[] = []
|
||||
const uebersprungen: string[] = []
|
||||
const aufgeraeumt: string[] = []
|
||||
|
||||
for (let i = 0; i < laeufe.length; i++) {
|
||||
const welcher = laeufe[i]
|
||||
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
|
||||
@@ -271,7 +377,7 @@ export class Pipeline {
|
||||
id,
|
||||
phase: 'rippt',
|
||||
prozent: gewichteterFortschritt(gewichte, i, -1),
|
||||
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`,
|
||||
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …${rettung ? ' (aus dem Rettungs-Abbild)' : ''}`,
|
||||
dateien: [...alleDateien],
|
||||
lesefehler: lesefehlerGesamt,
|
||||
titelNr: i + 1,
|
||||
@@ -285,7 +391,7 @@ export class Pipeline {
|
||||
const vorher = dateienIn(rohOrdner)
|
||||
const rip = await rippen({
|
||||
programm,
|
||||
geraetepfad: geraet.pfad,
|
||||
geraetepfad: quellePfad,
|
||||
zielOrdner: rohOrdner,
|
||||
titel: welcher,
|
||||
abbruch: titelAbbruch.signal,
|
||||
@@ -298,6 +404,9 @@ export class Pipeline {
|
||||
if (alt !== undefined) this.melden({ ...alt, text })
|
||||
},
|
||||
aufHaengt: (klemmt) => {
|
||||
// Der Defekt-Wächter hat angeschlagen: Ab jetzt ist die Rettung
|
||||
// eine Option (5.5.0).
|
||||
if (klemmt !== null && !rettung) this.fehlerGesehen.set(id, `Defekt-Wächter: hängt an Byte ${klemmt.offset}`)
|
||||
const alt = this.laufend.get(id)?.status
|
||||
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
|
||||
},
|
||||
@@ -305,7 +414,7 @@ export class Pipeline {
|
||||
abbruch.signal.removeEventListener('abort', weiterreichen)
|
||||
|
||||
if (rip.status === 'cancelled') {
|
||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher))
|
||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
||||
// Nur dieser Titel — oder der ganze Auftrag?
|
||||
if (this.laufend.get(id)?.uebersprungen !== true) {
|
||||
beenden(
|
||||
@@ -321,18 +430,20 @@ export class Pipeline {
|
||||
}
|
||||
|
||||
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
|
||||
if (rip.lesefehler && !rettung) this.fehlerGesehen.set(id, 'MakeMKV meldete Lesefehler')
|
||||
if (rip.status === 'error') {
|
||||
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
|
||||
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
|
||||
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
|
||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher))
|
||||
if (!rettung) this.fehlerGesehen.set(id, `Titel gescheitert: ${rip.fehler.slice(0, 80)}`)
|
||||
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher, rettung ? ['rettung.iso', 'rettung.iso.rettung.txt'] : []))
|
||||
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
|
||||
continue
|
||||
}
|
||||
// rippen() liefert den ganzen Zielordner zurück, also auch die
|
||||
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
|
||||
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
|
||||
for (const d of rip.dateien) alleDateien.add(d)
|
||||
const neu = rip.dateien.filter((d) => !alleDateien.has(d) && d.toLowerCase().endsWith('.mkv'))
|
||||
for (const d of rip.dateien) if (d.toLowerCase().endsWith('.mkv')) alleDateien.add(d)
|
||||
for (const d of neu) auftragJeDatei.set(d, auftrag[i])
|
||||
if (neu.length === 0) {
|
||||
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
|
||||
@@ -349,7 +460,6 @@ export class Pipeline {
|
||||
return
|
||||
}
|
||||
|
||||
const hinweise: string[] = []
|
||||
if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`)
|
||||
if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`)
|
||||
if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`)
|
||||
@@ -397,7 +507,7 @@ export class Pipeline {
|
||||
phase: 'fertig',
|
||||
prozent: 100,
|
||||
text:
|
||||
`Verlustfrei gesichert: ${dateien.length} ${dateien.length === 1 ? 'Titel' : 'Titel'}, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
|
||||
`Verlustfrei gesichert: ${dateien.length} Titel, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
|
||||
(platz > 0
|
||||
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
|
||||
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
|
||||
@@ -587,11 +697,12 @@ function dateienIn(ordner: string): Set<string> {
|
||||
}
|
||||
|
||||
/** Entfernt, was seit `vorher` im Ordner dazukam — die halbe Datei eines
|
||||
* abgebrochenen oder gescheiterten Titels. Liefert die Namen. */
|
||||
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>): string[] {
|
||||
* abgebrochenen oder gescheiterten Titels. `schonen` bleibt immer liegen
|
||||
* (das Rettungs-Abbild). Liefert die Namen. */
|
||||
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>, schonen: readonly string[] = []): string[] {
|
||||
const entfernt: string[] = []
|
||||
for (const name of dateienIn(ordner)) {
|
||||
if (vorher.has(name)) continue
|
||||
if (vorher.has(name) || schonen.includes(name)) continue
|
||||
try {
|
||||
unlinkSync(join(ordner, name))
|
||||
entfernt.push(name)
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und
|
||||
// werden zusammen nicht schneller.
|
||||
//
|
||||
// 5.5.0: Pause (Commander: „zum Beispiel beim Spielen") — der laufende
|
||||
// Film wird fertig gerechnet, der nächste wartet, bis es weitergeht.
|
||||
//
|
||||
// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel
|
||||
// prüft das Verhalten der Schlange auf Linux ohne HandBrake.
|
||||
import type { KompressionStand } from '../../gemeinsam/nachrichten'
|
||||
@@ -34,6 +37,7 @@ export class KompressionsWarteschlange {
|
||||
private laufend: Eintrag | null = null
|
||||
private laufendStand: KompressionStand | null = null
|
||||
private arbeitet = false
|
||||
private pausiert = false
|
||||
|
||||
constructor(
|
||||
private readonly ausfuehren: Ausfuehren,
|
||||
@@ -71,6 +75,7 @@ export class KompressionsWarteschlange {
|
||||
preset: '',
|
||||
fertigBytes: 0,
|
||||
hinweise: [],
|
||||
folgenVergeben: [],
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -81,6 +86,17 @@ export class KompressionsWarteschlange {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Pause: Der laufende Auftrag wird fertig, der nächste startet erst nach
|
||||
* `pausieren(false)`. */
|
||||
pausieren(an: boolean): void {
|
||||
this.pausiert = an
|
||||
if (!an) void this.schleife()
|
||||
}
|
||||
|
||||
istPausiert(): boolean {
|
||||
return this.pausiert
|
||||
}
|
||||
|
||||
stand(): WarteschlangenStand {
|
||||
return {
|
||||
laeuft: this.laufendStand === null ? null : { ...this.laufendStand },
|
||||
@@ -88,7 +104,7 @@ export class KompressionsWarteschlange {
|
||||
vorgangId: e.auftrag.vorgangId,
|
||||
titel: e.titel,
|
||||
prozent: -1,
|
||||
text: 'wartet',
|
||||
text: this.pausiert ? 'wartet (Pause)' : 'wartet',
|
||||
phase: 'wartet',
|
||||
})),
|
||||
}
|
||||
@@ -107,7 +123,7 @@ export class KompressionsWarteschlange {
|
||||
if (this.arbeitet) return
|
||||
this.arbeitet = true
|
||||
try {
|
||||
while (this.wartend.length > 0) {
|
||||
while (this.wartend.length > 0 && !this.pausiert) {
|
||||
const eintrag = this.wartend.shift()!
|
||||
this.laufend = eintrag
|
||||
this.laufendStand = {
|
||||
@@ -141,6 +157,7 @@ export class KompressionsWarteschlange {
|
||||
preset: '',
|
||||
fertigBytes: 0,
|
||||
hinweise: [],
|
||||
folgenVergeben: [],
|
||||
}
|
||||
}
|
||||
this.laufend = null
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// Die Automatik (5.5.0, aus ARM: Auto-Modus; KONZEPT § 5 Schritt 4:
|
||||
// „sicher genug → sofort losrippen, nur Bescheid sagen — Zero-Click").
|
||||
//
|
||||
// Ablauf: Disc rein → Erkennung sicher (≥ 90 %) und nicht schon gerippt →
|
||||
// Rippy liest die Titel von selbst → ist die Vorauswahl eindeutig, läuft
|
||||
// ein Countdown (Vorgabe 30 s, Einstellung automatikSekunden) → dann der
|
||||
// Rip mit Rippys Vorschlag. Ein Klick auf „Stopp" oder das Öffnen des
|
||||
// Dialogs beendet den Countdown; „Jetzt" überspringt ihn. Wo Rippy unsicher
|
||||
// ist, passiert NICHTS von selbst — es sagt nur, warum.
|
||||
//
|
||||
// PUR bis auf die Uhr: Die Abhängigkeiten kommen injiziert, die Ampel
|
||||
// prüft den Ablauf mit einer Fake-Uhr.
|
||||
import type { AutomatikStand, GeraeteInfo, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../../gemeinsam/nachrichten'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import { ABGELOEST } from '../rip/makemkv'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
|
||||
export const AUTOMATIK_SICHER = 0.9
|
||||
|
||||
export interface AutomatikAbhaengigkeiten {
|
||||
an(): boolean
|
||||
sekunden(): number
|
||||
ripLaeuft(id: string): boolean
|
||||
titelLesen(id: string): Promise<TitelStand>
|
||||
ripStarten(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftragOptionen): void
|
||||
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null
|
||||
serienMerker(zuordnung: Zuordnung): { staffel: number; naechsteFolge: number } | null
|
||||
benachrichtigen(titel: string, text: string): void
|
||||
/** Uhr — injizierbar. */
|
||||
takt?: (fn: () => void, ms: number) => NodeJS.Timeout
|
||||
taktStoppen?: (zeiger: NodeJS.Timeout) => void
|
||||
}
|
||||
|
||||
/** Rippys Vorschlag als Rip-Auftrag (pur): die vorausgewählten Titel mit
|
||||
* ihren Rollen; Sprachen leer = die globale Einstellung gilt. */
|
||||
export function auftragAusVorschlag(stand: TitelStand): RipAuftragTitel[] {
|
||||
return stand.titel
|
||||
.filter((t) => t.gewaehlt)
|
||||
.map((t) => ({ nr: t.nr, rolle: t.rolle, dauerS: t.dauerS, groesseBytes: t.groesseBytes }))
|
||||
}
|
||||
|
||||
/** Staffel und erste Folge für den Automatik-Rip (pur): Disc-Angabe vor
|
||||
* Merker; „ab Folge" nur vom Merker, und nur, wenn die Disc nicht die
|
||||
* erste ihrer Ausgabe ist (sonst würde Disc 1 bei Folge 3 anfangen). */
|
||||
export function optionenAus(
|
||||
angaben: { staffel: number | null; discNr: number | null } | null,
|
||||
merker: { staffel: number; naechsteFolge: number } | null,
|
||||
): RipAuftragOptionen {
|
||||
const staffel = angaben?.staffel ?? merker?.staffel ?? null
|
||||
const ersteFolge = merker !== null && (angaben?.discNr === null || angaben === null || angaben.discNr > 1) ? merker.naechsteFolge : null
|
||||
return { staffel, ersteFolge }
|
||||
}
|
||||
|
||||
export class AutomatikDienst {
|
||||
private zeiger = new Map<string, NodeJS.Timeout>()
|
||||
private staende = new Map<string, AutomatikStand>()
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly abh: AutomatikAbhaengigkeiten,
|
||||
) {}
|
||||
|
||||
stand(id: string): AutomatikStand | null {
|
||||
return this.staende.get(id) ?? null
|
||||
}
|
||||
|
||||
private melden(id: string, aktiv: boolean, sekunden: number, text: string): void {
|
||||
const stand = { id, aktiv, sekunden, text }
|
||||
this.staende.set(id, stand)
|
||||
this.kontext.anFenster({ art: 'automatik', stand })
|
||||
}
|
||||
|
||||
/** Disc rein, Erkennung fertig — soll die Automatik übernehmen? */
|
||||
async pruefen(geraet: GeraeteInfo, zuordnung: Zuordnung | null, schonGerippt: string | null): Promise<void> {
|
||||
const id = geraet.id
|
||||
if (!this.abh.an()) return
|
||||
if (geraet.typ === 'cd' || geraet.typ === 'unknown') return
|
||||
if (this.abh.ripLaeuft(id)) return
|
||||
if (zuordnung === null || zuordnung.confidence < AUTOMATIK_SICHER) {
|
||||
this.melden(id, false, 0, 'Automatik wartet: Die Erkennung ist nicht sicher genug — bitte selbst starten oder „Ändern …".')
|
||||
return
|
||||
}
|
||||
if (schonGerippt !== null) {
|
||||
this.melden(id, false, 0, `Automatik wartet: Diese Disc wurde schon gerippt (${schonGerippt.slice(0, 10)}).`)
|
||||
return
|
||||
}
|
||||
this.melden(id, false, 0, 'Automatik: liest die Titel …')
|
||||
const titel = await this.abh.titelLesen(id)
|
||||
if (titel.fehler === ABGELOEST) return
|
||||
if (this.abh.ripLaeuft(id)) return
|
||||
if (titel.fehler.length > 0) {
|
||||
this.melden(id, false, 0, `Automatik wartet: ${titel.fehler}`)
|
||||
return
|
||||
}
|
||||
if (titel.unsicher || !titel.titel.some((t) => t.gewaehlt)) {
|
||||
this.melden(id, false, 0, `Automatik wartet: ${titel.einordnung}`)
|
||||
return
|
||||
}
|
||||
this.countdown(id, titel)
|
||||
}
|
||||
|
||||
private countdown(id: string, titel: TitelStand): void {
|
||||
this.stopp(id, '')
|
||||
let rest = this.abh.sekunden()
|
||||
const n = titel.titel.filter((t) => t.gewaehlt).length
|
||||
const text = (s: number): string => `Automatik: Rip von ${n} ${n === 1 ? 'Titel' : 'Titeln'} startet in ${s} s — Rippys Vorschlag.`
|
||||
this.melden(id, true, rest, text(rest))
|
||||
const takt = this.abh.takt ?? ((fn, ms) => setInterval(fn, ms))
|
||||
const zeiger = takt(() => {
|
||||
rest -= 1
|
||||
if (rest > 0) {
|
||||
this.melden(id, true, rest, text(rest))
|
||||
return
|
||||
}
|
||||
this.stopp(id, '')
|
||||
this.starten(id, titel)
|
||||
}, 1000)
|
||||
this.zeiger.set(id, zeiger)
|
||||
}
|
||||
|
||||
private starten(id: string, titel: TitelStand): void {
|
||||
if (this.abh.ripLaeuft(id)) return
|
||||
const auftrag = auftragAusVorschlag(titel)
|
||||
if (auftrag.length === 0) return
|
||||
const zuordnungTitel = titel.titel.some((t) => t.gewaehlt && t.rolle === 'folge')
|
||||
const optionen = optionenAus(this.abh.discAngaben(id), zuordnungTitel ? this.merkerFuer(id) : null)
|
||||
this.melden(id, false, 0, `Automatik: Rip gestartet (${auftrag.length} ${auftrag.length === 1 ? 'Titel' : 'Titel'}).`)
|
||||
this.kontext.hinweis(`Automatik: Rip auf ${id} gestartet — Rippys Vorschlag, ${auftrag.length} Titel.`)
|
||||
this.abh.benachrichtigen('Rippy rippt', `Automatik hat auf Laufwerk ${id} gestartet: ${auftrag.length} Titel.`)
|
||||
this.abh.ripStarten(id, auftrag, optionen)
|
||||
}
|
||||
|
||||
private merkerZuordnung = new Map<string, Zuordnung>()
|
||||
|
||||
/** Die Zuordnung merken, damit der Serien-Merker beim Start gefragt werden kann. */
|
||||
zuordnungMerken(id: string, zuordnung: Zuordnung | null): void {
|
||||
if (zuordnung === null) this.merkerZuordnung.delete(id)
|
||||
else this.merkerZuordnung.set(id, zuordnung)
|
||||
}
|
||||
|
||||
private merkerFuer(id: string): { staffel: number; naechsteFolge: number } | null {
|
||||
const z = this.merkerZuordnung.get(id)
|
||||
return z === undefined ? null : this.abh.serienMerker(z)
|
||||
}
|
||||
|
||||
/** Countdown beenden — vom Nutzer (Grund wird gesagt) oder intern (leer). */
|
||||
stopp(id: string, grund: string): void {
|
||||
const zeiger = this.zeiger.get(id)
|
||||
if (zeiger !== undefined) {
|
||||
;(this.abh.taktStoppen ?? ((z) => clearInterval(z)))(zeiger)
|
||||
this.zeiger.delete(id)
|
||||
}
|
||||
if (grund.length > 0) this.melden(id, false, 0, grund)
|
||||
}
|
||||
|
||||
/** „Jetzt": den Countdown überspringen. */
|
||||
jetzt(id: string): void {
|
||||
const stand = this.staende.get(id)
|
||||
if (stand === undefined || !stand.aktiv) return
|
||||
this.stopp(id, '')
|
||||
const titel = this.letzteTitel.get(id)
|
||||
if (titel !== undefined) this.starten(id, titel)
|
||||
}
|
||||
|
||||
private letzteTitel = new Map<string, TitelStand>()
|
||||
|
||||
/** Der Titel-Stand, aus dem gestartet wird — die Erkennung reicht ihn durch. */
|
||||
titelMerken(id: string, stand: TitelStand): void {
|
||||
this.letzteTitel.set(id, stand)
|
||||
}
|
||||
|
||||
discEntnommen(id: string): void {
|
||||
this.stopp(id, '')
|
||||
this.staende.delete(id)
|
||||
this.letzteTitel.delete(id)
|
||||
this.merkerZuordnung.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Benachrichtigung aufs Handy (5.5.0, aus ARM: Apprise/Pushover/…) — zwei
|
||||
// Wege, beide ohne Bibliothek, beide dokumentiert (Regel D):
|
||||
// Discord-Webhook: POST <webhook-url> mit JSON { content } —
|
||||
// discord.com/developers/docs/resources/webhook („Execute Webhook":
|
||||
// content ist eine Zeichenkette bis 2000 Zeichen).
|
||||
// Telegram-Bot: POST https://api.telegram.org/bot<TOKEN>/sendMessage mit
|
||||
// JSON { chat_id, text } — core.telegram.org/bots/api#sendmessage.
|
||||
// Ein Fehlschlag wird GESAGT (Ereignis-Zeile), kippt aber nie den Vorgang,
|
||||
// der ihn ausgelöst hat.
|
||||
import type { KernKontext } from '../kontext'
|
||||
import type { EinstellungsDienst } from './einstellungen'
|
||||
|
||||
export interface Kanaele {
|
||||
discordWebhook: string
|
||||
telegramToken: string
|
||||
telegramChatId: string
|
||||
}
|
||||
|
||||
/** Was an Discord geht (pur, testbar). */
|
||||
export function discordAnfrage(webhook: string, titel: string, text: string): { url: string; body: string } {
|
||||
const inhalt = `**${titel}**\n${text}`.slice(0, 1990)
|
||||
return { url: webhook, body: JSON.stringify({ content: inhalt, username: 'Rippy' }) }
|
||||
}
|
||||
|
||||
/** Was an Telegram geht (pur, testbar). */
|
||||
export function telegramAnfrage(token: string, chatId: string, titel: string, text: string): { url: string; body: string } {
|
||||
return {
|
||||
url: `https://api.telegram.org/bot${token}/sendMessage`,
|
||||
body: JSON.stringify({ chat_id: chatId, text: `${titel}\n${text}`.slice(0, 4000) }),
|
||||
}
|
||||
}
|
||||
|
||||
export function kanaeleAus(lesen: (schluessel: string) => string): Kanaele {
|
||||
return {
|
||||
discordWebhook: lesen('discordWebhook').trim(),
|
||||
telegramToken: lesen('telegramToken').trim(),
|
||||
telegramChatId: lesen('telegramChatId').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function kanaeleEingerichtet(k: Kanaele): boolean {
|
||||
return k.discordWebhook.length > 0 || (k.telegramToken.length > 0 && k.telegramChatId.length > 0)
|
||||
}
|
||||
|
||||
export class BenachrichtigungsDienst {
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly einstellungen: EinstellungsDienst,
|
||||
private readonly holen: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
eingeschaltet(): boolean {
|
||||
return this.einstellungen.lesen('benachrichtigungen', 'an') !== 'aus'
|
||||
}
|
||||
|
||||
/** Schickt über alle eingerichteten Kanäle. Liefert die Fehler (leer = alles gut). */
|
||||
async senden(titel: string, text: string, erzwingen = false): Promise<string[]> {
|
||||
if (!erzwingen && !this.eingeschaltet()) return []
|
||||
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
|
||||
const fehler: string[] = []
|
||||
const anfragen: Array<{ name: string; url: string; body: string }> = []
|
||||
if (k.discordWebhook.length > 0) anfragen.push({ name: 'Discord', ...discordAnfrage(k.discordWebhook, titel, text) })
|
||||
if (k.telegramToken.length > 0 && k.telegramChatId.length > 0) {
|
||||
anfragen.push({ name: 'Telegram', ...telegramAnfrage(k.telegramToken, k.telegramChatId, titel, text) })
|
||||
}
|
||||
for (const a of anfragen) {
|
||||
try {
|
||||
const antwort = await this.holen(a.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: a.body,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (antwort.status >= 300) fehler.push(`${a.name}: HTTP ${antwort.status}`)
|
||||
} catch (e) {
|
||||
fehler.push(`${a.name}: ${String((e as Error).message ?? e).slice(0, 120)}`)
|
||||
}
|
||||
}
|
||||
for (const f of fehler) this.kontext.fehler(`Benachrichtigung ${f}`)
|
||||
if (anfragen.length > 0 && fehler.length === 0) this.kontext.protokoll.schreiben(`Benachrichtigt (${anfragen.map((a) => a.name).join(', ')}): ${titel}`)
|
||||
return fehler
|
||||
}
|
||||
|
||||
/** Der Knopf „Testnachricht" — antwortet IMMER etwas. */
|
||||
async testen(): Promise<void> {
|
||||
const k = kanaeleAus((s) => this.einstellungen.lesen(s))
|
||||
if (!kanaeleEingerichtet(k)) {
|
||||
this.kontext.fehler('Benachrichtigung: kein Kanal eingerichtet — Discord-Webhook oder Telegram-Bot eintragen.')
|
||||
return
|
||||
}
|
||||
const fehler = await this.senden('Rippy meldet sich', 'Testnachricht — die Verbindung steht.', true)
|
||||
if (fehler.length === 0) this.kontext.hinweis('Testnachricht verschickt.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Einstellungen (§ 6.7): Das Fenster liest und schreibt NUR über den Kern;
|
||||
// erlaubt sind genau die Schlüssel hier. Dazu die Pfad-Regeln: Ablage
|
||||
// (Fertiges — beim Commander der Server) und seit 5.5.0 der Arbeitsordner
|
||||
// (Roh-Dateien und Kompression — lokal, schnell; Vorgabe: unter der Ablage,
|
||||
// also wie bisher).
|
||||
import { join } from 'node:path'
|
||||
import { AUFBEWAHRUNG_SCHLUESSEL } from '../ablage/rohaufraeumen'
|
||||
import { einstellung, type KernKontext } from '../kontext'
|
||||
|
||||
export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
|
||||
'ablage',
|
||||
'arbeitsordner',
|
||||
'transcodeEnabled',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
'tmdbApiKey',
|
||||
'omdbApiKey',
|
||||
'makemkvAppKey',
|
||||
'updateUrl',
|
||||
'setup.done',
|
||||
'autoAuswurf',
|
||||
'werkzeug.makemkv',
|
||||
'werkzeug.handbrake',
|
||||
'werkzeug.flac',
|
||||
AUFBEWAHRUNG_SCHLUESSEL,
|
||||
// 5.5.0
|
||||
'automatik',
|
||||
'automatikSekunden',
|
||||
'benachrichtigungen',
|
||||
'discordWebhook',
|
||||
'telegramToken',
|
||||
'telegramChatId',
|
||||
])
|
||||
|
||||
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
|
||||
* frühere „Allgemeine Preset" (transcodePreset) ist seit 5.5.0 raus —
|
||||
* Commander-Wunsch: je Disc-Typ reicht, ein unsichtbarer Übersteuerer
|
||||
* verwirrt nur. */
|
||||
const PIPELINE_EINSTELLUNGEN = [
|
||||
'transcodeEnabled',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
] as const
|
||||
|
||||
export const AUTOMATIK_SEKUNDEN_VORGABE = 30
|
||||
|
||||
export class EinstellungsDienst {
|
||||
constructor(private readonly kontext: KernKontext) {}
|
||||
|
||||
lesen(schluessel: string, vorgabe = ''): string {
|
||||
return einstellung(this.kontext, schluessel, vorgabe)
|
||||
}
|
||||
|
||||
/** Wurzel der Ablage — Fertiges, Musik, ISO. */
|
||||
ablageOrdner(): string {
|
||||
const eingestellt = this.lesen('ablage')
|
||||
if (eingestellt.length > 0) return eingestellt
|
||||
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
|
||||
}
|
||||
|
||||
/** Ein eigener Arbeitsordner — '' wenn keiner gesetzt ist. */
|
||||
arbeitsordnerEigen(): string {
|
||||
return this.lesen('arbeitsordner').trim()
|
||||
}
|
||||
|
||||
/** Wo gearbeitet wird: der eigene Arbeitsordner, sonst die Ablage. */
|
||||
arbeitsordner(): string {
|
||||
const eigen = this.arbeitsordnerEigen()
|
||||
return eigen.length > 0 ? eigen : this.ablageOrdner()
|
||||
}
|
||||
|
||||
/** Wo die Roh-Ordner liegen. */
|
||||
rohWurzel(): string {
|
||||
return join(this.arbeitsordner(), 'roh')
|
||||
}
|
||||
|
||||
/** Was Pipeline und Ablage brauchen. */
|
||||
fuerPipeline(): Record<string, string> {
|
||||
const werte: Record<string, string> = {}
|
||||
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
|
||||
const wert = this.kontext.db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
}
|
||||
return werte
|
||||
}
|
||||
|
||||
automatikAn(): boolean {
|
||||
return this.lesen('automatik', 'an') !== 'aus'
|
||||
}
|
||||
|
||||
automatikSekunden(): number {
|
||||
const n = Number(this.lesen('automatikSekunden', String(AUTOMATIK_SEKUNDEN_VORGABE)))
|
||||
return Number.isInteger(n) && n >= 5 && n <= 600 ? n : AUTOMATIK_SEKUNDEN_VORGABE
|
||||
}
|
||||
|
||||
/** Alle erlaubten Werte ans Fenster — AUCH ans Haupt (Update-Adresse, § 6.8). */
|
||||
senden(): void {
|
||||
const werte: Record<string, string> = { ablage: this.ablageOrdner() }
|
||||
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
|
||||
const wert = this.kontext.db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
}
|
||||
this.kontext.anHauptUndFenster({ art: 'einstellungen', werte })
|
||||
}
|
||||
|
||||
/** Schreiben — nur erlaubte Schlüssel, nur mit Datenbank. Liefert, ob es klappte. */
|
||||
setzen(schluessel: string, wert: string): boolean {
|
||||
if (!ERLAUBTE_EINSTELLUNGEN.has(schluessel)) {
|
||||
this.kontext.fehler(`Unbekannter Einstellungs-Schlüssel: ${schluessel}`)
|
||||
return false
|
||||
}
|
||||
if (this.kontext.db === null) {
|
||||
this.kontext.fehler('Einstellung nicht gespeichert — die Datenbank ist nicht da.')
|
||||
return false
|
||||
}
|
||||
this.kontext.db.einstellungSchreiben(schluessel, wert)
|
||||
this.kontext.protokoll.schreiben(`Einstellung ${schluessel} gesetzt${istGeheim(schluessel) ? '' : `: ${wert.slice(0, 120)}`}`)
|
||||
this.senden()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function istGeheim(schluessel: string): boolean {
|
||||
return /key|token|webhook|passwort/i.test(schluessel)
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
// Die Erkennung (W-4, § 5 Schritt 3): Was liegt da drin? Disc-Merkmale,
|
||||
// TMDb/OMDb-Zuordnung mit Sicherheitsgrad, die Nachprüfung nach dem
|
||||
// Info-Lauf (Erkennung v2, 5.4.0), der Titel-Lauf für den Auswahl-Dialog,
|
||||
// die Metadaten-Suche des Ändern-Dialogs — und seit 5.5.0 der Serien-Merker
|
||||
// (welche Folge auf der nächsten Disc weitergeht) und die Poster für den
|
||||
// Hintergrund der Erst-Einrichtung.
|
||||
import type {
|
||||
DiscInfoStand,
|
||||
DiscTitelVorschlag,
|
||||
GeraeteInfo,
|
||||
MetaVorschlag,
|
||||
TitelStand,
|
||||
} from '../../gemeinsam/nachrichten'
|
||||
import { einstellung, type KernKontext } from '../kontext'
|
||||
import { discFingerprint, discTitel, type DiscTitel } from '../metadaten/discmerkmale'
|
||||
import { OmdbClient } from '../metadaten/omdb'
|
||||
import { TmdbClient } from '../metadaten/tmdb'
|
||||
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
|
||||
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
|
||||
import { sprachenFuerTitel, type TitelInfo } from '../rip/parser'
|
||||
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
|
||||
|
||||
export interface ErkennungAbhaengigkeiten {
|
||||
ripLaeuft(id: string): boolean
|
||||
geraetInfoVon(id: string): GeraeteInfo | null
|
||||
makemkvcon(): string
|
||||
}
|
||||
|
||||
/** Der Serien-Merker (5.5.0): je TMDb-Serie, was die vorige Disc hinterließ. */
|
||||
export interface SerienMerker {
|
||||
titel: string
|
||||
staffel: number
|
||||
naechsteFolge: number
|
||||
wann: string
|
||||
}
|
||||
|
||||
export const SERIEN_MERKER_SCHLUESSEL = 'serienMerker'
|
||||
|
||||
/** Liest den Merker-Speicher (pur, robust gegen Unsinn). */
|
||||
export function serienMerkerAusJson(text: string | null): Record<string, SerienMerker> {
|
||||
if (text === null || text.length === 0) return {}
|
||||
try {
|
||||
const roh: unknown = JSON.parse(text)
|
||||
if (typeof roh !== 'object' || roh === null) return {}
|
||||
const ergebnis: Record<string, SerienMerker> = {}
|
||||
for (const [id, wert] of Object.entries(roh as Record<string, unknown>)) {
|
||||
if (typeof wert !== 'object' || wert === null) continue
|
||||
const m = wert as Record<string, unknown>
|
||||
if (typeof m.staffel !== 'number' || typeof m.naechsteFolge !== 'number') continue
|
||||
ergebnis[id] = {
|
||||
titel: typeof m.titel === 'string' ? m.titel : '',
|
||||
staffel: m.staffel,
|
||||
naechsteFolge: m.naechsteFolge,
|
||||
wann: typeof m.wann === 'string' ? m.wann : '',
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
} catch {
|
||||
// Ein kaputter Merker ist kein Grund, nicht zu erkennen — er ist dann leer.
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function jahrAus(datum: string | undefined): number | null {
|
||||
if (datum === undefined || datum.length < 4) return null
|
||||
const jahr = Number(datum.slice(0, 4))
|
||||
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
||||
}
|
||||
|
||||
export class ErkennungDienst {
|
||||
private zuordnungen = new Map<string, Zuordnung>()
|
||||
private fingerprints = new Map<string, string>()
|
||||
/** Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl) —
|
||||
* das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt. */
|
||||
private merkmale = new Map<string, DiscTitel>()
|
||||
/** Der letzte Titel-Stand je Laufwerk — die Automatik (5.5.0) rippt daraus. */
|
||||
readonly letzteTitel = new Map<string, TitelStand>()
|
||||
// Je Laufwerk höchstens EIN Titel-Lauf, und nur der NEUESTE darf
|
||||
// antworten. Der Commander-Fund vom 01.09.2026: Die Titelliste kam
|
||||
// korrekt an — und 300 s später legte ein ÄLTERER, längst überholter Lauf
|
||||
// seinen Timeout darüber („keine Antwort nach 300 s"). Zwei makemkvcon
|
||||
// auf derselben Disc bremsen sich außerdem gegenseitig aus.
|
||||
private titelAbbrueche = new Map<string, AbortController>()
|
||||
private titelLaufZaehler = 0
|
||||
private titelLaufNummern = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly abh: ErkennungAbhaengigkeiten,
|
||||
) {}
|
||||
|
||||
private tmdb(): TmdbClient {
|
||||
return new TmdbClient(einstellung(this.kontext, 'tmdbApiKey'))
|
||||
}
|
||||
|
||||
private omdb(): OmdbClient {
|
||||
return new OmdbClient(einstellung(this.kontext, 'omdbApiKey'))
|
||||
}
|
||||
|
||||
zuordnung(id: string): Zuordnung | null {
|
||||
return this.zuordnungen.get(id) ?? null
|
||||
}
|
||||
|
||||
fingerprint(id: string): string | null {
|
||||
return this.fingerprints.get(id) ?? null
|
||||
}
|
||||
|
||||
discAngaben(id: string): { staffel: number | null; discNr: number | null } | null {
|
||||
const m = this.merkmale.get(id)
|
||||
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
|
||||
}
|
||||
|
||||
// ── Serien-Merker (5.5.0) ───────────────────────────────────────────────
|
||||
private alleMerker(): Record<string, SerienMerker> {
|
||||
return serienMerkerAusJson(this.kontext.db?.einstellungLesen(SERIEN_MERKER_SCHLUESSEL) ?? null)
|
||||
}
|
||||
|
||||
serienMerkerFuer(tmdbId: number | string | null): SerienMerker | null {
|
||||
if (tmdbId === null) return null
|
||||
return this.alleMerker()[String(tmdbId)] ?? null
|
||||
}
|
||||
|
||||
/** Nach einer fertigen Serien-Ablage: Mit welcher Folge geht es weiter? */
|
||||
serienMerkerSetzen(tmdbId: number | string, titel: string, staffel: number, naechsteFolge: number): void {
|
||||
if (this.kontext.db === null) return
|
||||
const alle = this.alleMerker()
|
||||
alle[String(tmdbId)] = { titel, staffel, naechsteFolge, wann: new Date().toISOString() }
|
||||
this.kontext.db.einstellungSchreiben(SERIEN_MERKER_SCHLUESSEL, JSON.stringify(alle))
|
||||
this.kontext.protokoll.schreiben(`Serien-Merker: „${titel}" Staffel ${staffel}, weiter mit Folge ${naechsteFolge}`)
|
||||
}
|
||||
|
||||
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
|
||||
* baut (5.4.0) — vorher stand dieselbe Struktur dreimal im Code. */
|
||||
discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
|
||||
const m = this.merkmale.get(id)
|
||||
const meta = zuordnung?.meta
|
||||
const merker = meta?.typ === 'tv' ? this.serienMerkerFuer(meta.id) : null
|
||||
return {
|
||||
id,
|
||||
titel: zuordnung?.titel ?? '',
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
confidence: zuordnung?.confidence ?? 0,
|
||||
quelle: quelle ?? meta?.quelle ?? '',
|
||||
posterPfad: meta?.posterPfad ?? '',
|
||||
backdropPfad: meta?.backdropPfad ?? '',
|
||||
beschreibung: meta?.beschreibung ?? '',
|
||||
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||
schonGerippt:
|
||||
zuordnung === null ? null : (this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null),
|
||||
discNr: m?.discNr ?? null,
|
||||
staffel: m?.staffel ?? null,
|
||||
folgen: m?.folgen ?? 0,
|
||||
typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '',
|
||||
logoPfad: meta?.logoPfad ?? '',
|
||||
tagline: meta?.tagline ?? '',
|
||||
bewertung: meta?.bewertung ?? 0,
|
||||
fsk: meta?.fsk ?? '',
|
||||
laufzeitMinuten: meta?.laufzeitMinuten ?? 0,
|
||||
genres: meta?.genres ?? [],
|
||||
besetzung: meta?.besetzung ?? [],
|
||||
hinweise: zuordnung?.hinweise ?? [],
|
||||
serienMerker: merker === null ? null : { staffel: merker.staffel, naechsteFolge: merker.naechsteFolge, wann: merker.wann },
|
||||
}
|
||||
}
|
||||
|
||||
schonGerippt(id: string): string | null {
|
||||
return this.kontext.db?.bibliothekSchonGerippt(this.fingerprints.get(id) ?? '') ?? null
|
||||
}
|
||||
|
||||
/** § 5 Schritt 3: Disc rein → was ist das? Liefert die Zuordnung (für die
|
||||
* Automatik) — null, wenn die Erkennung scheiterte. */
|
||||
async discErmitteln(geraet: GeraeteInfo): Promise<Zuordnung | null> {
|
||||
try {
|
||||
const gefunden = discTitel(geraet.pfad)
|
||||
this.fingerprints.set(geraet.id, discFingerprint(geraet.pfad, geraet.groesseBytes))
|
||||
this.merkmale.set(geraet.id, gefunden)
|
||||
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
|
||||
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede
|
||||
// Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0).
|
||||
const zuordnung = await zuordnen(gefunden.titel, this.tmdb(), this.omdb(), {
|
||||
istSerie: gefunden.istSerie,
|
||||
alternativen: gefunden.alternativen,
|
||||
})
|
||||
this.zuordnungen.set(geraet.id, zuordnung)
|
||||
this.kontext.protokoll.schreiben(
|
||||
`Erkennung ${geraet.id}: „${gefunden.titel}" (${gefunden.quelle || 'kein Titel'}) → „${zuordnung.titel}" ${Math.round(zuordnung.confidence * 100)} % ${zuordnung.meta.typ}`,
|
||||
)
|
||||
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(geraet.id, zuordnung) })
|
||||
return zuordnung
|
||||
} catch (fehler) {
|
||||
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
|
||||
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
|
||||
this.kontext.fehler(`Disc-Erkennung ${geraet.id}: ${String(fehler)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
discVergessen(id: string): void {
|
||||
this.zuordnungen.delete(id)
|
||||
this.fingerprints.delete(id)
|
||||
this.merkmale.delete(id)
|
||||
this.letzteTitel.delete(id)
|
||||
this.titelAbbrueche.get(id)?.abort()
|
||||
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, null) })
|
||||
}
|
||||
|
||||
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
|
||||
async metadatenSuchen(id: string, text: string): Promise<void> {
|
||||
try {
|
||||
const tmdb = this.tmdb()
|
||||
if (!tmdb.verfuegbar) {
|
||||
this.kontext.fehler('Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).')
|
||||
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
return
|
||||
}
|
||||
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
|
||||
const vorschlaege: MetaVorschlag[] = [
|
||||
...(filme ?? []).slice(0, 6).map((f) => ({
|
||||
typ: 'movie' as const,
|
||||
tmdbId: f.id,
|
||||
titel: f.title ?? '',
|
||||
jahr: jahrAus(f.release_date),
|
||||
posterPfad: f.poster_path ?? '',
|
||||
})),
|
||||
...(serien ?? []).slice(0, 6).map((s) => ({
|
||||
typ: 'tv' as const,
|
||||
tmdbId: s.id,
|
||||
titel: s.name ?? '',
|
||||
jahr: jahrAus(s.first_air_date),
|
||||
posterPfad: s.poster_path ?? '',
|
||||
})),
|
||||
].filter((v) => v.titel.length > 0)
|
||||
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Metadaten-Suche: ${String(fehler)}`)
|
||||
this.kontext.anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
this.zuordnungen.set(id, zuordnung)
|
||||
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${meta.typ} ${String(meta.id)})`)
|
||||
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, zuordnung, 'deine Wahl') })
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Zuordnung übernehmen: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
|
||||
// Der Info-Lauf dauert 20–120 s — deshalb geschieht er erst auf Zuruf und
|
||||
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
|
||||
// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der
|
||||
// Zeit nicht stumm dasteht (R2). Liefert den End-Stand zurück (Automatik).
|
||||
async titelLesen(id: string): Promise<TitelStand> {
|
||||
const leer: TitelStand = { id, laeuft: false, titel: [], einordnung: '', unsicher: false, fehler: '' }
|
||||
const senden = (stand: TitelStand): TitelStand => {
|
||||
if (!stand.laeuft) this.letzteTitel.set(id, stand)
|
||||
this.kontext.anFenster({ art: 'disc-titel', stand })
|
||||
return stand
|
||||
}
|
||||
if (this.abh.ripLaeuft(id)) return senden({ ...leer, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' })
|
||||
const geraet = this.abh.geraetInfoVon(id)
|
||||
if (geraet === null) return senden({ ...leer, fehler: `Laufwerk ${id}: gibt es nicht.` })
|
||||
const programm = this.abh.makemkvcon()
|
||||
if (programm.length === 0) {
|
||||
return senden({ ...leer, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' })
|
||||
}
|
||||
// Einen noch laufenden Lauf ablösen: Er gibt das Laufwerk sofort frei,
|
||||
// und seine Antwort wird verworfen (nur die neueste Nummer zählt).
|
||||
this.titelAbbrueche.get(id)?.abort()
|
||||
const abbruch = new AbortController()
|
||||
this.titelAbbrueche.set(id, abbruch)
|
||||
const meineNummer = ++this.titelLaufZaehler
|
||||
this.titelLaufNummern.set(id, meineNummer)
|
||||
const nochAktuell = (): boolean => this.titelLaufNummern.get(id) === meineNummer
|
||||
|
||||
senden({ ...leer, laeuft: true })
|
||||
const start = Date.now()
|
||||
try {
|
||||
const info = await titelInfoLesen(programm, geraet.pfad, 300_000, abbruch.signal)
|
||||
if (!nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
||||
const merkmale = this.merkmale.get(id)
|
||||
const wahl = titelVorauswahl(info.titel, {
|
||||
istSerie: merkmale?.istSerie ?? false,
|
||||
folgen: merkmale?.folgen ?? 0,
|
||||
})
|
||||
// Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der
|
||||
// Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026
|
||||
// gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit
|
||||
// TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe
|
||||
// nichts her — dabei sieht MakeMKV das LAUFWERK nicht.
|
||||
const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0
|
||||
this.kontext.protokoll.schreiben(
|
||||
`Titel-Lauf ${id}: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel — ${wahl.einordnung}${info.ursachen.length > 0 ? ` · ${info.ursachen.join(' · ')}` : ''}`,
|
||||
)
|
||||
const titel: DiscTitelVorschlag[] = wahl.titel.map((t) => ({
|
||||
...t,
|
||||
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
|
||||
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
|
||||
}))
|
||||
const stand = senden({
|
||||
id,
|
||||
laeuft: false,
|
||||
titel,
|
||||
einordnung: wahl.einordnung,
|
||||
unsicher: wahl.unsicher,
|
||||
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
|
||||
})
|
||||
// Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen.
|
||||
if (info.titel.length > 0) await this.nachpruefen(id, titel, info.titel)
|
||||
return stand
|
||||
} catch (fehler) {
|
||||
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
|
||||
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
|
||||
// der alte Lauf überschrieb sonst die längst gelieferte Titelliste.
|
||||
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return { ...leer, fehler: ABGELOEST }
|
||||
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
|
||||
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
|
||||
const grund = String(fehler).includes('keine Antwort nach')
|
||||
? 'MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer beschädigten Disc kann das passieren — Rippy hat den Versuch beendet und das Laufwerk freigegeben.'
|
||||
: `Titel lesen: ${String(fehler).slice(0, 200)}`
|
||||
this.kontext.protokoll.schreiben(`Titel-Lauf ${id} gescheitert: ${grund}`)
|
||||
return senden({ ...leer, fehler: grund })
|
||||
} finally {
|
||||
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
|
||||
if (nochAktuell()) {
|
||||
this.titelAbbrueche.delete(id)
|
||||
this.titelLaufNummern.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
|
||||
* Nach dem Info-Lauf wird noch einmal gerechnet — Laufzeit gegen TMDb,
|
||||
* Titel-Struktur gegen den Treffer-Typ. Aus „wahrscheinlich" wird
|
||||
* „bestätigt", oder Rippy merkt selbst, dass es danebenlag. */
|
||||
private async nachpruefen(id: string, vorschlag: readonly DiscTitelVorschlag[], titel: readonly TitelInfo[]): Promise<void> {
|
||||
const bisher = this.zuordnungen.get(id)
|
||||
if (bisher === undefined) return
|
||||
const gewaehlt = vorschlag.filter((t) => t.gewaehlt)
|
||||
const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS)
|
||||
const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0
|
||||
const messung = {
|
||||
hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster),
|
||||
folgenS: folgen,
|
||||
struktur: strukturHinweis(titel),
|
||||
}
|
||||
let zuordnung = bisher
|
||||
// Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat
|
||||
// nicht selbst gewählt): einmal als Serie fragen — das ist der
|
||||
// Fingerabdruck, der Serien auch ohne bdmt erkennt.
|
||||
if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) {
|
||||
const merkmale = this.merkmale.get(id)
|
||||
if (merkmale !== undefined && merkmale.titel.length > 0) {
|
||||
const nochmal = await zuordnen(merkmale.titel, this.tmdb(), this.omdb(), {
|
||||
istSerie: true,
|
||||
alternativen: merkmale.alternativen,
|
||||
})
|
||||
if (nochmal.meta.typ === 'tv') {
|
||||
zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
const geprueft = nachpruefen(zuordnung, messung)
|
||||
const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])]
|
||||
const neu = { ...geprueft, hinweise }
|
||||
this.zuordnungen.set(id, neu)
|
||||
if (hinweise.length > 0) this.kontext.protokoll.schreiben(`Nachprüfung ${id}: ${hinweise.join(' · ')} → ${Math.round(neu.confidence * 100)} %`)
|
||||
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, neu) })
|
||||
}
|
||||
|
||||
// ── Poster für die Erst-Einrichtung (5.5.0, Commander-Wunsch) ──────────
|
||||
// TMDb /movie/popular (de-DE) — dieselbe Schnittstelle wie die Suche,
|
||||
// die Antwort trägt poster_path je Film (gemessen: results[].poster_path).
|
||||
async posterLaden(): Promise<void> {
|
||||
const tmdb = this.tmdb()
|
||||
if (!tmdb.verfuegbar) {
|
||||
this.kontext.anFenster({ art: 'poster-vorschau', pfade: [] })
|
||||
return
|
||||
}
|
||||
const pfade = await tmdb.beliebtePoster(40)
|
||||
this.kontext.anFenster({ art: 'poster-vorschau', pfade })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Die Laufwerke (W-1, § 5 Schritt 1): Disc-Wache im 3-Sekunden-Takt,
|
||||
// Auswerfen mit Nachsehen (R3), Audio-CD-Zugriffe, die Laufwerks-Gesundheit
|
||||
// aus dem Windows-Protokoll (5.4.0) — und das nachgebaute Laufwerk des
|
||||
// Klick-Beweises.
|
||||
//
|
||||
// koffi wird erst HIER geladen, über einen echten dynamischen import():
|
||||
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
|
||||
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
|
||||
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
|
||||
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
|
||||
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import type { LaufwerkApi } from '../laufwerk/codes'
|
||||
import {
|
||||
audioSpurLesen,
|
||||
audioToc,
|
||||
auswerfenMitGrund,
|
||||
geraeteInfo,
|
||||
kennung,
|
||||
listOptischeLaufwerke,
|
||||
} from '../laufwerk/disc'
|
||||
import { beurteilen, ereignisseLesen } from '../laufwerk/ereignisse'
|
||||
import { DiscWache } from '../laufwerk/wache'
|
||||
import type { Spur } from '../rip/audiocd'
|
||||
|
||||
const GESUNDHEIT_STUNDEN = 24
|
||||
|
||||
export interface LaufwerkHorcher {
|
||||
discEingelegt(geraet: GeraeteInfo): void
|
||||
discEntnommen(geraet: GeraeteInfo): void
|
||||
}
|
||||
|
||||
export class LaufwerkDienst {
|
||||
private api: LaufwerkApi | null = null
|
||||
private wache: DiscWache | null = null
|
||||
fehler: string | null = null
|
||||
/** Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
|
||||
* Buchstabe, den es nicht gibt (Q) — die Disc-Merkmale finden dann
|
||||
* nichts, und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts).
|
||||
* Leer im normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg. */
|
||||
readonly fake: string
|
||||
readonly bereit: Promise<void>
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly horcher: LaufwerkHorcher,
|
||||
/** Läuft auf diesem Laufwerk gerade ein Rip? (Auswurf wird dann abgelehnt.) */
|
||||
private readonly ripLaeuft: (id: string) => boolean,
|
||||
umgebung: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
this.fake = (umgebung['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
|
||||
this.bereit = this.starten()
|
||||
}
|
||||
|
||||
private fakeGeraet(): GeraeteInfo {
|
||||
return {
|
||||
id: this.fake,
|
||||
name: `Laufwerk ${this.fake}:`,
|
||||
typ: 'bluray',
|
||||
pfad: `\\\\.\\${this.fake}:`,
|
||||
status: 'ready',
|
||||
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
|
||||
seriennummer: 'KLICK',
|
||||
groesseBytes: 33_759_100_928,
|
||||
grund: '',
|
||||
}
|
||||
}
|
||||
|
||||
private wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
|
||||
this.kontext.anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
|
||||
for (const ereignis of meldung.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
this.kontext.protokoll.schreiben(`Disc eingelegt in ${ereignis.geraet.id}: ${ereignis.geraet.typ}, ${ereignis.geraet.groesseBytes} Bytes`)
|
||||
this.horcher.discEingelegt(ereignis.geraet)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
this.kontext.protokoll.schreiben(`Disc entnommen aus ${ereignis.geraet.id}`)
|
||||
this.horcher.discEntnommen(ereignis.geraet)
|
||||
} else if (ereignis.art === 'laufwerk-weg') {
|
||||
this.kontext.protokoll.schreiben(`Laufwerk ${ereignis.id} nicht mehr da`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async starten(): Promise<void> {
|
||||
const wacheFehler = (fehler: unknown): void => {
|
||||
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
|
||||
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
|
||||
this.kontext.fehler(`Disc-Wache: ${String(fehler)}`)
|
||||
}
|
||||
try {
|
||||
if (this.fake.length > 0) {
|
||||
this.wache = new DiscWache(() => [this.fakeGeraet()], (m) => this.wacheMeldung(m), wacheFehler)
|
||||
this.wache.starten()
|
||||
return
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
|
||||
}
|
||||
const { win32Api } = await import('../laufwerk/win32')
|
||||
this.api = win32Api()
|
||||
this.wache = new DiscWache(
|
||||
() => listOptischeLaufwerke(this.api!).map((g) => geraeteInfo(this.api!, g)),
|
||||
(m) => this.wacheMeldung(m),
|
||||
wacheFehler,
|
||||
)
|
||||
this.wache.starten()
|
||||
} catch (fehler) {
|
||||
this.fehler = String(fehler)
|
||||
this.kontext.protokoll.schreiben(`Laufwerke: ${this.fehler}`)
|
||||
console.error(`[kern] Laufwerke: ${this.fehler}`)
|
||||
}
|
||||
}
|
||||
|
||||
aktuellerStand(): GeraeteInfo[] {
|
||||
return this.wache?.aktuellerStand() ?? []
|
||||
}
|
||||
|
||||
/** Der Geräte-Name zu einer Kennung — oder null. */
|
||||
geraetVon(id: string): string | null {
|
||||
if (this.api === null) return null
|
||||
return listOptischeLaufwerke(this.api).find((g) => kennung(g) === id) ?? null
|
||||
}
|
||||
|
||||
/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */
|
||||
geraetInfoVon(id: string): GeraeteInfo | null {
|
||||
if (this.fake.length > 0) return id === this.fake ? this.fakeGeraet() : null
|
||||
const geraet = this.geraetVon(id)
|
||||
if (geraet === null || this.api === null) return null
|
||||
return geraeteInfo(this.api, geraet)
|
||||
}
|
||||
|
||||
audioToc(id: string): Spur[] {
|
||||
const geraet = this.geraetVon(id)
|
||||
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioToc(this.api, geraet)
|
||||
}
|
||||
|
||||
audioSpurLesen(
|
||||
id: string,
|
||||
start: number,
|
||||
sektoren: number,
|
||||
schreiben: (daten: Buffer) => void,
|
||||
fortschritt?: (anteil: number) => void,
|
||||
): number {
|
||||
const geraet = this.geraetVon(id)
|
||||
if (this.api === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
||||
return audioSpurLesen(this.api, geraet, start, sektoren, schreiben, fortschritt)
|
||||
}
|
||||
|
||||
async auswerfen(id: string): Promise<void> {
|
||||
await this.bereit
|
||||
if (this.ripLaeuft(id)) {
|
||||
this.kontext.anFenster({
|
||||
art: 'auswurf-ergebnis',
|
||||
id,
|
||||
grund: 'abgelehnt',
|
||||
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.api === null) {
|
||||
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: this.fehler })
|
||||
return
|
||||
}
|
||||
const geraet = this.geraetVon(id)
|
||||
if (geraet === null) {
|
||||
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
|
||||
return
|
||||
}
|
||||
const { grund, fehler } = await auswerfenMitGrund(this.api, geraet)
|
||||
this.kontext.protokoll.schreiben(`Auswurf ${id}: ${grund}${fehler !== null ? ` (${fehler})` : ''}`)
|
||||
this.kontext.anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
|
||||
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
|
||||
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
|
||||
this.wache?.runde()
|
||||
}
|
||||
|
||||
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ─────────────────────────────
|
||||
async pruefen(id: string): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
|
||||
if ('fehler' in ergebnis) {
|
||||
this.kontext.anFenster({
|
||||
art: 'laufwerk-gesundheit',
|
||||
id,
|
||||
bericht: {
|
||||
urteil: 'nicht-lesbar',
|
||||
text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`,
|
||||
fehlerhafteBloecke: 0,
|
||||
controllerfehler: 0,
|
||||
sonstige: 0,
|
||||
letzte: [],
|
||||
stunden: GESUNDHEIT_STUNDEN,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
const bericht = beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN)
|
||||
this.kontext.protokoll.schreiben(`Laufwerk ${id} geprüft: ${bericht.urteil} — ${bericht.text}`)
|
||||
this.kontext.anFenster({ art: 'laufwerk-gesundheit', id, bericht })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Freier Platz (5.5.0, Commander: „Wie viel Speicher noch auf dem Server
|
||||
// oder auf dem Arbeitsordner frei ist") — GEMESSEN am 01.09.2026 mit Node
|
||||
// 24 (Regel D): fs.statfsSync liefert für Laufwerke (C:\, F:\), Ordner
|
||||
// und UNC-Freigaben (\\192.168.178.62\rippy) bavail/bsize/blocks; ein
|
||||
// Pfad, den es nicht gibt (Q:\), wirft ENOENT. Deshalb wird zum nächsten
|
||||
// vorhandenen Elternpfad hochgegangen: Die Ablage darf noch fehlen, ihr
|
||||
// Laufwerk gibt es trotzdem.
|
||||
import { existsSync, statfsSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { SpeicherPlatz, SpeicherStand } from '../../gemeinsam/nachrichten'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import type { EinstellungsDienst } from './einstellungen'
|
||||
|
||||
export type Messen = (pfad: string) => { bavail: number; bsize: number; blocks: number }
|
||||
|
||||
const echtMessen: Messen = (pfad) => {
|
||||
const s = statfsSync(pfad)
|
||||
return { bavail: Number(s.bavail), bsize: Number(s.bsize), blocks: Number(s.blocks) }
|
||||
}
|
||||
|
||||
/** Der nächste Pfad, den es gibt — der Ordner selbst, sonst sein Elternpfad. */
|
||||
export function vorhandenerPfad(pfad: string, existiert: (p: string) => boolean = existsSync): string {
|
||||
let p = pfad
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (existiert(p)) return p
|
||||
const eltern = dirname(p)
|
||||
if (eltern === p) return p
|
||||
p = eltern
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
export function platzMessen(pfad: string, messen: Messen = echtMessen, existiert: (p: string) => boolean = existsSync): SpeicherPlatz {
|
||||
if (pfad.trim().length === 0) return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: 'kein Pfad' }
|
||||
try {
|
||||
const s = messen(vorhandenerPfad(pfad, existiert))
|
||||
return { pfad, ok: true, freiBytes: s.bavail * s.bsize, gesamtBytes: s.blocks * s.bsize, fehler: '' }
|
||||
} catch (fehler) {
|
||||
// R2: „konnte nicht messen" ist etwas anderes als „0 frei" — das
|
||||
// Fenster zeigt den Grund, nie eine erfundene Null.
|
||||
return { pfad, ok: false, freiBytes: 0, gesamtBytes: 0, fehler: String((fehler as Error).message ?? fehler).slice(0, 120) }
|
||||
}
|
||||
}
|
||||
|
||||
export { platzUrteil } from '../../gemeinsam/platz'
|
||||
|
||||
export class SpeicherDienst {
|
||||
private zeiger: NodeJS.Timeout | null = null
|
||||
private letzter: SpeicherStand | null = null
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly einstellungen: EinstellungsDienst,
|
||||
private readonly messen: Messen = echtMessen,
|
||||
) {}
|
||||
|
||||
stand(): SpeicherStand {
|
||||
const arbeit = this.einstellungen.arbeitsordner()
|
||||
const ablage = this.einstellungen.ablageOrdner()
|
||||
const s: SpeicherStand = {
|
||||
arbeit: platzMessen(arbeit, this.messen),
|
||||
ablage: platzMessen(ablage, this.messen),
|
||||
getrennt: this.einstellungen.arbeitsordnerEigen().length > 0,
|
||||
}
|
||||
this.letzter = s
|
||||
return s
|
||||
}
|
||||
|
||||
letzterStand(): SpeicherStand | null {
|
||||
return this.letzter
|
||||
}
|
||||
|
||||
senden(): void {
|
||||
try {
|
||||
this.kontext.anFenster({ art: 'speicher', stand: this.stand() })
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Speicher messen: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Jede Minute neu messen — ohne den Prozess-Exit aufzuhalten. */
|
||||
taktStarten(ms = 60_000): void {
|
||||
if (this.zeiger !== null) return
|
||||
this.zeiger = setInterval(() => this.senden(), ms)
|
||||
this.zeiger.unref?.()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Vorgänge (5.4.0): jeder Rip als Datensatz, die Kompressions-Warteschlange,
|
||||
// das Roh-Aufräumen, Wiederholen ab Kompression, Wiederaufnahme nach dem
|
||||
// Neustart — und seit 5.5.0: Pause, Statistik, Serien-Merker und die
|
||||
// Benachrichtigung, wenn eine Kompression zu Ende ist.
|
||||
import { existsSync, rmSync } from 'node:fs'
|
||||
import type { KompressionStand, Statistik, VorgaengeStand, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
||||
import { komprimierenUndAblegen, type AblageErgebnis, type AblageUmgebung } from '../ablauf/ablegen'
|
||||
import { auftragAusJson, auftragZuJson, type KompressionsAuftrag } from '../ablauf/auftrag'
|
||||
import type { VorgangBeginn } from '../ablauf/pipeline'
|
||||
import { KompressionsWarteschlange } from '../ablauf/warteschlange'
|
||||
import {
|
||||
AUFBEWAHRUNG_SCHLUESSEL,
|
||||
aufraeumen,
|
||||
faelligAm,
|
||||
fremdeOrdner,
|
||||
istUnterRohWurzel,
|
||||
ordnerGroesse,
|
||||
regelLesen,
|
||||
regelText,
|
||||
} from '../ablage/rohaufraeumen'
|
||||
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import { TmdbClient } from '../metadaten/tmdb'
|
||||
import type { Vorgang } from '../speicher/db'
|
||||
import type { BenachrichtigungsDienst } from './benachrichtigung'
|
||||
import type { EinstellungsDienst } from './einstellungen'
|
||||
import type { ErkennungDienst } from './erkennung'
|
||||
import type { WerkzeugDienst } from './werkzeuge'
|
||||
|
||||
/** Statistik aus den Vorgängen (pur). */
|
||||
export function statistikAus(liste: readonly Pick<Vorgang, 'phase' | 'rohBytes' | 'fertigBytes' | 'begonnen' | 'beendet'>[]): Statistik {
|
||||
let fertige = 0
|
||||
let rohBytes = 0
|
||||
let fertigBytes = 0
|
||||
let dauerS = 0
|
||||
for (const v of liste) {
|
||||
rohBytes += v.rohBytes
|
||||
if (v.phase !== 'fertig') continue
|
||||
fertige += 1
|
||||
fertigBytes += v.fertigBytes
|
||||
if (v.beendet !== null) {
|
||||
const d = (new Date(v.beendet).getTime() - new Date(v.begonnen).getTime()) / 1000
|
||||
if (Number.isFinite(d) && d > 0) dauerS += d
|
||||
}
|
||||
}
|
||||
return { discs: liste.length, fertige, rohBytes, fertigBytes, dauerS: Math.round(dauerS) }
|
||||
}
|
||||
|
||||
export function dateienAusJson(text: string, melden?: (grund: string) => void): string[] {
|
||||
const liste: string[] = []
|
||||
try {
|
||||
const roh: unknown = JSON.parse(text)
|
||||
if (Array.isArray(roh)) liste.push(...roh.map(String))
|
||||
} catch (fehler) {
|
||||
// R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine
|
||||
// fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst.
|
||||
melden?.(`fertige Dateien nicht lesbar: ${String(fehler)}`)
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
export class VorgangDienst {
|
||||
readonly warteschlange: KompressionsWarteschlange
|
||||
/** Nur bei einer PHASEN-Änderung in die Datenbank — nicht bei jedem Prozent-Tick. */
|
||||
private letztePhase = new Map<string, string>()
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly einstellungen: EinstellungsDienst,
|
||||
private readonly werkzeuge: WerkzeugDienst,
|
||||
private readonly erkennung: ErkennungDienst,
|
||||
private readonly benachrichtigung: BenachrichtigungsDienst,
|
||||
/** Nach Änderungen an Vorgängen auch den Speicher neu messen. */
|
||||
private readonly speicherSenden: () => void,
|
||||
) {
|
||||
this.warteschlange = new KompressionsWarteschlange(
|
||||
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, this.ablageUmgebung(), melden, abbruch),
|
||||
(stand) => this.standGeaendert(stand.laeuft),
|
||||
(auftrag, ergebnis) => this.ergebnis(auftrag, ergebnis),
|
||||
)
|
||||
}
|
||||
|
||||
private get db() {
|
||||
return this.kontext.db
|
||||
}
|
||||
|
||||
ablageUmgebung(): AblageUmgebung {
|
||||
return {
|
||||
handbrake: () => this.werkzeuge.handbrake(),
|
||||
ablage: () => this.einstellungen.ablageOrdner(),
|
||||
arbeitsordner: () => this.einstellungen.arbeitsordnerEigen(),
|
||||
einstellungen: () => this.einstellungen.fuerPipeline(),
|
||||
presetEmpfehlung: (typ) => presetEmpfehlung(typ, this.werkzeuge.auskunft.backends, this.werkzeuge.auskunft.presets),
|
||||
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
|
||||
episodenLaufzeiten: async (tmdbId, staffel) => {
|
||||
const tmdb = new TmdbClient(this.einstellungen.lesen('tmdbApiKey'))
|
||||
if (!tmdb.verfuegbar) return []
|
||||
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
|
||||
const paare: Array<[number, number]> = []
|
||||
for (const ep of antwort?.episodes ?? []) {
|
||||
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
|
||||
}
|
||||
return paare
|
||||
},
|
||||
bibliothekEintragen: (eintrag) => this.bibliothekEintragen(eintrag),
|
||||
}
|
||||
}
|
||||
|
||||
bibliothekEintragen(eintrag: Parameters<NonNullable<AblageUmgebung['bibliothekEintragen']>>[0]): void {
|
||||
try {
|
||||
this.db?.bibliothekEintragen(eintrag)
|
||||
} catch (fehler) {
|
||||
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber gesagt.
|
||||
this.kontext.fehler(`Bibliothek: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private standGeaendert(laeuft: KompressionStand | null): void {
|
||||
if (laeuft !== null && this.letztePhase.get(laeuft.vorgangId) !== laeuft.phase) {
|
||||
this.letztePhase.set(laeuft.vorgangId, laeuft.phase)
|
||||
this.db?.vorgangAktualisieren(laeuft.vorgangId, { phase: laeuft.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
|
||||
}
|
||||
this.senden()
|
||||
}
|
||||
|
||||
private async ergebnis(auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis): Promise<void> {
|
||||
this.letztePhase.delete(auftrag.vorgangId)
|
||||
const jetzt = new Date().toISOString()
|
||||
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
|
||||
phase: ergebnis.status,
|
||||
beendet: jetzt,
|
||||
zielOrdner: ergebnis.zielOrdner,
|
||||
fertigBytes: ergebnis.fertigBytes,
|
||||
komprimiert: ergebnis.komprimiert,
|
||||
preset: ergebnis.preset,
|
||||
fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text,
|
||||
fertigeDateien: JSON.stringify(ergebnis.dateien),
|
||||
})
|
||||
const vorgang = this.db?.vorgangLesen(auftrag.vorgangId)
|
||||
const titel = vorgang?.titel ?? auftrag.vorgangId
|
||||
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
|
||||
this.kontext.protokoll.schreiben(`Kompression „${titel}": ${ergebnis.status} — ${text}`)
|
||||
this.kontext.anHauptUndFenster({
|
||||
art: 'kompression-ergebnis',
|
||||
vorgangId: auftrag.vorgangId,
|
||||
titel,
|
||||
ok: ergebnis.status === 'fertig',
|
||||
text,
|
||||
})
|
||||
// Serien-Merker (5.5.0): Mit welcher Folge geht die nächste Disc weiter?
|
||||
const tv = auftrag.zuordnung?.meta.typ === 'tv' ? auftrag.zuordnung.meta : null
|
||||
if (ergebnis.status === 'fertig' && tv !== null && tv.id !== null && ergebnis.folgenVergeben.length > 0) {
|
||||
const letzte = Math.max(...ergebnis.folgenVergeben)
|
||||
this.erkennung.serienMerkerSetzen(tv.id, auftrag.zuordnung?.titel ?? titel, auftrag.staffel ?? 1, letzte + 1)
|
||||
}
|
||||
if (ergebnis.status !== 'abgebrochen') {
|
||||
void this.benachrichtigung.senden(
|
||||
ergebnis.status === 'fertig' ? 'Rippy: fertig' : 'Rippy: Kompression fehlgeschlagen',
|
||||
`${titel}: ${ergebnis.text.slice(0, 300)}`,
|
||||
)
|
||||
}
|
||||
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
|
||||
if (ergebnis.status === 'fertig') this.rohAufraeumenLauf()
|
||||
else this.senden()
|
||||
}
|
||||
|
||||
// ── Pipeline-Haken ──────────────────────────────────────────────────────
|
||||
vorgangBeginnt(v: VorgangBeginn): void {
|
||||
const vorgang: Vorgang = {
|
||||
id: v.id,
|
||||
laufwerk: v.laufwerk,
|
||||
typ: v.typ,
|
||||
titel: v.titel,
|
||||
jahr: v.jahr,
|
||||
phase: 'rippt',
|
||||
begonnen: v.begonnen,
|
||||
beendet: null,
|
||||
rohOrdner: v.rohOrdner,
|
||||
rohBytes: 0,
|
||||
rohGeloeschtAm: null,
|
||||
zielOrdner: '',
|
||||
fertigBytes: 0,
|
||||
komprimiert: false,
|
||||
preset: '',
|
||||
fehler: '',
|
||||
auftrag: '{}',
|
||||
lesefehler: false,
|
||||
fertigeDateien: '[]',
|
||||
}
|
||||
this.db?.vorgangAnlegen(vorgang)
|
||||
this.kontext.protokoll.schreiben(`Vorgang ${v.id} beginnt: „${v.titel}" auf ${v.laufwerk} → ${v.rohOrdner}`)
|
||||
this.senden()
|
||||
}
|
||||
|
||||
vorgangEinreihen(auftrag: KompressionsAuftrag, titel: string): number {
|
||||
this.db?.vorgangAktualisieren(auftrag.vorgangId, {
|
||||
phase: 'wartet',
|
||||
titel,
|
||||
jahr: auftrag.zuordnung?.jahr ?? null,
|
||||
rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0),
|
||||
lesefehler: auftrag.lesefehler,
|
||||
auftrag: auftragZuJson(auftrag),
|
||||
})
|
||||
const platz = this.warteschlange.einreihen(auftrag, titel)
|
||||
this.kontext.protokoll.schreiben(`Vorgang ${auftrag.vorgangId}: ${auftrag.dateien.length} Roh-Dateien, Kompression eingereiht (Platz ${platz})`)
|
||||
this.senden()
|
||||
return platz
|
||||
}
|
||||
|
||||
vorgangEndet(id: string, phase: 'fehler' | 'abgebrochen', text: string): void {
|
||||
this.db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
|
||||
this.kontext.protokoll.schreiben(`Vorgang ${id} ${phase}: ${text}`)
|
||||
this.senden()
|
||||
}
|
||||
|
||||
// ── Stand fürs Fenster ──────────────────────────────────────────────────
|
||||
stand(): VorgaengeStand {
|
||||
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
|
||||
const liste = this.db?.vorgangListe() ?? []
|
||||
let rohGesamtBytes = 0
|
||||
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
|
||||
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
|
||||
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
|
||||
rohGesamtBytes += rohBytes
|
||||
const auftrag = auftragAusJson(v.auftrag)
|
||||
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
|
||||
return {
|
||||
id: v.id,
|
||||
laufwerk: v.laufwerk,
|
||||
typ: v.typ,
|
||||
titel: v.titel,
|
||||
jahr: v.jahr,
|
||||
phase: v.phase,
|
||||
begonnen: v.begonnen,
|
||||
beendet: v.beendet,
|
||||
rohOrdner: v.rohOrdner,
|
||||
rohBytes,
|
||||
rohGeloescht: !rohDa,
|
||||
zielOrdner: v.zielOrdner,
|
||||
fertigBytes: v.fertigBytes,
|
||||
komprimiert: v.komprimiert,
|
||||
preset: v.preset,
|
||||
fehler: v.fehler,
|
||||
titelZahl: auftrag?.dateien.length ?? 0,
|
||||
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !this.warteschlange.kennt(v.id),
|
||||
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
|
||||
}
|
||||
})
|
||||
const bekannte = new Set(liste.map((v) => v.rohOrdner))
|
||||
const fremde = fremdeOrdner(this.einstellungen.rohWurzel(), bekannte)
|
||||
for (const f of fremde) rohGesamtBytes += f.bytes
|
||||
return {
|
||||
vorgaenge,
|
||||
rohGesamtBytes,
|
||||
fremde,
|
||||
regel: regelText(regel),
|
||||
kompression: { ...this.warteschlange.stand(), pausiert: this.warteschlange.istPausiert() },
|
||||
statistik: statistikAus(liste),
|
||||
}
|
||||
}
|
||||
|
||||
senden(): void {
|
||||
try {
|
||||
this.kontext.anHauptUndFenster({ art: 'vorgaenge', stand: this.stand() })
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Vorgänge: ${String(fehler)}`)
|
||||
}
|
||||
this.speicherSenden()
|
||||
}
|
||||
|
||||
// ── Pause (5.5.0) ───────────────────────────────────────────────────────
|
||||
pause(an: boolean): void {
|
||||
this.warteschlange.pausieren(an)
|
||||
this.kontext.hinweis(an ? 'Kompression angehalten — der laufende Film wird fertig, danach wartet die Schlange.' : 'Kompression läuft weiter.')
|
||||
this.senden()
|
||||
}
|
||||
|
||||
// ── Roh-Aufräumen ───────────────────────────────────────────────────────
|
||||
rohAufraeumenLauf(): void {
|
||||
if (this.db === null) return
|
||||
try {
|
||||
const regel = regelLesen(this.einstellungen.lesen(AUFBEWAHRUNG_SCHLUESSEL, ''))
|
||||
const kandidaten = this.db.vorgangListe().map((v) => ({
|
||||
id: v.id,
|
||||
phase: v.phase,
|
||||
komprimiert: v.komprimiert,
|
||||
rohOrdner: v.rohOrdner,
|
||||
rohGeloeschtAm: v.rohGeloeschtAm,
|
||||
beendet: v.beendet,
|
||||
fertigeDateien: dateienAusJson(v.fertigeDateien, (g) => this.kontext.protokoll.schreiben(`${v.id}: ${g}`)),
|
||||
}))
|
||||
const ergebnis = aufraeumen(kandidaten, regel, new Date())
|
||||
const jetzt = new Date().toISOString()
|
||||
for (const id of ergebnis.geloescht) {
|
||||
this.db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: this.db.vorgangLesen(id)?.rohBytes ?? 0 })
|
||||
this.kontext.hinweis(`Roh-Dateien von „${this.db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
|
||||
}
|
||||
for (const f of ergebnis.fehler) this.kontext.fehler(`Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}`)
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Roh-Aufräumen: ${String(fehler)}`)
|
||||
}
|
||||
this.senden()
|
||||
}
|
||||
|
||||
rohLoeschen(vorgangId: string): void {
|
||||
const v = this.db?.vorgangLesen(vorgangId)
|
||||
if (v === null || v === undefined) {
|
||||
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
|
||||
return
|
||||
}
|
||||
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || this.warteschlange.kennt(vorgangId)) {
|
||||
this.kontext.fehler(`„${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.`)
|
||||
return
|
||||
}
|
||||
// Ein alter Roh-Ordner darf auch unter einer FRÜHEREN Roh-Wurzel liegen
|
||||
// (Arbeitsordner gewechselt) — geprüft wird nur, dass er ein Ordner
|
||||
// direkt unter irgendeinem roh\ ist.
|
||||
if (!/[\\/]roh[\\/][^\\/]+$/i.test(v.rohOrdner)) {
|
||||
this.kontext.fehler(`Roh-Ordner von „${v.titel}" liegt nicht unter einem roh\\-Ordner — nicht angefasst.`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
rmSync(v.rohOrdner, { recursive: true, force: true })
|
||||
this.db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString() })
|
||||
this.kontext.hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}`)
|
||||
}
|
||||
this.senden()
|
||||
}
|
||||
|
||||
fremdenOrdnerLoeschen(ordner: string): void {
|
||||
if (!istUnterRohWurzel(this.einstellungen.rohWurzel(), ordner)) {
|
||||
this.kontext.fehler(`„${ordner}" liegt nicht direkt unter ${this.einstellungen.rohWurzel()} — nicht angefasst.`)
|
||||
return
|
||||
}
|
||||
const bekannt = (this.db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
|
||||
if (bekannt) {
|
||||
this.kontext.fehler(`„${ordner}" gehört zu einem Vorgang — dort löschen.`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
rmSync(ordner, { recursive: true, force: true })
|
||||
this.kontext.hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`„${ordner}" nicht gelöscht: ${String(fehler)}`)
|
||||
}
|
||||
this.senden()
|
||||
}
|
||||
|
||||
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
|
||||
neuKomprimieren(vorgangId: string, preset: string): void {
|
||||
const v = this.db?.vorgangLesen(vorgangId)
|
||||
if (v === null || v === undefined) {
|
||||
this.kontext.fehler(`Vorgang ${vorgangId}: unbekannt.`)
|
||||
return
|
||||
}
|
||||
if (this.warteschlange.kennt(vorgangId)) {
|
||||
this.kontext.fehler(`„${v.titel}" ist schon in der Warteschlange.`)
|
||||
return
|
||||
}
|
||||
const auftrag = auftragAusJson(v.auftrag)
|
||||
if (auftrag === null) {
|
||||
this.kontext.fehler(`„${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.`)
|
||||
return
|
||||
}
|
||||
const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad))
|
||||
if (vorhandene.length === 0) {
|
||||
this.kontext.fehler(`„${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).`)
|
||||
return
|
||||
}
|
||||
if (vorhandene.length < auftrag.dateien.length) {
|
||||
this.kontext.hinweis(`„${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
|
||||
}
|
||||
const alteFassung = v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner)
|
||||
const neu: KompressionsAuftrag = {
|
||||
...auftrag,
|
||||
dateien: vorhandene,
|
||||
preset,
|
||||
// Die neue Fassung ersetzt die alte im selben Ordner — die alten
|
||||
// Dateien fallen erst, wenn die neuen fertig sind (ablegen.ts).
|
||||
zielOrdner: alteFassung ? v.zielOrdner : '',
|
||||
ersetzt: alteFassung ? dateienAusJson(v.fertigeDateien) : [],
|
||||
}
|
||||
this.db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
|
||||
const platz = this.warteschlange.einreihen(neu, v.titel)
|
||||
this.kontext.hinweis(`„${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
|
||||
this.senden()
|
||||
}
|
||||
|
||||
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
|
||||
wiederaufnehmen(): void {
|
||||
if (this.db === null) return
|
||||
const jetzt = new Date().toISOString()
|
||||
for (const v of this.db.vorgangListe()) {
|
||||
if (v.phase === 'rippt') {
|
||||
this.db.vorgangAktualisieren(v.id, {
|
||||
phase: 'abgebrochen',
|
||||
beendet: jetzt,
|
||||
fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.',
|
||||
})
|
||||
this.kontext.protokoll.schreiben(`Vorgang ${v.id}: war beim letzten Beenden mitten im Rip — als abgebrochen vermerkt`)
|
||||
continue
|
||||
}
|
||||
if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') {
|
||||
const auftrag = auftragAusJson(v.auftrag)
|
||||
if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) {
|
||||
this.warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
|
||||
this.kontext.hinweis(`„${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
|
||||
} else {
|
||||
this.db.vorgangAktualisieren(v.id, {
|
||||
phase: 'fehler',
|
||||
beendet: jetzt,
|
||||
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Die Werkzeuge (§ 9): wo makemkvcon, HandBrakeCLI und flac liegen, die
|
||||
// gemessenen HandBrake-Listen (Presets, Encoder), die MakeMKV-Schlüsselkette
|
||||
// (§ 9.1) samt Auswahlregel (5.4.0) und das MakeMKV-Selbst-Update.
|
||||
import type { KernNachricht } from '../../gemeinsam/nachrichten'
|
||||
import type { KernKontext } from '../kontext'
|
||||
import { finden } from '../werkzeuge/katalog'
|
||||
import type { EinstellungsDienst } from './einstellungen'
|
||||
|
||||
export type SchluesselStand = Extract<KernNachricht, { art: 'schluessel-status' }>['stand']
|
||||
|
||||
export class WerkzeugDienst {
|
||||
/** Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
|
||||
* Preset-Empfehlung, auch wenn gerade kein Fenster offen ist. */
|
||||
auskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
|
||||
schluesselStand: SchluesselStand | null = null
|
||||
private beschaffungLaeuft = false
|
||||
private installerAntwort: ((fehler: string | null) => void) | null = null
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
private readonly einstellungen: EinstellungsDienst,
|
||||
) {}
|
||||
|
||||
makemkvcon(): string {
|
||||
return finden('makemkv', this.einstellungen.lesen('werkzeug.makemkv'))
|
||||
}
|
||||
|
||||
handbrake(): string {
|
||||
return finden('handbrake', this.einstellungen.lesen('werkzeug.handbrake'))
|
||||
}
|
||||
|
||||
flac(): string {
|
||||
return finden('flac', this.einstellungen.lesen('werkzeug.flac'))
|
||||
}
|
||||
|
||||
/** Werkzeuge suchen, HandBrake messen, ans Fenster. */
|
||||
async senden(): Promise<void> {
|
||||
const makemkvcon = this.makemkvcon()
|
||||
const handbrake = this.handbrake()
|
||||
const flac = this.flac()
|
||||
const { encoderMessen } = await import('../komprimieren/encoder')
|
||||
const gemessen = await encoderMessen(handbrake)
|
||||
this.auskunft = { presets: gemessen.presets, backends: gemessen.backends }
|
||||
this.kontext.anFenster({
|
||||
art: 'werkzeuge',
|
||||
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
|
||||
})
|
||||
this.kontext.protokoll.schreiben(
|
||||
`Werkzeuge: makemkvcon=${makemkvcon || 'FEHLT'} handbrake=${handbrake || 'FEHLT'} flac=${flac || 'FEHLT'} · ${gemessen.presets.length} Presets · Encoder ${gemessen.backends.join(',') || '—'}`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ──────────────
|
||||
async schluesselLauf(): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
try {
|
||||
const { schluesselKette } = await import('../werkzeuge/schluessel')
|
||||
this.schluesselStand = await schluesselKette(
|
||||
this.makemkvcon(),
|
||||
this.einstellungen.lesen('makemkvAppKey'),
|
||||
new Date().toISOString(),
|
||||
)
|
||||
this.kontext.anHauptUndFenster({ art: 'schluessel-status', stand: this.schluesselStand })
|
||||
this.kontext.protokoll.schreiben(`Schlüssel: ${this.schluesselStand.text}`)
|
||||
} catch (fehler) {
|
||||
// R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt.
|
||||
this.kontext.fehler(`Schlüsselkette: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) — sonst fehlen
|
||||
* Sprachen in der Roh-Datei, die der Dialog anbot. */
|
||||
async spurauswahlLauf(): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
try {
|
||||
const { spurauswahlSicherstellen } = await import('../werkzeuge/spurauswahl')
|
||||
const stand = spurauswahlSicherstellen()
|
||||
if (stand.stand === 'eigen' || stand.stand === 'fehler') {
|
||||
this.kontext.fehler(`MakeMKV-Auswahlregel: ${stand.text}`)
|
||||
} else if (stand.stand === 'gesetzt') {
|
||||
this.kontext.hinweis(stand.text)
|
||||
} else {
|
||||
this.kontext.protokoll.schreiben(stand.text)
|
||||
}
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`MakeMKV-Auswahlregel: ${String(fehler)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Antwort des Haupts auf 'installer-starten'. */
|
||||
installerErgebnis(fehler: string | null): void {
|
||||
this.installerAntwort?.(fehler ?? '')
|
||||
this.installerAntwort = null
|
||||
}
|
||||
|
||||
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
|
||||
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
|
||||
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}`)
|
||||
}
|
||||
|
||||
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
|
||||
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
|
||||
// Prozess-Leine und überlebt ein geschlossenes Rippy).
|
||||
async makemkvUpdateLauf(): Promise<void> {
|
||||
if (this.beschaffungLaeuft) {
|
||||
this.beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true)
|
||||
return
|
||||
}
|
||||
this.beschaffungLaeuft = true
|
||||
try {
|
||||
const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import(
|
||||
'../werkzeuge/beschaffen'
|
||||
)
|
||||
this.beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true)
|
||||
const neueste = await makemkvNeuesteVersion()
|
||||
if (neueste.length === 0) {
|
||||
this.beschaffungMelden(
|
||||
'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' +
|
||||
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
this.beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
const installiert = this.schluesselStand?.version ?? ''
|
||||
if (installiert.length > 0 && !istNeuer(neueste, installiert)) {
|
||||
// Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND
|
||||
// die neueste angekündigte — die Beta ist nur abgelaufen): Ein
|
||||
// Download derselben Fassung würde nichts lösen.
|
||||
this.beschaffungMelden(
|
||||
`MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` +
|
||||
'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' +
|
||||
'bis dahin rippt der Beta-Modus weiter.',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
this.beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
// Merken, woran eine gelungene Installation zu erkennen ist: Die
|
||||
// Versions-Ressource der gefundenen EXE ändert sich (oder es gibt
|
||||
// erstmals eine).
|
||||
const alteExe = this.makemkvcon()
|
||||
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
||||
|
||||
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => this.beschaffungMelden(text, anteil, true))
|
||||
this.beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
|
||||
|
||||
const startFehler = await new Promise<string | null>((fertig) => {
|
||||
this.installerAntwort = fertig
|
||||
process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad })
|
||||
// Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen.
|
||||
setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref()
|
||||
})
|
||||
this.installerAntwort = null
|
||||
if (startFehler !== null && startFehler.length > 0) {
|
||||
this.beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true)
|
||||
this.beschaffungLaeuft = false
|
||||
return
|
||||
}
|
||||
this.beschaffungMelden(
|
||||
`Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` +
|
||||
'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.',
|
||||
null,
|
||||
true,
|
||||
)
|
||||
// Warten, bis die Installation durch ist: Die Versions-Ressource der
|
||||
// EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten.
|
||||
let runden = 0
|
||||
const zeiger = setInterval(() => {
|
||||
runden += 1
|
||||
const exe = this.makemkvcon()
|
||||
const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : ''
|
||||
if (jetztVersion.length > 0 && jetztVersion !== alteVersion) {
|
||||
clearInterval(zeiger)
|
||||
this.beschaffungLaeuft = false
|
||||
this.beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false)
|
||||
void this.schluesselLauf().then(() => this.senden())
|
||||
} else if (runden >= 80) {
|
||||
clearInterval(zeiger)
|
||||
this.beschaffungLaeuft = false
|
||||
this.beschaffungMelden(
|
||||
'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".',
|
||||
null,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}, 15_000)
|
||||
zeiger.unref()
|
||||
} catch (fehler) {
|
||||
this.beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true)
|
||||
this.beschaffungLaeuft = false
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
-1141
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
// Der gemeinsame Kontext der Kern-Dienste (5.5.0, Separation of Concerns):
|
||||
// Jeder Dienst bekommt genau das hier — die Datenbank, die Wege nach
|
||||
// draußen und das Protokoll — statt auf Modul-Globale zu greifen. So ist
|
||||
// jeder Dienst für sich lesbar und mit einem Fake-Kontext testbar.
|
||||
import type { DbStatus, KernNachricht } from '../gemeinsam/nachrichten'
|
||||
import type { Protokoll } from '../gemeinsam/protokoll'
|
||||
import type { Datenbank } from './speicher/db'
|
||||
|
||||
export interface KernKontext {
|
||||
/** null, wenn die Datenbank nicht zu öffnen war — der Kern läuft trotzdem (R4). */
|
||||
readonly db: Datenbank | null
|
||||
readonly dbStatus: DbStatus
|
||||
/** Nur ans Fenster (wenn eins verbunden ist). */
|
||||
anFenster(nachricht: KernNachricht): void
|
||||
/** Ans Haupt UND ans Fenster — für alles, was Tray/Taskleiste/Updater brauchen. */
|
||||
anHauptUndFenster(nachricht: KernNachricht): void
|
||||
/** Eine Zeile ohne Warnzeichen fürs Ereignis-Protokoll — und in die Datei. */
|
||||
hinweis(text: string): void
|
||||
/** Eine Zeile MIT Warnzeichen (kern-fehler) — und in die Datei. */
|
||||
fehler(text: string): void
|
||||
readonly protokoll: Protokoll
|
||||
/** Der Gerüst-Beweis (--smoke-kern): ohne Netz, ohne MakeMKV, ohne Takte. */
|
||||
readonly smoke: boolean
|
||||
}
|
||||
|
||||
/** --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
|
||||
* nicht — er ist ein reiner Node-Prozess). */
|
||||
export function datenbankPfadAusArgv(argv: readonly string[]): string | null {
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith('--datenbank=')) {
|
||||
const pfad = arg.slice('--datenbank='.length)
|
||||
if (pfad.length > 0) return pfad
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Einstellung lesen mit Vorgabe — die eine Quelle (§ 6.7). */
|
||||
export function einstellung(kontext: Pick<KernKontext, 'db'>, schluessel: string, vorgabe = ''): string {
|
||||
const wert = kontext.db?.einstellungLesen(schluessel)
|
||||
return wert === null || wert === undefined ? vorgabe : wert
|
||||
}
|
||||
@@ -185,6 +185,22 @@ export class TmdbClient {
|
||||
})) as FilmDetails | null
|
||||
}
|
||||
|
||||
/** Poster beliebter Filme (5.5.0, Hintergrund der Erst-Einrichtung):
|
||||
* /movie/popular liefert results[].poster_path — dieselbe Form wie die
|
||||
* Suche (FilmTreffer). Zwei Seiten à 20 reichen für ein volles Raster. */
|
||||
async beliebtePoster(hoechstens = 40): Promise<string[]> {
|
||||
const pfade: string[] = []
|
||||
for (let seite = 1; seite <= 2 && pfade.length < hoechstens; seite++) {
|
||||
const daten = (await this.anfrage('movie/popular', { language: 'de-DE', page: String(seite) })) as {
|
||||
results?: FilmTreffer[]
|
||||
} | null
|
||||
for (const f of daten?.results ?? []) {
|
||||
if (typeof f.poster_path === 'string' && f.poster_path.length > 0) pfade.push(f.poster_path)
|
||||
}
|
||||
}
|
||||
return pfade.slice(0, hoechstens)
|
||||
}
|
||||
|
||||
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
|
||||
async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> {
|
||||
return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as {
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
|
||||
// Pfades, nicht nach dem Rechner.
|
||||
export function quelle(geraetepfad: string): string {
|
||||
// 5.5.0: Das Rettungs-Abbild ist eine ISO-Datei — makemkvcon liest sie
|
||||
// als `iso:<pfad>` (Manpage makemkvcon: „iso:<FileName>").
|
||||
if (/\.iso$/i.test(geraetepfad)) return `iso:${geraetepfad}`
|
||||
let pfad = (geraetepfad ?? '').trim()
|
||||
if (pfad.length === 0) return ''
|
||||
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Das Rettungs-Abbild (5.5.0, Commander: „korrupte Discs … selbst
|
||||
// reparieren, mit 0 füllen") — Rippys eigener Sektor-Leser, wie beim
|
||||
// ISO-Backup (iso.ts), aber mit Wiederholung in immer kleineren Stücken und
|
||||
// Nullen für das, was die Disc nicht mehr hergibt:
|
||||
//
|
||||
// 1 MiB am Stück → scheitert: 64-KiB-Stücke, je zwei Versuche →
|
||||
// scheitert: einzelne 2048-Byte-Sektoren, je zwei Versuche → Nullen.
|
||||
//
|
||||
// Das Ergebnis ist ein Abbild ohne Loch, das MakeMKV als `iso:` lesen kann
|
||||
// (Manpage makemkvcon; MakeMKV-FAQ „decrypted backup"): Ein genullter
|
||||
// Sektor ist für MakeMKV kein Lesefehler mehr, an dem es hängen bleibt —
|
||||
// nur eine Bildstörung von Sekundenbruchteilen an dieser Stelle. Was
|
||||
// wirklich unlesbar ist, holt niemand zurück; aber der Rest der Disc geht
|
||||
// nicht mehr verloren. Läuft NUR, wenn Rippy vorher Lesefehler gesehen
|
||||
// hat (Defekt-Wächter, Lesefehler-Meldung) — eine heile Disc braucht das
|
||||
// nicht (Commander 01.09.2026).
|
||||
//
|
||||
// PUR bis auf die injizierten Lese-/Schreibfunktionen: Die Ampel prüft die
|
||||
// Wiederholungs- und Nullfüll-Logik mit einem nachgebauten Gerät.
|
||||
import { open } from 'node:fs/promises'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
export const BLOCK_BYTES = 1024 * 1024
|
||||
export const FEIN_BYTES = 64 * 1024
|
||||
export const SEKTOR_BYTES = 2048
|
||||
|
||||
export interface UnlesbareStelle {
|
||||
offset: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export type RettungsErgebnis =
|
||||
| { status: 'success'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
|
||||
| { status: 'cancelled'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
|
||||
| { status: 'error'; fehler: string; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] }
|
||||
|
||||
/** Liest `laenge` Bytes ab `position` in `puffer` (ab 0) und liefert, wie
|
||||
* viele es wurden. Wirft bei einem Lesefehler. */
|
||||
export type Leser = (puffer: Buffer, laenge: number, position: number) => Promise<number>
|
||||
export type Schreiber = (puffer: Buffer, laenge: number) => Promise<void>
|
||||
|
||||
export interface RettungsOptionen {
|
||||
leser: Leser
|
||||
schreiber: Schreiber
|
||||
erwarteteBytes: number
|
||||
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void
|
||||
abbruch?: AbortSignal
|
||||
/** Versuche je Stück — Vorgabe 2. */
|
||||
versuche?: number
|
||||
}
|
||||
|
||||
async function lesenMitVersuchen(leser: Leser, puffer: Buffer, laenge: number, position: number, versuche: number): Promise<boolean> {
|
||||
for (let i = 0; i < versuche; i++) {
|
||||
try {
|
||||
const n = await leser(puffer, laenge, position)
|
||||
if (n === laenge) return true
|
||||
} catch {
|
||||
// Ein Fehler ist ein Versuch weniger — mehr sagt das Gerät nicht.
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Zusammenhängende unlesbare Bereiche zu Stellen zusammenfassen (pur). */
|
||||
export function stelleHinzu(stellen: UnlesbareStelle[], offset: number, bytes: number): void {
|
||||
const letzte = stellen[stellen.length - 1]
|
||||
if (letzte !== undefined && letzte.offset + letzte.bytes === offset) {
|
||||
letzte.bytes += bytes
|
||||
return
|
||||
}
|
||||
stellen.push({ offset, bytes })
|
||||
}
|
||||
|
||||
export async function rettungsAbbild(o: RettungsOptionen): Promise<RettungsErgebnis> {
|
||||
const versuche = o.versuche ?? 2
|
||||
const puffer = Buffer.alloc(BLOCK_BYTES)
|
||||
const feinPuffer = Buffer.alloc(FEIN_BYTES)
|
||||
const sektorPuffer = Buffer.alloc(SEKTOR_BYTES)
|
||||
const stellen: UnlesbareStelle[] = []
|
||||
let unlesbareSektoren = 0
|
||||
let gelesen = 0
|
||||
let letzterProzent = -1
|
||||
const melden = (): void => {
|
||||
const prozent = o.erwarteteBytes > 0 ? Math.floor((gelesen * 100) / o.erwarteteBytes) : 0
|
||||
if (prozent !== letzterProzent) {
|
||||
letzterProzent = prozent
|
||||
o.aufFortschritt?.(prozent, unlesbareSektoren, stellen.length)
|
||||
}
|
||||
}
|
||||
if (o.erwarteteBytes <= 0) {
|
||||
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein Abbild.', bytes: 0, unlesbareSektoren, stellen }
|
||||
}
|
||||
try {
|
||||
while (gelesen < o.erwarteteBytes) {
|
||||
if (o.abbruch?.aborted === true) return { status: 'cancelled', bytes: gelesen, unlesbareSektoren, stellen }
|
||||
const n = Math.min(BLOCK_BYTES, o.erwarteteBytes - gelesen)
|
||||
if (!(await lesenMitVersuchen(o.leser, puffer, n, gelesen, 1))) {
|
||||
// Der Block klemmt — feiner lesen, nur das wirklich Kaputte nullen.
|
||||
for (let teil = 0; teil < n; teil += FEIN_BYTES) {
|
||||
const m = Math.min(FEIN_BYTES, n - teil)
|
||||
if (await lesenMitVersuchen(o.leser, feinPuffer, m, gelesen + teil, versuche)) {
|
||||
feinPuffer.copy(puffer, teil, 0, m)
|
||||
continue
|
||||
}
|
||||
for (let s = 0; s < m; s += SEKTOR_BYTES) {
|
||||
const k = Math.min(SEKTOR_BYTES, m - s)
|
||||
if (await lesenMitVersuchen(o.leser, sektorPuffer, k, gelesen + teil + s, versuche)) {
|
||||
sektorPuffer.copy(puffer, teil + s, 0, k)
|
||||
} else {
|
||||
puffer.fill(0, teil + s, teil + s + k)
|
||||
unlesbareSektoren += Math.ceil(k / SEKTOR_BYTES)
|
||||
stelleHinzu(stellen, gelesen + teil + s, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await o.schreiber(puffer, n)
|
||||
gelesen += n
|
||||
melden()
|
||||
}
|
||||
} catch (fehler) {
|
||||
return { status: 'error', fehler: `Schreiben brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen, unlesbareSektoren, stellen }
|
||||
}
|
||||
return { status: 'success', bytes: gelesen, unlesbareSektoren, stellen }
|
||||
}
|
||||
|
||||
/** Der Bericht neben dem Abbild — Klartext für den Commander. */
|
||||
export function rettungsBericht(quelle: string, ergebnis: RettungsErgebnis, erwarteteBytes: number): string {
|
||||
const zeilen = [
|
||||
`Rettungs-Abbild von ${quelle}`,
|
||||
`Stand: ${ergebnis.status === 'success' ? 'vollständig' : ergebnis.status === 'cancelled' ? 'abgebrochen' : 'Fehler'}`,
|
||||
`Gelesen: ${ergebnis.bytes} von ${erwarteteBytes} Bytes`,
|
||||
`Unlesbare Sektoren: ${ergebnis.unlesbareSektoren} (${((ergebnis.unlesbareSektoren * SEKTOR_BYTES * 100) / Math.max(1, erwarteteBytes)).toFixed(4)} %) an ${ergebnis.stellen.length} Stellen`,
|
||||
]
|
||||
if (ergebnis.status === 'error') zeilen.push(`Fehler: ${ergebnis.fehler}`)
|
||||
for (const s of ergebnis.stellen.slice(0, 200)) zeilen.push(` ab Byte ${s.offset}: ${s.bytes} Bytes genullt`)
|
||||
if (ergebnis.stellen.length > 200) zeilen.push(` … und ${ergebnis.stellen.length - 200} weitere Stellen`)
|
||||
return zeilen.join('\n') + '\n'
|
||||
}
|
||||
|
||||
/** Vom Gerät in eine Datei — mit Bericht daneben (<ziel>.rettung.txt). */
|
||||
export async function rettungsAbbildDatei(
|
||||
geraetepfad: string,
|
||||
zielDatei: string,
|
||||
erwarteteBytes: number,
|
||||
aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void,
|
||||
abbruch?: AbortSignal,
|
||||
): Promise<RettungsErgebnis> {
|
||||
let quelle
|
||||
try {
|
||||
quelle = await open(geraetepfad, 'r')
|
||||
} catch (fehler) {
|
||||
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
|
||||
}
|
||||
let ziel
|
||||
try {
|
||||
ziel = await open(zielDatei, 'w')
|
||||
} catch (fehler) {
|
||||
await quelle.close()
|
||||
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] }
|
||||
}
|
||||
const q = quelle
|
||||
const z = ziel
|
||||
let ergebnis: RettungsErgebnis
|
||||
try {
|
||||
ergebnis = await rettungsAbbild({
|
||||
leser: async (puffer, laenge, position) => (await q.read(puffer, 0, laenge, position)).bytesRead,
|
||||
schreiber: async (puffer, laenge) => {
|
||||
await z.write(puffer, 0, laenge)
|
||||
},
|
||||
erwarteteBytes,
|
||||
aufFortschritt,
|
||||
abbruch,
|
||||
})
|
||||
} finally {
|
||||
await q.close()
|
||||
await z.close()
|
||||
}
|
||||
try {
|
||||
writeFileSync(`${zielDatei}.rettung.txt`, rettungsBericht(geraetepfad, ergebnis, erwarteteBytes))
|
||||
} catch {
|
||||
// Der Bericht ist Beigabe — das Abbild zählt.
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
Reference in New Issue
Block a user