Native Windows-Auslieferung statt Container

Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt
TypeScript direkt aus - der Container schrieb nur noch drei Programme fest,
und dafuer braucht es keine Virtualisierung, sondern einen Ordner.

Der Anlass war, dass ADR-0004 zwar `docker compose pull` versprach, es aber
nie ein veroeffentlichtes Image gab. Wer installieren wollte, musste
whisper.cpp aus dem Quelltext uebersetzen.

Plattformschicht (packages/core/src/platform.ts):
- Programme werden neben der Anwendung gesucht, nicht im PATH
- Daten unter %LOCALAPPDATA%, damit Installation und Update ohne
  Administratorrechte auskommen
- Schriften mitgeliefert: ohne fontsdir setzt libass wortlos eine
  Ersatzschrift ein - gemessen 720 statt 826 Pixel Zeilenbreite

Windows-Eigenheiten, an echtem ffmpeg 8.1 durchprobiert:
- Pfade im Filtergraphen brauchen alle drei Schritte (Vorwaertsschraegstriche,
  escapter Doppelpunkt, Anfuehrungszeichen); je zwei davon reichen nicht
- `nice` gibt es nicht, ersetzt durch os.setPriority

Auslieferung:
- scripts/build-package.ts baut das Paket, laeuft auch unter Linux
- install.ps1 ohne Administratorrechte, Autostart ueber den Aufgabenplaner
- update legt neue Fassungen bereit; eingespielt wird beim naechsten Start,
  nie mitten im Stream
- ZIP lesen und schreiben ohne Abhaengigkeiten (packages/core/src/zip.ts)

Kein `irm ... | iex`-Einzeiler: git.zeasy.dev antwortete instanzweit mit einer
Umleitung auf /user/login, und `iex` fuehrte die Anmeldeseite als PowerShell
aus. Der Updater verfolgt Umleitungen jetzt nicht und hat keine erfundene
Vorgabeadresse mehr.

Ausserdem: backup.ps1 war an zwei Stellen dokumentiert, aber nie geschrieben.

ADR-0007 ersetzt ADR-0004. 84 Tests, Typpruefung sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 17:39:26 +02:00
1 parent 71dd660d01
commit 5f6301268a
41 files changed
+2254 -312

No files matched your search

