Paket bauen und veroeffentlichen / paket (push) Failing after 1m21s
- /pult als installierbare Web-App (Manifest, Service Worker, Symbole) - Soundboard: Sounds vom Handy hochladen, bearbeiten, im Stream spielen ueber die neue Browser-Source /sounds - Szenen, Clip und Stummschalter: der Stream-PC waehlt sich mit eigenem Schluessel beim Hub ein (fern:verbinden), Befehle warten auf OBS - Browser-Sources melden, welche Seite sie sind Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1022 lines
43 KiB
JavaScript
1022 lines
43 KiB
JavaScript
#!/usr/bin/env node
|
|
// Einstiegspunkt der Anwendung. Bis 2026-09 war das der Container-
|
|
// Entrypoint; siehe ADR-0007.
|
|
|
|
import { execFile, execFileSync } from 'node:child_process';
|
|
import { createWriteStream } from 'node:fs';
|
|
import fs from 'node:fs/promises';
|
|
import net from 'node:net';
|
|
import path from 'node:path';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import { loadLayout, paths, type Rect } from '#core/config.ts';
|
|
import { binaries, fontsDir } from '#core/platform.ts';
|
|
import { assFilter, probe, run as runMedia } from '#media/ffmpeg.ts';
|
|
import { micTrackArgs, thumbnailArgs, verticalArgs, verticalFilter } from '#media/vertical.ts';
|
|
import { transcribe } from '#media/whisper.ts';
|
|
import { buildAss, buildSrt, lineOptionsFor, toLines } from '#media/subtitles.ts';
|
|
import { loadConfig } from '#core/config.ts';
|
|
import { openDb } from '#db/index.ts';
|
|
import { secretStatus } from '#twitch/secrets.ts';
|
|
import { judgeSource } from '#obs/scene-report.ts';
|
|
|
|
// Nativ laeuft OBS auf demselben Rechner. `host.docker.internal` war die
|
|
// Bruecke aus dem Container heraus und gibt es hier nicht mehr.
|
|
const OBS_HOST = process.env.OBS_WS_HOST || '127.0.0.1';
|
|
const OBS_PORT = Number(process.env.OBS_WS_PORT || 4455);
|
|
|
|
const MODELS: Record<string, string> = {
|
|
'ggml-small.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
|
|
'ggml-medium.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
|
|
'ggml-base.bin': 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
|
|
};
|
|
|
|
const C = process.stdout.isTTY
|
|
? { ok: '\x1b[32m', warn: '\x1b[33m', bad: '\x1b[31m', dim: '\x1b[90m', off: '\x1b[0m' }
|
|
: { ok: '', warn: '', bad: '', dim: '', off: '' };
|
|
|
|
let hardFailures = 0;
|
|
let warnings = 0;
|
|
|
|
function report(state: 'ok' | 'warn' | 'bad', label: string, detail = ''): void {
|
|
const mark = { ok: `${C.ok} OK ${C.off}`, warn: `${C.warn} WARN ${C.off}`, bad: `${C.bad} FEHLT${C.off}` }[state];
|
|
if (state === 'bad') hardFailures++;
|
|
if (state === 'warn') warnings++;
|
|
console.log(`${mark} ${label.padEnd(34)} ${C.dim}${detail}${C.off}`);
|
|
}
|
|
|
|
function section(title: string): void {
|
|
console.log(`\n${title}`);
|
|
console.log('-'.repeat(Math.max(title.length, 40)));
|
|
}
|
|
|
|
function run(cmd: string, args: string[]): string {
|
|
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
}
|
|
|
|
function runAsync(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(cmd, args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) =>
|
|
err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
async function canWrite(dir: string): Promise<boolean> {
|
|
const probe = path.join(dir, `.probe-${process.pid}`);
|
|
try {
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(probe, 'x');
|
|
await fs.unlink(probe);
|
|
return true;
|
|
} catch { return false; }
|
|
}
|
|
|
|
function tcpProbe(host: string, port: number, timeout = 2500): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const sock = new net.Socket();
|
|
const done = (r: boolean) => { sock.destroy(); resolve(r); };
|
|
sock.setTimeout(timeout);
|
|
sock.once('connect', () => done(true));
|
|
sock.once('timeout', () => done(false));
|
|
sock.once('error', () => done(false));
|
|
sock.connect(port, host);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------- check-env
|
|
|
|
async function checkEnv(): Promise<void> {
|
|
console.log('\nabdulive - Selbsttest der Umgebung');
|
|
|
|
section('Laufzeit');
|
|
const major = Number(process.versions.node.split('.')[0]);
|
|
report(major >= 24 ? 'ok' : 'bad', 'Node.js', `v${process.versions.node}`);
|
|
try {
|
|
const { DatabaseSync } = await import('node:sqlite');
|
|
new DatabaseSync(':memory:').close();
|
|
report('ok', 'node:sqlite (eingebaut)');
|
|
} catch (err) {
|
|
report('bad', 'node:sqlite', (err as Error).message.slice(0, 50));
|
|
}
|
|
report(typeof WebSocket !== 'undefined' ? 'ok' : 'bad', 'WebSocket (eingebaut)');
|
|
|
|
section('Medienwerkzeuge');
|
|
// Die Programme liegen neben der Anwendung, nicht im PATH.
|
|
const nichtGefunden = (bin: string) =>
|
|
bin.includes('/') || bin.includes('\\')
|
|
? `nicht startbar: ${bin}`
|
|
: 'nicht gefunden - weder im mitgelieferten bin-Ordner noch im PATH';
|
|
try {
|
|
const v = run(binaries.ffmpeg, ['-version']).split('\n')[0] ?? '';
|
|
report('ok', 'ffmpeg', v.replace('ffmpeg version ', '').slice(0, 40));
|
|
const cfg = run(binaries.ffmpeg, ['-hide_banner', '-buildconf']);
|
|
report(cfg.includes('--enable-libx264') ? 'ok' : 'bad', ' libx264 (H.264-Encoder)');
|
|
report(cfg.includes('--enable-libass') ? 'ok' : 'bad', ' libass (Untertitel einbrennen)');
|
|
// Ohne die mitgelieferten Schriften setzt libass wortlos eine
|
|
// Ersatzschrift ein - der Untertitel sieht dann anders aus als gedacht.
|
|
const fonts = fontsDir();
|
|
report(fonts ? 'ok' : 'bad', ' Schriften', fonts ?? 'kein fonts-Ordner gefunden');
|
|
} catch { report('bad', 'ffmpeg', nichtGefunden(binaries.ffmpeg)); }
|
|
try { run(binaries.ffprobe, ['-version']); report('ok', 'ffprobe'); }
|
|
catch { report('bad', 'ffprobe', nichtGefunden(binaries.ffprobe)); }
|
|
try {
|
|
run(binaries.whisper, ['--help']);
|
|
report('ok', 'whisper-cli', binaries.whisper);
|
|
} catch (err) {
|
|
const text = `${(err as { stdout?: string }).stdout ?? ''}${(err as { stderr?: string }).stderr ?? ''}`;
|
|
const laeuft = text.includes('whisper') || text.includes('usage');
|
|
report(laeuft ? 'ok' : 'bad', 'whisper-cli',
|
|
laeuft ? binaries.whisper : nichtGefunden(binaries.whisper));
|
|
}
|
|
|
|
section('Sprachmodell');
|
|
let anyModel = false;
|
|
for (const name of Object.keys(MODELS)) {
|
|
try {
|
|
const st = await fs.stat(path.join(paths.models, name));
|
|
report('ok', name, `${(st.size / 1024 / 1024).toFixed(0)} MB`);
|
|
anyModel = true;
|
|
} catch { /* nicht vorhanden */ }
|
|
}
|
|
if (!anyModel) report('warn', 'kein Modell vorhanden', 'holen mit: fetch-model ggml-small.bin');
|
|
|
|
section('Verzeichnisse');
|
|
for (const [label, dir] of [
|
|
['Datenbank', paths.db],
|
|
['Arbeitsverzeichnis', paths.work],
|
|
['Sprachmodelle', paths.models],
|
|
['Clips', paths.clips],
|
|
] as const) {
|
|
report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`);
|
|
}
|
|
for (const [label, dir, hard] of [
|
|
['OBS-Wiedergabepuffer', paths.replay, false],
|
|
['Konfiguration', paths.config, true],
|
|
] as const) {
|
|
try {
|
|
const n = (await fs.readdir(dir)).length;
|
|
report('ok', label, `${dir} lesbar, ${n} Eintraege`);
|
|
} catch { report(hard ? 'bad' : 'warn', label, `${dir} nicht lesbar`); }
|
|
}
|
|
|
|
section('Datenbank');
|
|
try {
|
|
const db = openDb();
|
|
const n = (db.prepare('SELECT COUNT(*) AS n FROM schema_migrations').get() as { n: number }).n;
|
|
report('ok', 'Schema', `${n} Migration(en) angewendet`);
|
|
} catch (err) {
|
|
report('bad', 'Schema', (err as Error).message.slice(0, 60));
|
|
}
|
|
|
|
section('Layout (Facecam-Ausschnitt)');
|
|
try {
|
|
const layout = loadLayout();
|
|
const prof = layout.profiles?.['default'];
|
|
if (!prof) throw new Error('Profil "default" fehlt');
|
|
const sum = prof.cam.band.h + prof.game.band.h;
|
|
report(sum === layout.target.height ? 'ok' : 'bad', 'Baender ergeben Zielhoehe',
|
|
`${prof.cam.band.h} + ${prof.game.band.h} = ${sum} (soll ${layout.target.height})`);
|
|
for (const [name, c] of [['Facecam', prof.cam.crop], ['Gameplay', prof.game.crop]] as const) {
|
|
const inside = c.x >= 0 && c.y >= 0 &&
|
|
c.x + c.w <= layout.source.width && c.y + c.h <= layout.source.height;
|
|
report(inside ? 'ok' : 'bad', `${name}-Ausschnitt im Bild`, `${c.w}x${c.h} bei ${c.x},${c.y}`);
|
|
}
|
|
for (const [name, c, b] of [
|
|
['Facecam', prof.cam.crop, prof.cam.band], ['Gameplay', prof.game.crop, prof.game.band],
|
|
] as const) {
|
|
const dev = Math.abs((c.w / c.h) / (b.w / b.h) - 1) * 100;
|
|
report(dev < 1 ? 'ok' : 'warn', `${name}-Seitenverhaeltnis`,
|
|
`Abweichung ${dev.toFixed(2)} % (>1 % verzerrt sichtbar)`);
|
|
}
|
|
} catch (err) {
|
|
report('bad', 'config/layout.yaml', (err as Error).message);
|
|
}
|
|
|
|
section('OBS');
|
|
const obsUp = await tcpProbe(OBS_HOST, OBS_PORT);
|
|
report(obsUp ? 'ok' : 'warn', 'obs-websocket erreichbar',
|
|
`${OBS_HOST}:${OBS_PORT}${obsUp ? '' : ' - OBS laeuft nicht oder WebSocket-Server ist aus'}`);
|
|
|
|
section('Zugangsdaten');
|
|
// Meldet nur, OB etwas hinterlegt ist - nie was. Siehe ADR-0005.
|
|
const st = secretStatus();
|
|
for (const role of ['broadcaster', 'bot'] as const) {
|
|
const a = st.apps[role];
|
|
report(a.clientId && a.hasSecret ? 'ok' : 'warn', `${role}: App-Daten`,
|
|
a.clientId ? (a.hasSecret ? 'Client-ID und Secret hinterlegt' : 'Secret fehlt') : 'nichts hinterlegt');
|
|
report(st.tokens[role].connected ? 'ok' : 'warn', `${role}: Twitch verbunden`,
|
|
st.tokens[role].connected ? `als ${st.tokens[role].login}` : 'Einrichtung unter /setup');
|
|
}
|
|
report(st.obs ? 'ok' : 'warn', 'OBS-Passwort', st.obs ? 'hinterlegt' : 'Einrichtung unter /setup');
|
|
|
|
console.log('');
|
|
if (hardFailures > 0) {
|
|
console.log(`${C.bad}${hardFailures} harte Fehler${C.off}, ${warnings} Warnungen. Noch nicht einsatzbereit.`);
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(`${C.ok}Keine harten Fehler${C.off}, ${warnings} Warnungen.` +
|
|
(warnings ? ' Warnungen ohne Einrichtung sind normal.' : ''));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- fetch-model
|
|
|
|
async function fetchModel(name = 'ggml-small.bin'): Promise<void> {
|
|
const url = MODELS[name];
|
|
if (!url) {
|
|
console.error(`Unbekanntes Modell "${name}". Bekannt: ${Object.keys(MODELS).join(', ')}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
await fs.mkdir(paths.models, { recursive: true });
|
|
const target = path.join(paths.models, name);
|
|
try {
|
|
const st = await fs.stat(target);
|
|
console.log(`${name} liegt schon da (${(st.size / 1024 / 1024).toFixed(0)} MB).`);
|
|
return;
|
|
} catch { /* laden */ }
|
|
|
|
console.log(`Lade ${name} ...`);
|
|
const res = await fetch(url, { redirect: 'follow' });
|
|
if (!res.ok || !res.body) throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
|
|
const total = Number(res.headers.get('content-length') || 0);
|
|
let seen = 0;
|
|
let lastPrint = 0;
|
|
const tmp = `${target}.part`;
|
|
|
|
await pipeline(
|
|
(async function* () {
|
|
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
|
seen += chunk.length;
|
|
const now = Date.now();
|
|
if (now - lastPrint > 1000) {
|
|
lastPrint = now;
|
|
const pct = total ? ` (${((seen / total) * 100).toFixed(0)} %)` : '';
|
|
process.stdout.write(`\r ${(seen / 1024 / 1024).toFixed(0)} MB${pct} `);
|
|
}
|
|
yield chunk;
|
|
}
|
|
})(),
|
|
createWriteStream(tmp)
|
|
);
|
|
await fs.rename(tmp, target);
|
|
console.log(`\rFertig: ${target} (${(seen / 1024 / 1024).toFixed(0)} MB)`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- calibrate
|
|
|
|
async function calibrate(input?: string, profileName = 'default'): Promise<void> {
|
|
const layout = loadLayout();
|
|
const prof = layout.profiles?.[profileName];
|
|
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
|
|
|
|
if (!input) {
|
|
const files = (await fs.readdir(paths.replay).catch(() => [] as string[]))
|
|
.filter((f) => /\.(mp4|mkv|mov|flv|png|jpe?g)$/i.test(f)).sort();
|
|
const last = files[files.length - 1];
|
|
if (!last) throw new Error(`Keine Datei angegeben und nichts in ${paths.replay} gefunden.`);
|
|
input = path.join(paths.replay, last);
|
|
console.log(`Neueste Datei aus ${paths.replay}: ${last}`);
|
|
}
|
|
|
|
const outDir = path.join(paths.clips, '_calibration');
|
|
await fs.mkdir(outDir, { recursive: true });
|
|
const seek = /\.(png|jpe?g)$/i.test(input) ? [] : ['-ss', '5'];
|
|
const quiet = ['-y', '-hide_banner', '-loglevel', 'error'];
|
|
|
|
const frame = path.join(outDir, 'frame.png');
|
|
await runAsync('ffmpeg', [...quiet, ...seek, '-i', input, '-frames:v', '1', frame]);
|
|
|
|
const preview = path.join(outDir, `preview-${profileName}.png`);
|
|
await runAsync('ffmpeg', [...quiet, '-i', frame, '-filter_complex', verticalFilter(prof),
|
|
'-map', '[v]', '-frames:v', '1', preview]);
|
|
|
|
const marked = path.join(outDir, 'ausschnitte.png');
|
|
const box = (r: Rect, color: string) => `drawbox=x=${r.x}:y=${r.y}:w=${r.w}:h=${r.h}:color=${color}:t=4`;
|
|
await runAsync('ffmpeg', [...quiet, '-i', frame, '-vf',
|
|
`${box(prof.cam.crop, 'yellow')},${box(prof.game.crop, 'cyan')}`, '-frames:v', '1', marked]);
|
|
|
|
console.log('\nGeschrieben:');
|
|
console.log(` ${frame} Einzelbild aus der Quelle`);
|
|
console.log(` ${marked} Ausschnitte markiert (gelb = Cam, cyan = Gameplay)`);
|
|
console.log(` ${preview} fertige 9:16-Vorschau`);
|
|
console.log('\nSitzt der Ausschnitt nicht, die Werte in config/layout.yaml anpassen');
|
|
console.log('und den Befehl erneut ausfuehren.');
|
|
}
|
|
|
|
// ---------------------------------------------------------------- render
|
|
|
|
/**
|
|
* Rendert eine Rohdatei nach 9:16. Dieselben Bausteine wie die Warteschlange
|
|
* im Daemon, aber ohne Datenbank - gedacht zum Nachrendern, wenn sich das
|
|
* Layout geaendert hat.
|
|
*/
|
|
async function renderOne(input?: string, profileName = 'default'): Promise<void> {
|
|
if (!input) throw new Error('Datei angeben: render <datei> [profil]');
|
|
const layout = loadLayout();
|
|
const prof = layout.profiles?.[profileName];
|
|
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
|
|
const cfg = loadConfig();
|
|
|
|
const info = await probe(input);
|
|
console.log(`Quelle: ${info.width}x${info.height}, ${info.durationSeconds.toFixed(1)} s, ` +
|
|
`${info.audio.length} Tonspur(en)`);
|
|
for (const [i, a] of info.audio.entries()) {
|
|
console.log(` Spur ${i}: ${a.codec}, ${a.channels} Kanal/Kanaele` +
|
|
(a.title ? ` - "${a.title}"` : ''));
|
|
}
|
|
if (info.width !== layout.source.width || info.height !== layout.source.height) {
|
|
console.log(` Achtung: Layout ist fuer ${layout.source.width}x${layout.source.height} ` +
|
|
'eingemessen, der Ausschnitt sitzt vermutlich falsch.');
|
|
}
|
|
|
|
const outDir = path.join(paths.clips, '_manuell', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
await fs.mkdir(outDir, { recursive: true });
|
|
const vertical = path.join(outDir, 'vertical.mp4');
|
|
|
|
const track = cfg.clipper.audio.outputTrack < info.audio.length
|
|
? cfg.clipper.audio.outputTrack : 0;
|
|
|
|
const tail = Math.min(cfg.clipper.tailSeconds, Math.max(1, Math.floor(info.durationSeconds)));
|
|
const started = Date.now();
|
|
let lastShown = 0;
|
|
await runMedia('ffmpeg', verticalArgs({
|
|
input, output: vertical, profile: prof,
|
|
tailSeconds: tail,
|
|
audioTrack: track,
|
|
encoder: cfg.clipper.ffmpeg.encoder,
|
|
preset: cfg.clipper.ffmpeg.preset,
|
|
crf: cfg.clipper.ffmpeg.crf,
|
|
}), {
|
|
niceness: cfg.clipper.ffmpeg.niceness,
|
|
onProgress: (sec) => process.stdout.write(`
|
|
${sec.toFixed(0)} s ... `),
|
|
});
|
|
await runMedia('ffmpeg', thumbnailArgs(vertical, path.join(outDir, 'thumb.jpg')));
|
|
|
|
// ---- Untertitel (S4)
|
|
const model = path.join(paths.models, cfg.clipper.whisper.model);
|
|
let finalFile = vertical;
|
|
if (await fs.stat(model).then(() => true, () => false)) {
|
|
const micTrack = cfg.clipper.audio.transcribeTrack < info.audio.length
|
|
? cfg.clipper.audio.transcribeTrack : 0;
|
|
if (micTrack !== cfg.clipper.audio.transcribeTrack) {
|
|
console.log(` Hinweis: Tonspur ${cfg.clipper.audio.transcribeTrack} (Mikrofon) gibt es ` +
|
|
`nicht - transkribiere Spur ${micTrack}. Bei Twitch-Clips ist das normal, ` +
|
|
'die enthalten nur die Mischung.');
|
|
}
|
|
const wav = path.join(outDir, 'mic.wav');
|
|
await runMedia('ffmpeg', micTrackArgs(input, wav, micTrack, tail));
|
|
|
|
console.log(' Transkribiere ...');
|
|
const words = await transcribe(wav, {
|
|
binary: binaries.whisper,
|
|
model,
|
|
language: cfg.clipper.whisper.language,
|
|
threads: cfg.clipper.whisper.threads,
|
|
});
|
|
// Zeilenlaenge aus der Zielbreite ableiten, nicht raten - sonst laeuft
|
|
// der Text seitlich aus dem Bild (siehe subtitles.ts, maxUnitsFor).
|
|
const lines = toLines(words, lineOptionsFor(1080));
|
|
const ass = path.join(outDir, 'subs.ass');
|
|
await fs.writeFile(ass, buildAss(lines, { width: 1080, height: 1920 }));
|
|
await fs.writeFile(path.join(outDir, 'subs.srt'), buildSrt(lines));
|
|
await fs.writeFile(path.join(outDir, 'transcript.txt'),
|
|
lines.map((l) => l.words.map((w) => w.text).join(' ')).join('\n'));
|
|
|
|
finalFile = path.join(outDir, 'final.mp4');
|
|
await runMedia('ffmpeg', [
|
|
'-y', '-hide_banner', '-loglevel', 'error', '-i', vertical,
|
|
'-vf', assFilter(ass, fontsDir()), '-c:a', 'copy',
|
|
'-c:v', cfg.clipper.ffmpeg.encoder, '-preset', cfg.clipper.ffmpeg.preset,
|
|
'-crf', String(cfg.clipper.ffmpeg.crf), '-pix_fmt', 'yuv420p',
|
|
'-movflags', '+faststart', finalFile,
|
|
], { niceness: cfg.clipper.ffmpeg.niceness });
|
|
console.log(` ${words.length} Woerter, ${lines.length} Zeilen`);
|
|
} else {
|
|
console.log(` Kein Sprachmodell unter ${model} - ohne Untertitel. ` +
|
|
'Holen mit: fetch-model');
|
|
}
|
|
|
|
const out = await probe(finalFile);
|
|
if (process.stdout.isTTY) process.stdout.write('\r');
|
|
console.log(`\nFertig: ${finalFile}`);
|
|
console.log(` ${out.width}x${out.height}, ${out.durationSeconds.toFixed(1)} s, ` +
|
|
`Rechenzeit ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- sources
|
|
|
|
/**
|
|
* Listet auf, was OBS in der laufenden Szene zusammensetzt - und sagt zu jeder
|
|
* Quelle, ob sie im 9:16-Ausschnitt landet.
|
|
*
|
|
* Der Anlass: In einem fertigen Clip lagen fremde Untertitel im Bild, und
|
|
* niemand wusste, woher sie kommen. Das ist auch nicht zu erraten - sie sind
|
|
* ins Streambild eingebrannt, also muss man nachsehen, welche Quelle sie malt.
|
|
* Genau das macht dieser Befehl.
|
|
*/
|
|
|
|
type SceneItemTransform = {
|
|
positionX: number; positionY: number;
|
|
width: number; height: number;
|
|
cropLeft: number; cropRight: number; cropTop: number; cropBottom: number;
|
|
};
|
|
|
|
type SceneItem = {
|
|
sceneItemId: number;
|
|
sourceName: string;
|
|
inputKind: string | null;
|
|
isGroup: boolean | null;
|
|
sceneItemEnabled: boolean;
|
|
sceneItemTransform: SceneItemTransform;
|
|
};
|
|
|
|
async function listSources(profileName = 'default'): Promise<void> {
|
|
const cfg = loadConfig();
|
|
const layout = loadLayout();
|
|
const prof = layout.profiles?.[profileName];
|
|
if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`);
|
|
|
|
const { createObs } = await import('#obs/client.ts');
|
|
const { readSecrets } = await import('#twitch/secrets.ts');
|
|
|
|
const abort = new AbortController();
|
|
let ready: (() => void) | null = null;
|
|
const identified = new Promise<void>((res) => { ready = res; });
|
|
|
|
const obs = createObs(cfg.obs.url, () => readSecrets().obs?.password, {
|
|
onIdentified: () => ready?.(),
|
|
});
|
|
|
|
const running = obs.run(abort.signal);
|
|
running.catch(() => { /* Fehler kommt ueber das Rennen unten */ });
|
|
|
|
// Der Zeitgeber muss wieder weg. Bleibt er stehen, haengt der Befehl nach
|
|
// dem Bericht noch bis zu 15 s im Container, obwohl er fertig ist.
|
|
let ticker: NodeJS.Timeout | undefined;
|
|
const timeout = new Promise<never>((_, rej) => {
|
|
ticker = setTimeout(
|
|
() => rej(new Error(`OBS antwortet nicht unter ${cfg.obs.url}. Laeuft OBS, und ist ` +
|
|
'Werkzeuge -> WebSocket-Server-Einstellungen -> Server aktivieren gesetzt?')), 15_000);
|
|
});
|
|
|
|
try {
|
|
await Promise.race([identified, running, timeout]);
|
|
|
|
const { currentProgramSceneName: scene } =
|
|
await obs.request<{ currentProgramSceneName: string }>('GetCurrentProgramScene');
|
|
|
|
console.log(`\nSzene: ${scene}`);
|
|
console.log(`Layoutprofil: ${profileName} (Cam ${rectStr(prof.cam.crop)}, ` +
|
|
`Gameplay ${rectStr(prof.game.crop)})\n`);
|
|
|
|
const rows: string[] = [];
|
|
const verdaechtig: string[] = [];
|
|
|
|
// Von hinten nach vorne: OBS liefert die oberste Quelle zuerst, im Bild
|
|
// liegt sie aber oben drauf. Umgedreht liest es sich wie der Stapel.
|
|
const walk = async (name: string, tiefe: number): Promise<void> => {
|
|
const req = tiefe === 0 ? 'GetSceneItemList' : 'GetGroupSceneItemList';
|
|
const { sceneItems } = await obs.request<{ sceneItems: SceneItem[] }>(
|
|
req, { sceneName: name });
|
|
|
|
for (const it of [...sceneItems].reverse()) {
|
|
const t = it.sceneItemTransform;
|
|
const box = {
|
|
x: Math.round(t.positionX), y: Math.round(t.positionY),
|
|
w: Math.round(t.width), h: Math.round(t.height),
|
|
};
|
|
const kind = it.isGroup ? 'Gruppe' : (it.inputKind ?? 'unbekannt');
|
|
|
|
// Browser-Quellen verraten ueber die Adresse, welcher Dienst dahinter
|
|
// steckt - bei fremden Untertiteln ist das meistens die Antwort.
|
|
let url = '';
|
|
if (!it.isGroup && it.inputKind === 'browser_source') {
|
|
const got = await obs.request<{ inputSettings: { url?: string } }>(
|
|
'GetInputSettings', { inputName: it.sourceName }).catch(() => null);
|
|
url = got?.inputSettings?.url ?? '';
|
|
}
|
|
|
|
const urteil = judgeSource({
|
|
box, enabled: it.sceneItemEnabled, name: it.sourceName, kind, url,
|
|
cam: prof.cam.crop, game: prof.game.crop,
|
|
});
|
|
|
|
const einzug = ' '.repeat(tiefe);
|
|
const sicht = it.sceneItemEnabled ? '' : `${C.dim} (ausgeblendet)${C.off}`;
|
|
const lage = `${String(box.x).padStart(5)},${String(box.y).padStart(4)} ` +
|
|
`${String(box.w).padStart(4)}x${String(box.h).padEnd(4)}`;
|
|
const treffer = !it.sceneItemEnabled ? `${C.dim}—${C.off}`
|
|
: urteil.inShort
|
|
? `${C.warn}im Short${C.off} (Cam ${urteil.cam} %, Gameplay ${urteil.game} %)`
|
|
: `${C.ok}ausserhalb${C.off}`;
|
|
|
|
rows.push(`${einzug}${it.sourceName.padEnd(30 - tiefe * 2)} ${C.dim}${kind.padEnd(20)}` +
|
|
`${C.off}${lage} ${treffer}${sicht}`);
|
|
if (url) rows.push(`${einzug} ${C.dim}${url.slice(0, 110)}${C.off}`);
|
|
|
|
if (urteil.suspectCaption) {
|
|
verdaechtig.push(`${it.sourceName} (${kind})${url ? ` - ${url}` : ''}`);
|
|
}
|
|
|
|
if (it.isGroup) await walk(it.sourceName, tiefe + 1);
|
|
}
|
|
};
|
|
|
|
await walk(scene, 0);
|
|
|
|
console.log(`${'Quelle'.padEnd(30)} ${'Art'.padEnd(20)}${'x,y'.padStart(10)} ` +
|
|
`${'Groesse'.padEnd(10)} Im 9:16-Ausschnitt?`);
|
|
console.log('-'.repeat(100));
|
|
for (const r of rows) console.log(r);
|
|
|
|
console.log('\nAlles, was mit "im Short" markiert ist, landet im fertigen Clip.');
|
|
if (verdaechtig.length) {
|
|
console.log(`\n${C.warn}Sieht nach fremden Untertiteln aus:${C.off}`);
|
|
for (const v of verdaechtig) console.log(` - ${v}`);
|
|
console.log('\nDiese Quellen aus dem Gameplay-Band schieben (oder ausblenden) -\n' +
|
|
'das Toolkit macht eigene Untertitel. Plaetze: docs/obs-layout.md');
|
|
} else {
|
|
console.log('Nichts erkannt, was nach Untertiteln aussieht. Steht trotzdem Text im\n' +
|
|
'Clip, kommt er aus dem Spiel selbst (dort abschalten) oder aus einer Quelle,\n' +
|
|
'die anders heisst - dann die Liste oben von oben nach unten durchgehen.');
|
|
}
|
|
} finally {
|
|
clearTimeout(ticker);
|
|
abort.abort();
|
|
// Klemmt OBS, bleibt der WebSocket im Schliess-Handshake stehen und der
|
|
// Prozess laeuft weiter, obwohl der Bericht schon dasteht. Genau dann
|
|
// fuehrt man dieses Werkzeug aber aus. Der Wachhund ist ge-unref-t: im
|
|
// Normalfall haelt er nichts auf und schlaegt nie an.
|
|
setTimeout(() => {
|
|
console.error(`${C.dim}(OBS schliesst die Verbindung nicht - beende trotzdem)${C.off}`);
|
|
process.exit(process.exitCode === undefined ? 0 : Number(process.exitCode));
|
|
}, 3000).unref();
|
|
}
|
|
}
|
|
|
|
const rectStr = (r: Rect) => `x ${r.x}..${r.x + r.w}, y ${r.y}..${r.y + r.h}`;
|
|
|
|
// ---------------------------------------------------------------- discord
|
|
|
|
/** Liest eine Eingabe, ohne sie im Terminal anzuzeigen. */
|
|
function promptHidden(frage: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const input = process.stdin;
|
|
if (!input.isTTY) {
|
|
reject(new Error(`${frage.trim()} - dafuer wird ein Terminal gebraucht.`));
|
|
return;
|
|
}
|
|
process.stdout.write(frage);
|
|
input.setRawMode(true);
|
|
input.resume();
|
|
input.setEncoding('utf8');
|
|
let buf = '';
|
|
const stop = (): void => { input.off('data', onData); input.setRawMode(false); input.pause(); };
|
|
const onData = (chunk: string): void => {
|
|
for (const c of chunk) {
|
|
if (c === '\r' || c === '\n') { stop(); process.stdout.write('\n'); resolve(buf.trim()); return; }
|
|
if (c === '\u0003') { stop(); process.stdout.write('\n'); reject(new Error('Abgebrochen.')); return; }
|
|
if (c === '\u007f' || c === '\b') { buf = buf.slice(0, -1); continue; }
|
|
buf += c;
|
|
}
|
|
};
|
|
input.on('data', onData);
|
|
});
|
|
}
|
|
|
|
async function discordSetup(args: string[]): Promise<void> {
|
|
const dry = args.includes('--vorschau');
|
|
const { readSecrets, updateSecrets } = await import('#twitch/secrets.ts');
|
|
const stored = readSecrets().discord ?? {};
|
|
|
|
const at = args.indexOf('--server');
|
|
const fromArgs = at >= 0 ? args[at + 1] : undefined;
|
|
const guildId = fromArgs ?? stored.guildId ?? process.env.DISCORD_GUILD_ID;
|
|
if (!guildId || guildId.startsWith('--')) {
|
|
throw new Error(
|
|
'Keine Server-ID. Aufruf: abdulive discord:setup --server <ID>\n' +
|
|
'Die ID steht in Discord im Rechtsklickmenue des Servers unter "Server-ID kopieren"\n' +
|
|
'(dafuer in den Einstellungen unter "Erweitert" den Entwicklermodus einschalten).'
|
|
);
|
|
}
|
|
|
|
const token = stored.botToken || process.env.DISCORD_BOT_TOKEN ||
|
|
await promptHidden('Discord-Bot-Token (Eingabe wird nicht angezeigt): ');
|
|
if (!token) throw new Error('Ohne Bot-Token geht es nicht.');
|
|
|
|
const { Discord } = await import('#discord/api.ts');
|
|
const { applyPlan } = await import('#discord/apply.ts');
|
|
const result = await applyPlan(new Discord(token), guildId, { dryRun: dry });
|
|
|
|
if (!dry) updateSecrets((s) => { s.discord = { ...s.discord, botToken: token, guildId }; });
|
|
|
|
const mark: Record<string, string> = {
|
|
erstellt: `${C.ok} NEU ${C.off}`,
|
|
angepasst: `${C.ok} GESETZT${C.off}`,
|
|
unveraendert: `${C.dim} steht ${C.off}`,
|
|
uebersprungen: `${C.warn} UEBERSPRUNGEN${C.off}`,
|
|
};
|
|
|
|
console.log(`\nServer: ${result.guild}${dry ? ` ${C.dim}(Vorschau - nichts geaendert)${C.off}` : ''}\n`);
|
|
let kind = '';
|
|
for (const s of result.steps) {
|
|
if (s.kind !== kind) { console.log(`${C.dim}${s.kind}${C.off}`); kind = s.kind; }
|
|
const note = s.note ? ` ${C.dim}(${s.note})${C.off}` : '';
|
|
console.log(` ${mark[s.change] ?? s.change} ${s.name}${note}`);
|
|
}
|
|
|
|
const skipped = result.steps.filter((s) => s.change === 'uebersprungen').length;
|
|
console.log('');
|
|
if (dry) {
|
|
console.log('Nichts geaendert. Ohne --vorschau wird es angewendet.');
|
|
return;
|
|
}
|
|
console.log(`${result.webhooks} Webhook-URLs im verschluesselten Speicher abgelegt.`);
|
|
if (skipped) console.log(`${C.warn}${skipped} Punkt(e) uebersprungen - siehe Hinweise oben.${C.off}`);
|
|
console.log(`
|
|
Was jetzt noch von Hand gehoert:
|
|
1. Dir selbst die Rolle "Abdu" geben und die Bot-Rolle in den
|
|
Servereinstellungen ueber "Abdu" ziehen.
|
|
2. Server-Einstellungen > Onboarding: "Live-Ping" als waehlbare Rolle
|
|
anbieten, damit sie sich jeder selbst holen kann.
|
|
3. Einladungslink erzeugen (ohne Ablaufdatum) und in Twitch-Panel,
|
|
Kanalinfo und Chat-Befehl eintragen.
|
|
4. Twitch-Konto in den Servereinstellungen unter "Integrationen"
|
|
verbinden, dann bekommen Subs automatisch eine Rolle.`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- overlay
|
|
|
|
/**
|
|
* Overlay-Server allein starten. Zum Ansehen und Einrichten, ohne dass der
|
|
* ganze Hub laeuft. Mit --demo laeuft erfundener Betrieb: anders koennte man
|
|
* eine Raid-Einblendung nur pruefen, indem man auf einen echten Raid wartet.
|
|
*/
|
|
async function overlayOnly(args: string[]): Promise<void> {
|
|
const cfg = loadConfig();
|
|
const { startOverlayServer } = await import('../overlay-server/src/server.ts');
|
|
const { createBus } = await import('../overlay-server/src/bus.ts');
|
|
|
|
const bus = createBus();
|
|
const abort = new AbortController();
|
|
process.on('SIGINT', () => { abort.abort(); process.exit(0); });
|
|
|
|
startOverlayServer({
|
|
port: cfg.overlay.port,
|
|
channel: cfg.channel.login,
|
|
publicUrl: cfg.publicUrl,
|
|
bus,
|
|
});
|
|
|
|
if (args.includes('--demo')) {
|
|
const { startDemo } = await import('../overlay-server/src/demo.ts');
|
|
startDemo(bus, abort.signal);
|
|
}
|
|
|
|
const wo = cfg.publicUrl || `http://127.0.0.1:${cfg.overlay.port}`;
|
|
console.log(`
|
|
Uebersicht mit allen Adressen fuer OBS: ${wo}/`);
|
|
console.log(`${C.dim}Beenden mit Strg+C.${C.off}`);
|
|
await new Promise(() => { /* laeuft bis Strg+C */ });
|
|
}
|
|
|
|
/** Passwort fuer die Weboberflaeche setzen. Nur hier, nie ueber das Netz. */
|
|
async function webPasswort(args: string[]): Promise<void> {
|
|
const { setzePasswort } = await import('../overlay-server/src/auth.ts');
|
|
const pw = args[0] || process.env.ABDULIVE_WEB_PASSWORT ||
|
|
await promptHidden('Neues Passwort (Eingabe wird nicht angezeigt): ');
|
|
setzePasswort(pw.trim());
|
|
console.log(`${C.ok}Passwort gesetzt.${C.off} Angemeldet bleibt man 30 Tage.`);
|
|
}
|
|
|
|
/**
|
|
* Den Stream-PC beim Hub anmelden. Prueft vorher, ob der Hub den Schluessel
|
|
* annimmt - ein Tippfehler soll hier auffallen und nicht erst im Stream, wenn
|
|
* der Szenenknopf am Handy nichts tut.
|
|
*/
|
|
async function fernVerbinden(args: string[]): Promise<void> {
|
|
const { readSecrets, updateSecrets } = await import('#twitch/secrets.ts');
|
|
const [adresse, schluessel] = args;
|
|
if (adresse === 'aus') {
|
|
updateSecrets((s) => { delete s.fern; });
|
|
console.log('Fernsteuerung getrennt. Wirkt nach dem naechsten Start von abdulive ingest.');
|
|
return;
|
|
}
|
|
if (!adresse || !schluessel) {
|
|
const da = readSecrets().fern;
|
|
console.log(da ? `Eingerichtet fuer ${da.url}.` : 'Nicht eingerichtet.');
|
|
console.log('Aufruf: abdulive fern:verbinden <adresse> <schluessel> (steht auf der Uebersichtsseite)');
|
|
return;
|
|
}
|
|
const url = adresse.replace(/\/+$/, '');
|
|
if (!/^https?:\/\//.test(url)) throw new Error('Die Adresse muss mit https:// anfangen.');
|
|
|
|
const probe = new AbortController();
|
|
let status: number;
|
|
try {
|
|
const res = await fetch(`${url}/fern/befehle`, {
|
|
headers: { authorization: `Bearer ${schluessel}` },
|
|
signal: AbortSignal.any([probe.signal, AbortSignal.timeout(10_000)]),
|
|
});
|
|
status = res.status;
|
|
} catch (err) {
|
|
throw new Error(`Hub unter ${url} nicht erreichbar: ${(err as Error).message}`);
|
|
} finally {
|
|
// Die Leitung nur oeffnen, um den Schluessel zu pruefen. Offen gelassen
|
|
// haette sie der Hub als verbundenen PC gezaehlt.
|
|
probe.abort();
|
|
}
|
|
if (status === 403) throw new Error('Der Hub lehnt den Schluessel ab. Neu von der Uebersichtsseite kopieren.');
|
|
if (status !== 200) throw new Error(`Der Hub antwortet ${status}. Laeuft dort eine Fassung mit Pult?`);
|
|
|
|
updateSecrets((s) => { s.fern = { url, key: schluessel }; });
|
|
console.log(`${C.ok}Verbunden.${C.off} Der Hub nimmt den Schluessel an.`);
|
|
console.log('Jetzt abdulive ingest neu starten - danach steuert das Pult OBS.');
|
|
}
|
|
|
|
// ---------------------------------------------------------------- golive
|
|
|
|
/**
|
|
* Probelauf der Go-Live-Ankuendigung.
|
|
*
|
|
* Ohne Argument geht der Post in den internen Kanal und pingt niemanden -
|
|
* damit laesst sich das Aussehen pruefen, ohne den Server aufzuwecken. Mit
|
|
* `--echt` geht er dorthin, wo er im Betrieb hinginge, mit Ping. Das ist der
|
|
* einzige Weg, vor dem ersten Stream zu sehen, ob der Ping wirklich ankommt;
|
|
* eine Erwaehnung ohne `allowed_mentions` sieht naemlich genauso aus wie eine
|
|
* mit, benachrichtigt aber niemanden.
|
|
*/
|
|
async function goLiveTest(args: string[]): Promise<void> {
|
|
const echt = args.includes('--echt');
|
|
const cfg = loadConfig();
|
|
const { createAnnouncer } = await import('../broadcaster/src/golive.ts');
|
|
const { repos } = await import('#db/repos.ts');
|
|
const { helix } = await import('#twitch/helix.ts');
|
|
const { readSecrets } = await import('#twitch/secrets.ts');
|
|
|
|
const discord = readSecrets().discord;
|
|
const ziel = echt ? discord?.goLiveWebhook : discord?.technikWebhook;
|
|
if (!ziel) {
|
|
throw new Error(echt
|
|
? 'Kein Go-Live-Webhook hinterlegt. Einmal `abdulive discord:setup` laufen lassen.'
|
|
: 'Kein Webhook fuer den internen Kanal. Einmal `abdulive discord:setup` laufen lassen ' +
|
|
'- der Technik-Webhook ist seit Fassung 0.6 im Bauplan.');
|
|
}
|
|
|
|
// Wenn Abdu gerade live ist, wird der echte Stream angekuendigt. Sonst ein
|
|
// erfundener - das prueft denselben Weg, nur mit anderem Text.
|
|
let stream = {
|
|
id: `probe-${Date.now()}`,
|
|
title: 'Probelauf der Go-Live-Ankuendigung',
|
|
category: 'Just Chatting',
|
|
login: cfg.channel.login,
|
|
startedAt: new Date().toISOString(),
|
|
} as import('../broadcaster/src/golive.ts').StreamInfo;
|
|
|
|
try {
|
|
const user = await helix.userByLogin(cfg.channel.login);
|
|
const live = user ? await helix.streamByUserId(user.id) : null;
|
|
if (live) {
|
|
stream = {
|
|
id: `probe-${live.id}-${Date.now()}`,
|
|
title: live.title,
|
|
category: live.game_name,
|
|
login: cfg.channel.login,
|
|
startedAt: live.started_at,
|
|
thumbnailUrl: live.thumbnail_url,
|
|
};
|
|
console.log(`${C.dim}Stream laeuft gerade - der Probelauf nimmt die echten Daten.${C.off}`);
|
|
} else {
|
|
console.log(`${C.dim}Kein laufender Stream - der Probelauf nimmt erfundene Daten.${C.off}`);
|
|
}
|
|
} catch (err) {
|
|
// Haeufigster Fall ist nicht "Netz weg", sondern "noch keine Twitch-App
|
|
// hinterlegt". Der Satz soll das sagen, sonst sucht man am falschen Ende.
|
|
console.log(`${C.warn}Twitch nicht abgefragt:${C.off} ${(err as Error).message}`);
|
|
console.log(`${C.dim}Der Probelauf nimmt erfundene Daten - der Weg zum Webhook ` +
|
|
`wird trotzdem vollstaendig geprueft.${C.off}`);
|
|
}
|
|
|
|
// OBS nur dazunehmen, wenn es laeuft: sonst wartet der Screenshot ins Leere.
|
|
let obs: Awaited<ReturnType<typeof withObs>> | null = null;
|
|
if (cfg.broadcaster.obsScreenshot && await tcpProbe(OBS_HOST, OBS_PORT, 1500)) {
|
|
obs = await withObs();
|
|
}
|
|
|
|
const announcer = createAnnouncer({
|
|
cfg: {
|
|
enabled: true,
|
|
mode: echt ? 'live' : 'draft',
|
|
delaySeconds: 0,
|
|
cooldownHours: cfg.broadcaster.cooldownHours,
|
|
pingRole: cfg.broadcaster.pingRole,
|
|
obsScreenshot: Boolean(obs),
|
|
},
|
|
repo: repos(openDb()),
|
|
...(obs ? { obs: obs.client } : {}),
|
|
// Ein Probelauf geht durch die Abklingzeit hindurch, sonst koennte man ihn
|
|
// nach einem echten Stream sechs Stunden lang nicht wiederholen.
|
|
refresh: async (s) => s,
|
|
});
|
|
|
|
// Als eigene Art protokollieren: sonst sperrt der Probelauf ueber die
|
|
// Abklingzeit die naechste echte Ankuendigung.
|
|
const result = await announcer.announceNow(stream, { force: true, art: 'golive-test' });
|
|
obs?.stop();
|
|
|
|
if (result.status === 'gesendet') {
|
|
const wohin = result.mode === 'draft' ? 'interner Kanal' : 'Ankuendigungen';
|
|
const ping = result.pinged ? `mit Ping an "${cfg.broadcaster.pingRole}"` : 'ohne Ping';
|
|
console.log(`
|
|
${C.ok}Post ist raus${C.off} (${wohin}, ${ping}).`);
|
|
if (result.url) console.log(` ${result.url}`);
|
|
if (echt && !result.pinged) {
|
|
console.log(`
|
|
${C.warn}Kein Ping gesetzt.${C.off} Pruefen: gibt es die Rolle ` +
|
|
`"${cfg.broadcaster.pingRole}" auf dem Server, und ist ein Bot-Token hinterlegt?`);
|
|
}
|
|
if (!echt) console.log(`
|
|
${C.dim}Mit --echt geht derselbe Post in die Ankuendigungen, ` +
|
|
`mit Ping.${C.off}`);
|
|
} else {
|
|
console.log(`
|
|
${C.bad}${result.status}${C.off}: ${result.reason}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
/** OBS kurz verbinden, damit ein Screenshot moeglich ist. */
|
|
async function withObs() {
|
|
const { createObs } = await import('#obs/client.ts');
|
|
const { readSecrets } = await import('#twitch/secrets.ts');
|
|
const abort = new AbortController();
|
|
const client = createObs(`ws://${OBS_HOST}:${OBS_PORT}`,
|
|
() => readSecrets().obs?.password, {});
|
|
void client.run(abort.signal);
|
|
// Kurz auf die Identifikation warten; ohne sie gibt es keinen Screenshot,
|
|
// und das ist kein Grund zum Abbruch.
|
|
for (let i = 0; i < 30 && !client.isConnected(); i++) {
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
return { client, stop: () => abort.abort() };
|
|
}
|
|
|
|
async function discordBot(): Promise<void> {
|
|
const { createBot } = await import('#discord/bot.ts');
|
|
const bot = createBot();
|
|
if (!bot) {
|
|
throw new Error('Kein Discord-Bot hinterlegt. Erst einrichten: abdulive discord:setup');
|
|
}
|
|
const abort = new AbortController();
|
|
const stop = (sig: string): void => {
|
|
console.log(`\n${sig} empfangen, trenne die Verbindung.`);
|
|
abort.abort();
|
|
setTimeout(() => process.exit(0), 300);
|
|
};
|
|
process.on('SIGINT', () => stop('SIGINT'));
|
|
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
|
|
console.log('Discord-Bot laeuft. Beenden mit Strg+C.');
|
|
console.log(`${C.dim}Im Normalbetrieb laeuft er im Ingest-Daemon mit; dieser Befehl ` +
|
|
`ist zum Ausprobieren.${C.off}`);
|
|
await bot.run(abort.signal);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- rewards:anlegen
|
|
|
|
/**
|
|
* Eine Kanalpunkt-Belohnung ueber die API anlegen statt im Dashboard.
|
|
*
|
|
* Der Grund ist nicht Bequemlichkeit: Twitch laesst nur die App, die eine
|
|
* Belohnung angelegt hat, deren Einloesungen aendern. Eine im Dashboard
|
|
* angelegte Belohnung kann das Toolkit weder erstatten noch als erledigt
|
|
* markieren. Wiederholbar: steht die Belohnung schon, wird nur ihre ID
|
|
* gezeigt.
|
|
*/
|
|
async function rewardsAnlegen(args: string[]): Promise<void> {
|
|
const wert = (name: string): string | undefined => {
|
|
const i = args.indexOf(name);
|
|
return i === -1 ? undefined : args[i + 1];
|
|
};
|
|
const titel = wert('--titel');
|
|
const kosten = Number(wert('--kosten'));
|
|
if (!titel || !Number.isInteger(kosten) || kosten < 1) {
|
|
throw new Error('Aufruf: rewards:anlegen --titel "Nachricht vorlesen" --kosten 1000 ' +
|
|
'[--text] [--hinweis "..."] [--abklingzeit SEKUNDEN]');
|
|
}
|
|
const cfg = loadConfig();
|
|
const { helix } = await import('#twitch/helix.ts');
|
|
const user = await helix.userByLogin(cfg.channel.login);
|
|
if (!user) throw new Error(`Kanal ${cfg.channel.login} nicht gefunden`);
|
|
|
|
const alle = await helix.customRewards(user.id);
|
|
const eigene = new Set((await helix.customRewards(user.id, true)).map((r) => r.id));
|
|
const da = alle.find((r) => r.title.toLowerCase() === titel.toLowerCase());
|
|
if (da) {
|
|
console.log(`Steht schon: "${da.title}" (${da.cost} Punkte), ID ${da.id}`);
|
|
if (!eigene.has(da.id)) {
|
|
console.log('Achtung: im Dashboard angelegt, nicht vom Toolkit. Erstatten und ' +
|
|
'Abschliessen gehen damit nicht. Im Dashboard loeschen und hier neu anlegen.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const abklingzeit = Number(wert('--abklingzeit') ?? 0);
|
|
const neu = await helix.createCustomReward(user.id, {
|
|
title: titel,
|
|
cost: kosten,
|
|
...(args.includes('--text') ? { is_user_input_required: true } : {}),
|
|
...(wert('--hinweis') ? { prompt: wert('--hinweis')! } : {}),
|
|
...(abklingzeit > 0
|
|
? { is_global_cooldown_enabled: true, global_cooldown_seconds: abklingzeit } : {}),
|
|
should_redemptions_skip_request_queue: false,
|
|
});
|
|
console.log(`Angelegt: "${neu.title}" (${neu.cost} Punkte), ID ${neu.id}`);
|
|
console.log('In config.yaml unter rewards.belohnungen mit `id:` eintragen - die ID gewinnt gegen den Titel.');
|
|
}
|
|
|
|
// ---------------------------------------------------------------- help
|
|
|
|
function help(): void {
|
|
console.log(`
|
|
abdulive - Werkzeugkasten fuer Abdus Stream
|
|
|
|
hub Zentraler Dienst: Chat, Discord, Go-Live, Overlay
|
|
ingest Daemon auf dem Stream-PC (OBS, !clip, Rendern)
|
|
overlay [--demo] Nur den Overlay-Server, --demo mit erfundenem Betrieb
|
|
web:passwort [wort] Passwort fuer die Weboberflaeche setzen
|
|
fern:verbinden <adresse> <schluessel>
|
|
Stream-PC beim Hub anmelden, damit das Pult OBS
|
|
steuern kann. Der Befehl steht fertig auf der
|
|
Uebersichtsseite des Hubs. "aus" statt Adresse trennt.
|
|
check-env Umgebung pruefen
|
|
db:migrate Migrationen anwenden
|
|
fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin)
|
|
calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern
|
|
sources [profil] OBS-Quellen auflisten: was landet im 9:16-Ausschnitt?
|
|
update [--force] Nach einer neuen Fassung sehen und sie bereitlegen
|
|
render <datei> [profil] Rohdatei nach 9:16 rendern (zum Nachrendern)
|
|
discord:setup [--server ID] [--vorschau]
|
|
Discord-Server nach docs/projects/discord-community.md einrichten
|
|
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
|
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
|
|
Kanal ohne Ping, mit --echt in die Ankuendigungen
|
|
rewards:anlegen --titel T --kosten N [--text] [--hinweis H] [--abklingzeit S]
|
|
Kanalpunkt-Belohnung ueber die API anlegen (noetig
|
|
zum Erstatten, siehe apps/hub/src/rewards.ts)
|
|
help Diese Uebersicht
|
|
|
|
Einrichtung nach dem Start: http://localhost:4792/setup
|
|
|
|
Stand: Scheiben S0 bis S3 aus docs/07-mvp-plan.md. Untertitel (S4) und die
|
|
Sichtungsseite mit Onboarding (S5) folgen.
|
|
`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- main
|
|
|
|
const [cmd, ...args] = process.argv.slice(2);
|
|
try {
|
|
switch (cmd) {
|
|
case 'ingest': {
|
|
const { runIngest } = await import('../ingest/src/index.ts');
|
|
await runIngest();
|
|
break;
|
|
}
|
|
case 'check-env': await checkEnv(); break;
|
|
case 'db:migrate': openDb(); console.log('Migrationen sind aktuell.'); break;
|
|
case 'fetch-model': await fetchModel(args[0]); break;
|
|
case 'calibrate': await calibrate(args[0], args[1]); break;
|
|
case 'sources': await listSources(args[0]); break;
|
|
case 'update': {
|
|
const { checkForUpdate } = await import('./update.ts');
|
|
await checkForUpdate({ force: args.includes('--force') });
|
|
break;
|
|
}
|
|
case 'render': await renderOne(args[0], args[1]); break;
|
|
case 'discord:setup': await discordSetup(args); break;
|
|
case 'golive:test': await goLiveTest(args); break;
|
|
case 'rewards:anlegen': await rewardsAnlegen(args); break;
|
|
case 'hub': {
|
|
const { runHub } = await import('../hub/src/index.ts');
|
|
await runHub();
|
|
break;
|
|
}
|
|
case 'overlay': await overlayOnly(args); break;
|
|
case 'web:passwort': await webPasswort(args); break;
|
|
case 'fern:verbinden': await fernVerbinden(args); break;
|
|
case 'discord:bot': await discordBot(); break;
|
|
case 'help': case undefined: help(); break;
|
|
default:
|
|
console.error(`Unbekannter Befehl "${cmd}".`);
|
|
help();
|
|
process.exitCode = 1;
|
|
}
|
|
} catch (err) {
|
|
console.error(`\n${C.bad}Fehler:${C.off} ${(err as Error).message}`);
|
|
const stderr = (err as { stderr?: string }).stderr;
|
|
if (stderr) console.error(String(stderr).trim().split('\n').slice(-5).join('\n'));
|
|
process.exitCode = 1;
|
|
}
|