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:
@@ -0,0 +1,106 @@
|
||||
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 '../infra/db';
|
||||
import { createArchiveFilesStore, type ArchiveFilesStore } from './archive-files-store';
|
||||
|
||||
let tmpDir: string;
|
||||
let db: DbHandle;
|
||||
let store: ArchiveFilesStore;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-'));
|
||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||
store = createArchiveFilesStore(db);
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('createArchiveFilesStore', () => {
|
||||
test('upsert + get roundtrip', () => {
|
||||
const rec = store.upsert({
|
||||
path: 'C:/vods/foo/2026-05-11.mp4',
|
||||
streamerLogin: 'Foo',
|
||||
sizeBytes: 1024 * 1024 * 100,
|
||||
durationSeconds: 3600,
|
||||
createdAt: 1700000000,
|
||||
verified: true,
|
||||
});
|
||||
expect(rec.path).toBe('C:/vods/foo/2026-05-11.mp4');
|
||||
expect(rec.streamerLogin).toBe('foo');
|
||||
expect(rec.sizeBytes).toBe(1024 * 1024 * 100);
|
||||
expect(rec.verified).toBe(true);
|
||||
|
||||
const fetched = store.get('C:/vods/foo/2026-05-11.mp4');
|
||||
expect(fetched?.streamerLogin).toBe('foo');
|
||||
});
|
||||
|
||||
test('upsert same path updates instead of duplicating', () => {
|
||||
store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 100 });
|
||||
store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 200 });
|
||||
const list = store.list();
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].sizeBytes).toBe(200);
|
||||
});
|
||||
|
||||
test('list returns all, ordered by created_at DESC NULLS LAST', () => {
|
||||
store.upsert({ path: '/older', streamerLogin: 'a', createdAt: 1000 });
|
||||
store.upsert({ path: '/newer', streamerLogin: 'a', createdAt: 2000 });
|
||||
store.upsert({ path: '/no-date', streamerLogin: 'a' });
|
||||
const list = store.list();
|
||||
expect(list.map(r => r.path)).toEqual(['/newer', '/older', '/no-date']);
|
||||
});
|
||||
|
||||
test('list(streamerLogin) filters and normalizes', () => {
|
||||
store.upsert({ path: '/a1', streamerLogin: 'alice' });
|
||||
store.upsert({ path: '/a2', streamerLogin: 'Alice' }); // normalized to alice
|
||||
store.upsert({ path: '/b1', streamerLogin: 'bob' });
|
||||
const aliceFiles = store.list('@Alice');
|
||||
expect(aliceFiles).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('setVerified toggles the flag', () => {
|
||||
store.upsert({ path: '/v', verified: false });
|
||||
store.setVerified('/v', true);
|
||||
expect(store.get('/v')?.verified).toBe(true);
|
||||
store.setVerified('/v', false);
|
||||
expect(store.get('/v')?.verified).toBe(false);
|
||||
});
|
||||
|
||||
test('delete removes the record', () => {
|
||||
store.upsert({ path: '/d', streamerLogin: 'x' });
|
||||
store.delete('/d');
|
||||
expect(store.get('/d')).toBeNull();
|
||||
});
|
||||
|
||||
test('summaryByStreamer aggregates counts and total bytes', () => {
|
||||
store.upsert({ path: '/a1', streamerLogin: 'alice', sizeBytes: 100 });
|
||||
store.upsert({ path: '/a2', streamerLogin: 'alice', sizeBytes: 200 });
|
||||
store.upsert({ path: '/b1', streamerLogin: 'bob', sizeBytes: 50 });
|
||||
store.upsert({ path: '/orphan', sizeBytes: 999 }); // no streamer — excluded
|
||||
|
||||
const summary = store.summaryByStreamer();
|
||||
// Sorted by total DESC: alice (300), bob (50)
|
||||
expect(summary).toHaveLength(2);
|
||||
expect(summary[0]).toEqual({ streamerLogin: 'alice', fileCount: 2, totalBytes: 300 });
|
||||
expect(summary[1]).toEqual({ streamerLogin: 'bob', fileCount: 1, totalBytes: 50 });
|
||||
});
|
||||
|
||||
test('totalBytes sums across everything', () => {
|
||||
store.upsert({ path: '/1', sizeBytes: 100 });
|
||||
store.upsert({ path: '/2', sizeBytes: 200 });
|
||||
store.upsert({ path: '/3', sizeBytes: 300, streamerLogin: 'a' });
|
||||
store.upsert({ path: '/4' }); // null bytes — coalesced to 0
|
||||
expect(store.totalBytes()).toBe(600);
|
||||
});
|
||||
|
||||
test('get returns null for missing path', () => {
|
||||
expect(store.get('/nope')).toBeNull();
|
||||
});
|
||||
|
||||
test('totalBytes on empty table = 0', () => {
|
||||
expect(store.totalBytes()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { DbHandle } from '../infra/db';
|
||||
import { normalizeLogin } from './config-normalize';
|
||||
|
||||
export interface ArchiveFileRecord {
|
||||
path: string;
|
||||
streamerLogin: string | null;
|
||||
sizeBytes: number | null;
|
||||
durationSeconds: number | null;
|
||||
createdAt: number | null;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface ArchiveFileWriteInput {
|
||||
path: string;
|
||||
streamerLogin?: string;
|
||||
sizeBytes?: number;
|
||||
durationSeconds?: number;
|
||||
createdAt?: number;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
export interface ArchiveStreamerSummary {
|
||||
streamerLogin: string;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface ArchiveFilesStore {
|
||||
upsert(input: ArchiveFileWriteInput): ArchiveFileRecord;
|
||||
get(path: string): ArchiveFileRecord | null;
|
||||
list(streamerLogin?: string): ArchiveFileRecord[];
|
||||
setVerified(path: string, verified: boolean): void;
|
||||
delete(path: string): void;
|
||||
summaryByStreamer(): ArchiveStreamerSummary[];
|
||||
totalBytes(): number;
|
||||
}
|
||||
|
||||
interface ArchiveRow {
|
||||
path: string;
|
||||
streamer_login: string | null;
|
||||
size_bytes: number | null;
|
||||
duration_seconds: number | null;
|
||||
created_at: number | null;
|
||||
verified: number;
|
||||
}
|
||||
|
||||
function rowToRecord(row: ArchiveRow): ArchiveFileRecord {
|
||||
return {
|
||||
path: row.path,
|
||||
streamerLogin: row.streamer_login,
|
||||
sizeBytes: row.size_bytes,
|
||||
durationSeconds: row.duration_seconds,
|
||||
createdAt: row.created_at,
|
||||
verified: row.verified === 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function createArchiveFilesStore(db: DbHandle): ArchiveFilesStore {
|
||||
return {
|
||||
upsert(input) {
|
||||
const streamerLogin = input.streamerLogin
|
||||
? normalizeLogin(input.streamerLogin)
|
||||
: null;
|
||||
const verified = input.verified ? 1 : 0;
|
||||
db.run(
|
||||
`INSERT INTO archive_files(path, streamer_login, size_bytes, duration_seconds, created_at, verified)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
streamer_login = excluded.streamer_login,
|
||||
size_bytes = excluded.size_bytes,
|
||||
duration_seconds = excluded.duration_seconds,
|
||||
created_at = excluded.created_at,
|
||||
verified = excluded.verified`,
|
||||
[
|
||||
input.path,
|
||||
streamerLogin,
|
||||
input.sizeBytes ?? null,
|
||||
input.durationSeconds ?? null,
|
||||
input.createdAt ?? null,
|
||||
verified,
|
||||
]
|
||||
);
|
||||
const row = db.get<ArchiveRow>('SELECT * FROM archive_files WHERE path = ?', [input.path]);
|
||||
if (!row) throw new Error(`archive-files-store: upsert lookup failed for ${input.path}`);
|
||||
return rowToRecord(row);
|
||||
},
|
||||
|
||||
get(p) {
|
||||
const row = db.get<ArchiveRow>('SELECT * FROM archive_files WHERE path = ?', [p]);
|
||||
return row ? rowToRecord(row) : null;
|
||||
},
|
||||
|
||||
list(streamerLogin) {
|
||||
const rows = streamerLogin
|
||||
? db.all<ArchiveRow>(
|
||||
'SELECT * FROM archive_files WHERE streamer_login = ? ORDER BY created_at DESC NULLS LAST, path',
|
||||
[normalizeLogin(streamerLogin)]
|
||||
)
|
||||
: db.all<ArchiveRow>('SELECT * FROM archive_files ORDER BY created_at DESC NULLS LAST, path');
|
||||
return rows.map(rowToRecord);
|
||||
},
|
||||
|
||||
setVerified(p, verified) {
|
||||
db.run(
|
||||
'UPDATE archive_files SET verified = ? WHERE path = ?',
|
||||
[verified ? 1 : 0, p]
|
||||
);
|
||||
},
|
||||
|
||||
delete(p) {
|
||||
db.run('DELETE FROM archive_files WHERE path = ?', [p]);
|
||||
},
|
||||
|
||||
summaryByStreamer() {
|
||||
const rows = db.all<{ streamer_login: string | null; cnt: number; total: number | null }>(
|
||||
`SELECT streamer_login, COUNT(*) AS cnt, COALESCE(SUM(size_bytes), 0) AS total
|
||||
FROM archive_files
|
||||
WHERE streamer_login IS NOT NULL
|
||||
GROUP BY streamer_login
|
||||
ORDER BY total DESC`
|
||||
);
|
||||
return rows
|
||||
.filter((r): r is { streamer_login: string; cnt: number; total: number | null } => r.streamer_login !== null)
|
||||
.map(r => ({
|
||||
streamerLogin: r.streamer_login,
|
||||
fileCount: r.cnt,
|
||||
totalBytes: r.total ?? 0,
|
||||
}));
|
||||
},
|
||||
|
||||
totalBytes() {
|
||||
const row = db.get<{ total: number | null }>(
|
||||
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM archive_files'
|
||||
);
|
||||
return row?.total ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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 '../infra/db';
|
||||
import { createChunkIndexStore, type ChunkIndexStore } from './chunk-index-store';
|
||||
|
||||
let tmpDir: string;
|
||||
let db: DbHandle;
|
||||
let store: ChunkIndexStore;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkstore-'));
|
||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||
store = createChunkIndexStore(db);
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('createChunkIndexStore', () => {
|
||||
test('record returns ChunkRecord with id > 0', () => {
|
||||
const rec = store.record('item-1', 0, 'sha1-abc', 1024);
|
||||
expect(rec.id).toBeGreaterThan(0);
|
||||
expect(rec.itemId).toBe('item-1');
|
||||
expect(rec.chunkSeq).toBe(0);
|
||||
expect(rec.sha1Hex).toBe('sha1-abc');
|
||||
expect(rec.bytes).toBe(1024);
|
||||
});
|
||||
|
||||
test('listForItem returns chunks ordered by chunk_seq', () => {
|
||||
store.record('it', 2, 's2', 200);
|
||||
store.record('it', 0, 's0', 100);
|
||||
store.record('it', 1, 's1', 150);
|
||||
const all = store.listForItem('it');
|
||||
expect(all.map(r => r.chunkSeq)).toEqual([0, 1, 2]);
|
||||
expect(all.map(r => r.sha1Hex)).toEqual(['s0', 's1', 's2']);
|
||||
});
|
||||
|
||||
test('UNIQUE(item_id, chunk_seq): same key updates, no duplicate', () => {
|
||||
store.record('it', 0, 'first', 100);
|
||||
store.record('it', 0, 'second', 200);
|
||||
const list = store.listForItem('it');
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].sha1Hex).toBe('second');
|
||||
expect(list[0].bytes).toBe(200);
|
||||
});
|
||||
|
||||
test('countForItem', () => {
|
||||
expect(store.countForItem('it')).toBe(0);
|
||||
store.record('it', 0, 'a', 1);
|
||||
store.record('it', 1, 'b', 1);
|
||||
expect(store.countForItem('it')).toBe(2);
|
||||
expect(store.countForItem('other')).toBe(0);
|
||||
});
|
||||
|
||||
test('lookupBySha1 finds dedupe candidates', () => {
|
||||
store.record('item-A', 0, 'same-sha', 100);
|
||||
store.record('item-B', 5, 'same-sha', 100);
|
||||
store.record('item-C', 0, 'other-sha', 100);
|
||||
|
||||
const hits = store.lookupBySha1('same-sha');
|
||||
expect(hits).toHaveLength(2);
|
||||
expect(hits.map(r => r.itemId).sort()).toEqual(['item-A', 'item-B']);
|
||||
});
|
||||
|
||||
test('deleteForItem removes all chunks for that item and returns count', () => {
|
||||
store.record('it', 0, 'a', 1);
|
||||
store.record('it', 1, 'b', 1);
|
||||
store.record('keep', 0, 'c', 1);
|
||||
|
||||
const removed = store.deleteForItem('it');
|
||||
expect(removed).toBe(2);
|
||||
expect(store.countForItem('it')).toBe(0);
|
||||
expect(store.countForItem('keep')).toBe(1);
|
||||
});
|
||||
|
||||
test('deleteForItem on missing returns 0, doesnt throw', () => {
|
||||
expect(store.deleteForItem('does-not-exist')).toBe(0);
|
||||
});
|
||||
|
||||
test('bytes roundtrip', () => {
|
||||
const rec = store.record('it', 0, 'sha', 1234567);
|
||||
expect(rec.bytes).toBe(1234567);
|
||||
const list = store.listForItem('it');
|
||||
expect(list[0].bytes).toBe(1234567);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { DbHandle } from '../infra/db';
|
||||
|
||||
export interface ChunkRecord {
|
||||
id: number;
|
||||
itemId: string;
|
||||
chunkSeq: number;
|
||||
sha1Hex: string;
|
||||
bytes: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ChunkIndexStore {
|
||||
/**
|
||||
* Persistiert einen Chunk-Hash. Bei (itemId, chunkSeq)-Konflikt wird das
|
||||
* bestehende Tupel ersetzt — die zuletzt geschriebene sha1 gewinnt
|
||||
* (sinnvoll, falls dasselbe Segment neu geladen wurde).
|
||||
*/
|
||||
record(itemId: string, chunkSeq: number, sha1Hex: string, bytes: number): ChunkRecord;
|
||||
listForItem(itemId: string): ChunkRecord[];
|
||||
countForItem(itemId: string): number;
|
||||
lookupBySha1(sha1Hex: string): ChunkRecord[];
|
||||
deleteForItem(itemId: string): number;
|
||||
}
|
||||
|
||||
interface ChunkRow {
|
||||
id: number;
|
||||
item_id: string;
|
||||
chunk_seq: number;
|
||||
sha1_hex: string;
|
||||
bytes: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
function rowToRecord(row: ChunkRow): ChunkRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
itemId: row.item_id,
|
||||
chunkSeq: row.chunk_seq,
|
||||
sha1Hex: row.sha1_hex,
|
||||
bytes: row.bytes,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function createChunkIndexStore(db: DbHandle): ChunkIndexStore {
|
||||
return {
|
||||
record(itemId, chunkSeq, sha1Hex, bytes) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.run(
|
||||
`INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(item_id, chunk_seq) DO UPDATE SET
|
||||
sha1_hex = excluded.sha1_hex,
|
||||
bytes = excluded.bytes,
|
||||
created_at = excluded.created_at`,
|
||||
[itemId, chunkSeq, sha1Hex, bytes, now]
|
||||
);
|
||||
const row = db.get<ChunkRow>(
|
||||
'SELECT * FROM chunk_index WHERE item_id = ? AND chunk_seq = ?',
|
||||
[itemId, chunkSeq]
|
||||
);
|
||||
if (!row) throw new Error(`chunk-index-store: record lookup failed for ${itemId}/${chunkSeq}`);
|
||||
return rowToRecord(row);
|
||||
},
|
||||
|
||||
listForItem(itemId) {
|
||||
const rows = db.all<ChunkRow>(
|
||||
'SELECT * FROM chunk_index WHERE item_id = ? ORDER BY chunk_seq ASC',
|
||||
[itemId]
|
||||
);
|
||||
return rows.map(rowToRecord);
|
||||
},
|
||||
|
||||
countForItem(itemId) {
|
||||
const row = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId]);
|
||||
return row?.c ?? 0;
|
||||
},
|
||||
|
||||
lookupBySha1(sha1Hex) {
|
||||
const rows = db.all<ChunkRow>(
|
||||
'SELECT * FROM chunk_index WHERE sha1_hex = ? ORDER BY item_id, chunk_seq',
|
||||
[sha1Hex]
|
||||
);
|
||||
return rows.map(rowToRecord);
|
||||
},
|
||||
|
||||
deleteForItem(itemId) {
|
||||
const before = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId])?.c ?? 0;
|
||||
db.run('DELETE FROM chunk_index WHERE item_id = ?', [itemId]);
|
||||
return before;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import {
|
||||
normalizeLogin,
|
||||
normalizeAutoRecordPollSeconds,
|
||||
normalizeAutoRecordList,
|
||||
normalizeStreamlinkQuality,
|
||||
normalizeFilenameTemplate,
|
||||
normalizeMetadataCacheMinutes,
|
||||
normalizePerformanceMode,
|
||||
isPlainObject,
|
||||
VALID_STREAMLINK_QUALITIES,
|
||||
} from './config-normalize';
|
||||
|
||||
describe('normalizeLogin', () => {
|
||||
test('trim + lowercase', () => {
|
||||
expect(normalizeLogin(' Foo ')).toBe('foo');
|
||||
});
|
||||
test('strips single leading @', () => {
|
||||
expect(normalizeLogin('@foo')).toBe('foo');
|
||||
});
|
||||
test('strips multiple leading @', () => {
|
||||
expect(normalizeLogin('@@@foo')).toBe('foo');
|
||||
});
|
||||
test('preserves @ in middle of string', () => {
|
||||
expect(normalizeLogin('foo@bar')).toBe('foo@bar');
|
||||
});
|
||||
test('empty stays empty', () => {
|
||||
expect(normalizeLogin('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAutoRecordPollSeconds', () => {
|
||||
test('default 90 for non-numeric (NaN producer)', () => {
|
||||
// Number('x') === NaN, Number(undefined) === NaN → default 90.
|
||||
// Number(null) === 0 (finite) → clamp to 30, see boundary test below.
|
||||
expect(normalizeAutoRecordPollSeconds('x')).toBe(90);
|
||||
expect(normalizeAutoRecordPollSeconds(undefined)).toBe(90);
|
||||
expect(normalizeAutoRecordPollSeconds({})).toBe(90);
|
||||
});
|
||||
test('null becomes 0 then clamps to 30', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(null)).toBe(30);
|
||||
});
|
||||
test('clamps low to 30', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(5)).toBe(30);
|
||||
});
|
||||
test('clamps high to 1800', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(99999)).toBe(1800);
|
||||
});
|
||||
test('passes valid mid-range', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(120)).toBe(120);
|
||||
});
|
||||
test('floors fractional', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(120.9)).toBe(120);
|
||||
});
|
||||
test('boundary 30 stays', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(30)).toBe(30);
|
||||
});
|
||||
test('boundary 1800 stays', () => {
|
||||
expect(normalizeAutoRecordPollSeconds(1800)).toBe(1800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAutoRecordList', () => {
|
||||
test('empty for non-array', () => {
|
||||
expect(normalizeAutoRecordList(null)).toEqual([]);
|
||||
expect(normalizeAutoRecordList('x')).toEqual([]);
|
||||
expect(normalizeAutoRecordList(undefined)).toEqual([]);
|
||||
});
|
||||
test('empty array stays empty', () => {
|
||||
expect(normalizeAutoRecordList([])).toEqual([]);
|
||||
});
|
||||
test('lowercases + trims + dedupes', () => {
|
||||
expect(normalizeAutoRecordList(['Foo', 'foo', ' BAR '])).toEqual(['foo', 'bar']);
|
||||
});
|
||||
test('strips leading @ (twitch username paste-form)', () => {
|
||||
expect(normalizeAutoRecordList(['@foo', 'foo', '@@bar'])).toEqual(['foo', 'bar']);
|
||||
});
|
||||
test('drops non-string entries', () => {
|
||||
expect(normalizeAutoRecordList(['foo', 123, null, 'bar'])).toEqual(['foo', 'bar']);
|
||||
});
|
||||
test('drops empty strings after normalize', () => {
|
||||
expect(normalizeAutoRecordList(['', '@', ' ', 'foo'])).toEqual(['foo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStreamlinkQuality', () => {
|
||||
test('all valid values pass through', () => {
|
||||
for (const q of VALID_STREAMLINK_QUALITIES) {
|
||||
expect(normalizeStreamlinkQuality(q)).toBe(q);
|
||||
}
|
||||
});
|
||||
test('invalid string falls back to best', () => {
|
||||
expect(normalizeStreamlinkQuality('foo')).toBe('best');
|
||||
});
|
||||
test('null/undefined/number fall back to best', () => {
|
||||
expect(normalizeStreamlinkQuality(null)).toBe('best');
|
||||
expect(normalizeStreamlinkQuality(undefined)).toBe('best');
|
||||
expect(normalizeStreamlinkQuality(42)).toBe('best');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFilenameTemplate', () => {
|
||||
test('valid string used as-is', () => {
|
||||
expect(normalizeFilenameTemplate('{title}.mp4', 'FB')).toBe('{title}.mp4');
|
||||
});
|
||||
test('trims whitespace', () => {
|
||||
expect(normalizeFilenameTemplate(' hi ', 'FB')).toBe('hi');
|
||||
});
|
||||
test('empty string falls back', () => {
|
||||
expect(normalizeFilenameTemplate('', 'FB')).toBe('FB');
|
||||
});
|
||||
test('whitespace-only falls back', () => {
|
||||
expect(normalizeFilenameTemplate(' ', 'FB')).toBe('FB');
|
||||
});
|
||||
test('undefined falls back', () => {
|
||||
expect(normalizeFilenameTemplate(undefined, 'FB')).toBe('FB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMetadataCacheMinutes', () => {
|
||||
test('default 10 for NaN-producer', () => {
|
||||
expect(normalizeMetadataCacheMinutes('x')).toBe(10);
|
||||
expect(normalizeMetadataCacheMinutes(undefined)).toBe(10);
|
||||
expect(normalizeMetadataCacheMinutes({})).toBe(10);
|
||||
});
|
||||
test('null becomes 0 then clamps to 1', () => {
|
||||
expect(normalizeMetadataCacheMinutes(null)).toBe(1);
|
||||
});
|
||||
test('clamps low to 1', () => {
|
||||
expect(normalizeMetadataCacheMinutes(0)).toBe(1);
|
||||
expect(normalizeMetadataCacheMinutes(-5)).toBe(1);
|
||||
});
|
||||
test('clamps high to 120', () => {
|
||||
expect(normalizeMetadataCacheMinutes(999)).toBe(120);
|
||||
});
|
||||
test('passes valid mid-range', () => {
|
||||
expect(normalizeMetadataCacheMinutes(15)).toBe(15);
|
||||
});
|
||||
test('floors fractional', () => {
|
||||
expect(normalizeMetadataCacheMinutes(15.9)).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePerformanceMode', () => {
|
||||
test('stability passes', () => {
|
||||
expect(normalizePerformanceMode('stability')).toBe('stability');
|
||||
});
|
||||
test('balanced passes', () => {
|
||||
expect(normalizePerformanceMode('balanced')).toBe('balanced');
|
||||
});
|
||||
test('speed passes', () => {
|
||||
expect(normalizePerformanceMode('speed')).toBe('speed');
|
||||
});
|
||||
test('invalid string falls back to balanced', () => {
|
||||
expect(normalizePerformanceMode('foo')).toBe('balanced');
|
||||
});
|
||||
test('null/undefined fall back to balanced', () => {
|
||||
expect(normalizePerformanceMode(null)).toBe('balanced');
|
||||
expect(normalizePerformanceMode(undefined)).toBe('balanced');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlainObject', () => {
|
||||
test('true for object literal', () => {
|
||||
expect(isPlainObject({})).toBe(true);
|
||||
expect(isPlainObject({ a: 1 })).toBe(true);
|
||||
});
|
||||
test('false for array', () => {
|
||||
expect(isPlainObject([])).toBe(false);
|
||||
expect(isPlainObject([1, 2, 3])).toBe(false);
|
||||
});
|
||||
test('false for null', () => {
|
||||
expect(isPlainObject(null)).toBe(false);
|
||||
});
|
||||
test('false for undefined', () => {
|
||||
expect(isPlainObject(undefined)).toBe(false);
|
||||
});
|
||||
test('false for primitives', () => {
|
||||
expect(isPlainObject('x')).toBe(false);
|
||||
expect(isPlainObject(42)).toBe(false);
|
||||
expect(isPlainObject(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Pure normalizer-Helpers fuer Config-Felder. Keine Side-Effects, keine Globals.
|
||||
|
||||
export type PerformanceMode = 'stability' | 'balanced' | 'speed';
|
||||
|
||||
export const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;
|
||||
|
||||
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
||||
const AUTO_RECORD_POLL_MAX_SECONDS = 1800;
|
||||
export const DEFAULT_METADATA_CACHE_MINUTES = 10;
|
||||
export const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
|
||||
|
||||
/** trim + strip leading @ + lowercase. Verbatim aus altem main.ts. */
|
||||
export function normalizeLogin(input: string): string {
|
||||
return input.trim().replace(/^@+/, '').toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeAutoRecordPollSeconds(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 90;
|
||||
return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export function normalizeAutoRecordList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const v of value) {
|
||||
if (typeof v !== 'string') continue;
|
||||
const cleaned = normalizeLogin(v);
|
||||
if (cleaned && !seen.has(cleaned)) {
|
||||
seen.add(cleaned);
|
||||
out.push(cleaned);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeStreamlinkQuality(value: unknown): string {
|
||||
if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return 'best';
|
||||
}
|
||||
|
||||
export function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
|
||||
const value = (template || '').trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
export function normalizeMetadataCacheMinutes(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_METADATA_CACHE_MINUTES;
|
||||
}
|
||||
return Math.max(1, Math.min(120, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export function normalizePerformanceMode(mode: unknown): PerformanceMode {
|
||||
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
|
||||
return mode;
|
||||
}
|
||||
return DEFAULT_PERFORMANCE_MODE;
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { tBackend, BACKEND_MESSAGES, type BackendMessageKey } from './i18n-backend';
|
||||
|
||||
describe('tBackend', () => {
|
||||
test('returns DE message for known key (default language)', () => {
|
||||
expect(tBackend('invalidVodUrl', undefined, 'de')).toBe(BACKEND_MESSAGES.de.invalidVodUrl);
|
||||
});
|
||||
|
||||
test('returns EN message when language=en', () => {
|
||||
expect(tBackend('invalidVodUrl', undefined, 'en')).toBe(BACKEND_MESSAGES.en.invalidVodUrl);
|
||||
});
|
||||
|
||||
test('unknown language falls back to de', () => {
|
||||
expect(tBackend('invalidVodUrl', undefined, 'fr')).toBe(BACKEND_MESSAGES.de.invalidVodUrl);
|
||||
expect(tBackend('invalidVodUrl', undefined, '')).toBe(BACKEND_MESSAGES.de.invalidVodUrl);
|
||||
});
|
||||
|
||||
test('substitutes single {param}', () => {
|
||||
const result = tBackend('streamlinkExitCode', { code: 42 }, 'en');
|
||||
expect(result).toBe('Streamlink exit code 42');
|
||||
});
|
||||
|
||||
test('substitutes multiple {params}', () => {
|
||||
const result = tBackend('integrityDurationMismatch', { actual: 100, expected: 120 }, 'de');
|
||||
expect(result).toContain('100');
|
||||
expect(result).toContain('120');
|
||||
expect(result).not.toContain('{actual}');
|
||||
expect(result).not.toContain('{expected}');
|
||||
});
|
||||
|
||||
test('numeric params stringify', () => {
|
||||
const result = tBackend('fileTooSmall', { bytes: 256 }, 'en');
|
||||
expect(result).toBe('File too small (256 bytes)');
|
||||
});
|
||||
|
||||
test('every DE key has an EN counterpart', () => {
|
||||
const deKeys = Object.keys(BACKEND_MESSAGES.de) as BackendMessageKey[];
|
||||
const enKeys = Object.keys(BACKEND_MESSAGES.en);
|
||||
for (const k of deKeys) {
|
||||
expect(enKeys).toContain(k);
|
||||
}
|
||||
});
|
||||
|
||||
test('no template literal left after substitution for typical params', () => {
|
||||
// attemptFailed has {attempt}, {max}, {errorClass}, {error}
|
||||
const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en');
|
||||
expect(result).toBe('Attempt 1/3 failed (network): ETIMEDOUT');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird
|
||||
// als Parameter uebergeben statt aus globalem config geholt.
|
||||
|
||||
export const BACKEND_MESSAGES = {
|
||||
de: {
|
||||
invalidVodUrl: 'Ungueltige VOD-URL',
|
||||
invalidClipUrl: 'Ungueltige Clip-URL',
|
||||
clipNotFound: 'Clip nicht gefunden',
|
||||
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
|
||||
streamlinkMissing: 'Streamlink fehlt.',
|
||||
streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).',
|
||||
streamlinkExitCode: 'Streamlink Fehlercode {code}',
|
||||
ffmpegMissing: 'FFmpeg fehlt.',
|
||||
ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.',
|
||||
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
|
||||
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
|
||||
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
|
||||
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
|
||||
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
|
||||
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
|
||||
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
|
||||
downloadCancelled: 'Download wurde abgebrochen.',
|
||||
downloadPaused: 'Download wurde pausiert.',
|
||||
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
|
||||
unknownDownloadError: 'Unbekannter Fehler beim Download',
|
||||
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
|
||||
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
|
||||
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
|
||||
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
|
||||
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
|
||||
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
|
||||
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
|
||||
statusCheckingTools: 'Prufe Download-Tools...',
|
||||
statusDownloadStarted: 'Download gestartet',
|
||||
statusBytesDownloaded: '{bytes} heruntergeladen',
|
||||
statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...',
|
||||
statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}',
|
||||
preflightNoInternet: 'Keine Internetverbindung erkannt.',
|
||||
preflightStreamlinkMissing: 'Streamlink fehlt oder ist nicht startbar.',
|
||||
preflightFfmpegMissing: 'FFmpeg fehlt oder ist nicht startbar.',
|
||||
preflightFfprobeMissing: 'FFprobe fehlt oder ist nicht startbar.',
|
||||
preflightDownloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar.'
|
||||
},
|
||||
en: {
|
||||
invalidVodUrl: 'Invalid VOD URL',
|
||||
invalidClipUrl: 'Invalid clip URL',
|
||||
clipNotFound: 'Clip not found',
|
||||
streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.',
|
||||
streamlinkMissing: 'Streamlink is missing.',
|
||||
streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).',
|
||||
streamlinkExitCode: 'Streamlink exit code {code}',
|
||||
ffmpegMissing: 'FFmpeg is missing.',
|
||||
ffmpegMergeFailed: 'FFmpeg merge failed.',
|
||||
ffmpegSplitFailed: 'FFmpeg split failed.',
|
||||
fileTooSmall: 'File too small ({bytes} bytes)',
|
||||
clipFileTooSmall: 'Clip file too small ({bytes} bytes) - Twitch may not have served the stream.',
|
||||
integrityNoVideo: 'Integrity check failed: no video stream found.',
|
||||
integrityTooShort: 'Integrity check failed: duration too short ({duration}s).',
|
||||
integrityDurationMismatch: 'Integrity check failed: {actual}s instead of expected ~{expected}s.',
|
||||
integrityFailedGeneric: 'Integrity check failed.',
|
||||
downloadCancelled: 'Download was cancelled.',
|
||||
downloadPaused: 'Download was paused.',
|
||||
downloadFailedExitCode: 'Download failed (exit code {code})',
|
||||
unknownDownloadError: 'Unknown download error',
|
||||
notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.',
|
||||
notAllPartsDownloaded: 'Not all parts could be downloaded.',
|
||||
mergeGroupFileMissing: 'Downloaded file {index} is missing.',
|
||||
diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.',
|
||||
diskSpaceShortGeneric: 'Not enough disk space.',
|
||||
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
|
||||
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
|
||||
statusCheckingTools: 'Checking download tools...',
|
||||
statusDownloadStarted: 'Download started',
|
||||
statusBytesDownloaded: '{bytes} downloaded',
|
||||
statusFetchingChatReplay: 'Fetching chat replay...',
|
||||
statusChatMessagesFetched: 'Chat messages fetched: {count}',
|
||||
preflightNoInternet: 'No internet connection detected.',
|
||||
preflightStreamlinkMissing: 'Streamlink is missing or not runnable.',
|
||||
preflightFfmpegMissing: 'FFmpeg is missing or not runnable.',
|
||||
preflightFfprobeMissing: 'FFprobe is missing or not runnable.',
|
||||
preflightDownloadPathNotWritable: 'Download folder is not writable.'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de;
|
||||
export type BackendLanguage = 'de' | 'en';
|
||||
|
||||
export function tBackend(
|
||||
key: BackendMessageKey,
|
||||
params: Record<string, string | number> | undefined,
|
||||
language: BackendLanguage | string
|
||||
): string {
|
||||
const lang: BackendLanguage = (language === 'en') ? 'en' : 'de';
|
||||
let template: string = BACKEND_MESSAGES[lang][key];
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
template = template.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return template;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { parseFfprobeJson, assessIntegrity, verifyIntegrityFromJson } from './integrity-check';
|
||||
|
||||
const FIXTURE_GOOD = JSON.stringify({
|
||||
streams: [
|
||||
{ index: 0, codec_type: 'video', codec_name: 'h264', width: 1920, height: 1080, duration: '600.5' },
|
||||
{ index: 1, codec_type: 'audio', codec_name: 'aac', duration: '600.5' },
|
||||
],
|
||||
format: { duration: '600.5', size: '50000000' },
|
||||
});
|
||||
|
||||
const FIXTURE_NO_VIDEO = JSON.stringify({
|
||||
streams: [
|
||||
{ index: 0, codec_type: 'audio', codec_name: 'aac', duration: '10' },
|
||||
],
|
||||
format: { duration: '10', size: '500000' },
|
||||
});
|
||||
|
||||
const FIXTURE_EMPTY = JSON.stringify({
|
||||
streams: [],
|
||||
format: { duration: '0.04', size: '1234' },
|
||||
});
|
||||
|
||||
describe('parseFfprobeJson', () => {
|
||||
test('parses streams + format', () => {
|
||||
const r = parseFfprobeJson(FIXTURE_GOOD);
|
||||
expect(r.streams).toHaveLength(2);
|
||||
expect(r.streams[0].codecType).toBe('video');
|
||||
expect(r.streams[0].codecName).toBe('h264');
|
||||
expect(r.streams[0].width).toBe(1920);
|
||||
expect(r.durationSeconds).toBe(600.5);
|
||||
expect(r.sizeBytes).toBe(50000000);
|
||||
});
|
||||
|
||||
test('handles missing format gracefully', () => {
|
||||
const r = parseFfprobeJson(JSON.stringify({ streams: [] }));
|
||||
expect(r.durationSeconds).toBe(0);
|
||||
expect(r.sizeBytes).toBe(0);
|
||||
});
|
||||
|
||||
test('throws on malformed JSON', () => {
|
||||
expect(() => parseFfprobeJson('{not-valid')).toThrow(/parse failed/);
|
||||
});
|
||||
|
||||
test('coerces numeric strings to numbers', () => {
|
||||
const r = parseFfprobeJson(JSON.stringify({
|
||||
streams: [{ codec_type: 'video', duration: '12.34' }],
|
||||
format: { duration: '12.34', size: '987654' },
|
||||
}));
|
||||
expect(r.durationSeconds).toBe(12.34);
|
||||
expect(r.streams[0].durationSeconds).toBe(12.34);
|
||||
expect(r.sizeBytes).toBe(987654);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assessIntegrity', () => {
|
||||
test('valid file: ok=true, no reasons', () => {
|
||||
const probe = parseFfprobeJson(FIXTURE_GOOD);
|
||||
const v = assessIntegrity(probe);
|
||||
expect(v.ok).toBe(true);
|
||||
expect(v.reasons).toEqual([]);
|
||||
expect(v.hasVideo).toBe(true);
|
||||
expect(v.hasAudio).toBe(true);
|
||||
expect(v.durationSeconds).toBe(600.5);
|
||||
});
|
||||
|
||||
test('no-video stream rejected', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_NO_VIDEO));
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reasons).toContain('no-video-stream');
|
||||
expect(v.hasVideo).toBe(false);
|
||||
});
|
||||
|
||||
test('zero-duration rejected as too-short', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_EMPTY));
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true);
|
||||
});
|
||||
|
||||
test('expected-duration mismatch outside tolerance flagged', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), {
|
||||
expectedDurationSeconds: 700,
|
||||
durationToleranceSeconds: 5,
|
||||
});
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reasons.some(r => r.startsWith('duration-mismatch'))).toBe(true);
|
||||
});
|
||||
|
||||
test('expected-duration within tolerance accepted', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), {
|
||||
expectedDurationSeconds: 598,
|
||||
durationToleranceSeconds: 5,
|
||||
});
|
||||
expect(v.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('custom minDurationSeconds threshold', () => {
|
||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), {
|
||||
minDurationSeconds: 700,
|
||||
});
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyIntegrityFromJson', () => {
|
||||
test('one-shot parse + assess', () => {
|
||||
const v = verifyIntegrityFromJson(FIXTURE_GOOD);
|
||||
expect(v.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('propagates parse errors', () => {
|
||||
expect(() => verifyIntegrityFromJson('{broken')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// Wrappt ffprobe -show_streams -show_format -of json + entscheidet, ob eine
|
||||
// fertige Recording-/Download-Datei strukturell valide ist.
|
||||
// Pure-Parser-Layer ist getrennt testbar; das eigentliche Spawn ist im Caller.
|
||||
|
||||
export interface ProbeStream {
|
||||
index: number;
|
||||
codecType: string; // 'video' | 'audio' | 'subtitle' | ...
|
||||
codecName?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
streams: ProbeStream[];
|
||||
durationSeconds: number;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface IntegrityVerdict {
|
||||
ok: boolean;
|
||||
reasons: string[];
|
||||
durationSeconds: number;
|
||||
hasVideo: boolean;
|
||||
hasAudio: boolean;
|
||||
}
|
||||
|
||||
export interface IntegrityCheckOptions {
|
||||
expectedDurationSeconds?: number;
|
||||
durationToleranceSeconds?: number; // default 5
|
||||
minDurationSeconds?: number; // default 1
|
||||
}
|
||||
|
||||
interface FfprobeJsonStream {
|
||||
index?: number;
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: string | number;
|
||||
}
|
||||
|
||||
interface FfprobeJson {
|
||||
streams?: FfprobeJsonStream[];
|
||||
format?: {
|
||||
duration?: string | number;
|
||||
size?: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(v: unknown, fallback = 0): number {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
if (typeof v === 'string') {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function parseFfprobeJson(rawJson: string): ProbeResult {
|
||||
let parsed: FfprobeJson;
|
||||
try {
|
||||
parsed = JSON.parse(rawJson) as FfprobeJson;
|
||||
} catch (e) {
|
||||
throw new Error(`integrity-check: ffprobe JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const streams: ProbeStream[] = (parsed.streams ?? []).map((s, idx) => ({
|
||||
index: typeof s.index === 'number' ? s.index : idx,
|
||||
codecType: typeof s.codec_type === 'string' ? s.codec_type : 'unknown',
|
||||
codecName: typeof s.codec_name === 'string' ? s.codec_name : undefined,
|
||||
width: typeof s.width === 'number' ? s.width : undefined,
|
||||
height: typeof s.height === 'number' ? s.height : undefined,
|
||||
durationSeconds: s.duration !== undefined ? toNumber(s.duration) : undefined,
|
||||
}));
|
||||
|
||||
const formatDuration = toNumber(parsed.format?.duration, 0);
|
||||
const formatSize = toNumber(parsed.format?.size, 0);
|
||||
|
||||
return {
|
||||
streams,
|
||||
durationSeconds: formatDuration,
|
||||
sizeBytes: formatSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function assessIntegrity(probe: ProbeResult, opts: IntegrityCheckOptions = {}): IntegrityVerdict {
|
||||
const minDuration = opts.minDurationSeconds ?? 1;
|
||||
const tolerance = opts.durationToleranceSeconds ?? 5;
|
||||
|
||||
const hasVideo = probe.streams.some(s => s.codecType === 'video');
|
||||
const hasAudio = probe.streams.some(s => s.codecType === 'audio');
|
||||
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (!hasVideo) {
|
||||
reasons.push('no-video-stream');
|
||||
}
|
||||
|
||||
if (probe.durationSeconds < minDuration) {
|
||||
reasons.push(`duration-too-short:${probe.durationSeconds.toFixed(2)}s<${minDuration}s`);
|
||||
}
|
||||
|
||||
if (typeof opts.expectedDurationSeconds === 'number' && opts.expectedDurationSeconds > 0) {
|
||||
const diff = Math.abs(probe.durationSeconds - opts.expectedDurationSeconds);
|
||||
if (diff > tolerance) {
|
||||
reasons.push(
|
||||
`duration-mismatch:actual=${probe.durationSeconds.toFixed(2)}s,` +
|
||||
`expected=${opts.expectedDurationSeconds.toFixed(2)}s,` +
|
||||
`tolerance=${tolerance}s`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: reasons.length === 0,
|
||||
reasons,
|
||||
durationSeconds: probe.durationSeconds,
|
||||
hasVideo,
|
||||
hasAudio,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: vollstaendige integrity-check Pipeline. Caller liefert die
|
||||
* ffprobe-JSON-Ausgabe als String (so bleibt das Modul Spawn-frei + leicht
|
||||
* testbar; die main.ts hat schon ffprobe-Spawn-Helpers).
|
||||
*/
|
||||
export function verifyIntegrityFromJson(rawJson: string, opts?: IntegrityCheckOptions): IntegrityVerdict {
|
||||
const probe = parseFfprobeJson(rawJson);
|
||||
return assessIntegrity(probe, opts);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 '../infra/db';
|
||||
import { migrateJsonToSqlite } from './migrator';
|
||||
|
||||
let tmpDir: string;
|
||||
let appDataDir: string;
|
||||
let db: DbHandle;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrator-'));
|
||||
appDataDir = path.join(tmpDir, 'appdata');
|
||||
fs.mkdirSync(appDataDir, { recursive: true });
|
||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
function writeJson(name: string, payload: unknown): string {
|
||||
const target = path.join(appDataDir, name);
|
||||
fs.writeFileSync(target, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
return target;
|
||||
}
|
||||
|
||||
describe('migrateJsonToSqlite', () => {
|
||||
test('no JSON files: writes migrations_applied marker', () => {
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.configMigrated).toBe(false);
|
||||
expect(result.queueMigrated).toBe(false);
|
||||
expect(result.downloadedVodsCount).toBe(0);
|
||||
expect(result.streamersCount).toBe(0);
|
||||
|
||||
const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['v4-to-v5-jsons']);
|
||||
expect(marker?.name).toBe('v4-to-v5-jsons');
|
||||
});
|
||||
|
||||
test('migrates config.json keys into config_kv', () => {
|
||||
writeJson('config.json', {
|
||||
language: 'de',
|
||||
performance_mode: 'speed',
|
||||
metadata_cache_minutes: 30,
|
||||
downloaded_vod_ids: ['1', '2', '3'],
|
||||
auto_record_streamers: ['foo', 'bar'],
|
||||
});
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.configMigrated).toBe(true);
|
||||
|
||||
const lang = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']);
|
||||
expect(JSON.parse(lang!.value)).toBe('de');
|
||||
|
||||
const perf = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['performance_mode']);
|
||||
expect(JSON.parse(perf!.value)).toBe('speed');
|
||||
});
|
||||
|
||||
test('migrates downloaded_vod_ids', () => {
|
||||
writeJson('config.json', { downloaded_vod_ids: ['100', '200', '300'] });
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.downloadedVodsCount).toBe(3);
|
||||
const rows = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
|
||||
expect(rows.map(r => r.vod_id)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
|
||||
test('migrates streamers from both auto-record and auto-vod-download lists', () => {
|
||||
writeJson('config.json', {
|
||||
auto_record_streamers: ['Alice', '@bob'],
|
||||
auto_vod_download_streamers: ['bob', 'carol'],
|
||||
});
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.streamersCount).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const alice = db.get<{ login: string; auto_record: number }>('SELECT login, auto_record FROM streamers WHERE login = ?', ['alice']);
|
||||
expect(alice?.auto_record).toBe(1);
|
||||
|
||||
const bob = db.get<{ login: string; auto_record: number; auto_vod_download: number }>('SELECT login, auto_record, auto_vod_download FROM streamers WHERE login = ?', ['bob']);
|
||||
expect(bob?.auto_record).toBe(1);
|
||||
expect(bob?.auto_vod_download).toBe(1);
|
||||
|
||||
const carol = db.get<{ login: string; auto_vod_download: number }>('SELECT login, auto_vod_download FROM streamers WHERE login = ?', ['carol']);
|
||||
expect(carol?.auto_vod_download).toBe(1);
|
||||
});
|
||||
|
||||
test('migrates download_queue.json items', () => {
|
||||
writeJson('download_queue.json', [
|
||||
{ id: 'q1', status: 'pending', streamer: 'foo', vod_id: 'v1', created_at: 1000, updated_at: 1000 },
|
||||
{ id: 'q2', status: 'completed', streamer: 'bar', vod_id: 'v2', created_at: 2000, updated_at: 3000, completed_at: 3000 },
|
||||
]);
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.queueMigrated).toBe(true);
|
||||
|
||||
const all = db.all<{ id: string; status: string }>('SELECT id, status FROM queue_items ORDER BY id');
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all[0].status).toBe('pending');
|
||||
expect(all[1].status).toBe('completed');
|
||||
});
|
||||
|
||||
test('idempotent second run', () => {
|
||||
writeJson('config.json', { downloaded_vod_ids: ['1', '2'] });
|
||||
migrateJsonToSqlite({ db, appDataDir });
|
||||
const result2 = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result2.alreadyApplied).toBe(true);
|
||||
const count = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
||||
expect(count?.c).toBe(2);
|
||||
});
|
||||
|
||||
test('writes .v4-backup of source JSONs', () => {
|
||||
const configPath = writeJson('config.json', { language: 'en' });
|
||||
migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(fs.existsSync(configPath + '.v4-backup')).toBe(true);
|
||||
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"');
|
||||
});
|
||||
|
||||
test('malformed JSON is logged + skipped', () => {
|
||||
fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8');
|
||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
||||
expect(result.configMigrated).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0].source).toBe('config.json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { DbHandle } from '../infra/db';
|
||||
import { normalizeLogin } from './config-normalize';
|
||||
|
||||
export interface MigratorOptions {
|
||||
db: DbHandle;
|
||||
appDataDir: string;
|
||||
}
|
||||
|
||||
export interface MigrationError {
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
alreadyApplied: boolean;
|
||||
configMigrated: boolean;
|
||||
queueMigrated: boolean;
|
||||
downloadedVodsCount: number;
|
||||
streamersCount: number;
|
||||
errors: MigrationError[];
|
||||
}
|
||||
|
||||
const MIGRATION_NAME = 'v4-to-v5-jsons';
|
||||
|
||||
const CONFIG_KV_KEYS = [
|
||||
'language', 'performance_mode', 'metadata_cache_minutes', 'streamlink_quality',
|
||||
'streamlink_disable_ads', 'download_chat_replay', 'capture_live_chat',
|
||||
'discord_webhook_url', 'discord_notify_live_start', 'discord_notify_live_end',
|
||||
'discord_notify_vod_complete', 'discord_notify_vod_auto_queued',
|
||||
'auto_cleanup_enabled', 'auto_cleanup_days', 'auto_cleanup_target',
|
||||
'auto_cleanup_action', 'log_stream_events', 'auto_vod_download_poll_minutes',
|
||||
'auto_vod_max_age_hours', 'auto_resume_live_recording',
|
||||
'auto_merge_resumed_parts', 'delete_parts_after_merge',
|
||||
'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts',
|
||||
'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads',
|
||||
'persist_queue_on_restart', 'auto_resume_queue_on_startup',
|
||||
'notify_on_each_completion',
|
||||
] as const;
|
||||
|
||||
function backupOnce(srcPath: string): void {
|
||||
const backupPath = srcPath + '.v4-backup';
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
fs.copyFileSync(srcPath, backupPath);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateConfig(db: DbHandle, configPath: string, errors: MigrationError[]): { ok: boolean; vodCount: number } {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
let vodCount = 0;
|
||||
db.transaction(() => {
|
||||
for (const key of CONFIG_KV_KEYS) {
|
||||
if (key in config) {
|
||||
db.run(
|
||||
"INSERT OR REPLACE INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))",
|
||||
[key, JSON.stringify(config[key])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const vodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : [];
|
||||
for (const id of vodIds) {
|
||||
if (typeof id !== 'string' || !id) continue;
|
||||
db.run('INSERT OR IGNORE INTO downloaded_vods(vod_id) VALUES (?)', [id]);
|
||||
vodCount += 1;
|
||||
}
|
||||
|
||||
const autoRec = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : [];
|
||||
for (const s of autoRec) {
|
||||
if (typeof s !== 'string' || !s) continue;
|
||||
const login = normalizeLogin(s);
|
||||
if (!login) continue;
|
||||
db.run(
|
||||
'INSERT INTO streamers(login, auto_record) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_record = 1',
|
||||
[login]
|
||||
);
|
||||
}
|
||||
|
||||
const autoDl = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : [];
|
||||
for (const s of autoDl) {
|
||||
if (typeof s !== 'string' || !s) continue;
|
||||
const login = normalizeLogin(s);
|
||||
if (!login) continue;
|
||||
db.run(
|
||||
'INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1',
|
||||
[login]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
backupOnce(configPath);
|
||||
return { ok: true, vodCount };
|
||||
} catch (e) {
|
||||
errors.push({ source: 'config.json', message: e instanceof Error ? e.message : String(e) });
|
||||
return { ok: false, vodCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function migrateQueue(db: DbHandle, queuePath: string, errors: MigrationError[]): boolean {
|
||||
try {
|
||||
const raw = fs.readFileSync(queuePath, 'utf-8');
|
||||
const queue = JSON.parse(raw);
|
||||
if (!Array.isArray(queue)) return false;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.transaction(() => {
|
||||
for (const rawItem of queue) {
|
||||
if (!rawItem || typeof rawItem !== 'object') continue;
|
||||
const item = rawItem as Record<string, unknown>;
|
||||
const id = typeof item.id === 'string' ? item.id : null;
|
||||
if (!id) continue;
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO queue_items
|
||||
(id, streamer_login, vod_id, clip_id, title, output_path, status,
|
||||
progress_pct, error_message, created_at, updated_at, completed_at, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null,
|
||||
typeof item.vod_id === 'string' ? item.vod_id : null,
|
||||
typeof item.clip_id === 'string' ? item.clip_id : null,
|
||||
typeof item.title === 'string' ? item.title : null,
|
||||
typeof item.output_path === 'string' ? item.output_path : null,
|
||||
typeof item.status === 'string' ? item.status : 'pending',
|
||||
typeof item.progress_pct === 'number' ? item.progress_pct : null,
|
||||
typeof item.error_message === 'string' ? item.error_message : null,
|
||||
typeof item.created_at === 'number' ? item.created_at : now,
|
||||
typeof item.updated_at === 'number' ? item.updated_at : now,
|
||||
typeof item.completed_at === 'number' ? item.completed_at : null,
|
||||
JSON.stringify(item),
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
backupOnce(queuePath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
errors.push({ source: 'download_queue.json', message: e instanceof Error ? e.message : String(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
|
||||
const { db, appDataDir } = opts;
|
||||
const errors: MigrationError[] = [];
|
||||
|
||||
const existing = db.get<{ name: string }>(
|
||||
'SELECT name FROM migrations_applied WHERE name = ?',
|
||||
[MIGRATION_NAME]
|
||||
);
|
||||
if (existing) {
|
||||
return {
|
||||
alreadyApplied: true,
|
||||
configMigrated: false,
|
||||
queueMigrated: false,
|
||||
downloadedVodsCount: 0,
|
||||
streamersCount: 0,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
let configMigrated = false;
|
||||
let queueMigrated = false;
|
||||
let downloadedVodsCount = 0;
|
||||
|
||||
const configPath = path.join(appDataDir, 'config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const r = migrateConfig(db, configPath, errors);
|
||||
configMigrated = r.ok;
|
||||
downloadedVodsCount = r.vodCount;
|
||||
}
|
||||
|
||||
const queuePath = path.join(appDataDir, 'download_queue.json');
|
||||
if (fs.existsSync(queuePath)) {
|
||||
queueMigrated = migrateQueue(db, queuePath, errors);
|
||||
}
|
||||
|
||||
const streamersCount = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM streamers')?.c ?? 0;
|
||||
|
||||
db.run(
|
||||
'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)',
|
||||
[
|
||||
MIGRATION_NAME,
|
||||
JSON.stringify({ configMigrated, queueMigrated, downloadedVodsCount, streamersCount, errorCount: errors.length }),
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
alreadyApplied: false,
|
||||
configMigrated,
|
||||
queueMigrated,
|
||||
downloadedVodsCount,
|
||||
streamersCount,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import * as crypto from 'crypto';
|
||||
import { createPkcePair, generateState } from './pkce';
|
||||
|
||||
describe('createPkcePair', () => {
|
||||
test('returns S256 method', () => {
|
||||
expect(createPkcePair().codeChallengeMethod).toBe('S256');
|
||||
});
|
||||
|
||||
test('verifier is 43+ chars base64url-safe', () => {
|
||||
const { codeVerifier } = createPkcePair();
|
||||
expect(codeVerifier.length).toBeGreaterThanOrEqual(43);
|
||||
// RFC 7636 unreserved chars only: [A-Z a-z 0-9 - . _ ~]
|
||||
// base64url uses [A-Z a-z 0-9 - _], no = padding.
|
||||
expect(/^[A-Za-z0-9_-]+$/.test(codeVerifier)).toBe(true);
|
||||
});
|
||||
|
||||
test('challenge matches sha256(verifier) base64url-encoded', () => {
|
||||
const pair = createPkcePair();
|
||||
const expected = crypto.createHash('sha256').update(pair.codeVerifier).digest('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
expect(pair.codeChallenge).toBe(expected);
|
||||
});
|
||||
|
||||
test('two pairs differ (sufficient entropy)', () => {
|
||||
const a = createPkcePair();
|
||||
const b = createPkcePair();
|
||||
expect(a.codeVerifier).not.toBe(b.codeVerifier);
|
||||
expect(a.codeChallenge).not.toBe(b.codeChallenge);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateState', () => {
|
||||
test('returns >= 16 chars', () => {
|
||||
expect(generateState().length).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
test('base64url-safe charset', () => {
|
||||
expect(/^[A-Za-z0-9_-]+$/.test(generateState())).toBe(true);
|
||||
});
|
||||
|
||||
test('two states differ', () => {
|
||||
expect(generateState()).not.toBe(generateState());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange) Helper fuer OAuth 2.1 Authorization Code Flow.
|
||||
* RFC 7636. Twitch unterstuetzt S256.
|
||||
*/
|
||||
|
||||
export interface PkcePair {
|
||||
codeVerifier: string; // 43-128 ASCII chars [A-Z a-z 0-9 - . _ ~]
|
||||
codeChallenge: string; // base64url(sha256(codeVerifier))
|
||||
codeChallengeMethod: 'S256';
|
||||
}
|
||||
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function createPkcePair(): PkcePair {
|
||||
// 32 random bytes → 43-char base64url. Innerhalb der RFC-Range.
|
||||
const verifier = base64url(crypto.randomBytes(32));
|
||||
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
|
||||
return {
|
||||
codeVerifier: verifier,
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
};
|
||||
}
|
||||
|
||||
export function generateState(): string {
|
||||
// 16 random bytes als base64url-State-Parameter (CSRF-Schutz).
|
||||
return base64url(crypto.randomBytes(16));
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 '../infra/db';
|
||||
import { MemorySecureStorage } from '../infra/secure-storage';
|
||||
import { createTokenStore, type TokenStore } from './token-store';
|
||||
|
||||
let tmpDir: string;
|
||||
let db: DbHandle;
|
||||
let store: TokenStore;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-'));
|
||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||
store = createTokenStore(db, new MemorySecureStorage());
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe('createTokenStore', () => {
|
||||
test('upsert new account returns record with id > 0', () => {
|
||||
const rec = store.upsert({
|
||||
provider: 'twitch',
|
||||
twitchUserId: 'u1',
|
||||
login: 'alice',
|
||||
accessToken: 'aaa.aaa.aaa',
|
||||
});
|
||||
expect(rec.id).toBeGreaterThan(0);
|
||||
expect(rec.login).toBe('alice');
|
||||
expect(rec.provider).toBe('twitch');
|
||||
expect(rec.twitchUserId).toBe('u1');
|
||||
});
|
||||
|
||||
test('upsert same (provider, twitch_user_id) updates, no duplicate row', () => {
|
||||
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice', accessToken: 't1' });
|
||||
const updated = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice2', accessToken: 't2' });
|
||||
expect(updated.login).toBe('alice2');
|
||||
const all = store.list('twitch');
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].login).toBe('alice2');
|
||||
});
|
||||
|
||||
test('list() returns all accounts, list(provider) filters', () => {
|
||||
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
||||
store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
||||
store.upsert({ provider: 'youtube', twitchUserId: undefined, login: 'c', accessToken: 'z' });
|
||||
expect(store.list()).toHaveLength(3);
|
||||
expect(store.list('twitch')).toHaveLength(2);
|
||||
expect(store.list('youtube')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('getDefault returns null when nothing default', () => {
|
||||
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
||||
expect(store.getDefault('twitch')).toBeNull();
|
||||
});
|
||||
|
||||
test('upsert with isDefault=true makes it default, demotes siblings', () => {
|
||||
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
||||
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y', isDefault: true });
|
||||
|
||||
const def = store.getDefault('twitch');
|
||||
expect(def?.id).toBe(b.id);
|
||||
|
||||
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
||||
expect(aAgain?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
test('setDefault toggles is_default exclusivity within provider', () => {
|
||||
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
||||
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
||||
|
||||
store.setDefault(b.id);
|
||||
expect(store.getDefault('twitch')?.id).toBe(b.id);
|
||||
|
||||
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
||||
expect(aAgain?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
test('getAccessToken returns decrypted plaintext', () => {
|
||||
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'super-secret-token' });
|
||||
expect(store.getAccessToken(rec.id)).toBe('super-secret-token');
|
||||
});
|
||||
|
||||
test('getRefreshToken returns null if not provided, value if provided', () => {
|
||||
const noRefresh = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 't1' });
|
||||
expect(store.getRefreshToken(noRefresh.id)).toBeNull();
|
||||
|
||||
const withRefresh = store.upsert({
|
||||
provider: 'twitch', twitchUserId: 'u2', login: 'b',
|
||||
accessToken: 't2', refreshToken: 'refresh-xyz',
|
||||
});
|
||||
expect(store.getRefreshToken(withRefresh.id)).toBe('refresh-xyz');
|
||||
});
|
||||
|
||||
test('scopes roundtrip as array', () => {
|
||||
const rec = store.upsert({
|
||||
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
||||
accessToken: 't', scopes: ['user:read:email', 'channel:read:subscriptions'],
|
||||
});
|
||||
expect(rec.scopes).toEqual(['user:read:email', 'channel:read:subscriptions']);
|
||||
});
|
||||
|
||||
test('delete removes the record', () => {
|
||||
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
||||
store.delete(rec.id);
|
||||
expect(store.list('twitch')).toHaveLength(0);
|
||||
expect(() => store.getAccessToken(rec.id)).toThrow();
|
||||
});
|
||||
|
||||
test('expiresAt roundtrip', () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600;
|
||||
const rec = store.upsert({
|
||||
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
||||
accessToken: 't', expiresAt: future,
|
||||
});
|
||||
expect(rec.expiresAt).toBe(future);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { DbHandle } from '../infra/db';
|
||||
import type { SecureStorage } from '../infra/secure-storage';
|
||||
|
||||
export interface TokenRecord {
|
||||
id: number;
|
||||
provider: string;
|
||||
twitchUserId: string | null;
|
||||
login: string | null;
|
||||
displayName: string | null;
|
||||
expiresAt: number | null;
|
||||
scopes: string[];
|
||||
isDefault: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TokenWriteInput {
|
||||
provider: string;
|
||||
twitchUserId?: string;
|
||||
login?: string;
|
||||
displayName?: string;
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
scopes?: string[];
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface TokenStore {
|
||||
upsert(input: TokenWriteInput): TokenRecord;
|
||||
list(provider?: string): TokenRecord[];
|
||||
getDefault(provider: string): TokenRecord | null;
|
||||
setDefault(id: number): void;
|
||||
getAccessToken(id: number): string;
|
||||
getRefreshToken(id: number): string | null;
|
||||
delete(id: number): void;
|
||||
}
|
||||
|
||||
interface TokenRow {
|
||||
id: number;
|
||||
provider: string;
|
||||
twitch_user_id: string | null;
|
||||
login: string | null;
|
||||
display_name: string | null;
|
||||
encrypted_access_token: string;
|
||||
encrypted_refresh_token: string | null;
|
||||
expires_at: number | null;
|
||||
scopes_json: string | null;
|
||||
is_default: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
function rowToRecord(row: TokenRow): TokenRecord {
|
||||
let scopes: string[] = [];
|
||||
if (row.scopes_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.scopes_json);
|
||||
if (Array.isArray(parsed)) {
|
||||
scopes = parsed.filter((s): s is string => typeof s === 'string');
|
||||
}
|
||||
} catch { /* malformed scopes payload — treat as empty */ }
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
twitchUserId: row.twitch_user_id,
|
||||
login: row.login,
|
||||
displayName: row.display_name,
|
||||
expiresAt: row.expires_at,
|
||||
scopes,
|
||||
isDefault: row.is_default === 1,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTokenStore(db: DbHandle, storage: SecureStorage): TokenStore {
|
||||
function getRowOrThrow(id: number): TokenRow {
|
||||
const row = db.get<TokenRow>('SELECT * FROM oauth_accounts WHERE id = ?', [id]);
|
||||
if (!row) throw new Error(`token-store: account id=${id} not found`);
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
upsert(input: TokenWriteInput): TokenRecord {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const encryptedAccess = storage.encrypt(input.accessToken);
|
||||
const encryptedRefresh = input.refreshToken !== undefined
|
||||
? storage.encrypt(input.refreshToken)
|
||||
: null;
|
||||
const scopesJson = input.scopes && input.scopes.length > 0
|
||||
? JSON.stringify(input.scopes)
|
||||
: null;
|
||||
const isDefault = input.isDefault ? 1 : 0;
|
||||
const twitchUserId = input.twitchUserId ?? null;
|
||||
|
||||
let resultId: number | null = null;
|
||||
|
||||
db.transaction(() => {
|
||||
// Insert or update conditional on UNIQUE(provider, twitch_user_id).
|
||||
// Sqlite's ON CONFLICT braucht den vollstaendigen Konflikt-Ausdruck.
|
||||
db.run(
|
||||
`INSERT INTO oauth_accounts(
|
||||
provider, twitch_user_id, login, display_name,
|
||||
encrypted_access_token, encrypted_refresh_token,
|
||||
expires_at, scopes_json, is_default, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider, twitch_user_id) DO UPDATE SET
|
||||
login = excluded.login,
|
||||
display_name = excluded.display_name,
|
||||
encrypted_access_token = excluded.encrypted_access_token,
|
||||
encrypted_refresh_token = excluded.encrypted_refresh_token,
|
||||
expires_at = excluded.expires_at,
|
||||
scopes_json = excluded.scopes_json,
|
||||
is_default = excluded.is_default,
|
||||
updated_at = excluded.updated_at`,
|
||||
[
|
||||
input.provider,
|
||||
twitchUserId,
|
||||
input.login ?? null,
|
||||
input.displayName ?? null,
|
||||
encryptedAccess,
|
||||
encryptedRefresh,
|
||||
input.expiresAt ?? null,
|
||||
scopesJson,
|
||||
isDefault,
|
||||
now,
|
||||
now,
|
||||
]
|
||||
);
|
||||
|
||||
// Wenn dieser Eintrag default ist: alle anderen mit gleichem provider auf 0 setzen.
|
||||
if (isDefault === 1) {
|
||||
db.run(
|
||||
`UPDATE oauth_accounts
|
||||
SET is_default = 0, updated_at = ?
|
||||
WHERE provider = ?
|
||||
AND NOT (twitch_user_id IS ? AND provider IS ?)`,
|
||||
[now, input.provider, twitchUserId, input.provider]
|
||||
);
|
||||
}
|
||||
|
||||
const lookup = db.get<{ id: number }>(
|
||||
`SELECT id FROM oauth_accounts
|
||||
WHERE provider = ?
|
||||
AND (twitch_user_id IS ? OR (twitch_user_id IS NULL AND ? IS NULL))`,
|
||||
[input.provider, twitchUserId, twitchUserId]
|
||||
);
|
||||
resultId = lookup?.id ?? null;
|
||||
});
|
||||
|
||||
if (resultId === null) throw new Error('token-store: upsert lookup failed');
|
||||
return rowToRecord(getRowOrThrow(resultId));
|
||||
},
|
||||
|
||||
list(provider?: string): TokenRecord[] {
|
||||
const rows = provider
|
||||
? db.all<TokenRow>('SELECT * FROM oauth_accounts WHERE provider = ? ORDER BY id', [provider])
|
||||
: db.all<TokenRow>('SELECT * FROM oauth_accounts ORDER BY id');
|
||||
return rows.map(rowToRecord);
|
||||
},
|
||||
|
||||
getDefault(provider: string): TokenRecord | null {
|
||||
const row = db.get<TokenRow>(
|
||||
'SELECT * FROM oauth_accounts WHERE provider = ? AND is_default = 1 LIMIT 1',
|
||||
[provider]
|
||||
);
|
||||
return row ? rowToRecord(row) : null;
|
||||
},
|
||||
|
||||
setDefault(id: number): void {
|
||||
const target = getRowOrThrow(id);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.transaction(() => {
|
||||
db.run(
|
||||
'UPDATE oauth_accounts SET is_default = 0, updated_at = ? WHERE provider = ?',
|
||||
[now, target.provider]
|
||||
);
|
||||
db.run(
|
||||
'UPDATE oauth_accounts SET is_default = 1, updated_at = ? WHERE id = ?',
|
||||
[now, id]
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
getAccessToken(id: number): string {
|
||||
const row = getRowOrThrow(id);
|
||||
return storage.decrypt(row.encrypted_access_token);
|
||||
},
|
||||
|
||||
getRefreshToken(id: number): string | null {
|
||||
const row = getRowOrThrow(id);
|
||||
return row.encrypted_refresh_token
|
||||
? storage.decrypt(row.encrypted_refresh_token)
|
||||
: null;
|
||||
},
|
||||
|
||||
delete(id: number): void {
|
||||
db.run('DELETE FROM oauth_accounts WHERE id = ?', [id]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import { fetchTopClips, rangeLastDays } from './top-clips-crawler';
|
||||
|
||||
function fakeFetch(rows: Array<Record<string, unknown>>, status = 200): typeof fetch {
|
||||
return (async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
// verify request shape lightly inside the fake
|
||||
const headers = init?.headers as Record<string, string> | undefined;
|
||||
if (status === 200 && (!headers?.['Authorization'] || !headers?.['Client-Id'])) {
|
||||
return new Response('missing auth headers', { status: 401 });
|
||||
}
|
||||
return new Response(JSON.stringify({ data: rows }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('fetchTopClips', () => {
|
||||
test('returns parsed clips sorted by view_count desc', async () => {
|
||||
const fakeRows = [
|
||||
{
|
||||
id: 'C2', url: 'u2', embed_url: 'e2', broadcaster_id: 'b', broadcaster_name: 'B',
|
||||
creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en',
|
||||
title: 'mid', view_count: 50, created_at: '2026-05-10T00:00:00Z',
|
||||
thumbnail_url: 't', duration: 30, vod_offset: 120,
|
||||
},
|
||||
{
|
||||
id: 'C1', url: 'u1', embed_url: 'e1', broadcaster_id: 'b', broadcaster_name: 'B',
|
||||
creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en',
|
||||
title: 'high', view_count: 200, created_at: '2026-05-09T00:00:00Z',
|
||||
thumbnail_url: 't', duration: 45, vod_offset: null,
|
||||
},
|
||||
];
|
||||
const clips = await fetchTopClips({
|
||||
clientId: 'CID', accessToken: 'TOK', broadcasterId: 'b',
|
||||
fetchImpl: fakeFetch(fakeRows),
|
||||
});
|
||||
|
||||
expect(clips).toHaveLength(2);
|
||||
expect(clips[0].id).toBe('C1');
|
||||
expect(clips[0].viewCount).toBe(200);
|
||||
expect(clips[1].id).toBe('C2');
|
||||
expect(clips[1].vodOffsetSeconds).toBe(120);
|
||||
expect(clips[0].vodOffsetSeconds).toBeNull();
|
||||
});
|
||||
|
||||
test('snake_case → camelCase mapping for broadcaster fields', async () => {
|
||||
const fakeRows = [
|
||||
{
|
||||
id: 'X', url: 'u', embed_url: 'e', broadcaster_id: 'bid', broadcaster_name: 'BName',
|
||||
creator_id: 'cid', creator_name: 'CName', video_id: 'vid', game_id: 'gid',
|
||||
language: 'de', title: 'T', view_count: 10, created_at: '2026-05-01T00:00:00Z',
|
||||
thumbnail_url: 'th', duration: 12,
|
||||
},
|
||||
];
|
||||
const [c] = await fetchTopClips({
|
||||
clientId: 'CID', accessToken: 'TOK', broadcasterId: 'bid',
|
||||
fetchImpl: fakeFetch(fakeRows),
|
||||
});
|
||||
expect(c.broadcasterId).toBe('bid');
|
||||
expect(c.broadcasterName).toBe('BName');
|
||||
expect(c.creatorId).toBe('cid');
|
||||
expect(c.creatorName).toBe('CName');
|
||||
expect(c.videoId).toBe('vid');
|
||||
expect(c.gameId).toBe('gid');
|
||||
});
|
||||
|
||||
test('builds query string with broadcaster_id + first + date range', async () => {
|
||||
let capturedUrl: string | null = null;
|
||||
const captureFetch = (async (url: string | URL | Request): Promise<Response> => {
|
||||
capturedUrl = String(url);
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await fetchTopClips({
|
||||
clientId: 'CID', accessToken: 'TOK', broadcasterId: '12345',
|
||||
startedAt: '2026-05-01T00:00:00Z', endedAt: '2026-05-11T00:00:00Z',
|
||||
first: 50, fetchImpl: captureFetch,
|
||||
});
|
||||
expect(capturedUrl).toContain('broadcaster_id=12345');
|
||||
expect(capturedUrl).toContain('first=50');
|
||||
expect(capturedUrl).toContain('started_at=2026-05-01T00%3A00%3A00Z');
|
||||
expect(capturedUrl).toContain('ended_at=2026-05-11T00%3A00%3A00Z');
|
||||
});
|
||||
|
||||
test('clamps first to [1, 100]', async () => {
|
||||
let capturedUrl: string | null = null;
|
||||
const captureFetch = (async (url: string | URL | Request): Promise<Response> => {
|
||||
capturedUrl = String(url);
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 999, fetchImpl: captureFetch });
|
||||
expect(capturedUrl).toContain('first=100');
|
||||
|
||||
await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 0, fetchImpl: captureFetch });
|
||||
expect(capturedUrl).toContain('first=1');
|
||||
});
|
||||
|
||||
test('throws on non-2xx response', async () => {
|
||||
await expect(fetchTopClips({
|
||||
clientId: 'C', accessToken: 'T', broadcasterId: 'b',
|
||||
fetchImpl: fakeFetch([], 503),
|
||||
})).rejects.toThrow(/503/);
|
||||
});
|
||||
|
||||
test('throws on malformed JSON', async () => {
|
||||
const brokenFetch = (async (): Promise<Response> => new Response('{not-json', { status: 200 })) as unknown as typeof fetch;
|
||||
await expect(fetchTopClips({
|
||||
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch,
|
||||
})).rejects.toThrow(/parse failed/);
|
||||
});
|
||||
|
||||
test('empty data returns empty array (not null)', async () => {
|
||||
const emptyFetch = (async (): Promise<Response> => new Response(JSON.stringify({ data: [] }), { status: 200 })) as unknown as typeof fetch;
|
||||
const clips = await fetchTopClips({
|
||||
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: emptyFetch,
|
||||
});
|
||||
expect(clips).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeLastDays', () => {
|
||||
test('produces ISO RFC3339 strings exactly N days apart', () => {
|
||||
const now = new Date('2026-05-11T12:00:00Z');
|
||||
const range = rangeLastDays(7, now);
|
||||
expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z');
|
||||
expect(range.startedAt).toBe('2026-05-04T12:00:00.000Z');
|
||||
});
|
||||
|
||||
test('1-day range', () => {
|
||||
const now = new Date('2026-05-11T12:00:00Z');
|
||||
const range = rangeLastDays(1, now);
|
||||
expect(range.startedAt).toBe('2026-05-10T12:00:00.000Z');
|
||||
expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// Twitch Helix Top-Clips Crawler. Pure: fetch wird via injizierter fetchImpl
|
||||
// aufgerufen (Tests koennen mocken). Helix-Endpunkt:
|
||||
// GET https://api.twitch.tv/helix/clips?broadcaster_id=X&first=N
|
||||
//
|
||||
// Auth: Client-Credentials (app-token) reicht — kein User-Token noetig.
|
||||
// Spaeter koennen wir aus token-store den default-Twitch-User-Token nehmen.
|
||||
|
||||
const HELIX_CLIPS_URL = 'https://api.twitch.tv/helix/clips';
|
||||
|
||||
export interface TopClip {
|
||||
id: string;
|
||||
url: string;
|
||||
embedUrl: string;
|
||||
broadcasterId: string;
|
||||
broadcasterName: string;
|
||||
creatorId: string;
|
||||
creatorName: string;
|
||||
videoId: string;
|
||||
gameId: string;
|
||||
language: string;
|
||||
title: string;
|
||||
viewCount: number;
|
||||
createdAt: string; // ISO timestamp
|
||||
thumbnailUrl: string;
|
||||
duration: number; // seconds
|
||||
vodOffsetSeconds: number | null;
|
||||
}
|
||||
|
||||
interface HelixClipRow {
|
||||
id: string;
|
||||
url: string;
|
||||
embed_url: string;
|
||||
broadcaster_id: string;
|
||||
broadcaster_name: string;
|
||||
creator_id: string;
|
||||
creator_name: string;
|
||||
video_id: string;
|
||||
game_id: string;
|
||||
language: string;
|
||||
title: string;
|
||||
view_count: number;
|
||||
created_at: string;
|
||||
thumbnail_url: string;
|
||||
duration: number;
|
||||
vod_offset?: number | null;
|
||||
}
|
||||
|
||||
interface HelixClipsResponse {
|
||||
data?: HelixClipRow[];
|
||||
pagination?: { cursor?: string };
|
||||
}
|
||||
|
||||
export interface FetchTopClipsOptions {
|
||||
clientId: string;
|
||||
accessToken: string;
|
||||
broadcasterId: string;
|
||||
startedAt?: string; // ISO RFC3339
|
||||
endedAt?: string;
|
||||
first?: number; // 1-100, default 20
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
function rowToClip(row: HelixClipRow): TopClip {
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
embedUrl: row.embed_url,
|
||||
broadcasterId: row.broadcaster_id,
|
||||
broadcasterName: row.broadcaster_name,
|
||||
creatorId: row.creator_id,
|
||||
creatorName: row.creator_name,
|
||||
videoId: row.video_id,
|
||||
gameId: row.game_id,
|
||||
language: row.language,
|
||||
title: row.title,
|
||||
viewCount: row.view_count,
|
||||
createdAt: row.created_at,
|
||||
thumbnailUrl: row.thumbnail_url,
|
||||
duration: row.duration,
|
||||
vodOffsetSeconds: row.vod_offset ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchTopClips(opts: FetchTopClipsOptions): Promise<TopClip[]> {
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
const first = Math.min(100, Math.max(1, opts.first ?? 20));
|
||||
|
||||
const params = new URLSearchParams({
|
||||
broadcaster_id: opts.broadcasterId,
|
||||
first: String(first),
|
||||
});
|
||||
if (opts.startedAt) params.set('started_at', opts.startedAt);
|
||||
if (opts.endedAt) params.set('ended_at', opts.endedAt);
|
||||
|
||||
const res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${opts.accessToken}`,
|
||||
'Client-Id': opts.clientId,
|
||||
},
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`top-clips-crawler: helix ${res.status}: ${text}`);
|
||||
}
|
||||
let parsed: HelixClipsResponse;
|
||||
try {
|
||||
parsed = JSON.parse(text) as HelixClipsResponse;
|
||||
} catch (e) {
|
||||
throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const rows = parsed.data ?? [];
|
||||
// Helix returns clips already sorted by view_count desc, but we re-sort
|
||||
// defensively in case that order ever changes.
|
||||
return rows.map(rowToClip).sort((a, b) => b.viewCount - a.viewCount);
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: ISO range fuer "letzte N Tage" ab jetzt. Twitch erwartet
|
||||
* RFC3339 Format (`2026-05-11T00:00:00Z`).
|
||||
*/
|
||||
export function rangeLastDays(days: number, now: Date = new Date()): DateRange {
|
||||
const end = new Date(now.getTime());
|
||||
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
return {
|
||||
startedAt: start.toISOString(),
|
||||
endedAt: end.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import {
|
||||
startLoginFlow,
|
||||
awaitAuthorizationCode,
|
||||
exchangeCodeForToken,
|
||||
fetchTwitchUserInfo,
|
||||
} from './twitch-oauth';
|
||||
import * as http from 'http';
|
||||
|
||||
function httpGet(url: string): Promise<{ status: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(url, res => {
|
||||
res.on('data', () => { /* drain */ });
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0 }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('startLoginFlow', () => {
|
||||
test('builds Twitch authorize URL with required params + PKCE + state', async () => {
|
||||
const flow = await startLoginFlow({
|
||||
clientId: 'test-client',
|
||||
scopes: ['user:read:email', 'channel:read:subscriptions'],
|
||||
});
|
||||
try {
|
||||
expect(flow.authUrl).toContain('https://id.twitch.tv/oauth2/authorize');
|
||||
const url = new URL(flow.authUrl);
|
||||
expect(url.searchParams.get('client_id')).toBe('test-client');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('scope')).toBe('user:read:email channel:read:subscriptions');
|
||||
expect(url.searchParams.get('state')).toBe(flow.state);
|
||||
expect(url.searchParams.get('code_challenge')).toBe(flow.pkce.codeChallenge);
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(url.searchParams.get('redirect_uri')).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/oauth\/callback$/);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('awaitAuthorizationCode', () => {
|
||||
test('returns code on successful redirect with matching state', async () => {
|
||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
||||
try {
|
||||
const captureP = awaitAuthorizationCode(flow, 3000);
|
||||
await httpGet(`${flow.server.url}?code=AUTHCODE&state=${flow.state}`);
|
||||
const result = await captureP;
|
||||
expect(result.code).toBe('AUTHCODE');
|
||||
expect(result.state).toBe(flow.state);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects on state mismatch (CSRF protection)', async () => {
|
||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
||||
try {
|
||||
// .catch fangt unhandled rejection ab — wir pruefen den Error manuell.
|
||||
const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e);
|
||||
await httpGet(`${flow.server.url}?code=AUTHCODE&state=WRONG_STATE`);
|
||||
const err = await captureP;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/state mismatch/);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects on error parameter', async () => {
|
||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
||||
try {
|
||||
const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e);
|
||||
await httpGet(`${flow.server.url}?error=access_denied&error_description=user+denied`);
|
||||
const err = await captureP;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/access_denied/);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects on missing code', async () => {
|
||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
||||
try {
|
||||
const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e);
|
||||
await httpGet(`${flow.server.url}?state=${flow.state}`);
|
||||
const err = await captureP;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toMatch(/missing code/);
|
||||
} finally {
|
||||
flow.server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeCodeForToken', () => {
|
||||
test('POSTs correct body and returns parsed token', async () => {
|
||||
let capturedBody: string | null = null;
|
||||
const fakeFetch = async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(JSON.stringify({
|
||||
access_token: 'ACC',
|
||||
refresh_token: 'REF',
|
||||
expires_in: 14400,
|
||||
scope: ['user:read:email'],
|
||||
token_type: 'bearer',
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
const token = await exchangeCodeForToken({
|
||||
clientId: 'cid', code: 'CODE', codeVerifier: 'VERIFIER',
|
||||
redirectUri: 'http://127.0.0.1:5555/oauth/callback',
|
||||
fetchImpl: fakeFetch as unknown as typeof fetch,
|
||||
});
|
||||
expect(token.access_token).toBe('ACC');
|
||||
expect(token.refresh_token).toBe('REF');
|
||||
expect(capturedBody).toContain('client_id=cid');
|
||||
expect(capturedBody).toContain('code=CODE');
|
||||
expect(capturedBody).toContain('code_verifier=VERIFIER');
|
||||
expect(capturedBody).toContain('grant_type=authorization_code');
|
||||
});
|
||||
|
||||
test('throws on non-2xx response', async () => {
|
||||
const fakeFetch = async (): Promise<Response> => new Response('bad request', { status: 400 });
|
||||
await expect(exchangeCodeForToken({
|
||||
clientId: 'cid', code: 'X', codeVerifier: 'V', redirectUri: 'http://x',
|
||||
fetchImpl: fakeFetch as unknown as typeof fetch,
|
||||
})).rejects.toThrow(/400/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTwitchUserInfo', () => {
|
||||
test('returns first user from helix /users response', async () => {
|
||||
const fakeFetch = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer TOKEN');
|
||||
expect(headers['Client-Id']).toBe('CID');
|
||||
return new Response(JSON.stringify({
|
||||
data: [{ id: '12345', login: 'alice', display_name: 'Alice' }],
|
||||
}), { status: 200 });
|
||||
};
|
||||
const user = await fetchTwitchUserInfo('TOKEN', 'CID', fakeFetch as unknown as typeof fetch);
|
||||
expect(user.id).toBe('12345');
|
||||
expect(user.login).toBe('alice');
|
||||
expect(user.display_name).toBe('Alice');
|
||||
});
|
||||
|
||||
test('throws when no user in response', async () => {
|
||||
const fakeFetch = async (): Promise<Response> => new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch))
|
||||
.rejects.toThrow(/no user/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createPkcePair, generateState, type PkcePair } from './pkce';
|
||||
import { startLoopbackServer, type LoopbackServer } from '../infra/loopback-server';
|
||||
|
||||
/**
|
||||
* Twitch OAuth 2.1 Authorization Code Flow + PKCE.
|
||||
*
|
||||
* Twitch supports PKCE since ~2022. Endpoints:
|
||||
* Authorize: https://id.twitch.tv/oauth2/authorize
|
||||
* Token: https://id.twitch.tv/oauth2/token
|
||||
* Validate: https://id.twitch.tv/oauth2/validate
|
||||
* Helix /users (whoami): https://api.twitch.tv/helix/users
|
||||
*
|
||||
* Flow:
|
||||
* 1. startLoginFlow({clientId, scopes}) → { authUrl, ... }
|
||||
* 2. shell.openExternal(authUrl) im Caller (main.ts hat shell)
|
||||
* 3. await completeLoginFlow(state) → wartet auf Loopback-Redirect
|
||||
* 4. Exchange code+verifier gegen token via fetch
|
||||
* 5. Helix /users mit Bearer-Token → twitch_user_id + login + display_name
|
||||
*
|
||||
* Plan 03b liefert NUR Module + Tests. Eigentlicher login-flow IPC handler
|
||||
* + Renderer-Button kommt in Folgeplan, weil das Twitch-Account-Setup
|
||||
* (Client-ID in Twitch Dev Console mit korrektem Redirect-URI) erst
|
||||
* vorbereitet werden muss.
|
||||
*/
|
||||
|
||||
const TWITCH_AUTHORIZE_URL = 'https://id.twitch.tv/oauth2/authorize';
|
||||
const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token';
|
||||
const TWITCH_HELIX_USERS_URL = 'https://api.twitch.tv/helix/users';
|
||||
|
||||
export interface TwitchTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
scope: string[];
|
||||
token_type: 'bearer';
|
||||
}
|
||||
|
||||
export interface TwitchUserInfo {
|
||||
id: string;
|
||||
login: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface LoginStart {
|
||||
authUrl: string;
|
||||
state: string;
|
||||
pkce: PkcePair;
|
||||
server: LoopbackServer;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface LoginStartOptions {
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
pathPrefix?: string; // default '/oauth/callback'
|
||||
port?: number; // 0 = OS-chooses
|
||||
}
|
||||
|
||||
export async function startLoginFlow(opts: LoginStartOptions): Promise<LoginStart> {
|
||||
const server = await startLoopbackServer({
|
||||
pathPrefix: opts.pathPrefix ?? '/oauth/callback',
|
||||
port: opts.port,
|
||||
});
|
||||
|
||||
const pkce = createPkcePair();
|
||||
const state = generateState();
|
||||
const redirectUri = server.url;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: opts.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: opts.scopes.join(' '),
|
||||
state,
|
||||
code_challenge: pkce.codeChallenge,
|
||||
code_challenge_method: pkce.codeChallengeMethod,
|
||||
force_verify: 'true',
|
||||
});
|
||||
const authUrl = `${TWITCH_AUTHORIZE_URL}?${params.toString()}`;
|
||||
|
||||
return { authUrl, state, pkce, server, redirectUri };
|
||||
}
|
||||
|
||||
export interface CompleteLoginResult {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet auf Redirect-Capture und prueft state.
|
||||
* Throws bei mismatch state, bei `?error=` Parameter, oder bei Timeout.
|
||||
*/
|
||||
export async function awaitAuthorizationCode(login: LoginStart, timeoutMs?: number): Promise<CompleteLoginResult> {
|
||||
const params = await login.server.awaitParams({ timeoutMs });
|
||||
if (params.has('error')) {
|
||||
const err = params.get('error') ?? 'unknown_error';
|
||||
const desc = params.get('error_description') ?? '';
|
||||
throw new Error(`twitch-oauth: provider error: ${err}${desc ? ` — ${desc}` : ''}`);
|
||||
}
|
||||
const returnedState = params.get('state') ?? '';
|
||||
if (returnedState !== login.state) {
|
||||
throw new Error('twitch-oauth: state mismatch (possible CSRF or stale flow)');
|
||||
}
|
||||
const code = params.get('code');
|
||||
if (!code) {
|
||||
throw new Error('twitch-oauth: missing code parameter');
|
||||
}
|
||||
return { code, state: returnedState };
|
||||
}
|
||||
|
||||
export interface TokenExchangeOptions {
|
||||
clientId: string;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(opts: TokenExchangeOptions): Promise<TwitchTokenResponse> {
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
const body = new URLSearchParams({
|
||||
client_id: opts.clientId,
|
||||
code: opts.code,
|
||||
code_verifier: opts.codeVerifier,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: opts.redirectUri,
|
||||
});
|
||||
|
||||
const res = await fetchFn(TWITCH_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`twitch-oauth: token endpoint ${res.status}: ${text}`);
|
||||
}
|
||||
return JSON.parse(text) as TwitchTokenResponse;
|
||||
}
|
||||
|
||||
export async function fetchTwitchUserInfo(
|
||||
accessToken: string,
|
||||
clientId: string,
|
||||
fetchImpl?: typeof fetch
|
||||
): Promise<TwitchUserInfo> {
|
||||
const fetchFn = fetchImpl ?? fetch;
|
||||
const res = await fetchFn(TWITCH_HELIX_USERS_URL, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Client-Id': clientId,
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`twitch-oauth: helix /users ${res.status}: ${text}`);
|
||||
}
|
||||
const json = JSON.parse(text) as { data?: TwitchUserInfo[] };
|
||||
const first = json.data?.[0];
|
||||
if (!first) throw new Error('twitch-oauth: helix /users returned no user');
|
||||
return first;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect, describe } from 'vitest';
|
||||
import {
|
||||
normalizeUpdateVersion,
|
||||
compareUpdateVersions,
|
||||
isNewerUpdateVersion,
|
||||
} from './update-version-utils';
|
||||
|
||||
describe('normalizeUpdateVersion', () => {
|
||||
test('strips v-prefix lowercase', () => {
|
||||
expect(normalizeUpdateVersion('v1.2.3')).toBe('1.2.3');
|
||||
});
|
||||
test('strips V-prefix uppercase', () => {
|
||||
expect(normalizeUpdateVersion('V1.2.3')).toBe('1.2.3');
|
||||
});
|
||||
test('trims whitespace', () => {
|
||||
expect(normalizeUpdateVersion(' 1.2.3 ')).toBe('1.2.3');
|
||||
});
|
||||
test('handles null and undefined as empty string', () => {
|
||||
expect(normalizeUpdateVersion(null)).toBe('');
|
||||
expect(normalizeUpdateVersion(undefined)).toBe('');
|
||||
});
|
||||
test('passes plain version unchanged', () => {
|
||||
expect(normalizeUpdateVersion('1.0.1')).toBe('1.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareUpdateVersions', () => {
|
||||
test('older < newer in same minor', () => {
|
||||
expect(compareUpdateVersions('1.0.1', '1.0.2')).toBeLessThan(0);
|
||||
});
|
||||
test('newer > older in same minor', () => {
|
||||
expect(compareUpdateVersions('1.0.2', '1.0.1')).toBeGreaterThan(0);
|
||||
});
|
||||
test('equal versions return 0', () => {
|
||||
expect(compareUpdateVersions('1.0.1', '1.0.1')).toBe(0);
|
||||
});
|
||||
test('v-prefix is normalized away', () => {
|
||||
expect(compareUpdateVersions('v1.0.1', '1.0.1')).toBe(0);
|
||||
});
|
||||
test('extra trailing part is newer', () => {
|
||||
expect(compareUpdateVersions('1.0.1', '1.0.1.1')).toBeLessThan(0);
|
||||
});
|
||||
test('major bump wins', () => {
|
||||
expect(compareUpdateVersions('2.0.0', '1.99.99')).toBeGreaterThan(0);
|
||||
});
|
||||
test('null versions sort lowest', () => {
|
||||
expect(compareUpdateVersions(null, '1.0.0')).toBeLessThan(0);
|
||||
expect(compareUpdateVersions('1.0.0', null)).toBeGreaterThan(0);
|
||||
});
|
||||
test('both null returns 0', () => {
|
||||
expect(compareUpdateVersions(null, null)).toBe(0);
|
||||
expect(compareUpdateVersions('', '')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNewerUpdateVersion', () => {
|
||||
test('strictly newer returns true', () => {
|
||||
expect(isNewerUpdateVersion('1.0.2', '1.0.1')).toBe(true);
|
||||
});
|
||||
test('equal returns false', () => {
|
||||
expect(isNewerUpdateVersion('1.0.1', '1.0.1')).toBe(false);
|
||||
});
|
||||
test('older returns false', () => {
|
||||
expect(isNewerUpdateVersion('1.0.1', '1.0.2')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export function normalizeUpdateVersion(version: string | null | undefined): string {
|
||||
return (version || '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
function parseVersionPart(part: string): number {
|
||||
const numeric = Number(part.replace(/[^0-9].*$/, ''));
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
export function compareUpdateVersions(left: string | null | undefined, right: string | null | undefined): number {
|
||||
const a = normalizeUpdateVersion(left);
|
||||
const b = normalizeUpdateVersion(right);
|
||||
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return -1;
|
||||
if (!b) return 1;
|
||||
|
||||
const aParts = a.split('.').map(parseVersionPart);
|
||||
const bParts = b.split('.').map(parseVersionPart);
|
||||
const maxLength = Math.max(aParts.length, bParts.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i += 1) {
|
||||
const av = aParts[i] || 0;
|
||||
const bv = bParts[i] || 0;
|
||||
if (av > bv) return 1;
|
||||
if (av < bv) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isNewerUpdateVersion(candidate: string | null | undefined, baseline: string | null | undefined): boolean {
|
||||
return compareUpdateVersions(candidate, baseline) > 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Stammverzeichnis fuer das v5-Architektur-Refactoring.
|
||||
// Plan 04 macht daraus den Entry-Point statt src/main.ts.
|
||||
export {};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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')));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 */ }
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user