+14 -7
View File
@@ -4,14 +4,19 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseYaml, type YamlMap } from './yaml.ts';
import { defaultDirs } from './platform.ts';
// Die Vorgaben haengen am Betriebssystem (siehe platform.ts); die
// Umgebungsvariablen ueberschreiben sie wie bisher.
const dirs = defaultDirs();
export const paths = {
config: process.env.CONFIG_DIR || '/app/config',
db: process.env.DB_DIR || '/db',
work: process.env.WORK_DIR || '/work',
clips: process.env.CLIPS_DIR || '/app/data/clips',
replay: process.env.REPLAY_DIR || '/replay',
models: process.env.MODEL_DIR || '/models',
config: process.env.CONFIG_DIR || dirs.config,
db: process.env.DB_DIR || dirs.db,
work: process.env.WORK_DIR || dirs.work,
clips: process.env.CLIPS_DIR || dirs.clips,
replay: process.env.REPLAY_DIR || dirs.replay,
models: process.env.MODEL_DIR || dirs.models,
};
export type Config = {
@@ -27,6 +32,7 @@ export type Config = {
};
};
obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] };
update: { url: string };
clipper: {
enabled: boolean;
alsoCreateTwitchClip: boolean;
@@ -57,7 +63,8 @@ const DEFAULTS = {
laughEmotes: ['KEKW', 'LUL', 'OMEGALUL', 'PogChamp', 'Pog'],
},
},
obs: { url: 'ws://host.docker.internal:4455', replayBufferSeconds: 90, chapterScenes: [] },
obs: { url: 'ws://127.0.0.1:4455', replayBufferSeconds: 90, chapterScenes: [] },
update: { url: '' },
clipper: {
enabled: true,
alsoCreateTwitchClip: true,
+105
View File
@@ -0,0 +1,105 @@
// Wo die Anwendung ihre Programme, Schriften und Daten findet.
//
// Bis 2026-09 lief alles in einem Container: ffmpeg lag im PATH, die Daten
// unter festen Linux-Absolutpfaden (/db, /work, /models). Nativ unter Windows
// gibt es beides nicht - die Programme liegen neben der Anwendung, die Daten
// unter %ProgramData%. Siehe ADR-0007.
//
// Die Linux-Pfade bleiben als Vorgabe stehen, damit die Tests im Container
// unveraendert laufen und ein Betrieb unter Linux moeglich bleibt.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const isWindows = process.platform === 'win32';
const HERE = path.dirname(fileURLToPath(import.meta.url));
/** Wurzel des Quellbaums - im Paket der Ordner `app`. */
export const appRoot = path.resolve(HERE, '..', '..', '..');
/**
* Wurzel der Installation. Im Paket liegt darin `app/`, `bin/`, `fonts/` und
* `node.exe`; im Quell-Checkout ist es schlicht der Ordner ueber dem Repo und
* enthaelt nichts davon - dann greift die Suche im PATH.
*/
export const installRoot = path.resolve(appRoot, '..');
/** Verzeichnisse, in denen mitgelieferte Dateien gesucht werden. */
const bundleDirs = (sub: string): string[] =>
[path.join(installRoot, sub), path.join(appRoot, sub)];
const found = new Map<string, string>();
/**
* Ein Programm auflösen: erst die Umgebungsvariable, dann das mitgelieferte
* `bin/`, sonst der blanke Name - dann sucht das Betriebssystem im PATH.
*
* Die Reihenfolge ist Absicht. Die Umgebungsvariable gewinnt, damit sich eine
* kaputte Mitlieferung ohne Neuinstallation ueberschreiben laesst.
*/
export function findBinary(name: string, envVar: string): string {
const fromEnv = process.env[envVar];
if (fromEnv) return fromEnv;
const cached = found.get(name);
if (cached) return cached;
const exe = isWindows ? `${name}.exe` : name;
for (const dir of bundleDirs('bin')) {
const p = path.join(dir, exe);
if (fs.existsSync(p)) { found.set(name, p); return p; }
}
return name;
}
export const binaries = {
get ffmpeg(): string { return findBinary('ffmpeg', 'FFMPEG_BIN'); },
get ffprobe(): string { return findBinary('ffprobe', 'FFPROBE_BIN'); },
get whisper(): string { return findBinary('whisper-cli', 'WHISPER_BIN'); },
};
/**
* Ordner mit den mitgelieferten Schriften, oder `undefined`.
*
* Im Container kam DejaVu Sans aus `fonts-dejavu-core` und libass fand sie
* ueber fontconfig. Windows hat DejaVu nicht. Ohne diesen Ordner setzt libass
* still eine Ersatzschrift ein - der Untertitel sieht dann anders aus, ohne
* dass irgendetwas eine Warnung ausgibt.
*/
export function fontsDir(): string | undefined {
if (process.env['FONTS_DIR']) return process.env['FONTS_DIR'];
for (const dir of bundleDirs('fonts')) if (fs.existsSync(dir)) return dir;
return undefined;
}
/** Vorgabe fuer die Datenverzeichnisse, je nach Betriebssystem. */
export function defaultDirs(): Record<
'config' | 'db' | 'work' | 'clips' | 'replay' | 'models', string
> {
if (!isWindows) {
return {
config: '/app/config', db: '/db', work: '/work',
clips: '/app/data/clips', replay: '/replay', models: '/models',
};
}
// Nicht neben die Programme: ein Update tauscht den Programmordner
// komplett aus, und die Datenbank darf dabei nicht mitgehen.
//
// LOCALAPPDATA und nicht ProgramData, weil die Anwendung als angemeldeter
// Benutzer laeuft - dieselbe Sitzung wie OBS. Damit braucht weder die
// Installation noch ein Update Administratorrechte. Genau daran scheitern
// Aktualisierungen sonst auf einem Rechner, an dem gerade gestreamt wird.
const local = process.env['LOCALAPPDATA'] ??
path.join(process.env['USERPROFILE'] ?? 'C:\\', 'AppData', 'Local');
const base = path.join(local, 'abdulive');
return {
config: path.join(base, 'config'),
db: path.join(base, 'db'),
work: path.join(base, 'work'),
clips: path.join(base, 'clips'),
replay: path.join(base, 'replay'),
models: path.join(base, 'models'),
};
}
+121
View File
@@ -0,0 +1,121 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import zlib from 'node:zlib';
import crypto from 'node:crypto';
import { readZip, writeZip } from './zip.ts';
/**
* Baut ein ZIP von Hand. Ein Schreiber gehoert nicht ins Projekt, aber ohne
* eigenes Archiv braeuchte der Test eine Beispieldatei im Repo - und die
* saehe niemand mehr an.
*/
function makeZip(files: Array<{ name: string; body: string; deflate?: boolean }>): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
for (const f of files) {
const name = Buffer.from(f.name, 'utf8');
const raw = Buffer.from(f.body, 'utf8');
const data = f.deflate ? zlib.deflateRawSync(raw) : raw;
const method = f.deflate ? 8 : 0;
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(method, 8);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(name.length, 26);
name.copy(local, 30);
locals.push(local, data);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(method, 10);
central.writeUInt32LE(data.length, 20);
central.writeUInt32LE(raw.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(offset, 42);
name.copy(central, 46);
centrals.push(central);
offset += local.length + data.length;
}
const dir = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(files.length, 8);
eocd.writeUInt16LE(files.length, 10);
eocd.writeUInt32LE(dir.length, 12);
eocd.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, dir, eocd]);
}
test('liest gespeicherte und komprimierte Eintraege', () => {
const zip = makeZip([
{ name: 'bin/ffmpeg.exe', body: 'MZ-so-tun-als-ob' },
{ name: 'liesmich.txt', body: 'Hallo '.repeat(200), deflate: true },
]);
const entries = readZip(zip);
assert.equal(entries.length, 2);
assert.equal(entries[0]?.data().toString(), 'MZ-so-tun-als-ob');
assert.equal(entries[1]?.data().toString(), 'Hallo '.repeat(200));
assert.equal(entries[1]?.size, 1200);
});
test('erkennt Verzeichniseintraege am Schraegstrich', () => {
const entries = readZip(makeZip([{ name: 'bin/', body: '' }, { name: 'bin/a', body: 'x' }]));
assert.equal(entries[0]?.isDirectory, true);
assert.equal(entries[1]?.isDirectory, false);
});
test('findet das Inhaltsverzeichnis auch hinter einem Kommentar', () => {
// Der Kommentar steht hinter dem EOCD - von hinten zu suchen reicht nicht,
// man muss das Feld tatsaechlich pruefen.
const zip = makeZip([{ name: 'a.txt', body: 'inhalt' }]);
const withComment = Buffer.concat([zip, Buffer.from('# angehaengter Kommentar')]);
withComment.writeUInt16LE(24, withComment.length - 24 - 22 + 20);
assert.equal(readZip(withComment)[0]?.data().toString(), 'inhalt');
});
test('sagt deutlich, wenn kein ZIP vorliegt', () => {
assert.throws(() => readZip(Buffer.alloc(200)), /Inhaltsverzeichnis/);
});
test('meldet ZIP64 statt still Unsinn zu liefern', () => {
const zip = makeZip([{ name: 'a', body: 'x' }]);
zip.writeUInt32LE(0xffffffff, zip.length - 22 + 16); // dirStart
assert.throws(() => readZip(zip), /ZIP64/);
});
test('Geschriebenes laesst sich wieder lesen', () => {
const gross = Buffer.from('x'.repeat(50_000));
const zip = writeZip([
{ name: 'bin/ffmpeg.exe', data: Buffer.from([0x4d, 0x5a, 0x90, 0x00]) },
{ name: 'app/gross.txt', data: gross },
]);
const back = readZip(zip);
assert.deepEqual(back.map((e) => e.name), ['bin/ffmpeg.exe', 'app/gross.txt']);
assert.deepEqual(back[1]?.data(), gross);
// Gut komprimierbar - der Eintrag muss deutlich kleiner geworden sein
assert.ok(zip.length < 5_000, `Archiv ist ${zip.length} Bytes gross`);
});
test('schreibt eine gueltige CRC32 - sonst meckert der Explorer', () => {
const data = Buffer.from('Wiedergabepuffer');
const zip = writeZip([{ name: 'a.txt', data }]);
// Im lokalen Kopf steht die Pruefsumme an Offset 14
assert.equal(zip.readUInt32LE(14), zlib.crc32(data));
});
test('legt bereits komprimierte Daten unveraendert ab', () => {
// Echter Zufall laesst sich nicht packen, deflate waere groesser als das
// Original. Eine gerechnete Folge taugt dafuer nicht - `i * k % 256`
// wiederholt sich nach 256 Werten und ist bestens komprimierbar.
const rauschen = crypto.randomBytes(4096);
const zip = writeZip([{ name: 'r.bin', data: rauschen }]);
assert.equal(zip.readUInt16LE(8), 0, 'Verfahren 0 = gespeichert');
assert.deepEqual(readZip(zip)[0]?.data(), rauschen);
});
+164
View File
@@ -0,0 +1,164 @@
// ZIP lesen. Node bringt zlib mit, aber kein Archivformat, und das Projekt
// kommt ohne Abhaengigkeiten aus (ADR-0001) - wie beim YAML-Parser und beim
// obs-websocket-Client also selbst gebaut.
//
// Gebraucht wird das an zwei Stellen: beim Paketbau kommen ffmpeg,
// whisper.cpp und Node als ZIP von ihren Herausgebern, und der Updater auf
// Abdus Rechner packt damit das neue Paket aus - ohne PowerShell, ohne
// externes Programm.
import zlib from 'node:zlib';
const EOCD = 0x06054b50; // End of Central Directory
const CENTRAL = 0x02014b50; // Eintrag im Inhaltsverzeichnis
const LOCAL = 0x04034b50; // Kopf vor den eigentlichen Daten
export type ZipEntry = {
name: string;
/** Unkomprimierte Groesse in Bytes. */
size: number;
/** Verzeichniseintraege haben keine Daten. */
isDirectory: boolean;
data: () => Buffer;
};
/**
* Inhaltsverzeichnis eines ZIP lesen.
*
* Die Daten werden erst beim Aufruf von `data()` entpackt - beim Paketbau
* werden von 800 Eintraegen keine 30 gebraucht.
*/
export function readZip(buf: Buffer): ZipEntry[] {
const eocd = findEocd(buf);
const count = buf.readUInt16LE(eocd + 10);
const dirSize = buf.readUInt32LE(eocd + 12);
const dirStart = buf.readUInt32LE(eocd + 16);
if (dirStart === 0xffffffff || dirSize === 0xffffffff || count === 0xffff) {
throw new Error('ZIP64 wird nicht unterstuetzt - das Archiv ist zu gross.');
}
const entries: ZipEntry[] = [];
let p = dirStart;
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(p) !== CENTRAL) {
throw new Error(`Beschaedigtes Inhaltsverzeichnis bei Eintrag ${i}`);
}
const method = buf.readUInt16LE(p + 10);
const compressed = buf.readUInt32LE(p + 20);
const size = buf.readUInt32LE(p + 24);
const nameLen = buf.readUInt16LE(p + 28);
const extraLen = buf.readUInt16LE(p + 30);
const commentLen = buf.readUInt16LE(p + 32);
const localOffset = buf.readUInt32LE(p + 42);
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
entries.push({
name,
size,
isDirectory: name.endsWith('/'),
data: () => extract(buf, localOffset, method, compressed, size, name),
});
p += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
function extract(
buf: Buffer, localOffset: number, method: number,
compressed: number, size: number, name: string
): Buffer {
if (buf.readUInt32LE(localOffset) !== LOCAL) {
throw new Error(`Kein lokaler Dateikopf fuer ${name}`);
}
// Der lokale Kopf hat eigene Laengen fuer Name und Extra-Feld; sie koennen
// von denen im Inhaltsverzeichnis abweichen und muessen von hier kommen.
const nameLen = buf.readUInt16LE(localOffset + 26);
const extraLen = buf.readUInt16LE(localOffset + 28);
const start = localOffset + 30 + nameLen + extraLen;
const raw = buf.subarray(start, start + compressed);
if (method === 0) return Buffer.from(raw);
if (method === 8) {
const out = zlib.inflateRawSync(raw);
if (out.length !== size) {
throw new Error(`${name}: ${out.length} Bytes entpackt, ${size} erwartet`);
}
return out;
}
throw new Error(`${name}: unbekanntes Kompressionsverfahren ${method}`);
}
/**
* Das Inhaltsverzeichnis steht am Dateiende, hinter einem Kommentar
* beliebiger Laenge. Also von hinten suchen - hoechstens 64 KB weit, mehr
* darf der Kommentar nicht sein.
*/
function findEocd(buf: Buffer): number {
const min = Math.max(0, buf.length - 0x10000 - 22);
for (let i = buf.length - 22; i >= min; i--) {
if (buf.readUInt32LE(i) === EOCD) return i;
}
throw new Error('Kein ZIP-Inhaltsverzeichnis gefunden.');
}
/**
* ZIP schreiben. Nur so viel Format, wie der Windows-Explorer und
* `Expand-Archive` verlangen - insbesondere die CRC32-Pruefsumme, sonst
* halten beide das Archiv fuer beschaedigt.
*/
export function writeZip(files: Array<{ name: string; data: Buffer }>): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
for (const f of files) {
const name = Buffer.from(f.name.replace(/\\/g, '/'), 'utf8');
const packed = zlib.deflateRawSync(f.data, { level: 9 });
// Groesser als das Original kommt bei schon komprimierten Dateien vor -
// dann unveraendert ablegen.
const useDeflate = packed.length < f.data.length;
const body = useDeflate ? packed : f.data;
const crc = zlib.crc32(f.data);
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(LOCAL, 0);
local.writeUInt16LE(20, 4); // benoetigte Version: 2.0
local.writeUInt16LE(useDeflate ? 8 : 0, 8);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(f.data.length, 22);
local.writeUInt16LE(name.length, 26);
name.copy(local, 30);
locals.push(local, body);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(CENTRAL, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(useDeflate ? 8 : 0, 10);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(f.data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(offset, 42);
name.copy(central, 46);
centrals.push(central);
offset += local.length + body.length;
}
const dir = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(EOCD, 0);
eocd.writeUInt16LE(files.length, 8);
eocd.writeUInt16LE(files.length, 10);
eocd.writeUInt32LE(dir.length, 12);
eocd.writeUInt32LE(offset, 16);
const out = Buffer.concat([...locals, dir, eocd]);
if (out.length > 0xffffffff) throw new Error('Ueber 4 GB braeuchte ZIP64.');
return out;
}