release: veröffentliche Twitch VOD Manager 1.0.1

Startet die öffentliche Versionslinie mit einer bereinigten Ein-Commit-Historie, stellt den Updater auf GitHub Releases um, entfernt interne Release-Ziele und beschränkt den gepackten Anwendungssatz auf notwendige Laufzeitdateien. Enthält aktualisierte produktive Abhängigkeiten ohne bekannte npm-Audit-Funde sowie die geprüfte öffentliche Quell-Positivliste.
This commit is contained in:
Sucukdeluxe
2026-08-05 21:38:06 +02:00
commit aed40de4bd
79 changed files with 33546 additions and 0 deletions
View File
+67
View File
@@ -0,0 +1,67 @@
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { hashBuffer, hashFile } from './chunk-hash';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkhash-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('hashBuffer', () => {
test('"hello" sha1', () => {
expect(hashBuffer(Buffer.from('hello', 'utf-8')))
.toBe('aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d');
});
test('empty buffer sha1', () => {
expect(hashBuffer(Buffer.alloc(0)))
.toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
});
test('large buffer hashes deterministically', () => {
const big = Buffer.alloc(1024 * 1024, 0x42); // 1MB of 'B' bytes
const a = hashBuffer(big);
const b = hashBuffer(big);
expect(a).toBe(b);
expect(a).toHaveLength(40); // sha1 = 40 hex chars
});
test('different content produces different hashes', () => {
expect(hashBuffer(Buffer.from('a'))).not.toBe(hashBuffer(Buffer.from('b')));
});
});
describe('hashFile', () => {
test('file hash matches buffer hash for same content', async () => {
const content = 'roundtrip-test-payload';
const filePath = path.join(tmpDir, 'a.bin');
fs.writeFileSync(filePath, content, 'utf-8');
const fileHash = await hashFile(filePath);
const bufHash = hashBuffer(Buffer.from(content, 'utf-8'));
expect(fileHash).toBe(bufHash);
});
test('empty file = empty-buffer sha1', async () => {
const filePath = path.join(tmpDir, 'empty.bin');
fs.writeFileSync(filePath, '');
const fileHash = await hashFile(filePath);
expect(fileHash).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
});
test('large file (4MB) hashes correctly', async () => {
const filePath = path.join(tmpDir, 'big.bin');
const payload = Buffer.alloc(4 * 1024 * 1024, 0x55);
fs.writeFileSync(filePath, payload);
const fileHash = await hashFile(filePath);
expect(fileHash).toBe(hashBuffer(payload));
});
test('missing file rejects', async () => {
await expect(hashFile(path.join(tmpDir, 'does-not-exist'))).rejects.toThrow();
});
});
+26
View File
@@ -0,0 +1,26 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
export function hashBuffer(b: Buffer): string {
return crypto.createHash('sha1').update(b).digest('hex');
}
/**
* Streaming sha1-Hash einer Datei. Async, damit grosse Recorded-Segments
* (oft mehrere MB) nicht den Event-Loop blockieren.
*/
export function hashFile(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha1');
const stream = fs.createReadStream(filePath);
stream.on('error', reject);
stream.on('data', (chunk: Buffer | string) => {
if (typeof chunk === 'string') {
hash.update(chunk, 'utf-8');
} else {
hash.update(chunk);
}
});
stream.on('end', () => resolve(hash.digest('hex')));
});
}
+137
View File
@@ -0,0 +1,137 @@
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { openDatabase, type DbHandle } from './db';
let tmpDir: string;
let db: DbHandle | null = null;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-test-'));
});
afterEach(() => {
try { db?.close(); } catch { /* ignore */ }
db = null;
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('openDatabase', () => {
test('creates a new file', () => {
const target = path.join(tmpDir, 'a.db');
db = openDatabase(target);
expect(fs.existsSync(target)).toBe(true);
expect(typeof db.run).toBe('function');
expect(typeof db.get).toBe('function');
expect(typeof db.all).toBe('function');
expect(typeof db.close).toBe('function');
expect(typeof db.transaction).toBe('function');
expect(typeof db.runBatch).toBe('function');
});
test('schema_meta row exists with schema_version=5', () => {
db = openDatabase(path.join(tmpDir, 'b.db'));
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
expect(row?.value).toBe('5');
});
test('WAL mode active', () => {
db = openDatabase(path.join(tmpDir, 'c.db'));
const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode');
expect(row?.journal_mode).toBe('wal');
});
test('idempotent open: existing file keeps schema_version=5', () => {
const target = path.join(tmpDir, 'd.db');
db = openDatabase(target);
db.close();
db = openDatabase(target);
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
expect(row?.value).toBe('5');
});
test('run + get + all roundtrip on downloaded_vods', () => {
db = openDatabase(path.join(tmpDir, 'e.db'));
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['1234']);
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['5678']);
const one = db.get<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods WHERE vod_id = ?', ['1234']);
expect(one?.vod_id).toBe('1234');
const all = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
expect(all.map(r => r.vod_id)).toEqual(['1234', '5678']);
});
test('transaction commits as bracket', () => {
db = openDatabase(path.join(tmpDir, 'f.db'));
const handle = db;
const inserted = handle.transaction(() => {
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t1']);
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t2']);
return 2;
});
expect(inserted).toBe(2);
const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
expect(c?.c).toBe(2);
});
test('chunk_index table accepts insert + UNIQUE(item_id, chunk_seq)', () => {
db = openDatabase(path.join(tmpDir, 'chunk.db'));
db.run(
'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)',
['item1', 0, 'abc123', 1024]
);
const handle = db;
expect(() => {
handle.run(
'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)',
['item1', 0, 'different', 2048]
);
}).toThrow(); // UNIQUE violation
const rows = handle.all<{ sha1_hex: string }>('SELECT sha1_hex FROM chunk_index WHERE item_id = ?', ['item1']);
expect(rows).toHaveLength(1);
expect(rows[0].sha1_hex).toBe('abc123');
});
test('oauth_accounts table exists and accepts insert', () => {
db = openDatabase(path.join(tmpDir, 'oauth.db'));
db.run(
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
VALUES (?, ?, ?, ?)`,
['twitch', 'user-123', 'alice', 'ciphertext-blob']
);
const row = db.get<{ login: string; provider: string }>(
'SELECT login, provider FROM oauth_accounts WHERE twitch_user_id = ?',
['user-123']
);
expect(row?.login).toBe('alice');
expect(row?.provider).toBe('twitch');
});
test('oauth_accounts UNIQUE(provider, twitch_user_id) enforced', () => {
db = openDatabase(path.join(tmpDir, 'oauth-unique.db'));
db.run(
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
VALUES (?, ?, ?, ?)`,
['twitch', 'u1', 'a', 'x']
);
const handle = db;
expect(() => {
handle.run(
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
VALUES (?, ?, ?, ?)`,
['twitch', 'u1', 'b', 'y']
);
}).toThrow();
});
test('transaction rolls back on throw', () => {
db = openDatabase(path.join(tmpDir, 'g.db'));
const handle = db;
expect(() => {
handle.transaction(() => {
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['x1']);
throw new Error('boom');
});
}).toThrow('boom');
const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
expect(c?.c).toBe(0);
});
});
+60
View File
@@ -0,0 +1,60 @@
import Database, { type Database as DatabaseT } from 'better-sqlite3';
import { SCHEMA_V5_SQL } from './schema-v5';
/**
* Public DB-Handle. Schmaler Wrapper um better-sqlite3.
*/
export interface DbHandle {
run(sql: string, params?: unknown[]): void;
get<T = unknown>(sql: string, params?: unknown[]): T | undefined;
all<T = unknown>(sql: string, params?: unknown[]): T[];
transaction<R>(fn: () => R): R;
runBatch(sql: string): void;
close(): void;
readonly raw: DatabaseT;
}
function splitStatements(sql: string): string[] {
return sql
.split(';')
.map(s => s.trim())
.filter(s => s.length > 0);
}
function runMultiStatement(db: DatabaseT, sql: string): void {
for (const stmt of splitStatements(sql)) {
db.prepare(stmt).run();
}
}
export function openDatabase(filePath: string): DbHandle {
const db = new Database(filePath);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
db.pragma('foreign_keys = ON');
runMultiStatement(db, SCHEMA_V5_SQL);
const handle: DbHandle = {
run(sql, params) {
db.prepare(sql).run(...(params ?? []) as unknown[]);
},
get<T>(sql: string, params?: unknown[]): T | undefined {
return db.prepare(sql).get(...(params ?? []) as unknown[]) as T | undefined;
},
all<T>(sql: string, params?: unknown[]): T[] {
return db.prepare(sql).all(...(params ?? []) as unknown[]) as T[];
},
transaction<R>(fn: () => R): R {
return db.transaction(fn)();
},
runBatch(sql) {
runMultiStatement(db, sql);
},
close() {
db.close();
},
get raw() { return db; },
};
return handle;
}
+65
View File
@@ -0,0 +1,65 @@
import { test, expect, describe } from 'vitest';
import { parseDuration, formatDuration, formatDurationDashed } from './duration';
describe('parseDuration', () => {
test('1h2m3s = 3723', () => {
expect(parseDuration('1h2m3s')).toBe(3723);
});
test('45m = 2700', () => {
expect(parseDuration('45m')).toBe(2700);
});
test('10s = 10', () => {
expect(parseDuration('10s')).toBe(10);
});
test('empty string = 0', () => {
expect(parseDuration('')).toBe(0);
});
test('unknown format = 0', () => {
expect(parseDuration('abcdef')).toBe(0);
});
test('partial 2h = 7200', () => {
expect(parseDuration('2h')).toBe(7200);
});
test('h and s without m = 3601', () => {
expect(parseDuration('1h1s')).toBe(3601);
});
});
describe('formatDuration', () => {
test('3723 = 01:02:03', () => {
expect(formatDuration(3723)).toBe('01:02:03');
});
test('0 = 00:00:00', () => {
expect(formatDuration(0)).toBe('00:00:00');
});
test('negative = 00:00:00', () => {
expect(formatDuration(-1)).toBe('00:00:00');
});
test('Infinity = 00:00:00', () => {
expect(formatDuration(Infinity)).toBe('00:00:00');
});
test('NaN = 00:00:00', () => {
expect(formatDuration(NaN)).toBe('00:00:00');
});
test('3600 = 01:00:00', () => {
expect(formatDuration(3600)).toBe('01:00:00');
});
test('86399 = 23:59:59', () => {
expect(formatDuration(86399)).toBe('23:59:59');
});
test('fractional seconds floored', () => {
expect(formatDuration(3723.9)).toBe('01:02:03');
});
});
describe('formatDurationDashed', () => {
test('3723 = 01-02-03', () => {
expect(formatDurationDashed(3723)).toBe('01-02-03');
});
test('negative = 00-00-00', () => {
expect(formatDurationDashed(-1)).toBe('00-00-00');
});
test('NaN = 00-00-00', () => {
expect(formatDurationDashed(NaN)).toBe('00-00-00');
});
});
+28
View File
@@ -0,0 +1,28 @@
export function parseDuration(duration: string): number {
let seconds = 0;
const hours = duration.match(/(\d+)h/);
const minutes = duration.match(/(\d+)m/);
const secs = duration.match(/(\d+)s/);
if (hours) seconds += parseInt(hours[1]) * 3600;
if (minutes) seconds += parseInt(minutes[1]) * 60;
if (secs) seconds += parseInt(secs[1]);
return seconds;
}
export function formatDuration(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '00:00:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
export function formatDurationDashed(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '00-00-00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
}
+103
View File
@@ -0,0 +1,103 @@
import { test, expect, describe } from 'vitest';
import {
sanitizeFilenamePart,
formatTwitchDurationFromSeconds,
formatDateWithPattern,
getMergeGroupPhaseText,
} from './format-helpers';
describe('sanitizeFilenamePart', () => {
test('replaces Windows-invalid chars with underscore', () => {
expect(sanitizeFilenamePart('a<b>c:d"e|f?g*h')).toBe('a_b_c_d_e_f_g_h');
});
test('replaces path separators', () => {
expect(sanitizeFilenamePart('a/b\\c')).toBe('a_b_c');
});
test('strips control chars', () => {
expect(sanitizeFilenamePart('a\x00b\x1fc')).toBe('a_b_c');
});
test('trims whitespace', () => {
expect(sanitizeFilenamePart(' hi ')).toBe('hi');
});
test('empty falls back to default', () => {
expect(sanitizeFilenamePart('')).toBe('unnamed');
});
test('custom fallback', () => {
expect(sanitizeFilenamePart('', 'FB')).toBe('FB');
});
test('only-invalid-chars falls back', () => {
expect(sanitizeFilenamePart('////').trim()).not.toBe('');
// '////' becomes '____' which is non-empty, so no fallback
expect(sanitizeFilenamePart('////')).toBe('____');
});
});
describe('formatTwitchDurationFromSeconds', () => {
test('0 = 0s', () => {
expect(formatTwitchDurationFromSeconds(0)).toBe('0s');
});
test('45 = 45s', () => {
expect(formatTwitchDurationFromSeconds(45)).toBe('45s');
});
test('65 = 1m5s', () => {
expect(formatTwitchDurationFromSeconds(65)).toBe('1m5s');
});
test('3725 = 1h2m5s', () => {
expect(formatTwitchDurationFromSeconds(3725)).toBe('1h2m5s');
});
test('3600 = 1h0m0s', () => {
expect(formatTwitchDurationFromSeconds(3600)).toBe('1h0m0s');
});
test('negative clamped to 0', () => {
expect(formatTwitchDurationFromSeconds(-5)).toBe('0s');
});
test('NaN clamped to 0', () => {
expect(formatTwitchDurationFromSeconds(NaN)).toBe('0s');
});
test('Infinity clamped to 0', () => {
expect(formatTwitchDurationFromSeconds(Infinity)).toBe('0s');
});
});
describe('formatDateWithPattern', () => {
const d = new Date(2026, 4, 11, 23, 5, 7); // 2026-05-11 23:05:07
test('yyyy-MM-dd', () => {
expect(formatDateWithPattern(d, 'yyyy-MM-dd')).toBe('2026-05-11');
});
test('yy MM dd', () => {
expect(formatDateWithPattern(d, 'yy/MM/dd')).toBe('26/05/11');
});
test('HH:mm:ss', () => {
expect(formatDateWithPattern(d, 'HH:mm:ss')).toBe('23:05:07');
});
test('combined pattern', () => {
expect(formatDateWithPattern(d, 'yyyy-MM-dd_HH-mm-ss')).toBe('2026-05-11_23-05-07');
});
test('backslashes are stripped after token substitution', () => {
// Note: \ does NOT escape the date-token (no negative-lookbehind in regex).
// It only removes the literal backslash from the output. So 'yyyy\\X' → 'YYYYX'.
expect(formatDateWithPattern(d, 'yyyy\\X')).toBe('2026X');
});
});
describe('getMergeGroupPhaseText', () => {
test('known DE phases', () => {
expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen');
expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...');
expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt');
expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...');
});
test('known EN phases', () => {
expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD');
expect(getMergeGroupPhaseText('merging', 'en')).toBe('Merging...');
expect(getMergeGroupPhaseText('splitting', 'en')).toBe('Splitting Part');
expect(getMergeGroupPhaseText('cleanup', 'en')).toBe('Cleaning up...');
});
test('unknown phase passes through', () => {
expect(getMergeGroupPhaseText('unknown', 'de')).toBe('unknown');
});
test('unknown language falls back to DE', () => {
expect(getMergeGroupPhaseText('downloading', 'fr')).toBe('VOD wird heruntergeladen');
});
});
+78
View File
@@ -0,0 +1,78 @@
// Pure-Format-Helpers, extrahiert aus main.ts. Keine Globals, keine I/O.
const FILENAME_INVALID_RE = /[<>:"|?*\x00-\x1f]/g;
const FILENAME_PATH_SEP_RE = /[\\/]/g;
/**
* Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem
* Datei-Namen-Teilstring. Fallback wird zurueckgegeben, wenn nach Cleanup
* nichts uebrig bleibt.
*/
export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
const cleaned = (input || '')
.replace(FILENAME_INVALID_RE, '_')
.replace(FILENAME_PATH_SEP_RE, '_')
.trim();
return cleaned || fallback;
}
/**
* Twitch-Style Duration-Format: `1h2m3s`, `2m5s`, `42s`. Negative oder
* NaN-Inputs werden auf 0 geclamt.
*/
export function formatTwitchDurationFromSeconds(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(Number.isFinite(totalSeconds) ? totalSeconds : 0));
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h${m}m${s}s`;
if (m > 0) return `${m}m${s}s`;
return `${s}s`;
}
const DATE_TOKEN_RE = /yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g;
/**
* Date-Formatter mit Pattern-Tokens (yyyy, yy, MM, M, dd, d, HH, H, hh, h,
* mm, m, ss, s). Backslash-escapes (\T) lassen das Folgezeichen literal.
*/
export function formatDateWithPattern(date: Date, pattern: string): string {
const tokenMap: Record<string, string> = {
yyyy: date.getFullYear().toString(),
yy: date.getFullYear().toString().slice(-2),
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
M: (date.getMonth() + 1).toString(),
dd: date.getDate().toString().padStart(2, '0'),
d: date.getDate().toString(),
HH: date.getHours().toString().padStart(2, '0'),
H: date.getHours().toString(),
hh: date.getHours().toString().padStart(2, '0'),
h: date.getHours().toString(),
mm: date.getMinutes().toString().padStart(2, '0'),
m: date.getMinutes().toString(),
ss: date.getSeconds().toString().padStart(2, '0'),
s: date.getSeconds().toString(),
};
return pattern
.replace(DATE_TOKEN_RE, token => tokenMap[token] ?? token)
.replace(/\\(.)/g, '$1');
}
export type MergeGroupLanguage = 'de' | 'en';
/**
* Label fuer den aktuellen Merge-Group-Phase-Status. Pure variant — Sprache
* wird vom Caller injiziert.
*/
export function getMergeGroupPhaseText(phase: string, language: MergeGroupLanguage | string): string {
const isEnglish = language === 'en';
switch (phase) {
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
default: return phase;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { writeFileAtomicSync } from './fs-atomic';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsatomic-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('writeFileAtomicSync', () => {
test('writes a string payload', () => {
const target = path.join(tmpDir, 'a.txt');
writeFileAtomicSync(target, 'hello');
expect(fs.readFileSync(target, 'utf-8')).toBe('hello');
});
test('writes a buffer payload', () => {
const target = path.join(tmpDir, 'b.bin');
writeFileAtomicSync(target, Buffer.from([1, 2, 3, 4]));
expect(fs.readFileSync(target)).toEqual(Buffer.from([1, 2, 3, 4]));
});
test('overwrites existing file', () => {
const target = path.join(tmpDir, 'c.txt');
fs.writeFileSync(target, 'old');
writeFileAtomicSync(target, 'new');
expect(fs.readFileSync(target, 'utf-8')).toBe('new');
});
test('cleans up tmp file after success', () => {
const target = path.join(tmpDir, 'd.txt');
writeFileAtomicSync(target, 'x');
expect(fs.existsSync(target + '.tmp')).toBe(false);
});
test('utf-8 multibyte chars roundtrip', () => {
const target = path.join(tmpDir, 'e.txt');
writeFileAtomicSync(target, 'aeoeue-aeoeue');
expect(fs.readFileSync(target, 'utf-8')).toBe('aeoeue-aeoeue');
});
test('empty payload writes empty file', () => {
const target = path.join(tmpDir, 'f.txt');
writeFileAtomicSync(target, '');
expect(fs.readFileSync(target, 'utf-8')).toBe('');
expect(fs.statSync(target).size).toBe(0);
});
});
+29
View File
@@ -0,0 +1,29 @@
import * as fs from 'fs';
/**
* Atomic write via tmp + rename. Survives crash mid-write — either old or
* new content, never partial. Windows fallback: copy + unlink if rename
* fails (e.g. target locked by reader). fsync best-effort.
*/
export function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
const tmpPath = targetPath + '.tmp';
let fd: number | null = null;
try {
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, buffer, 0, buffer.length, 0);
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { /* ignore */ }
}
}
try {
fs.renameSync(tmpPath, targetPath);
} catch {
fs.copyFileSync(tmpPath, targetPath);
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
}
+58
View File
@@ -0,0 +1,58 @@
import { test, expect, describe } from 'vitest';
import * as http from 'http';
import { startLoopbackServer } from './loopback-server';
function httpGet(url: string): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.get(url, res => {
let body = '';
res.on('data', chunk => { body += chunk.toString(); });
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
});
req.on('error', reject);
});
}
describe('startLoopbackServer', () => {
test('binds to 127.0.0.1 and returns url with pathPrefix', async () => {
const server = await startLoopbackServer({ pathPrefix: '/cb' });
expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/cb$/);
server.close();
});
test('captures redirect params (code + state)', async () => {
const server = await startLoopbackServer({ pathPrefix: '/cb' });
const captureP = server.awaitParams({ timeoutMs: 3000 });
const response = await httpGet(`${server.url}?code=abc123&state=xyz`);
expect(response.status).toBe(200);
const params = await captureP;
expect(params.get('code')).toBe('abc123');
expect(params.get('state')).toBe('xyz');
server.close();
});
test('non-matching path returns 404, capture not triggered', async () => {
const server = await startLoopbackServer({ pathPrefix: '/cb' });
const captureP = server.awaitParams({ timeoutMs: 500 });
const response = await httpGet(`${server.url.replace('/cb', '/other')}`);
expect(response.status).toBe(404);
await expect(captureP).rejects.toThrow(/timeout/);
server.close();
});
test('error param renders errorHtml', async () => {
const server = await startLoopbackServer({ pathPrefix: '/cb' });
const captureP = server.awaitParams({ timeoutMs: 3000 });
const response = await httpGet(`${server.url}?error=access_denied`);
expect(response.body).toContain('Fehler');
const params = await captureP;
expect(params.get('error')).toBe('access_denied');
server.close();
});
test('timeout rejects', async () => {
const server = await startLoopbackServer({ pathPrefix: '/cb' });
await expect(server.awaitParams({ timeoutMs: 200 })).rejects.toThrow(/timeout/);
server.close();
});
});
+120
View File
@@ -0,0 +1,120 @@
import * as http from 'http';
import { URL } from 'url';
/**
* Ephemerer HTTP-Server auf localhost:PORT fuer OAuth-Redirect-Capture.
* RFC 8252 (OAuth 2.0 for Native Apps) — System-Browser + Loopback-Redirect.
*
* Lifecycle:
* const server = await startLoopbackServer({ pathPrefix: '/oauth/callback' });
* console.log(server.url); // http://127.0.0.1:54321/oauth/callback
* const params = await server.awaitParams({ timeoutMs: 5 * 60 * 1000 });
* server.close();
*
* Bindet immer auf 127.0.0.1 (nicht 0.0.0.0) — der OS-Listener ist nur lokal
* erreichbar, kein Firewall-Prompt unter Windows.
*/
export interface LoopbackServerOptions {
pathPrefix: string; // z.B. '/oauth/callback'
port?: number; // 0 = OS waehlt freien Port
successHtml?: string; // HTML-Antwort beim Capture
errorHtml?: string;
}
export interface LoopbackServer {
readonly url: string;
awaitParams(opts?: { timeoutMs?: number }): Promise<URLSearchParams>;
close(): void;
}
const DEFAULT_SUCCESS = `<!doctype html><html><head><meta charset="utf-8"><title>Login erfolgreich</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
h1{color:#9146FF;margin:0 0 0.5rem}</style></head>
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schliessen.</p></div></body></html>`;
const DEFAULT_ERROR = `<!doctype html><html><head><meta charset="utf-8"><title>Fehler</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
h1{color:#ff4444;margin:0 0 0.5rem}</style></head>
<body><div class="box"><h1>Fehler</h1><p>Login abgebrochen.</p></div></body></html>`;
export function startLoopbackServer(opts: LoopbackServerOptions): Promise<LoopbackServer> {
const successHtml = opts.successHtml ?? DEFAULT_SUCCESS;
const errorHtml = opts.errorHtml ?? DEFAULT_ERROR;
const pathPrefix = opts.pathPrefix.startsWith('/') ? opts.pathPrefix : '/' + opts.pathPrefix;
return new Promise((resolve, reject) => {
let resolveCapture: ((p: URLSearchParams) => void) | null = null;
let rejectCapture: ((e: Error) => void) | null = null;
let captureSettled = false;
const captureP = new Promise<URLSearchParams>((res, rej) => {
resolveCapture = res;
rejectCapture = rej;
});
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url || '/', 'http://127.0.0.1');
if (!url.pathname.startsWith(pathPrefix)) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
return;
}
const params = url.searchParams;
const hasError = params.has('error');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(hasError ? errorHtml : successHtml);
if (!captureSettled && resolveCapture) {
captureSettled = true;
resolveCapture(params);
}
} catch (e) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('internal error');
if (!captureSettled && rejectCapture) {
captureSettled = true;
rejectCapture(e instanceof Error ? e : new Error(String(e)));
}
}
});
server.on('error', reject);
server.listen(opts.port ?? 0, '127.0.0.1', () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
server.close();
reject(new Error('loopback-server: failed to determine bound port'));
return;
}
const url = `http://127.0.0.1:${addr.port}${pathPrefix}`;
resolve({
url,
async awaitParams(awaitOpts) {
const timeoutMs = awaitOpts?.timeoutMs ?? 5 * 60 * 1000;
let timer: NodeJS.Timeout | null = null;
const timeoutP = new Promise<URLSearchParams>((_, rej) => {
timer = setTimeout(() => {
if (!captureSettled && rejectCapture) {
captureSettled = true;
rejectCapture(new Error('loopback-server: timeout waiting for redirect'));
}
rej(new Error('loopback-server: timeout waiting for redirect'));
}, timeoutMs);
});
try {
return await Promise.race([captureP, timeoutP]);
} finally {
if (timer) clearTimeout(timer);
}
},
close() {
try { server.close(); } catch { /* already closed */ }
},
});
});
});
}
+104
View File
@@ -0,0 +1,104 @@
// SQLite-Schema v5 fuer Twitch VOD Manager.
// Inline-Konstante damit tsc kein non-TS-Asset kopieren muss.
// Alle Tabellen mit IF NOT EXISTS — Schema-Bootstrap ist idempotent.
// PRAGMA-Statements (WAL etc.) werden separat von db.ts vor dem Bootstrap gesetzt.
export const SCHEMA_V5_SQL = `
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('schema_version', '5');
INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('created_at', CAST(strftime('%s','now') AS TEXT));
CREATE TABLE IF NOT EXISTS config_kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS queue_items (
id TEXT PRIMARY KEY,
streamer_login TEXT,
vod_id TEXT,
clip_id TEXT,
title TEXT,
output_path TEXT,
status TEXT NOT NULL,
progress_pct REAL,
error_message TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
completed_at INTEGER,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_items(status);
CREATE INDEX IF NOT EXISTS idx_queue_streamer ON queue_items(streamer_login);
CREATE INDEX IF NOT EXISTS idx_queue_created ON queue_items(created_at);
CREATE TABLE IF NOT EXISTS downloaded_vods (
vod_id TEXT PRIMARY KEY,
downloaded_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS streamers (
login TEXT PRIMARY KEY,
auto_record INTEGER NOT NULL DEFAULT 0,
auto_vod_download INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_streamers_autorec ON streamers(auto_record);
CREATE INDEX IF NOT EXISTS idx_streamers_autodl ON streamers(auto_vod_download);
CREATE TABLE IF NOT EXISTS archive_files (
path TEXT PRIMARY KEY,
streamer_login TEXT,
size_bytes INTEGER,
duration_seconds INTEGER,
created_at INTEGER,
verified INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_archive_streamer ON archive_files(streamer_login);
CREATE TABLE IF NOT EXISTS chunk_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
chunk_seq INTEGER NOT NULL,
sha1_hex TEXT NOT NULL,
bytes INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(item_id, chunk_seq)
);
CREATE INDEX IF NOT EXISTS idx_chunk_item ON chunk_index(item_id);
CREATE INDEX IF NOT EXISTS idx_chunk_sha1 ON chunk_index(sha1_hex);
CREATE TABLE IF NOT EXISTS oauth_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
twitch_user_id TEXT,
login TEXT,
display_name TEXT,
encrypted_access_token TEXT NOT NULL,
encrypted_refresh_token TEXT,
expires_at INTEGER,
scopes_json TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(provider, twitch_user_id)
);
CREATE INDEX IF NOT EXISTS idx_oauth_provider ON oauth_accounts(provider);
CREATE INDEX IF NOT EXISTS idx_oauth_default ON oauth_accounts(is_default);
CREATE TABLE IF NOT EXISTS migrations_applied (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
payload TEXT
);
`;
+45
View File
@@ -0,0 +1,45 @@
import { test, expect, describe } from 'vitest';
import { MemorySecureStorage, createElectronSecureStorage, type SecureStorage } from './secure-storage';
describe('MemorySecureStorage', () => {
test('isEncryptionAvailable returns false (kennzeichnet Memory-Mode)', () => {
const s: SecureStorage = new MemorySecureStorage();
expect(s.isEncryptionAvailable()).toBe(false);
});
test('roundtrip ascii', () => {
const s = new MemorySecureStorage();
const cipher = s.encrypt('hello');
expect(cipher).not.toBe('hello'); // base64-Kodierung greift
expect(s.decrypt(cipher)).toBe('hello');
});
test('roundtrip multi-byte', () => {
const s = new MemorySecureStorage();
expect(s.decrypt(s.encrypt('aeoeue-test'))).toBe('aeoeue-test');
});
test('roundtrip empty string', () => {
const s = new MemorySecureStorage();
expect(s.decrypt(s.encrypt(''))).toBe('');
});
test('long token (simuliert OAuth access_token Groesse)', () => {
const s = new MemorySecureStorage();
const token = 'a'.repeat(256);
expect(s.decrypt(s.encrypt(token))).toBe(token);
});
});
describe('createElectronSecureStorage', () => {
test('is exported as function', () => {
expect(typeof createElectronSecureStorage).toBe('function');
});
test('throws useful error if called outside Electron (vitest env)', () => {
// In vitest (Node-only) ist electron entweder nicht installiert oder hat keine
// app-context-Funktionen. Genaues Error-Wording ist nicht stable, aber Aufruf
// muss throwen statt undefined zurueckgeben.
expect(() => createElectronSecureStorage()).toThrow();
});
});
+58
View File
@@ -0,0 +1,58 @@
// Verschluesselt String-Payloads im OS-Keystore (Win Credential Manager via
// Electron safeStorage). MemorySecureStorage ist fuer Tests/Headless-Envs —
// gibt plaintext zurueck und meldet isEncryptionAvailable() === false, damit
// Caller das in den Log schreiben oder verweigern koennen.
export interface SecureStorage {
isEncryptionAvailable(): boolean;
encrypt(plaintext: string): string;
decrypt(ciphertext: string): string;
}
export class MemorySecureStorage implements SecureStorage {
isEncryptionAvailable(): boolean {
return false;
}
encrypt(plaintext: string): string {
// Base64 als Kennzeichnung — kein Schutz, nur damit `decrypt(encrypt(x)) === x`
// semantisch konsistent ist (kein literal plaintext zwischen den Methoden).
return Buffer.from(plaintext, 'utf-8').toString('base64');
}
decrypt(ciphertext: string): string {
return Buffer.from(ciphertext, 'base64').toString('utf-8');
}
}
interface SafeStorageLike {
isEncryptionAvailable(): boolean;
encryptString(plain: string): Buffer;
decryptString(buf: Buffer): string;
}
/**
* Wrappt electron.safeStorage. Setzt voraus, dass `app.whenReady()` gefired ist.
* Wird per Lazy-Require konstruiert, sodass Module ausserhalb von Electron
* (zB Tests) das Modul importieren koennen ohne Crash.
*/
export function createElectronSecureStorage(): SecureStorage {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const electron = require('electron');
const safeStorage = electron?.safeStorage as SafeStorageLike | undefined;
if (!safeStorage) {
throw new Error('Electron safeStorage not available (called before app.whenReady?)');
}
return {
isEncryptionAvailable(): boolean {
return safeStorage.isEncryptionAvailable();
},
encrypt(plaintext: string): string {
const buf = safeStorage.encryptString(plaintext);
return buf.toString('base64');
},
decrypt(ciphertext: string): string {
const buf = Buffer.from(ciphertext, 'base64');
return safeStorage.decryptString(buf);
},
};
}