feat(v5): Etappe W-5 — Oberflaeche: Tray, Toasts, Taskleiste, Einstellungen, Bibliothek
Ampel / ampel (push) Successful in 1m23s
Ampel / ampel (push) Successful in 1m23s
haupt/: tray.ts (Symbol mit Zustand, Kontextmenue, Fenster zu = weiter- laufen, Beenden nur noch gewollt), Windows-Toasts bei Disc erkannt / fertig / Fehler (Klick holt das Fenster), Taskleisten-Fortschritt (setProgressBar, Fehlermodus), echter Windows-Ordnerdialog per IPC. Eigenes Icon (bau/icon.png + tray.png, Disc-Optik). Kern: Einstellungen lesen/schreiben NUR ueber die eine Quelle (§ 6.7, Whitelist der Schluessel), Werkzeug-Auskunft (Katalog + gemessene HandBrake-Presets/Backends), Bibliothek in db.ts (Schema v2 — fingerprint, Titel, Groesse, Ort, Dauer), Duplikat-Erkennung beim Einlegen (§ 6.6: 'schon einmal gerippt am …' als disc-info + Toast), Pipeline traegt fertige Vorgaenge ein. Fenster: drei Bereiche (Uebersicht / Einstellungen / Bibliothek). Einstellungen: Ablage mit Durchsuchen-Dialog, Kompression je Disc-Typ (Preset-Liste VOM eigenen HandBrake gemessen, 'NICHT komprimieren' waehlbar), Sprachlisten, TMDb/OMDb-Keys, Medienserver, Werkzeug-Stand mit Klartext (gefunden/fehlt + Folgen). Bibliothek als Tabelle. Der Smoke-Screenshot zeigt nebenbei den Ernstfall in schoen: Das Laufwerk ist nach dem rc11-Vorfall inzwischen auch am Storage-Stack in 'unknown' gekippt — und die Kachel zeigt den ZUGRIFFS_GRUENDE-Klartext samt Abhilfe statt eines stummen Zustands. 165 Tests gruen, Smoke gruen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a9fa8fa2d8
commit
c78ff22ab7
@@ -7,10 +7,10 @@
|
||||
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in
|
||||
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
|
||||
// Einstellung im UI (W-5) eine Stimme.
|
||||
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { basename, join } from 'node:path'
|
||||
import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
import { posterSpeichern } from '../ablage/poster'
|
||||
@@ -38,6 +38,10 @@ export interface PipelineUmgebung {
|
||||
melden: (status: RipStatus) => void
|
||||
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
|
||||
zuordnung?: (id: string) => Zuordnung | null
|
||||
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
|
||||
fingerprint?: (id: string) => string | null
|
||||
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
|
||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||
jetzt?: () => Date
|
||||
}
|
||||
@@ -99,7 +103,8 @@ export class Pipeline {
|
||||
}
|
||||
|
||||
const abbruch = new AbortController()
|
||||
const zeit = (this.umgebung.jetzt?.() ?? new Date()).toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
|
||||
const startZeit = this.umgebung.jetzt?.() ?? new Date()
|
||||
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
|
||||
const vorgangsName = `${geraet.typ}-${zeit}`
|
||||
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
|
||||
|
||||
@@ -267,6 +272,30 @@ export class Pipeline {
|
||||
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
|
||||
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
|
||||
|
||||
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
|
||||
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
|
||||
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
|
||||
let groesse = 0
|
||||
for (const datei of fertigeDateien) {
|
||||
try {
|
||||
groesse += statSync(datei).size
|
||||
} catch {
|
||||
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts
|
||||
// am Eintrag selbst.
|
||||
}
|
||||
}
|
||||
this.umgebung.bibliothekEintragen({
|
||||
fingerprint,
|
||||
titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
typ: geraet.typ,
|
||||
groesseBytes: groesse,
|
||||
ablageort: fertigOrdner,
|
||||
datum: startZeit.toISOString(),
|
||||
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
|
||||
})
|
||||
}
|
||||
|
||||
this.laufend.delete(id)
|
||||
this.melden({
|
||||
id,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Pipeline } from './ablauf/pipeline'
|
||||
import type { LaufwerkApi } from './laufwerk/codes'
|
||||
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
|
||||
import { DiscWache } from './laufwerk/wache'
|
||||
import { discTitel } from './metadaten/discmerkmale'
|
||||
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
|
||||
import { OmdbClient } from './metadaten/omdb'
|
||||
import { TmdbClient } from './metadaten/tmdb'
|
||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||
@@ -84,10 +84,13 @@ let wache: DiscWache | null = null
|
||||
|
||||
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
|
||||
const discZuordnungen = new Map<string, Zuordnung>()
|
||||
const discFingerprints = new Map<string, string>()
|
||||
|
||||
async function discErmitteln(id: string, geraetepfad: string): Promise<void> {
|
||||
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
||||
try {
|
||||
const gefunden = discTitel(geraetepfad)
|
||||
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
||||
discFingerprints.set(id, fingerprint)
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
||||
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
|
||||
@@ -101,6 +104,9 @@ async function discErmitteln(id: string, geraetepfad: string): Promise<void> {
|
||||
confidence: zuordnung.confidence,
|
||||
quelle: zuordnung.meta.quelle,
|
||||
posterPfad: zuordnung.meta.posterPfad,
|
||||
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
|
||||
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
|
||||
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
@@ -124,12 +130,21 @@ const laufwerkeBereit: Promise<void> = (async () => {
|
||||
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
|
||||
for (const ereignis of meldung.ereignisse) {
|
||||
if (ereignis.art === 'disc-eingelegt') {
|
||||
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad)
|
||||
void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes)
|
||||
} else if (ereignis.art === 'disc-entnommen') {
|
||||
discZuordnungen.delete(ereignis.geraet.id)
|
||||
discFingerprints.delete(ereignis.geraet.id)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: { id: ereignis.geraet.id, titel: '', jahr: null, confidence: 0, quelle: '', posterPfad: '' },
|
||||
info: {
|
||||
id: ereignis.geraet.id,
|
||||
titel: '',
|
||||
jahr: null,
|
||||
confidence: 0,
|
||||
quelle: '',
|
||||
posterPfad: '',
|
||||
schonGerippt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -193,8 +208,62 @@ const ripVerwaltung = new Pipeline({
|
||||
einstellungen: einstellungenLaden,
|
||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
||||
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
||||
bibliothekEintragen: (eintrag) => {
|
||||
try {
|
||||
db?.bibliothekEintragen(eintrag)
|
||||
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||
} catch (fehler) {
|
||||
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
|
||||
// gesagt.
|
||||
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
|
||||
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
|
||||
const ERLAUBTE_EINSTELLUNGEN = new Set([
|
||||
'ablage',
|
||||
'transcodeEnabled',
|
||||
'transcodePreset',
|
||||
'transcodePresetDvd',
|
||||
'transcodePresetBluray',
|
||||
'transcodePresetUhd',
|
||||
'audioSprachen',
|
||||
'untertitelSprachen',
|
||||
'mediaServer',
|
||||
'mediaServerUrl',
|
||||
'mediaServerApiKey',
|
||||
'tmdbApiKey',
|
||||
'omdbApiKey',
|
||||
'autoAuswurf',
|
||||
'werkzeug.makemkv',
|
||||
'werkzeug.handbrake',
|
||||
'werkzeug.flac',
|
||||
])
|
||||
|
||||
function einstellungenSenden(): void {
|
||||
const werte: Record<string, string> = { ablage: ablageOrdner() }
|
||||
for (const schluessel of ERLAUBTE_EINSTELLUNGEN) {
|
||||
const wert = db?.einstellungLesen(schluessel)
|
||||
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
||||
}
|
||||
anFenster({ art: 'einstellungen', werte })
|
||||
}
|
||||
|
||||
async function werkzeugeSenden(): Promise<void> {
|
||||
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
|
||||
const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '')
|
||||
const { encoderMessen } = await import('./komprimieren/encoder')
|
||||
const gemessen = await encoderMessen(handbrake)
|
||||
anFenster({
|
||||
art: 'werkzeuge',
|
||||
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
|
||||
})
|
||||
}
|
||||
|
||||
async function auswerfen(id: string): Promise<void> {
|
||||
await laufwerkeBereit
|
||||
if (ripVerwaltung.laeuft(id)) {
|
||||
@@ -242,6 +311,21 @@ process.parentPort.on('message', (ereignis) => {
|
||||
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
|
||||
} else if (frage.art === 'rip-abbruch') {
|
||||
ripVerwaltung.abbrechen(frage.id)
|
||||
} else if (frage.art === 'einstellung-setzen') {
|
||||
if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) {
|
||||
anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` })
|
||||
} else if (db === null) {
|
||||
anFenster({ art: 'kern-fehler', text: 'Einstellung nicht gespeichert — die Datenbank ist nicht da.' })
|
||||
} else {
|
||||
db.einstellungSchreiben(frage.schluessel, frage.wert)
|
||||
einstellungenSenden()
|
||||
}
|
||||
} else if (frage.art === 'einstellungen-laden') {
|
||||
einstellungenSenden()
|
||||
} else if (frage.art === 'werkzeuge-laden') {
|
||||
void werkzeugeSenden()
|
||||
} else if (frage.art === 'bibliothek-laden') {
|
||||
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
||||
}
|
||||
})
|
||||
fensterPort.start()
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { BibliothekEintrag } from '../../gemeinsam/nachrichten'
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
export const SCHEMA_VERSION = 2
|
||||
|
||||
export class Datenbank {
|
||||
readonly pfad: string
|
||||
@@ -36,6 +37,16 @@ export class Datenbank {
|
||||
CREATE TABLE IF NOT EXISTS einstellungen (
|
||||
schluessel TEXT PRIMARY KEY,
|
||||
wert TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS bibliothek (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
titel TEXT NOT NULL,
|
||||
jahr INTEGER,
|
||||
typ TEXT NOT NULL,
|
||||
groesse_bytes INTEGER NOT NULL,
|
||||
ablageort TEXT NOT NULL,
|
||||
datum TEXT NOT NULL,
|
||||
dauer_s INTEGER NOT NULL
|
||||
);`,
|
||||
)
|
||||
this.db
|
||||
@@ -64,6 +75,51 @@ export class Datenbank {
|
||||
return zeile === undefined ? 0 : Number(zeile.wert)
|
||||
}
|
||||
|
||||
// ── Bibliothek (§ 6.6): alles Gerippte, wiedererkennbar am
|
||||
// Disc-Fingerabdruck ────────────────────────────────────────────────
|
||||
bibliothekEintragen(eintrag: BibliothekEintrag): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO bibliothek
|
||||
(fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
eintrag.fingerprint,
|
||||
eintrag.titel,
|
||||
eintrag.jahr,
|
||||
eintrag.typ,
|
||||
eintrag.groesseBytes,
|
||||
eintrag.ablageort,
|
||||
eintrag.datum,
|
||||
eintrag.dauerS,
|
||||
)
|
||||
}
|
||||
|
||||
bibliothekListe(): BibliothekEintrag[] {
|
||||
const zeilen = this.db
|
||||
.prepare(`SELECT * FROM bibliothek ORDER BY datum DESC`)
|
||||
.all() as Array<Record<string, unknown>>
|
||||
return zeilen.map((z) => ({
|
||||
fingerprint: String(z.fingerprint),
|
||||
titel: String(z.titel),
|
||||
jahr: z.jahr === null ? null : Number(z.jahr),
|
||||
typ: String(z.typ),
|
||||
groesseBytes: Number(z.groesse_bytes),
|
||||
ablageort: String(z.ablageort),
|
||||
datum: String(z.datum),
|
||||
dauerS: Number(z.dauer_s),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Datum des früheren Rips dieser Disc — oder null (neu). */
|
||||
bibliothekSchonGerippt(fingerprint: string): string | null {
|
||||
const zeile = this.db
|
||||
.prepare(`SELECT datum FROM bibliothek WHERE fingerprint = ?`)
|
||||
.get(fingerprint) as { datum: string } | undefined
|
||||
return zeile === undefined ? null : zeile.datum
|
||||
}
|
||||
|
||||
schliessen(): void {
|
||||
this.db.close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user