Compare commits
No commits in common. "v5.0.0-alpha.1" and "main" have entirely different histories.
v5.0.0-alp
...
main
58
CLAUDE.md
58
CLAUDE.md
@ -1,58 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npm run build` | Compile TypeScript (`tsc` → `dist/`) |
|
||||
| `npm start` | Build + launch Electron app |
|
||||
| `npm run test:unit` | Vitest Unit-Tests (`src/**/*.test.ts`) |
|
||||
| `npm run test:unit:watch` | Vitest Watch-Mode |
|
||||
| `npm run test:e2e:update-logic` | Unit test for version comparison logic (fast, no Playwright) |
|
||||
| `npm run test:e2e` | Basic Playwright smoke test |
|
||||
| `npm run test:e2e:guide` | Template guide smoke test |
|
||||
| `npm run test:e2e:full` | Full end-to-end test suite |
|
||||
| `npm run test:e2e:release` | Full pre-release verification (build + unit + update-logic + 3 Playwright-Stages) |
|
||||
| `npm run dist:win` | Run all tests then build Windows NSIS installer |
|
||||
| `npm run release:gitea` | Upload release assets to Gitea (requires `GITEA_TOKEN` env var) |
|
||||
|
||||
## Architecture
|
||||
|
||||
**Electron app** (TypeScript, no UI framework — vanilla HTML/CSS/JS in renderer).
|
||||
|
||||
### Process Model
|
||||
|
||||
- **Main process** (`src/main.ts`, ~7300 lines): Core logic — Twitch API, download queue, streamlink/ffmpeg orchestration, config persistence, auto-updater, caching
|
||||
- **Preload** (`src/preload.ts`): Context bridge exposing a controlled `window.api` IPC surface
|
||||
- **Renderer** (`src/renderer*.ts`): UI split across multiple files by concern (queue, settings, updates, streamers, localization)
|
||||
- **Extrahierte Module** (`src/main/`): v5-Architektur-Umbau im Gang. Reine Helpers bereits ausgelagert nach `src/main/infra/` (fs-atomic, duration) und `src/main/domain/` (update-version-utils, i18n-backend, config-normalize). Roadmap fuer den vollstaendigen Split: `tasks/v5.0.0-roadmap.md`.
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **IPC via context bridge only** — all main↔renderer communication goes through `preload.ts`. Never use `ipcRenderer` directly.
|
||||
- **State**: Global variables in both processes. Main process is source of truth; renderer syncs via IPC events.
|
||||
- **Persistence**: JSON files in `C:\ProgramData\Twitch_VOD_Manager\` (config, queue, debug log). Config auto-saves with debounce.
|
||||
- **Queue sync throttling**: Adaptive refresh rates based on window visibility (900ms active → 9000ms hidden).
|
||||
- **Caching**: Multi-tier in-memory caches (user IDs, VOD lists, clip info) with configurable TTL and periodic cleanup.
|
||||
- **Error classification**: Errors categorized as network/rate_limit/auth/tooling/integrity/io/validation/unknown with retry scheduling.
|
||||
- **Localization**: EN (`renderer-locale-en.ts`) and DE (`renderer-locale-de.ts`) string tables.
|
||||
|
||||
### External Tools
|
||||
|
||||
Downloads depend on **streamlink** and **ffmpeg** being available. The app caches their paths with 10-second TTL.
|
||||
|
||||
## Release Process
|
||||
|
||||
Documented in `README_AI_RELEASE.md`. Key points:
|
||||
- Hosted on Gitea at `git.24-music.de`
|
||||
- Auto-updater requires 3 assets per release: `.exe`, `.exe.blockmap`, `latest.yml`
|
||||
- Version set via `npm version X.Y.Z --no-git-tag-version`, commit as `release: X.Y.Z`, tag as `vX.Y.Z`
|
||||
- Never commit tokens; use `$env:GITEA_TOKEN` in PowerShell
|
||||
|
||||
## TypeScript Config
|
||||
|
||||
- Target: ES2022, strict mode enabled
|
||||
- Source in `src/`, compiled to `dist/`
|
||||
- No linter configured — TypeScript strict mode is the primary safety net
|
||||
1498
package-lock.json
generated
1498
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "5.0.0-alpha.1",
|
||||
"version": "4.6.155",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
@ -8,13 +8,11 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "npm run build && electron .",
|
||||
"test:unit": "vitest run --passWithNoTests",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js",
|
||||
"test:e2e": "node scripts/smoke-test.js",
|
||||
"test:e2e:guide": "node scripts/smoke-test-template-guide.js",
|
||||
"test:e2e:full": "node scripts/smoke-test-full.js",
|
||||
"test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full",
|
||||
"test:e2e:release": "npm run build && npm run test:e2e:update-logic && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full",
|
||||
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder",
|
||||
@ -24,12 +22,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"electron-updater": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20.10.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.0",
|
||||
@ -37,8 +33,7 @@
|
||||
"eslint-plugin-security": "^4.0.0",
|
||||
"playwright": "^1.59.1",
|
||||
"typescript": "^5.3.0",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vitest": "^4.1.6"
|
||||
"typescript-eslint": "^8.57.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "de.24-music.twitch-vod-manager",
|
||||
|
||||
@ -4,7 +4,7 @@ const {
|
||||
normalizeUpdateVersion,
|
||||
compareUpdateVersions,
|
||||
isNewerUpdateVersion
|
||||
} = require(path.join(process.cwd(), 'dist', 'main', 'domain', 'update-version-utils.js'));
|
||||
} = require(path.join(process.cwd(), 'dist', 'update-version-utils.js'));
|
||||
|
||||
function run() {
|
||||
const failures = [];
|
||||
|
||||
265
src/main.ts
265
src/main.ts
@ -5,22 +5,7 @@ import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
|
||||
import { connect as tlsConnect, TLSSocket } from 'node:tls';
|
||||
import axios from 'axios';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
||||
import { writeFileAtomicSync } from './main/infra/fs-atomic';
|
||||
import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';
|
||||
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
||||
import {
|
||||
normalizeLogin,
|
||||
normalizeAutoRecordPollSeconds,
|
||||
normalizeAutoRecordList,
|
||||
normalizeStreamlinkQuality,
|
||||
normalizeFilenameTemplate,
|
||||
normalizeMetadataCacheMinutes,
|
||||
normalizePerformanceMode,
|
||||
isPlainObject,
|
||||
VALID_STREAMLINK_QUALITIES,
|
||||
type PerformanceMode,
|
||||
} from './main/domain/config-normalize';
|
||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
|
||||
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
|
||||
import {
|
||||
setDebugLogFn, initToolDirs,
|
||||
@ -80,6 +65,7 @@ const DEFAULT_RETRY_DELAY_SECONDS = 5;
|
||||
const MIN_FILE_BYTES = 256 * 1024;
|
||||
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||
|
||||
type PerformanceMode = 'stability' | 'balanced' | 'speed';
|
||||
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown';
|
||||
type UpdateCheckSource = 'startup' | 'interval' | 'manual';
|
||||
type UpdateDownloadSource = 'auto' | 'manual';
|
||||
@ -98,11 +84,102 @@ function getMergeGroupPhaseText(phase: string): string {
|
||||
// ==========================================
|
||||
// BACKEND I18N
|
||||
// ==========================================
|
||||
// Backend-Messages sind in src/main/domain/i18n-backend.ts.
|
||||
// tBackend bleibt als 2-Arg-Adapter hier — pure Variante uebernimmt language
|
||||
// als 3. Parameter, der hier aus config.language injected wird.
|
||||
// User-visible messages produced in main.ts. Keep keys stable — the renderer
|
||||
// no longer translates these (renderer.ts:downloadClip used to translate a
|
||||
// hardcoded set, which was brittle as the strings drifted). Internal
|
||||
// debug log messages stay English-only since they're developer-facing.
|
||||
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;
|
||||
|
||||
type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de;
|
||||
|
||||
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
|
||||
return tBackendCore(key, params, config?.language ?? 'de');
|
||||
const lang: 'de' | 'en' = config.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;
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
@ -295,15 +372,72 @@ const defaultConfig: Config = {
|
||||
delete_parts_after_merge: false
|
||||
};
|
||||
|
||||
// normalize* helpers + VALID_STREAMLINK_QUALITIES + isPlainObject + normalizeLogin
|
||||
// kommen aus ./main/domain/config-normalize. getStreamlinkStreamArg bleibt
|
||||
// hier, da es config liest.
|
||||
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
||||
const AUTO_RECORD_POLL_MAX_SECONDS = 1800;
|
||||
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)));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Whitelist of streamlink stream specifiers we surface in Settings. The
|
||||
// user's choice is passed to streamlink with "best" appended as a fallback
|
||||
// (streamlink supports comma-separated stream lists, picks the first match)
|
||||
// so a missing quality on the source stream still produces a download.
|
||||
const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;
|
||||
|
||||
function normalizeStreamlinkQuality(value: unknown): string {
|
||||
if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return 'best';
|
||||
}
|
||||
|
||||
function getStreamlinkStreamArg(): string {
|
||||
const choice = normalizeStreamlinkQuality(config.streamlink_quality);
|
||||
if (choice === 'best') return 'best';
|
||||
// Fall back to "best" if the chosen rendition isn't offered (e.g. an
|
||||
// older stream archived before that resolution existed).
|
||||
return `${choice},best`;
|
||||
}
|
||||
|
||||
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
|
||||
const value = (template || '').trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
function normalizePerformanceMode(mode: unknown): PerformanceMode {
|
||||
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
|
||||
return mode;
|
||||
}
|
||||
|
||||
return DEFAULT_PERFORMANCE_MODE;
|
||||
}
|
||||
|
||||
function normalizeConfigTemplates(input: Config): Config {
|
||||
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate
|
||||
// an unbounded list across years of downloads. Latest entries kept.
|
||||
@ -384,6 +518,10 @@ function recordDownloadedVodId(vodId: string): void {
|
||||
saveConfig(config);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function loadConfig(): Config {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
@ -401,6 +539,30 @@ function loadConfig(): Config {
|
||||
return normalizeConfigTemplates(defaultConfig);
|
||||
}
|
||||
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(tmpPath, targetPath);
|
||||
} catch {
|
||||
// On Windows, rename can fail if target exists or is locked. Fall back to copy.
|
||||
fs.copyFileSync(tmpPath, targetPath);
|
||||
try { fs.unlinkSync(tmpPath); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfig(config: Config): void {
|
||||
try {
|
||||
writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
@ -934,6 +1096,38 @@ function appendDebugLog(message: string, details?: unknown): void {
|
||||
setDebugLogFn(appendDebugLog);
|
||||
initToolDirs(TOOLS_STREAMLINK_DIR, TOOLS_FFMPEG_DIR, () => app.getPath('temp'));
|
||||
|
||||
// ==========================================
|
||||
// DURATION HELPERS
|
||||
// ==========================================
|
||||
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;
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
const claimedFilenames = new Set<string>();
|
||||
const itemClaimedFilenames = new Map<string, Set<string>>();
|
||||
|
||||
@ -1713,6 +1907,10 @@ async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
|
||||
return await requestTwitchLogin();
|
||||
}
|
||||
|
||||
function normalizeLogin(input: string): string {
|
||||
return input.trim().replace(/^@+/, '').toLowerCase();
|
||||
}
|
||||
|
||||
function formatTwitchDurationFromSeconds(totalSeconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@ -7211,27 +7409,6 @@ app.whenReady().then(() => {
|
||||
refreshBundledToolPaths(true);
|
||||
startMetadataCacheCleanup();
|
||||
startDebugLogFlushTimer();
|
||||
|
||||
// SQLite-Shadow-Migration (Plan 02 / v5.0.0-alpha.1). Idempotent + fail-soft —
|
||||
// bei Fehler bleibt JSON der Master. Lazy require, damit Native-Build-Fehler
|
||||
// den App-Start nicht verhindern.
|
||||
try {
|
||||
const { openDatabase } = require('./main/infra/db');
|
||||
const { migrateJsonToSqlite } = require('./main/domain/migrator');
|
||||
const dbPath = path.join(APPDATA_DIR, 'app.db');
|
||||
const db = openDatabase(dbPath);
|
||||
try {
|
||||
const result = migrateJsonToSqlite({ db, appDataDir: APPDATA_DIR });
|
||||
appendDebugLog('sqlite-migrator', result);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (e) {
|
||||
appendDebugLog('sqlite-migrator-failed', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
restartAutoRecordPoller();
|
||||
restartAutoVodPoller();
|
||||
restartLiveStatusPoller();
|
||||
|
||||
@ -1,183 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -1,67 +0,0 @@
|
||||
// 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;
|
||||
const DEFAULT_METADATA_CACHE_MINUTES = 10;
|
||||
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);
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -1,101 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@ -1,122 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -1,201 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
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('4.6.155')).toBe('4.6.155');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareUpdateVersions', () => {
|
||||
test('older < newer in same minor', () => {
|
||||
expect(compareUpdateVersions('4.1.10', '4.1.18')).toBeLessThan(0);
|
||||
});
|
||||
test('newer > older in same minor', () => {
|
||||
expect(compareUpdateVersions('4.1.18', '4.1.10')).toBeGreaterThan(0);
|
||||
});
|
||||
test('equal versions return 0', () => {
|
||||
expect(compareUpdateVersions('4.6.155', '4.6.155')).toBe(0);
|
||||
});
|
||||
test('v-prefix is normalized away', () => {
|
||||
expect(compareUpdateVersions('v4.1.12', '4.1.12')).toBe(0);
|
||||
});
|
||||
test('extra trailing part is newer', () => {
|
||||
expect(compareUpdateVersions('4.1.12', '4.1.12.1')).toBeLessThan(0);
|
||||
});
|
||||
test('major bump wins', () => {
|
||||
expect(compareUpdateVersions('5.0.0', '4.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('4.1.18', '4.1.17')).toBe(true);
|
||||
});
|
||||
test('equal returns false', () => {
|
||||
expect(isNewerUpdateVersion('4.1.18', '4.1.18')).toBe(false);
|
||||
});
|
||||
test('older returns false', () => {
|
||||
expect(isNewerUpdateVersion('4.1.17', '4.1.18')).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -1,3 +0,0 @@
|
||||
// Stammverzeichnis fuer das v5-Architektur-Refactoring.
|
||||
// Plan 04 macht daraus den Entry-Point statt src/main.ts.
|
||||
export {};
|
||||
@ -1,87 +0,0 @@
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
@ -1,60 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -1,28 +0,0 @@
|
||||
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')}`;
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -1,29 +0,0 @@
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
// 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 migrations_applied (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
payload TEXT
|
||||
);
|
||||
`;
|
||||
@ -1,275 +0,0 @@
|
||||
# Twitch VOD Manager — v5.0.0 Goal
|
||||
|
||||
**Baseline:** v4.6.155 (Stand 2026-05-11)
|
||||
**Target:** v5.0.0
|
||||
**Status:** Draft / nicht freigegeben
|
||||
**Sprache:** Deutsch (Stack-Begriffe englisch)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Nach 155 Patches im 4.6er-Zyklus ist das Produkt poliert, aber im Kern unverändert seit 4.0: monolithische `main.ts` (~3700 Zeilen), JSON-File-Persistenz, Vanilla-DOM-Renderer, kein Live-Recording, keine sub-only Unterstützung, keine Tests jenseits Playwright-Smoke.
|
||||
|
||||
**v5.0.0 ist der Bruch:** Aufnahme statt nur Download, SQLite statt JSON, modulare Domain-Architektur, Twitch-OAuth, optionales Web-Worker-Offloading für grosse Listen. Sieben Feature-Pillars, drei harte Breaking Changes, klarer Migrationspfad von 4.6.x.
|
||||
|
||||
**Was 5.0.0 NICHT ist:** kein React/Vue-Rewrite, kein Plattform-Hop (bleibt Electron + Win), kein neues Bezahlmodell. Es bleibt der gleiche Single-User-Desktop-Client — nur deutlich erwachsener.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why Major Bump?
|
||||
|
||||
Ein Major-Sprung ist gerechtfertigt, wenn **mindestens zwei** Kriterien erfuellt sind:
|
||||
|
||||
| Kriterium | 5.0.0 trifft zu? | Belege |
|
||||
|---|---|---|
|
||||
| Breaking Change im Config-/Datenformat | Ja | JSON → SQLite Migration, Schema v4 → v5 |
|
||||
| Neues Kern-Feature (nicht Inkrement) | Ja | Live-Stream-Recording (sub-only via OAuth), Scheduled-Recording |
|
||||
| Architektur-Bruch | Ja | main.ts Split in 6+ Domain-Module |
|
||||
| Min-OS-/Runtime-Bump | Ja | Electron 28 → 32, Node 18 → 22, Drop Win < 10 1809 |
|
||||
| User-spuerbare UI-Verschiebung | Ja | Virtual-List, Mini-Player, Theme-Engine |
|
||||
|
||||
---
|
||||
|
||||
## 3. Themes & Vision
|
||||
|
||||
Drei Leitthemen ueber allen Features:
|
||||
|
||||
### 3.1 Vom Downloader zum Recorder
|
||||
v4 reagiert: VOD existiert auf Twitch → wir laden runter. v5 agiert proaktiv: Stream geht live → wir nehmen direkt mit. Damit fallen Sub-Only-VODs (oft 60 Tage Retention), 7-Tage-Standard-Retention und geloeschte VODs als Verlust-Szenarien weg.
|
||||
|
||||
### 3.2 Vom Tool zur Platform
|
||||
Domain-Module mit klaren Schnittstellen statt einem 3700-Zeilen-Monolith. Optionaler Plugin-Hook fuer Post-Download-Actions (z. B. Auto-Upload-Skript, Custom-Rename, Notify-Discord). Spaeter wartbar, leichter testbar.
|
||||
|
||||
### 3.3 Vom Hobby zum stabilen Daily-Driver
|
||||
SQLite + Crash-Recovery + Chunk-Hashing + echte Unit-Tests. Wer 500 VODs queued, soll nicht in JSON-Lock-Konflikte laufen oder beim Crash 4 Stunden Download verlieren.
|
||||
|
||||
---
|
||||
|
||||
## 4. Feature-Pillars
|
||||
|
||||
### Pillar 1 — Live Stream Recording (P0)
|
||||
|
||||
**Was:** App kann laufende Streams in Echtzeit mitschneiden, nicht nur VODs post-hoc downloaden.
|
||||
|
||||
**Warum:** Sub-only Streams haben oft keine VOD-Retention oder Sub-only-VODs werden nach 60 Tagen geloescht. Recording ist der einzige Weg, sie dauerhaft zu sichern.
|
||||
|
||||
**Scope:**
|
||||
- Streamlink-basierte Aufnahme aktiver Live-Streams (Quality-Selector wie bei VODs)
|
||||
- Auto-Start: "wenn Streamer X live geht, Recording starten" (Polling-Intervall pro Streamer konfigurierbar, default 60s)
|
||||
- Auto-Stop bei Stream-Ende + Final-Mux mit ffmpeg
|
||||
- Resume bei Mid-Stream-Disconnect (neuer HLS-Segment-Pointer, append-Mode)
|
||||
- Sub-only Streams: nutzt OAuth-Token aus Pillar 2
|
||||
|
||||
**Acceptance:**
|
||||
- 4-Stunden-Test-Stream wird ohne Datenverlust aufgenommen
|
||||
- Disconnect-Wiederherstellung in < 15s
|
||||
- File-Output ist mux-fertig MP4 (kein .ts-Reste, kein orphan-Index)
|
||||
- UI zeigt "LIVE REC" Badge + Recording-Dauer im Streamer-Eintrag
|
||||
|
||||
### Pillar 2 — Twitch-OAuth (Device Code Flow) (P0)
|
||||
|
||||
**Was:** Native Anmeldung mit Twitch-Account, OAuth-Token wird verschluesselt in OS-Keychain (Win Credential Manager) gespeichert.
|
||||
|
||||
**Warum:** Sub-only Inhalte, hoehere Rate-Limits (Helix mit User-Token > App-Token in vielen Endpoints), private Follow-Liste fuer Auto-Discovery, korrekte Mature-Content-Gating-Behandlung.
|
||||
|
||||
**Scope:**
|
||||
- Device Code Flow (kein Browser-Embed, kein localhost-Callback noetig — sauberer fuer Desktop)
|
||||
- Multi-Account: bis zu 3 parallele Accounts (Haupt, Alt, Auto-Sub)
|
||||
- Token-Refresh automatisch, Fail-Soft auf App-Token
|
||||
- Settings-Tab "Accounts" mit Liste, Login/Logout, "Default fuer Recording"
|
||||
|
||||
**Acceptance:**
|
||||
- Login dauert < 60s vom Klick bis Token in Keychain
|
||||
- Sub-only Test-Channel laesst sich aufnehmen mit eingeloggtem Sub-Account
|
||||
- Logout entfernt Token sauber aus Keychain (audit per Tool: kein orphan-Entry)
|
||||
|
||||
### Pillar 3 — SQLite-Migration (P0, Breaking)
|
||||
|
||||
**Was:** Alle App-Daten (Queue, Stats, Archiv-Index, Streamer-Liste, Recording-History) wandern von JSON-Files nach `app.db` (better-sqlite3 oder besser-im-Bundle-Alternative).
|
||||
|
||||
**Warum:** JSON wird ab ~1000 VODs spuerbar lahm (Full-File-Rewrite bei jedem Save). SQLite gibt indexed lookups, atomic transactions, kein Lock-Konflikt bei parallelen Writes, einfache Migrationen.
|
||||
|
||||
**Scope:**
|
||||
- Schema v5 mit Tabellen: `queue`, `archive`, `streamers`, `recordings`, `stats_daily`, `clip_index`, `config_blob`
|
||||
- Migrator-Script: liest alle `C:\ProgramData\Twitch_VOD_Manager\*.json` und schreibt in `app.db`
|
||||
- Backup vor Migration: `.json` → `.json.v4-backup`
|
||||
- Read-Path bleibt API-kompatibel fuer Renderer (IPC-Surface unveraendert)
|
||||
- WAL-Mode + busy-timeout 5000ms
|
||||
|
||||
**Acceptance:**
|
||||
- Migrator laeuft idempotent (zweimal aufrufen = gleicher Endzustand, kein Daten-Duplikat)
|
||||
- Failure-Rollback: bei Migrator-Crash bleibt App auf JSON, kein Daten-Verlust
|
||||
- Query "alle VODs eines Streamers" < 50ms bei 10k Archiv-Eintraegen (gemessen)
|
||||
|
||||
### Pillar 4 — Architektur-Refactor: main.ts Split (P1)
|
||||
|
||||
**Was:** `main.ts` (3700 LoC) wird in Domain-Module zerlegt.
|
||||
|
||||
**Ziel-Struktur:**
|
||||
```
|
||||
src/main/
|
||||
index.ts Entry, app lifecycle, BrowserWindow
|
||||
domain/
|
||||
twitch-api.ts Helix + GraphQL Calls, Rate-Limit-Handling
|
||||
downloader.ts Streamlink/ffmpeg Orchestration (VOD + Live)
|
||||
recorder.ts Live-Stream-Recording (NEU)
|
||||
queue.ts Queue-State-Machine, persistence-call
|
||||
config.ts Settings-Schema, defaults, validation
|
||||
auth.ts OAuth, Token-Storage, Keychain (NEU)
|
||||
cache.ts In-Memory-TTL-Caches
|
||||
updater.ts electron-updater Wrapper
|
||||
infra/
|
||||
db.ts SQLite-Wrapper, prepared statements
|
||||
logger.ts Strukturiertes Logging (JSON-Lines)
|
||||
ipc.ts Channel-Registry, type-safe Handler
|
||||
ipc-bridge.ts exportiert nur die API-Surface fuer preload.ts
|
||||
```
|
||||
|
||||
**Constraint:** Renderer-IPC-Surface bleibt rueckwaertskompatibel — kein Renderer-Rewrite noetig.
|
||||
|
||||
**Acceptance:**
|
||||
- Kein Modul groesser als 800 LoC
|
||||
- Zyklische Imports = 0 (`madge --circular` oder aequivalent)
|
||||
- E2E-Tests gruen ohne Anpassung
|
||||
|
||||
### Pillar 5 — UI Power-Features (P1)
|
||||
|
||||
**Was:** Renderer-seitige Spruenge, die User direkt sehen.
|
||||
|
||||
**Scope:**
|
||||
- **Virtualized List** fuer Queue/Archive (react-window-aequivalent vanilla, oder `@tanstack/virtual-core` vanilla-Adapter) — 10k Eintraege ohne Lag
|
||||
- **Mini-Player Panel**: HLS-Stream in App-eigenem `<video>` (hls.js), kollabierbar, fuer Recording-Live-Preview + VOD-Vorschau
|
||||
- **Theme-Engine**: drei eingebaute Themes (Default, Dark High-Contrast, Light) + Custom-CSS-Slot mit Live-Reload
|
||||
- **Template-Preview live**: Title-Template aendert sich, Beispiel-Output rendert in Echtzeit unter dem Input
|
||||
- **Drag-Reorder** in Queue (HTML5-DnD, persistiert Position-Index in DB)
|
||||
- **Command-Palette** (Ctrl+K): Schnellaktionen, Streamer-Suche, Settings-Jump
|
||||
|
||||
**Acceptance:**
|
||||
- Scroll-Performance bei 5000 Eintraegen: 60fps (Chrome DevTools Performance-Trace)
|
||||
- Theme-Switch ohne Reload
|
||||
- Command-Palette < 100ms First-Result-Latency
|
||||
|
||||
### Pillar 6 — Smart-Resume & Integrity (P1)
|
||||
|
||||
**Was:** Downloads/Recordings ueberleben Crash, Disconnect, Reboot.
|
||||
|
||||
**Scope:**
|
||||
- Jeder HLS-Segment-Chunk bekommt sha1, geschrieben in DB-Tabelle `chunk_index`
|
||||
- Bei Resume: vergleicht local-chunk-hash mit Manifest, ueberspringt valide Chunks, faengt am ersten Mismatch an
|
||||
- Crash-Recovery beim Startup: scannt `recordings`-Tabelle nach `state=running` und bietet Resume-Dialog an
|
||||
- Final-Integrity-Check: ffprobe-Aufruf auf fertige Datei, schreibt `verified=1` in DB
|
||||
|
||||
**Acceptance:**
|
||||
- App-Kill mid-download → Neustart → Resume genau ab Abbruch-Chunk (manueller Test)
|
||||
- Korrupte Chunk-Datei wird erkannt und neu geladen (per Mutation-Test: zufaelliges Byte flippen)
|
||||
|
||||
### Pillar 7 — Scheduled Recording & Auto-Discovery (P2)
|
||||
|
||||
**Was:** Automatisierungs-Layer ueber Pillar 1+2.
|
||||
|
||||
**Scope:**
|
||||
- Pro Streamer: "Auto-Record on Live: ja/nein" + Quality-Preset
|
||||
- Zeit-Plan: "nur zwischen 18-02 Uhr aufnehmen" (vermeidet Reruns/Marathons)
|
||||
- **Top-Clip-Crawler**: pro Streamer alle X Tage die N besten Clips (Helix `/clips`) downloaden
|
||||
- **Follow-Sync**: importiert eingeloggte Follow-Liste als Streamer-Eintraege (opt-in)
|
||||
|
||||
**Acceptance:**
|
||||
- 3-Streamer-Auto-Record-Test ueber 24h ohne Eingriff, kein Doppelaufnahme bei Stream-Reconnect
|
||||
|
||||
---
|
||||
|
||||
## 5. Breaking Changes
|
||||
|
||||
| # | Was bricht | Migration | User-Impact |
|
||||
|---|---|---|---|
|
||||
| BC-1 | Config-Speicherort | Migrator on first 5.0 start, `.v4-backup` der JSONs bleibt liegen | Einmalig 5-30s Migrations-Dialog |
|
||||
| BC-2 | Min-Windows: Win10 1809 (Oktober 2018) | Installer-Check, blockiert Setup auf alten OS | Sehr wenige User betroffen |
|
||||
| BC-3 | Node-API-Version: Plugins die auf Node 18 N-API gebaut sind muessen rebuild (relevant fuer better-sqlite3, evtl. fsevents) | Pre-built Binaries im Bundle | Transparent fuer User |
|
||||
| BC-4 | Streamer-Eintrag-Format aendert sich (neue Felder: `oauth_account_id`, `auto_record`, `record_quality`) | Auto-Default bei Migration | Transparent |
|
||||
| BC-5 | Auto-Updater Channel: alte 4.x-Clients sehen 5.0 nicht im stable-Channel, sondern in `next` (opt-in) | User muss bewusst Channel wechseln; nach 4 Wochen Beta: 5.0 → stable | Bewusste User-Aktion |
|
||||
|
||||
---
|
||||
|
||||
## 6. Tech-Stack Upgrades
|
||||
|
||||
| Komponente | 4.6 | 5.0 | Grund |
|
||||
|---|---|---|---|
|
||||
| Electron | 28.x | 32.x (oder LTS aktuell) | Security-Patches, Chromium-Upgrade |
|
||||
| Node | 18 | 22 | LTS, V8 perf |
|
||||
| TypeScript | 5.3 | 5.6+ | satisfies / decorators stable |
|
||||
| ESLint | 10 | aktueller flat-config | passt |
|
||||
| Neu: better-sqlite3 | — | latest | SQLite-Persistenz |
|
||||
| Neu: hls.js | — | latest | Mini-Player |
|
||||
| Neu: keytar (oder Electron safeStorage) | — | latest | OAuth-Token-Storage |
|
||||
| Playwright | 1.59 | aktuell | E2E |
|
||||
| Neu: vitest | — | latest | Unit-Tests |
|
||||
|
||||
---
|
||||
|
||||
## 7. Quality & DevX
|
||||
|
||||
- **Unit-Tests (Vitest):** mind. 60% Coverage in `src/main/domain/*` zum 5.0.0-GA. Reine Logik-Tests, kein Electron-Boot.
|
||||
- **E2E (Playwright):** bestehende Suite plus drei neue Szenarien (Login, Live-Record-Start, SQLite-Migration).
|
||||
- **CI:** weiterhin lokal (Solo-Projekt), aber `npm run check:all` = build + lint + vitest + e2e in einem Skript.
|
||||
- **Logging:** strukturiert JSONL in `logs/app-YYYY-MM-DD.jsonl`, 14-Tage-Retention, mit Level (debug/info/warn/error) und Trace-IDs pro Download/Recording.
|
||||
- **Crash-Reports:** opt-in Sentry (oder einfacher: lokaler Crash-Log-Export + Copy-to-Clipboard-Button). Default: aus, kein Telemetrie-Surprise.
|
||||
|
||||
---
|
||||
|
||||
## 8. Release-Phasen
|
||||
|
||||
| Phase | Version | Was rein | Wann (relativ) |
|
||||
|---|---|---|---|
|
||||
| Alpha-1 | 5.0.0-alpha.1 | SQLite-Migrator + Architektur-Split | T+0 |
|
||||
| Alpha-2 | 5.0.0-alpha.2 | OAuth + Live-Recording (Baseline) | T+2 Wo |
|
||||
| Beta-1 | 5.0.0-beta.1 | UI Power-Features, Smart-Resume | T+4 Wo |
|
||||
| Beta-2 | 5.0.0-beta.2 | Scheduled/Auto-Discovery, Bug-Fixes | T+6 Wo |
|
||||
| RC | 5.0.0-rc.1 | nur noch Bugfixes, kein Feature-Add | T+8 Wo |
|
||||
| GA | 5.0.0 | Public, `next`-Channel → `stable` | T+10 Wo |
|
||||
|
||||
Konkret Daten setze ich nicht — die haengen davon ab, wie eng du das fahren willst.
|
||||
|
||||
---
|
||||
|
||||
## 9. Out-of-Scope (bewusst NICHT in 5.0.0)
|
||||
|
||||
- React/Vue/Svelte-Rewrite des Renderers (Risiko zu gross, Vanilla bleibt)
|
||||
- macOS/Linux-Builds (Electron-Builder kann, aber kein Test-Setup → erst 5.1+)
|
||||
- Cloud-Sync (Queue/Settings ueber Geraete) — eigener Major-Bump waert
|
||||
- Chat-Recording / Chat-Renderer (TwitchDownloaderCLI Integration) — verlockend, aber Scope-Creep → 5.1
|
||||
- Mobile-Companion-App
|
||||
- Plugin-API fuer Dritte (Hooks koennen wir intern bauen, aber kein public-API-Versprechen in 5.0)
|
||||
- AI-Highlight-Detection (lokales LLM) — Forschung, kein Produkt-Feature 2026
|
||||
|
||||
---
|
||||
|
||||
## 10. Definition of Done (5.0.0 GA)
|
||||
|
||||
Alle Punkte erfuellt:
|
||||
|
||||
1. [ ] Pillars 1-6 implementiert und in E2E-Tests abgedeckt (Pillar 7 darf teilweise sein, mind. Auto-Record-on-Live)
|
||||
2. [ ] SQLite-Migrator getestet auf realer 4.6-Installation mit 5k+ Archive-Eintraegen, idempotent, mit Rollback-Path
|
||||
3. [ ] OAuth-Token landet in Keychain, audit zeigt sauberen Logout
|
||||
4. [ ] Vitest-Coverage `src/main/domain` >= 60%
|
||||
5. [ ] Playwright-Suite gruen, inkl. drei neue Szenarien
|
||||
6. [ ] Mini-Player + Theme-Engine + Virtual-List manuell verifiziert (Demo-Video)
|
||||
7. [ ] Auto-Updater: bestehende 4.6-Installation kriegt 5.0 ueber `next`-Channel, Migration laeuft, App startet
|
||||
8. [ ] README + CLAUDE.md aktualisiert (neue Architektur, neue Commands, neue Settings)
|
||||
9. [ ] CHANGELOG-Eintrag mit allen Breaking Changes prominent oben
|
||||
10. [ ] Frische Installation aus `Twitch-VOD-Manager-Setup-5.0.0.exe` auf clean Win11-VM: erstes Recording lauft binnen 5 Minuten User-Klicks
|
||||
|
||||
---
|
||||
|
||||
## 11. Naechste Schritte (wenn du dieses Goal annimmst)
|
||||
|
||||
1. Goal mit eigenem Realitaetscheck lesen — was streichen, was zuerst
|
||||
2. Pillar 3 (SQLite) zuerst, weil es Voraussetzung fuer 1, 6, 7 ist
|
||||
3. Branch `feat/v5-foundation` aufmachen, dort Architektur-Split + SQLite parallel
|
||||
4. Nach Alpha-1 entscheiden, ob OAuth vor UI-Refactor oder umgekehrt
|
||||
5. Eigenes `tasks/v5.0.0-checklist.md` aus Pillar-Acceptance-Criteria generieren, abhakbar
|
||||
|
||||
---
|
||||
|
||||
**Autor:** Claude (Opus 4.7, 1M context) — 2026-05-11
|
||||
**Review-Status:** unreviewed, Erstentwurf
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,416 +0,0 @@
|
||||
# Plan 02: SQLite-Foundation (Pillar 3)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** SQLite-Infrastruktur ins Projekt einbauen + Migrator, der bestehende JSON-Daten aus `C:\ProgramData\Twitch_VOD_Manager\*.json` nach `app.db` ueberfuehrt. JSON-Pfade bleiben Source-of-Truth — SQLite laeuft als Shadow-Schreibziel. Cutover (SQLite wird Master) erfolgt in einem spaeteren Plan.
|
||||
|
||||
**Architecture:** `better-sqlite3` (sync embedded). Schema-First via Inline-Konstante (`schema-v5.ts`) damit `tsc` keine SQL-Files kopieren muss. WAL-Mode, busy_timeout 5000ms, foreign_keys ON. Migrator ist idempotent (Marker in `migrations_applied` Tabelle). Bei Crash bleibt JSON-Pfad funktionsfaehig.
|
||||
|
||||
**Tech Stack:** better-sqlite3 + @types/better-sqlite3, vitest mit echten temp-DB-Files.
|
||||
|
||||
**Verifikation pro Task:** `npm run build` + `npm run test:unit` gruen.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope fuer Plan 02
|
||||
|
||||
- Reads aus SQLite umstellen (Renderer/IPC bleibt auf JSON)
|
||||
- electron-rebuild fuer better-sqlite3 (Workaround in Plan 02, voller Pipeline-Fix in spaeterem Plan)
|
||||
- Schema-Migrationen v5 → v6
|
||||
- Sub-only-Tabellen (kommen mit OAuth in Plan 03)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Neu:**
|
||||
- `src/main/infra/schema-v5.ts` — Schema als TypeScript-String-Konstante
|
||||
- `src/main/infra/db.ts` — better-sqlite3 Wrapper
|
||||
- `src/main/infra/db.test.ts`
|
||||
- `src/main/domain/migrator.ts` — JSON to SQLite
|
||||
- `src/main/domain/migrator.test.ts`
|
||||
|
||||
**Modifiziert:**
|
||||
- `package.json`
|
||||
- `src/main.ts` — Migrator beim App-Start (fail-soft)
|
||||
- `CLAUDE.md`
|
||||
- `tasks/v5.0.0-roadmap.md`
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: better-sqlite3 installieren
|
||||
|
||||
- [ ] `npm install --save better-sqlite3`
|
||||
- [ ] `npm install --save-dev @types/better-sqlite3`
|
||||
- [ ] Verify: `node -e "const D=require('better-sqlite3'); const d=new D(':memory:'); d.prepare('SELECT 1 AS x').get(); d.close(); console.log('ok')"` → "ok"
|
||||
- [ ] `npm run build` Exit 0
|
||||
- [ ] Commit: `build: add better-sqlite3 + @types`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: schema-v5.ts (Schema als Inline-String)
|
||||
|
||||
- [ ] Datei `src/main/infra/schema-v5.ts` mit `export const SCHEMA_V5_SQL = \`...sql...\`;`
|
||||
- [ ] SQL-Inhalt umfasst Tabellen: `schema_meta`, `config_kv`, `queue_items`, `downloaded_vods`, `streamers`, `archive_files`, `migrations_applied`. Plus Indices.
|
||||
- [ ] Alle Tabellen `IF NOT EXISTS`. `INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('schema_version', '5')`.
|
||||
- [ ] Commit: `feat(db): schema v5 inline (7 tables + indices)`
|
||||
|
||||
**Schema-Tabellen-Spec (Spalten):**
|
||||
|
||||
```
|
||||
schema_meta(key PK, value)
|
||||
config_kv(key PK, value, updated_at)
|
||||
queue_items(id PK, streamer_login, vod_id, clip_id, title, output_path,
|
||||
status, progress_pct, error_message, created_at, updated_at,
|
||||
completed_at, payload_json)
|
||||
idx_queue_status(status), idx_queue_streamer(streamer_login),
|
||||
idx_queue_created(created_at)
|
||||
downloaded_vods(vod_id PK, downloaded_at default now)
|
||||
streamers(login PK, auto_record, auto_vod_download, added_at default now)
|
||||
idx_streamers_autorec(auto_record), idx_streamers_autodl(auto_vod_download)
|
||||
archive_files(path PK, streamer_login, size_bytes, duration_seconds,
|
||||
created_at, verified default 0)
|
||||
idx_archive_streamer(streamer_login)
|
||||
migrations_applied(name PK, applied_at default now, payload)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: db.ts Wrapper + 5 Tests
|
||||
|
||||
**Public DbHandle Interface:**
|
||||
- `run(sql, params?)` — single-statement execute
|
||||
- `get<T>(sql, params?)` — first row or undefined
|
||||
- `all<T>(sql, params?)` — alle Rows
|
||||
- `transaction<R>(fn)` — atomares Bracket
|
||||
- `runBatch(sql)` — multi-statement (intern via `_db.exec(sql)`, fuer Schema-Bootstrap)
|
||||
- `close()`
|
||||
- `raw` getter
|
||||
|
||||
**openDatabase(filePath: string): DbHandle**
|
||||
- Erstellt File (better-sqlite3 default-Verhalten)
|
||||
- `pragma journal_mode = WAL`, `pragma busy_timeout = 5000`, `pragma foreign_keys = ON`
|
||||
- Fuehrt Schema-Bootstrap aus: `runBatch(SCHEMA_V5_SQL)`
|
||||
- Returnt das Handle-Objekt
|
||||
|
||||
**Tests** (`src/main/infra/db.test.ts`):
|
||||
|
||||
```typescript
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Tests schreiben (sollen fehlschlagen — Modul fehlt)
|
||||
- [ ] `npm run test:unit` zeigt 5 neue failures
|
||||
- [ ] db.ts implementieren (better-sqlite3 wrappen, Interface oben einhalten)
|
||||
- [ ] Tests passen — `npm run test:unit` Exit 0
|
||||
- [ ] Build gruen
|
||||
- [ ] Commit: `feat(db): better-sqlite3 wrapper + schema bootstrap (5 tests)`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Migrator + 8 Tests
|
||||
|
||||
**MigratorOptions:**
|
||||
- `db: DbHandle`
|
||||
- `appDataDir: string`
|
||||
|
||||
**MigrationResult:**
|
||||
- `alreadyApplied: boolean`
|
||||
- `configMigrated: boolean`
|
||||
- `queueMigrated: boolean`
|
||||
- `downloadedVodsCount: number`
|
||||
- `streamersCount: number`
|
||||
- `errors: MigrationError[]` (`{ source, message }`)
|
||||
|
||||
**migrateJsonToSqlite(opts):**
|
||||
1. Check `migrations_applied` fuer `name='v4-to-v5-jsons'` → wenn vorhanden → return `alreadyApplied: true`
|
||||
2. Lies `appDataDir/config.json` falls vorhanden:
|
||||
- Parse, in `db.transaction(() => { ... })`:
|
||||
- Whitelisted Keys (siehe Liste unten) → `INSERT OR REPLACE INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))` mit `value = JSON.stringify(config[key])`
|
||||
- `downloaded_vod_ids` (Array of String) → `INSERT OR IGNORE INTO downloaded_vods(vod_id) VALUES (?)`
|
||||
- `auto_record_streamers` → normalizeLogin + `INSERT...ON CONFLICT DO UPDATE auto_record=1`
|
||||
- `auto_vod_download_streamers` → normalizeLogin + `INSERT...ON CONFLICT DO UPDATE auto_vod_download=1`
|
||||
- Bei Erfolg: `.v4-backup` Copy schreiben (`fs.copyFileSync`)
|
||||
- Bei Fehler: error pushen, continue
|
||||
3. Lies `appDataDir/download_queue.json` falls vorhanden:
|
||||
- Parse, fuer jedes Item (mit `id`-Feld): `INSERT OR REPLACE INTO queue_items(...) VALUES (...)`
|
||||
- Fields: id, streamer→normalizeLogin, vod_id, clip_id, title, output_path, status (default 'pending'), progress_pct, error_message, created_at, updated_at, completed_at, payload_json=JSON.stringify(item)
|
||||
- Bei Erfolg: `.v4-backup` Copy
|
||||
4. Schreibe Marker: `INSERT INTO migrations_applied(name, payload) VALUES ('v4-to-v5-jsons', JSON.stringify({summary}))`
|
||||
|
||||
**Whitelisted Config-Keys fuer config_kv (verbatim):**
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
**Tests (verbatim, src/main/domain/migrator.test.ts):**
|
||||
|
||||
```typescript
|
||||
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 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');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Tests schreiben (failing — Modul fehlt)
|
||||
- [ ] Migrator implementieren (gemaess Interface + Step-Liste oben)
|
||||
- [ ] Tests passen — `npm run test:unit` Exit 0
|
||||
- [ ] Build gruen
|
||||
- [ ] Commit: `feat(db): JSON to SQLite migrator (idempotent, fail-soft, 8 tests)`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Migrator-Aufruf beim App-Start
|
||||
|
||||
**main.ts:** im `app.whenReady().then(() => { ... })` Block, vor dem ersten `createWindow()`-Aufruf, einen try/catch-Block mit:
|
||||
- `require('./main/infra/db')` und `require('./main/domain/migrator')` (lazy, damit Native-Fehler kein App-Start verhindern)
|
||||
- `openDatabase(path.join(APPDATA_DIR, 'app.db'))`
|
||||
- `migrateJsonToSqlite({ db, appDataDir: APPDATA_DIR })`
|
||||
- Result ins debug.log loggen (falls `debugLog` Funktion vorhanden)
|
||||
- `db.close()` im `finally`
|
||||
- Catch: fail-soft, nur ins debug.log oder console.error
|
||||
|
||||
- [ ] Code adden
|
||||
- [ ] Build gruen
|
||||
- [ ] `npm run test:e2e` Exit 0 (Playwright startet App, Migrator laeuft auf leerer AppData)
|
||||
- [ ] Commit: `feat(db): wire migrator into app startup (fail-soft, lazy require)`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Full Verification + Version Bump 5.0.0-alpha.1
|
||||
|
||||
- [ ] `npm run test:e2e:release` Exit 0 (build + unit (mit jetzt >=104 Tests) + update-logic + 3 Playwright-Stages)
|
||||
- [ ] `npm version 5.0.0-alpha.1 --no-git-tag-version`
|
||||
- [ ] `npm run build` Exit 0
|
||||
- [ ] Commit: `release: 5.0.0-alpha.1 - SQLite migrator (shadow write, JSON stays master)`
|
||||
- [ ] Tag: `git tag v5.0.0-alpha.1`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: CLAUDE.md + Roadmap Update
|
||||
|
||||
**CLAUDE.md Key Patterns / Persistence:**
|
||||
> JSON files in `C:\ProgramData\Twitch_VOD_Manager\` bleiben Source-of-Truth. Seit v5.0.0-alpha.1 spiegelt der Migrator (`src/main/domain/migrator.ts`) Konfiguration, Queue, downloaded_vod_ids und Streamer-Listen einmalig in `app.db` (SQLite, better-sqlite3). Schema: `src/main/infra/schema-v5.ts`. Cutover (SQLite wird Master) erfolgt in einem spaeteren Plan.
|
||||
|
||||
**Roadmap:** Plan 02 Status → `DONE (v5.0.0-alpha.1)`, Plan 03 wird `NEXT`.
|
||||
|
||||
- [ ] CLAUDE.md aktualisieren
|
||||
- [ ] Roadmap aktualisieren
|
||||
- [ ] Commit: `docs: SQLite migrator pattern + roadmap status update`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- [ ] Tests echt (kein Skip / Stub)
|
||||
- [ ] Idempotenz getestet
|
||||
- [ ] Malformed-JSON getestet
|
||||
- [ ] `.v4-backup` Copy getestet
|
||||
- [ ] Fail-Soft Pfad in main.ts: App startet auch ohne native-build-success
|
||||
- [ ] Spec-Coverage: Pillar 3 SQLite-Migrator + Schema v5 abgedeckt. Cutover bleibt fuer spaeter
|
||||
|
||||
## Done-Definition
|
||||
|
||||
1. `npm run test:unit` Exit 0 mit >= 13 neuen Tests (5 db + 8 migrator)
|
||||
2. `npm run test:e2e:release` Exit 0
|
||||
3. SQLite `app.db` wird beim ersten Start angelegt
|
||||
4. Migrator idempotent (zweimaliger Run)
|
||||
5. `.v4-backup` Files existieren
|
||||
6. Version 5.0.0-alpha.1 getaggt
|
||||
7. CLAUDE.md + Roadmap aktualisiert
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Inline-Execution via `superpowers:executing-plans`. Naechster Plan (`tasks/v5.0.0-plan-03-oauth.md`) nach Done-Definition.
|
||||
@ -1,90 +0,0 @@
|
||||
# v5.0.0 Roadmap (Reality-Aligned)
|
||||
|
||||
**Baseline:** v4.6.155
|
||||
**Spec:** `tasks/v5.0.0-goal.md`
|
||||
**Last verified:** 2026-05-11
|
||||
|
||||
---
|
||||
|
||||
## Reality Check vs. Goal
|
||||
|
||||
Beim Vergleich der echten Codebase (`src/main.ts` 7485 LoC mit 30+ Sektionen) gegen die Goal-Annahmen zeigt sich: einige "neu zu bauenden" Features existieren teilweise schon.
|
||||
|
||||
| Pillar | Goal sagt | Reality | Restarbeit % |
|
||||
|---|---|---|---|
|
||||
| P1 — Live Stream Recording | "neu" | Auto-Record-Poller, Live-Recording-Events-Log, Live-Chat-Capture existieren | ~30% (Polish + Sub-only-Pfad nach OAuth) |
|
||||
| P2 — Twitch OAuth | "neu" | App nutzt nur public Web-Client-ID, keine User-Auth | 100% |
|
||||
| P3 — SQLite-Migration | "Breaking" | Config + Queue + Stats sind JSON-Files | 100% |
|
||||
| P4 — main.ts Split | "neu" | 7485 LoC Monolith, 30 Sektionen, schon klar getrennt | 100% (mechanische Arbeit) |
|
||||
| P5 — UI Power-Features | "neu" | Vanilla DOM, keine Virtualisierung, kein Mini-Player, ein Theme | 90% |
|
||||
| P6 — Smart-Resume | "neu" | `auto_resume_live_recording` Flag existiert, `auto_merge_resumed_parts`, Crash-Recovery rudimentaer | ~60% (Chunk-Hash + Integrity-Check fehlen) |
|
||||
| P7 — Scheduled/Auto-Discovery | "neu" | Auto-VOD-Download-Poller + Live-Status-Batch-Poller schon implementiert | ~30% (Top-Clip-Crawler, Follow-Sync fehlen) |
|
||||
|
||||
**Konsequenz:** Reihenfolge der Plane orientiert sich am Restarbeits-Volumen und Dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order (reordered post Plan 01)
|
||||
|
||||
```
|
||||
Plan 01: Foundation — Vitest + Pure-Utility-Extraction [DONE — v5.0.0-alpha.0]
|
||||
└─> Plan 02: SQLite-Foundation (P3) [NEXT]
|
||||
└─> Plan 03: OAuth Device Code (P2)
|
||||
└─> Plan 04: Live-Recording Polish + Sub-only (P1)
|
||||
├─> Plan 05: Auto-Discovery Erweiterung (P7)
|
||||
└─> Plan 06: UI Power-Features (P5)
|
||||
└─> Plan 07: Smart-Resume Finalisierung (P6)
|
||||
└─> Plan 08: Architektur-Split Pt. 1 (state-coupled modules)
|
||||
└─> Plan 09: Architektur-Split Pt. 2 (final split)
|
||||
└─> Plan 10: v5.0.0-rc.1 → GA
|
||||
```
|
||||
|
||||
**Begruendung der Umordnung:** Plan 01 hat das Pure-Extraktion-Pattern validiert (Pillar 4 Teil-1).
|
||||
Weiter splitten der state-gekoppelten Module braucht zuerst eine State-Container-Strategie — das ist
|
||||
besser informiert nachdem Features (SQLite, OAuth, Live-Rec) ins System gehen und die echten
|
||||
Querverbindungen sichtbar sind. Bis dahin lebt das Pattern aus Plan 01 weiter (neue Module landen
|
||||
in `src/main/{infra,domain}/`), nur die alten in main.ts steckenden Stateful-Funktionen bleiben dort.
|
||||
|
||||
Plan 02 = Pillar 3 (SQLite).
|
||||
Plan 03 = Pillar 2 (OAuth).
|
||||
Plan 04 = Pillar 1.
|
||||
Plan 05 = Pillar 7.
|
||||
Plan 06 = Pillar 5.
|
||||
Plan 07 = Pillar 6.
|
||||
Plan 08-09 = Pillar 4 Rest.
|
||||
Plan 10 = Release.
|
||||
|
||||
---
|
||||
|
||||
## Plan Status
|
||||
|
||||
| # | Plan | Datei | Status |
|
||||
|---|---|---|---|
|
||||
| 01 | Foundation: Vitest + Pure-Utility-Extraction | `tasks/v5.0.0-plan-01-foundation.md` | **DONE** (v5.0.0-alpha.0) |
|
||||
| 02 | SQLite-Foundation (Pillar 3) | `tasks/v5.0.0-plan-02-sqlite.md` | **NEXT** |
|
||||
| 03 | OAuth Device Code (Pillar 2) | `tasks/v5.0.0-plan-03-oauth.md` | nach Plan 02 |
|
||||
| 04 | Live-Recording Polish (Pillar 1) | `tasks/v5.0.0-plan-04-live-rec.md` | nach Plan 03 |
|
||||
| 05 | Auto-Discovery Erweiterung (Pillar 7) | `tasks/v5.0.0-plan-05-auto-disc.md` | nach Plan 04 |
|
||||
| 06 | UI Power (Pillar 5) | `tasks/v5.0.0-plan-06-ui-power.md` | nach Plan 04 |
|
||||
| 07 | Smart-Resume Final (Pillar 6) | `tasks/v5.0.0-plan-07-smart-resume.md` | nach Plan 06 |
|
||||
| 08 | Architektur-Split Pt. 1 (Pillar 4) | `tasks/v5.0.0-plan-08-split-pt1.md` | nach Plan 07 |
|
||||
| 09 | Architektur-Split Pt. 2 (Pillar 4) | `tasks/v5.0.0-plan-09-split-pt2.md` | nach Plan 08 |
|
||||
| 10 | Release 5.0.0 GA | `tasks/v5.0.0-plan-10-release.md` | nach Plan 09 |
|
||||
|
||||
---
|
||||
|
||||
## Versionsstrategie (post Reorder)
|
||||
|
||||
| Plan abgeschlossen | Version |
|
||||
|---|---|
|
||||
| Plan 01 | **5.0.0-alpha.0** Foundation, vitest + pure modules (DONE) |
|
||||
| Plan 02 | 5.0.0-alpha.1 SQLite Migrator (Breaking, opt-in next-channel) |
|
||||
| Plan 03 | 5.0.0-alpha.2 OAuth |
|
||||
| Plan 04 | 5.0.0-beta.0 Live-Recording Polish + Sub-only |
|
||||
| Plan 05 | 5.0.0-beta.1 Auto-Discovery |
|
||||
| Plan 06 | 5.0.0-beta.2 UI Power |
|
||||
| Plan 07 | 5.0.0-beta.3 Smart-Resume |
|
||||
| Plan 08-09 | 5.0.0-rc.0 / rc.1 Architektur-Split |
|
||||
| Plan 10 | 5.0.0 stable |
|
||||
|
||||
Pro Plan: ein Release-Tag, Auto-Updater Channel `next`. 4.6-User bleiben auf `stable`.
|
||||
@ -1,11 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
reporters: ['default'],
|
||||
clearMocks: true,
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user