Release Twitch VOD Manager 1.0.18

Harden update, system-check, queue, cutter, streamer and shutdown state transitions.

Add multi-user installer recovery, secret-safe config migration, provider fallback handling, managed-tool validation and real media export coverage.

Refresh the English public documentation, release notes and 1.0.18 product screenshot.
This commit is contained in:
Sucukdeluxe
2026-08-13 22:59:15 +02:00
parent f37142401f
commit 9f1b052afd
124 changed files with 21119 additions and 7777 deletions
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
function fragment(source: string, start: string, end: string): string {
const from = source.indexOf(start);
const to = source.indexOf(end, from);
if (from < 0 || to < 0) throw new Error(`Missing production fragment: ${start}`);
return source.slice(from, to);
}
describe('cutter workspace actions production path', () => {
const html = readFileSync(join(__dirname, 'index.html'), 'utf8');
test('keeps project open and save actions in the persistent loaded toolbar', () => {
const toolbar = fragment(html, '<div class="toolbar-context" data-toolbar-for="cutter"', '<div class="toolbar-context" data-toolbar-for="merge"');
const sourceBar = fragment(html, '<div class="cutter-source-bar">', '<div class="cutter-recovery-panel"');
expect(toolbar).toContain('id="cutterOpenProjectBtn"');
expect(toolbar).toContain('id="cutterSaveProjectBtn"');
expect(sourceBar).not.toContain('id="cutterOpenProjectBtn"');
expect(sourceBar).not.toContain('id="cutterSaveProjectBtn"');
});
test('opens the video picker directly from the cutter context action', () => {
const cutterContext = fragment(html, '<section class="context-panel" data-context-for="cutter"', '<section class="context-panel" data-context-for="merge"');
expect(cutterContext).toContain('onclick="selectCutterVideo()"');
expect(cutterContext).not.toContain("focusWorkspaceTarget('cutterBrowseBtn'");
});
test('shows the unambiguous frame timecode format on editable fields', () => {
const trimCard = fragment(html, '<div class="cutter-trim-card">', '<div class="cutter-cut-section">');
expect(trimCard.match(/placeholder="HH:MM:SS:FF"/g)).toHaveLength(2);
expect(trimCard.match(/title="HH:MM:SS:FF"/g)).toHaveLength(2);
});
test('uses correct German umlauts in owned fallback and locale sources', () => {
const german = readFileSync(join(__dirname, 'renderer-locale-de.ts'), 'utf8');
expect(html).toContain('Max Stabilität');
expect(german).toContain('Unterstützte Formate');
expect(german).toContain("openFolder: 'Öffnen'");
expect(german).toContain("partMinutesLabel: 'Teil-Länge (Minuten)'");
expect(german).toContain('Einige Änderungen erfordern');
expect(german).toContain('Öffnet während einer Live-Aufnahme');
expect(german).toContain("invalidDuration: 'Ungültig!'");
expect(german).toContain("empty: 'Keine Videos ausgewählt'");
expect(german).toContain("success: 'Videos erfolgreich zusammengefügt!'");
expect(german).toContain("phaseCleanup: 'Aufräumen...'");
expect(german).toContain("checkInProgress: 'Update-Prüfung läuft bereits.'");
expect(german).toContain("checkFailed: 'Update-Prüfung fehlgeschlagen.'");
expect(german).toContain("downloadInProgress: 'Update-Download läuft bereits.'");
expect(html).not.toContain('Max Stabilitat');
expect(german).not.toMatch(/Unterstutzte|\bOffnen\b|Teil-Lange|Aenderungen|Oeffnet|Ungultig|ausgewahlt|zusammengefugt|Aufraumen|Update-Prufung|\blauft\b/);
});
test('provides localized System Check failure copy to the settings renderer', () => {
const german = readFileSync(join(__dirname, 'renderer-locale-de.ts'), 'utf8');
const english = readFileSync(join(__dirname, 'renderer-locale-en.ts'), 'utf8');
expect(german).toContain("preflightError: 'System-Check fehlgeschlagen.'");
expect(english).toContain("preflightError: 'System check failed.'");
});
});
@@ -2,8 +2,12 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
const styles = readFileSync(join(__dirname, 'styles.css'), 'utf8');
const workspaceStyles = readFileSync(join(__dirname, 'workspace.css'), 'utf8');
const styles = ['styles.css', 'styles-workflows.css', 'styles-overlays.css']
.map((fileName) => readFileSync(join(__dirname, fileName), 'utf8'))
.join('');
const workspaceStyles = ['workspace.css', 'workspace-refinements.css']
.map((fileName) => readFileSync(join(__dirname, fileName), 'utf8'))
.join('');
describe('cutter workspace style production paths', () => {
test('keeps loaded-source visibility independent from the large-window media query', () => {
@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function source(fileName: string): string {
return readFileSync(join(__dirname, fileName), 'utf8');
}
describe('German production text', () => {
it('keeps visible HTML and archive fallbacks free of replacement spellings', () => {
expect(source('index.html')).not.toMatch(/>Offnen</);
expect(source('index.html')).not.toMatch(/>Spater</);
expect(source('renderer-archive.ts')).not.toContain("'Oeffnen'");
});
});
+30 -27
View File
@@ -6,7 +6,10 @@
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' file: blob:;">
<title>Twitch VOD Manager</title>
<link rel="stylesheet" href="./styles.css">
<link rel="stylesheet" href="./styles-workflows.css">
<link rel="stylesheet" href="./styles-overlays.css">
<link rel="stylesheet" href="./workspace.css">
<link rel="stylesheet" href="./workspace-refinements.css">
</head>
<body class="theme-twitch">
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
@@ -240,7 +243,7 @@
</div>
<div class="workspace-update-popover-actions">
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
<button type="button" id="workspaceUpdateLater" onclick="postponeWorkspaceUpdatePopover()">Spater</button>
<button type="button" id="workspaceUpdateLater" onclick="postponeWorkspaceUpdatePopover()">Später</button>
</div>
</div>
</div>
@@ -294,7 +297,7 @@
<section class="context-panel" data-context-for="cutter" hidden>
<div class="context-panel-heading" data-context-heading>Video schneiden</div>
<nav class="context-list" aria-label="Cutter sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('cutterBrowseBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswählen</span></button>
<button type="button" class="context-link active" onclick="selectCutterVideo()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswählen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('timelineContainer', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16M7 8v8M17 8v8"></path></svg><span data-label-source="cutterInfoSelectionLabel">Auswahl</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnCut', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg><span data-label-source="btnCut">Schneiden</span></button>
</nav>
@@ -357,7 +360,9 @@
<button type="button" class="toolbar-icon-button" id="toolbarClipDownloadBtn" onclick="downloadClip()" aria-label="Download clip" title="Download clip"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg></button>
</div>
<div class="toolbar-context" data-toolbar-for="cutter" hidden>
<button type="button" class="toolbar-primary" onclick="selectCutterVideo()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterBrowseBtn">Durchsuchen</span></button>
<button type="button" class="toolbar-primary" id="cutterNewVideoBtn" onclick="selectCutterVideo()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span id="cutterNewVideoText">Neues Video</span></button>
<button type="button" class="toolbar-icon-button" id="cutterOpenProjectBtn" onclick="openCutterProject()" disabled aria-label="Projekt öffnen" title="Projekt öffnen"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path><path d="m9 13 3 3 3-3"></path></svg></button>
<button type="button" class="toolbar-icon-button" id="cutterSaveProjectBtn" onclick="saveCutterProject()" disabled aria-label="Projekt speichern" title="Projekt speichern"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 4h12l2 2v14H5z"></path><path d="M8 4v6h8V4M8 20v-6h8v6"></path></svg></button>
<button type="button" class="toolbar-icon-button" id="toolbarCutBtn" onclick="startCutting()" aria-label="Cut video" title="Cut video"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg></button>
</div>
<div class="toolbar-context" data-toolbar-for="merge" hidden>
@@ -437,7 +442,7 @@
<div class="settings-card centered">
<h3 id="clipsInfoTitle">Info</h3>
<p id="clipsInfoText" class="info-text">
Unterstutzte Formate:
Unterstützte Formate:
- https://clips.twitch.tv/ClipName
- https://www.twitch.tv/streamer/clip/ClipName
@@ -455,13 +460,11 @@
<input type="text" id="cutterFilePath" readonly aria-labelledby="cutterSelectTitle" placeholder="Keine Datei ausgewählt…">
</div>
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
<button type="button" class="btn-secondary" id="cutterOpenProjectBtn" onclick="openCutterProject()" disabled>Projekt öffnen</button>
<button type="button" class="btn-secondary" id="cutterSaveProjectBtn" onclick="saveCutterProject()" disabled>Projekt speichern</button>
</div>
<div class="cutter-recovery-panel" id="cutterRecoveryPanel" role="status" hidden>
<span id="cutterRecoveryText">Gespeicherte Bearbeitung gefunden</span>
<button type="button" class="btn-secondary" onclick="recoverCutterProject()">Wiederherstellen</button>
<button type="button" class="btn-secondary" onclick="discardCutterProject()">Verwerfen</button>
<button type="button" class="btn-secondary" id="cutterRecoveryRestoreBtn" onclick="recoverCutterProject()">Wiederherstellen</button>
<button type="button" class="btn-secondary" id="cutterRecoveryDiscardBtn" onclick="discardCutterProject()">Verwerfen</button>
</div>
<div class="cutter-workspace" id="cutterWorkspace">
@@ -484,31 +487,31 @@
<span class="cutter-toggle-track" aria-hidden="true"></span>
</label>
<div class="cutter-export-options">
<label for="cutterExportProfile">Exportprofil</label>
<label for="cutterExportProfile" id="cutterExportProfileLabel">Exportprofil</label>
<select id="cutterExportProfile" onchange="setCutterExportProfile(this.value)" disabled>
<option value="quality">Quality</option>
<option value="balanced" selected>Balanced</option>
<option value="fast">Fast</option>
<option value="archive">Archive</option>
<option value="quality" id="cutterProfileQualityOption">Qualität</option>
<option value="balanced" id="cutterProfileBalancedOption" selected>Ausgewogen</option>
<option value="fast" id="cutterProfileFastOption">Schnell</option>
<option value="archive" id="cutterProfileArchiveOption">Archiv</option>
</select>
<label for="cutterExportEncoder">Encoder</label>
<label for="cutterExportEncoder" id="cutterExportEncoderLabel">Encoder</label>
<select id="cutterExportEncoder" onchange="setCutterExportEncoder(this.value)" disabled>
<option value="software">Software</option>
<option value="software" id="cutterEncoderSoftwareOption">Software</option>
</select>
<label for="cutterAudioStream">Audiospur</label>
<label for="cutterAudioStream" id="cutterAudioStreamLabel">Audiospur</label>
<select id="cutterAudioStream" onchange="setCutterAudioStream(this.value)" disabled>
<option value="0">Keine Audiospur</option>
<option value="0" id="cutterAudioStreamEmptyOption">Keine Audiospur</option>
</select>
</div>
<div class="cutter-trim-card">
<div class="cutter-card-title" id="cutterGlobalTrimLabel">Gesamtauswahl</div>
<div class="cutter-time-field-row">
<label for="startTime" id="cutterStartLabel">Start</label>
<input type="text" id="startTime" value="00:00:00" spellcheck="false" onchange="updateTimeFromInput()">
<input type="text" id="startTime" value="00:00:00:00" placeholder="HH:MM:SS:FF" title="HH:MM:SS:FF" spellcheck="false" onchange="updateTimeFromInput()">
</div>
<div class="cutter-time-field-row">
<label for="endTime" id="cutterEndLabel">Ende</label>
<input type="text" id="endTime" value="00:00:00" spellcheck="false" onchange="updateTimeFromInput()">
<input type="text" id="endTime" value="00:00:00:00" placeholder="HH:MM:SS:FF" title="HH:MM:SS:FF" spellcheck="false" onchange="updateTimeFromInput()">
</div>
</div>
<div class="cutter-cut-section">
@@ -553,7 +556,7 @@
</button>
<input type="range" class="cutter-volume" id="cutterVolume" min="0" max="1" step="0.05" value="1" disabled aria-label="Lautstärke">
</div>
<span class="cutter-player-time"><span id="cutterCurrentTime">00:00:00</span><span>/</span><span id="cutterTotalTime">00:00:00</span></span>
<span class="cutter-player-time"><span id="cutterCurrentTime">00:00:00:00</span><span>/</span><span id="cutterTotalTime">00:00:00:00</span></span>
<select id="cutterPlaybackRate" hidden disabled aria-label="Wiedergabegeschwindigkeit">
<option value="0.5">0,5×</option>
<option value="0.75">0,75×</option>
@@ -571,7 +574,7 @@
<div class="cutter-speed-options">
<button type="button" data-rate="0.5" onclick="setCutterPlaybackRate(0.5)">0,5×</button>
<button type="button" data-rate="0.75" onclick="setCutterPlaybackRate(0.75)">0,75×</button>
<button type="button" class="active" data-rate="1" onclick="setCutterPlaybackRate(1)">Normal</button>
<button type="button" class="active" id="cutterSpeedNormalBtn" data-rate="1" onclick="setCutterPlaybackRate(1)">Normal</button>
<button type="button" data-rate="1.25" onclick="setCutterPlaybackRate(1.25)">1,25×</button>
<button type="button" data-rate="1.5" onclick="setCutterPlaybackRate(1.5)">1,5×</button>
<button type="button" data-rate="2" onclick="setCutterPlaybackRate(2)">2×</button>
@@ -595,7 +598,7 @@
<div class="timeline-container" id="timelineContainer">
<div class="cutter-timeline-toolbar">
<div class="cutter-timeline-timecode" id="cutterTimelineTimecode">00:00:00</div>
<div class="cutter-timeline-timecode" id="cutterTimelineTimecode">00:00:00:00</div>
<div class="cutter-history-controls">
<button type="button" class="cutter-icon-button" id="cutterUndoBtn" onclick="undoCutterEdit()" disabled aria-label="Rückgängig" title="Rückgängig (Strg+Z)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 7 4 12l5 5v-3h5a5 5 0 0 1 5 5v1h2v-1a7 7 0 0 0-7-7H9V7z"></path></svg></button>
<button type="button" class="cutter-icon-button" id="cutterRedoBtn" onclick="redoCutterEdit()" disabled aria-label="Wiederholen" title="Wiederholen (Strg+Y)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15 7 5 5-5 5v-3h-5a5 5 0 0 0-5 5v1H3v-1a7 7 0 0 1 7-7h5V7z"></path></svg></button>
@@ -659,7 +662,7 @@
<div class="file-list" id="mergeFileList">
<div class="empty-state merge-empty-state">
<svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
<p id="mergeEmptyText">Keine Videos ausgewahlt</p>
<p id="mergeEmptyText">Keine Videos ausgewählt</p>
</div>
</div>
@@ -825,7 +828,7 @@
<div class="form-row">
<input type="text" id="downloadPath" readonly>
<button type="button" class="btn-secondary" id="selectFolderBtn" onclick="selectFolder()">Ordner</button>
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Öffnen</button>
</div>
</div>
<div class="download-settings-layout">
@@ -840,7 +843,7 @@
</select>
</div>
<div class="form-group">
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label>
<label id="partMinutesLabel" for="partMinutes">Teil-Länge (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
<div class="form-group">
@@ -865,7 +868,7 @@
<div class="form-group">
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
<select id="performanceMode">
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
<option value="stability" id="performanceModeStability">Max Stabilität</option>
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
<option value="speed" id="performanceModeSpeed">Max Geschwindigkeit</option>
</select>
@@ -942,7 +945,7 @@
<div class="settings-card" data-settings-pane="updates" hidden>
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v1.0.17</p>
<p id="versionInfo" class="card-intro">Version: v1.0.18</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
+239
View File
@@ -0,0 +1,239 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function mainSource(): string {
return readFileSync(join(__dirname, 'main.ts'), 'utf8');
}
describe('main runtime safety production paths', () => {
it('isolates startup secret reads from the persistent application state', () => {
const source = mainSource();
expect(source).toContain("readSecretSafely(appSecretStore, 'twitch_client_secret'");
expect(source).toContain("readSecretSafely(appSecretStore, 'discord_webhook_url'");
expect(source).toContain("entry.source !== 'legacy-config-scrub'");
});
it('rejects oversized config files before reading them', () => {
const source = mainSource();
const handler = source.slice(source.indexOf("ipcMain.handle('import-config'"), source.indexOf('function isTrustedRendererEvent'));
expect(handler.indexOf('fs.statSync(importPath)')).toBeGreaterThan(-1);
expect(handler.indexOf('fs.readFileSync(importPath')).toBeGreaterThan(handler.indexOf('fs.statSync(importPath)'));
expect(handler).toContain('MAX_CONFIG_IMPORT_BYTES');
});
it('tracks both cleanup timers and guards their callbacks during shutdown', () => {
const source = mainSource();
expect(source).toContain('let autoCleanupStartupTimer: NodeJS.Timeout | null = null;');
expect(source).toContain('clearTimeout(autoCleanupStartupTimer)');
expect(source).toMatch(/autoCleanupStartupTimer = setTimeout\([\s\S]*?appShutdownStarted/);
expect(source).toMatch(/function restartAutoCleanupTimer\(\): void \{\s*stopAutoCleanupTimer\(\);\s*if \(appShutdownStarted\) return;/);
});
it('cancels the deferred updater setup and refuses to initialize after shutdown', () => {
const source = mainSource();
expect(source).toContain('let autoUpdaterSetupTimer: NodeJS.Timeout | null = null;');
expect(source).toContain('clearTimeout(autoUpdaterSetupTimer)');
expect(source).toMatch(/autoUpdaterSetupTimer = setTimeout\([\s\S]*?!appShutdownStarted[\s\S]*?setupAutoUpdater/);
expect(source).toMatch(/function setupAutoUpdater\(\) \{\s*if \(appShutdownStarted\) return;/);
});
it('tracks frame extraction processes and cleans them after waiting during shutdown', () => {
const source = mainSource();
const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'"));
expect(source).toContain('currentCutterFrameProcesses.add(proc)');
expect(source).toContain('currentCutterFrameFiles.add(tempFile)');
expect(source).toMatch(/currentCutterFrameProcesses[\s\S]*?waitForChildProcessExit/);
expect(source).toMatch(/currentCutterFrameFiles[\s\S]*?fs\.rmSync/);
expect(shutdown).toContain('runResilientSteps(frameFiles.map');
expect(shutdown).toContain('if (!frameProcessesExited) return;');
});
it('waits for standalone clip processes before discarding partial output', () => {
const source = mainSource();
const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'"));
const wait = shutdown.indexOf('waitForChildProcessExit(tracking.process)');
const discard = shutdown.indexOf('partialDownloadRegistry.discard(tracking.partialFilename)');
expect(wait).toBeGreaterThan(-1);
expect(discard).toBeGreaterThan(wait);
const clipCleanup = shutdown.slice(shutdown.indexOf("['clip-processes'"), shutdown.indexOf("['editor-process'"));
expect(clipCleanup).toContain('Promise.allSettled');
expect(clipCleanup).not.toContain("['clip-wait'");
expect(clipCleanup).toMatch(/await waitForChildProcessExit\(tracking\.process\)[\s\S]*?tracking\.output\.cancel\(\)[\s\S]*?partialDownloadRegistry\.discard/);
});
it('cannot start or publish a standalone clip after shutdown begins', () => {
const source = mainSource();
const handler = source.slice(source.indexOf("registerTrustedIpcHandler(ipcMain, 'download-clip'"), source.indexOf("registerTrustedIpcHandler(ipcMain, 'run-preflight'"));
const request = handler.indexOf('await getClipInfo(clipId)');
expect(handler.indexOf('if (appShutdownStarted)', 0)).toBeGreaterThan(-1);
expect(handler.indexOf('if (appShutdownStarted)', request)).toBeGreaterThan(request);
const partial = handler.indexOf('const partialFilename = partialDownloadRegistry.begin(filename)');
const spawn = handler.indexOf('const proc = spawn(');
const finalGuard = handler.indexOf('if (appShutdownStarted)', partial);
expect(finalGuard).toBeGreaterThan(partial);
expect(finalGuard).toBeLessThan(spawn);
expect(handler.slice(finalGuard, spawn)).toContain('partialDownloadRegistry.discard(partialFilename)');
expect(handler).toContain('activeClipProcesses.add(tracking)');
expect(handler).toContain('activeClipProcesses.delete(tracking)');
const finishHandler = handler.slice(handler.indexOf('const finish ='), handler.indexOf('activeClipProcesses.add(tracking)'));
expect(finishHandler).toContain('activeClipProcesses.delete(tracking)');
const closeHandler = handler.slice(handler.indexOf("proc.on('close'"), handler.indexOf("proc.on('error'"));
expect(closeHandler).not.toContain('activeClipProcesses.delete(tracking)');
expect(closeHandler.indexOf('if (appShutdownStarted)')).toBeGreaterThan(closeHandler.indexOf('await outputFinished'));
expect(closeHandler.indexOf('partialDownloadRegistry.commit')).toBeGreaterThan(closeHandler.indexOf('if (appShutdownStarted)'));
expect(closeHandler.indexOf('finish({ success: true, filename })')).toBeGreaterThan(closeHandler.indexOf('partialDownloadRegistry.commit'));
});
it('guards and tracks every standalone cut and merge process across shutdown', () => {
const source = mainSource();
const cut = source.slice(source.indexOf('async function cutVideo('), source.indexOf('async function mergeVideos('));
const merge = source.slice(source.indexOf('async function mergeVideos('), source.indexOf('async function splitMergedFile('));
const handlers = source.slice(source.indexOf("ipcMain.handle('cut-video'"), source.indexOf("ipcMain.handle('select-multiple-videos'"));
const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'"));
expect(cut).toMatch(/await ensureFfmpegInstalled\(\);\s*if \(appShutdownStarted\) return false;/);
expect(cut).toMatch(/const runCutAttempt[\s\S]*?if \(appShutdownStarted\) return false;[\s\S]*?const proc = spawn[\s\S]*?currentEditorProcesses\.add\(proc\)/);
expect(cut).toMatch(/const copySuccess = await runCutAttempt\(true\);\s*if \(appShutdownStarted\) return false;/);
expect(merge).toMatch(/await ensureFfmpegInstalled\(\);\s*if \(appShutdownStarted\) return false;/);
expect(merge).toMatch(/const runMergeAttempt[\s\S]*?if \(appShutdownStarted\) return false;[\s\S]*?const proc = spawn[\s\S]*?currentEditorProcesses\.add\(proc\)/);
expect(merge).toMatch(/const copySuccess = await runMergeAttempt\(true\);\s*if \(appShutdownStarted\) return false;/);
expect(handlers).toContain('const success = completed && !appShutdownStarted');
expect(handlers).toContain('return produced && !appShutdownStarted');
expect(shutdown).toContain('const editorProcesses = [...currentEditorProcesses]');
expect(shutdown).toMatch(/\['editor-processes'[\s\S]*?process\.kill\(\)[\s\S]*?waitForAllChildProcessesExit\(editorProcesses\)/);
});
it('runs shutdown poller and cache stops inside the resilient cleanup sequence', () => {
const source = mainSource();
const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'"));
const resilientStart = shutdown.indexOf('await runResilientSteps([');
for (const operation of [
'stopMetadataCacheCleanup()',
"cleanupMetadataCaches('shutdown')",
'stopAutoUpdatePolling()',
'stopAutoRecordPoller()',
'stopAutoVodPoller()',
'stopLiveStatusPoller()',
'stopAutoCleanupTimer()',
]) {
expect(shutdown.indexOf(operation)).toBeGreaterThan(resilientStart);
}
});
it('uses a safe archive-open allowlist', () => {
const source = mainSource();
expect(source).toContain('SAFE_ARCHIVE_OPEN_EXTENSIONS');
expect(source).toContain("'.mp4'");
expect(source).not.toMatch(/SAFE_ARCHIVE_OPEN_EXTENSIONS[^;]+\.url/);
expect(source).not.toMatch(/SAFE_ARCHIVE_OPEN_EXTENSIONS[^;]+\.chm/);
});
it('routes auto VOD additions through the same atomic live duplicate check', () => {
const source = mainSource();
const poller = source.slice(source.indexOf('async function runAutoVodPoll'), source.indexOf('// ==========================================\n// LIVE RECORDING'));
expect(poller).toContain('commitQueueItemWithResult(queueItem, false)');
expect(poller).not.toContain('const queuedUrls');
expect(poller).not.toContain('downloadQueue.push(queueItem)');
});
it('uses the built Twitch provider request and fallback orchestration in production refreshes', () => {
const source = mainSource();
const publicRequest = source.slice(source.indexOf('async function fetchPublicTwitchGqlOutcome'), source.indexOf('async function fetchPublicTwitchGql<'));
expect(publicRequest).toContain('requestPublicTwitchGraphql<T>(');
const users = source.slice(source.indexOf('async function getUserId'), source.indexOf('async function getVODs'));
expect(users).toContain('requestTwitchHelixUsers(axios');
const vods = source.slice(source.indexOf('async function getVODs'), source.indexOf('interface LiveStreamInfo'));
expect(vods).toContain('refreshTwitchProviderData(');
expect(vods).toContain('requestTwitchHelixVideos(axios');
expect(vods).toContain('vodListLastGood.get(cacheKey)');
expect(vods).toContain("refreshed.source === 'last-good'");
});
it('regenerates invalid or duplicate persisted queue ids before renderer exposure', () => {
const source = mainSource();
const queueLoad = source.slice(source.indexOf('function sanitizeQueueItem'), source.indexOf('let queueSaveTimer'));
expect(queueLoad).toContain('isValidPersistedQueueId(raw.id) ? raw.id : generateQueueItemId()');
expect(queueLoad).toContain('loadedIds.has(sanitized.id)');
expect(queueLoad).toContain('sanitized.id = generateQueueItemId()');
expect(queueLoad).toContain("raw.status === 'downloading' && isPlainObject(raw.mergeGroup)");
expect(queueLoad).not.toContain('interruptedMergeItemIds.has(rawId)');
});
it('clears transfer metrics on resume, retry, completion, and error transitions', () => {
const source = mainSource();
const phaseBoundary = source.slice(source.indexOf('async function waitForQueuePhaseBoundary'), source.indexOf('// userId -> login reverse map'));
const resumed = phaseBoundary.slice(phaseBoundary.indexOf('onResumed:'), phaseBoundary.indexOf(' });', phaseBoundary.indexOf('onResumed:')));
expect(resumed).toContain('delete item.speed');
expect(resumed).toContain('delete item.eta');
expect(resumed).toContain('delete item.progressStatus');
expect(source).toContain("clearQueueTransferState(item, 'pending', 0)");
expect(source).toContain("clearQueueTransferState(candidate, 'pending', 0)");
expect(source).toContain("clearQueueTransferState(item, 'downloading', item.progress)");
expect(source).toContain("finalResult.success ? 'completed' : 'error'");
expect(source).toContain('const retryProgress = prepareQueueRetryProgress(');
expect(source).toContain('recordDownloadProgress(retryProgress)');
const retryBlock = source.slice(source.indexOf('const retryProgress = prepareQueueRetryProgress('), source.indexOf('queueProcessRegistry.whenCancelled(item.id)', source.indexOf('const retryProgress = prepareQueueRetryProgress(')));
expect(retryBlock).toContain("if (!queuePaused) mainWindow?.webContents.send('download-progress', retryProgress)");
});
it('does not restore transfer byte counters into inactive persisted queue states', () => {
const source = mainSource();
const sanitizer = source.slice(source.indexOf('function sanitizeQueueItem'), source.indexOf('interface QueueLoadResult'));
expect(sanitizer).toMatch(/if \(finalStatus === 'paused'\) \{[\s\S]*?raw\.downloadedBytes[\s\S]*?raw\.totalBytes[\s\S]*?\}/);
});
it('persists live recording health in main state and queue fingerprints', () => {
const source = mainSource();
const progress = source.slice(source.indexOf('function getQueueBroadcastFingerprint'), source.indexOf('function clearDownloadProgress'));
expect(progress).toContain("item.recordingHealth || ''");
expect(progress).toContain('mergeQueueProgressState(item, progress, false)');
});
it('keeps merge cleanup recoverable when an artifact cannot be removed', () => {
const source = mainSource();
const cleanup = source.slice(source.indexOf("mg.mergePhase = 'cleanup'"), source.indexOf('async function processOneQueueItem'));
expect(cleanup).toContain('if (failedCleanup.size > 0)');
expect(cleanup).toContain('item.mergeRecoveryBlocked = true');
expect(cleanup).toMatch(/catch\s*\{\s*failedCleanup\.add\(filePath\);\s*\}/);
expect(cleanup).toMatch(/catch\s*\{\s*failedCleanup\.add\(mg\.mergedFile\);\s*\}/);
expect(cleanup.indexOf("mg.mergePhase = 'done'")).toBeGreaterThan(cleanup.indexOf('if (failedCleanup.size > 0)'));
const startup = source.slice(source.indexOf('const queueLoad ='), source.indexOf('lastPersistedQueueSnapshot = cloneQueue(downloadQueue)'));
expect(startup).toContain('item.mergeRecoveryBlocked');
expect(startup).toContain('queueLoad.interruptedMergeItemIds.add(item.id)');
const removal = source.slice(source.indexOf("registerTrustedIpcHandler(ipcMain, 'remove-from-queue'"), source.indexOf("ipcMain.handle('clear-completed'"));
expect(removal).toContain('recoverInterruptedMergeArtifacts([removedItem]');
expect(removal).toContain('if (recovery.failedFiles.length > 0)');
});
it('pins every merge phase to the persisted canonical artifact root', () => {
const source = mainSource();
const mergePipeline = source.slice(source.indexOf('async function processDownloadMergeGroup'), source.indexOf('async function processOneQueueItem'));
expect(mergePipeline).toContain('resolveMergeArtifactRoot(item, config.download_path)');
expect(mergePipeline).toContain('item.artifactRoot = artifactRoot');
expect(mergePipeline).not.toContain('path.join(config.download_path');
expect(mergePipeline.match(/path\.join\(artifactRoot/g)?.length).toBeGreaterThanOrEqual(3);
});
it('exposes actual managed-tool execution counters only through the trusted cutter E2E gate', () => {
const source = mainSource();
const handler = source.slice(source.indexOf("ipcMain.handle('get-managed-tool-execution-diagnostics'"), source.indexOf("ipcMain.handle('repair-managed-tools'"));
expect(handler).toContain('isTrustedRendererEvent(event)');
expect(source).toContain('createManagedToolExecutionTracker(Boolean(process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT))');
expect(handler).toContain('managedToolExecutionTracker.snapshot()');
expect(source).toContain("recordManagedToolExecution('ffmpeg'");
expect(source).toContain("recordManagedToolExecution('ffprobe'");
expect(source).toContain("recordManagedToolExecution('streamlink'");
});
it('rejects merge, split, and concat work when shutdown rejects registration', () => {
const source = mainSource();
const registrations = [...source.matchAll(/const registration = itemId[\s\S]*?queueProcessRegistry\.register\([\s\S]*?\n\s*: null;/g)];
expect(registrations).toHaveLength(3);
for (const registration of registrations) {
const tail = source.slice((registration.index ?? 0) + registration[0].length, (registration.index ?? 0) + registration[0].length + 220);
expect(tail).toContain('if (registration && !registration.accepted)');
expect(tail).toContain('resolve(false)');
}
});
});
+66
View File
@@ -0,0 +1,66 @@
import { EventEmitter } from 'node:events';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
import { describe, expect, test, vi } from 'vitest';
function mainSource(): string {
return readFileSync(join(__dirname, 'main.ts'), 'utf8');
}
function sourceFragment(start: string, end: string): string {
const source = mainSource();
const from = source.indexOf(start);
const to = source.indexOf(end, from);
if (from < 0 || to < 0) throw new Error('Missing main production fragment');
return source.slice(from, to);
}
describe('main shutdown production paths', () => {
test('does not spawn a cutter probe after shutdown starts', async () => {
const spawn = vi.fn(() => {
const child = Object.assign(new EventEmitter(), {
stderr: { resume: () => undefined },
stdout: new EventEmitter(),
kill: () => true,
});
queueMicrotask(() => child.emit('close', 0));
return child;
});
const context: Record<string, unknown> = {
appShutdownStarted: true,
spawn,
getFFmpegPath: () => 'ffmpeg.exe',
currentCutterProbeProcesses: new Set(),
setTimeout,
clearTimeout,
globalThis: null,
};
context.globalThis = context;
const fragment = sourceFragment('async function runCutterFfmpegProbe', 'async function getCutterHardwareEncoders');
const compiled = transpileModule(`${fragment}\nglobalThis.__runCutterFfmpegProbe = runCutterFfmpegProbe;`, {
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 },
}).outputText;
runInNewContext(compiled, context);
const result = await (context.__runCutterFfmpegProbe as (args: string[], capture: boolean) => Promise<{ success: boolean; output: string }>)([], false);
expect(result).toEqual({ success: false, output: '' });
expect(spawn).not.toHaveBeenCalled();
});
test('applies imported configuration through the shared transition before returning success', () => {
const handler = sourceFragment("ipcMain.handle('import-config'", 'function isTrustedRendererEvent');
const transition = sourceFragment('function applyConfigTransition', "ipcMain.handle('save-config'");
const appliedTransition = handler.indexOf('applyConfigTransition(previousConfig, merged);');
const returned = handler.indexOf('return { success: true');
const persisted = transition.indexOf('config = persistStateChange');
const appliedTheme = transition.indexOf('nativeTheme.themeSource = resolveNativeThemeSource(config.theme)');
expect(persisted).toBeGreaterThan(-1);
expect(appliedTheme).toBeGreaterThan(persisted);
expect(appliedTransition).toBeGreaterThan(-1);
expect(returned).toBeGreaterThan(appliedTransition);
});
});
+1094 -834
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
export { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange } from '../domain/video-editor';
export type { EditorCut } from '../domain/video-editor';
export {
calculateCutterExportProgress,
createCutterExportPlan,
CUTTER_EXPORT_PROFILES,
getCutterExportProfile,
parseCutterHardwareEncoders,
probeCutterHardwareEncoders,
} from '../domain/cutter-export';
export type { CutterExportEncoder, CutterExportProfile, CutterHardwareEncoder } from '../domain/cutter-export';
export { createCutterProjectAutosaveStore } from '../domain/cutter-project';
export type { CutterProject, CutterProjectSource } from '../domain/cutter-project';
+7
View File
@@ -5,13 +5,20 @@ describe('isRendererReloadTarget', () => {
test('reloads renderer output and static renderer assets', () => {
expect(isRendererReloadTarget('renderer.js')).toBe(true);
expect(isRendererReloadTarget('renderer-settings.js')).toBe(true);
expect(isRendererReloadTarget('renderer.workspace.js')).toBe(true);
expect(isRendererReloadTarget('index.html')).toBe(true);
expect(isRendererReloadTarget('styles.css')).toBe(true);
expect(isRendererReloadTarget('styles-workflows.css')).toBe(true);
expect(isRendererReloadTarget('styles-overlays.css')).toBe(true);
expect(isRendererReloadTarget('workspace.css')).toBe(true);
expect(isRendererReloadTarget('workspace-refinements.css')).toBe(true);
});
test('does not reload for main-process output', () => {
expect(isRendererReloadTarget('main.js')).toBe(false);
expect(isRendererReloadTarget('preload.js')).toBe(false);
expect(isRendererReloadTarget('rendererworker.js')).toBe(false);
expect(isRendererReloadTarget('renderer-.js')).toBe(false);
expect(isRendererReloadTarget('main/domain/config.js')).toBe(false);
});
});
+7 -2
View File
@@ -1,11 +1,16 @@
import { watch, type FSWatcher } from 'node:fs';
const staticRendererAssets = new Set(['index.html', 'styles.css', 'workspace.css']);
const staticRendererAssets = new Set(['index.html']);
export function isRendererReloadTarget(fileName: string): boolean {
const normalized = fileName.replaceAll('\\', '/');
const baseName = normalized.split('/').at(-1) ?? '';
return staticRendererAssets.has(baseName) || /^renderer(?:[-.].+)?\.js$/.test(baseName);
const rendererSuffix = baseName.slice('renderer'.length, -'.js'.length);
const isRendererScript = baseName === 'renderer.js'
|| (baseName.endsWith('.js')
&& rendererSuffix.length > 1
&& (rendererSuffix.startsWith('-') || rendererSuffix.startsWith('.')));
return staticRendererAssets.has(baseName) || baseName.endsWith('.css') || isRendererScript;
}
export function watchRendererChanges(
+38
View File
@@ -65,4 +65,42 @@ describe('createAppStateStore', () => {
{ id: 'q1', queue_position: 1 },
]);
});
it('persists only known valid config fields and normalizes streamer logins', () => {
const store = createAppStateStore(db);
store.saveConfig({
language: 'de',
theme: 'twitch',
streamers: [' Alice ', '@ALICE', 'bad/name', 42],
auto_record_streamers: [' Bob ', 'bad login'],
accessToken: 'camel-access-token',
refresh_token: 'snake-refresh-token',
clientSecret: 'camel-client-secret',
unknown_setting: 'must-not-persist',
parallel_downloads: 99,
});
const recovered = store.loadConfig();
const persisted = JSON.stringify(db.all('SELECT key, value FROM config_kv'));
expect(recovered).toEqual({
language: 'de',
theme: 'twitch',
streamers: ['alice'],
auto_record_streamers: ['bob'],
});
for (const forbidden of ['camel-access-token', 'snake-refresh-token', 'camel-client-secret', 'must-not-persist', 'unknown_setting', 'parallel_downloads']) {
expect(persisted).not.toContain(forbidden);
}
});
it('sanitizes and scrubs pre-existing config rows when loading', () => {
db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['language', JSON.stringify('de')]);
db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['accessToken', JSON.stringify('legacy-token')]);
db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['parallel_downloads', JSON.stringify(99)]);
db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['unknown_setting', JSON.stringify(true)]);
expect(createAppStateStore(db).loadConfig()).toEqual({ language: 'de' });
expect(db.all<{ key: string }>('SELECT key FROM config_kv ORDER BY key')).toEqual([{ key: 'language' }]);
});
});
+35 -49
View File
@@ -1,5 +1,6 @@
import type { DbHandle } from '../infra/db';
import { normalizeLogin } from './config-normalize';
import { sanitizeConfigInput } from './config-input';
export interface AppStateStore {
loadConfig(): Record<string, unknown>;
@@ -8,67 +9,52 @@ export interface AppStateStore {
saveQueue<T extends object>(queue: T[]): void;
}
const SECRET_CONFIG_KEYS = new Set(['client_secret', 'discord_webhook_url']);
function normalizedLogins(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value
.filter((entry): entry is string => typeof entry === 'string')
.map(normalizeLogin)
.filter(Boolean))];
}
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))];
}
function normalizeConfig(config: object): Record<string, unknown> {
const source = config as Record<string, unknown>;
const normalized = Object.fromEntries(
Object.entries(source).filter(([key]) => !SECRET_CONFIG_KEYS.has(key))
);
normalized.downloaded_vod_ids = stringArray(source.downloaded_vod_ids);
normalized.auto_record_streamers = normalizedLogins(source.auto_record_streamers);
normalized.auto_vod_download_streamers = normalizedLogins(source.auto_vod_download_streamers);
return normalized;
return sanitizeConfigInput(config);
}
function replaceConfig(db: DbHandle, normalized: Record<string, unknown>): void {
db.transaction(() => {
db.run('DELETE FROM config_kv');
for (const [key, value] of Object.entries(normalized)) {
db.run(
`INSERT INTO config_kv(key, value, updated_at)
VALUES (?, ?, strftime('%s','now'))`,
[key, JSON.stringify(value)]
);
}
db.run('DELETE FROM downloaded_vods');
for (const vodId of (normalized.downloaded_vod_ids as string[] | undefined) ?? []) {
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', [vodId]);
}
db.run('DELETE FROM streamers');
for (const login of (normalized.auto_record_streamers as string[] | undefined) ?? []) {
db.run('INSERT INTO streamers(login, auto_record) VALUES (?, 1)', [login]);
}
for (const login of (normalized.auto_vod_download_streamers as string[] | undefined) ?? []) {
db.run(
`INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1)
ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1`,
[login]
);
}
});
}
export function createAppStateStore(db: DbHandle): AppStateStore {
return {
loadConfig() {
return Object.fromEntries(
const stored = Object.fromEntries(
db.all<{ key: string; value: string }>('SELECT key, value FROM config_kv')
.map((row) => [row.key, JSON.parse(row.value)])
);
const normalized = normalizeConfig(stored);
if (JSON.stringify(stored) !== JSON.stringify(normalized)) replaceConfig(db, normalized);
return normalized;
},
saveConfig(config) {
const normalized = normalizeConfig(config);
db.transaction(() => {
db.run('DELETE FROM config_kv');
for (const [key, value] of Object.entries(normalized)) {
db.run(
`INSERT INTO config_kv(key, value, updated_at)
VALUES (?, ?, strftime('%s','now'))`,
[key, JSON.stringify(value)]
);
}
db.run('DELETE FROM downloaded_vods');
for (const vodId of normalized.downloaded_vod_ids as string[]) {
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', [vodId]);
}
db.run('DELETE FROM streamers');
for (const login of normalized.auto_record_streamers as string[]) {
db.run('INSERT INTO streamers(login, auto_record) VALUES (?, 1)', [login]);
}
for (const login of normalized.auto_vod_download_streamers as string[]) {
db.run(
`INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1)
ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1`,
[login]
);
}
});
replaceConfig(db, normalized);
},
loadQueue<T extends object>() {
return db.all<{ payload_json: string }>(
+21
View File
@@ -21,4 +21,25 @@ describe('createExportableConfig', () => {
expect(serialized).not.toContain(forbidden);
}
});
it('removes camelCase and separator variants of secret fields', () => {
const exported = createExportableConfig({
accessToken: 'camel-access',
refreshToken: 'camel-refresh',
clientSecret: 'camel-secret',
'client-secret': 'dash-secret',
'AUTH TOKEN': 'spaced-token',
discordWebhookUrl: 'https://discord.com/api/webhooks/camel',
nested: {
AuthorizationHeader: 'Bearer nested-token',
safeValue: 'keep-me',
},
});
const serialized = JSON.stringify(exported);
expect(exported).toMatchObject({ nested: { safeValue: 'keep-me' } });
for (const forbidden of ['camel-access', 'camel-refresh', 'camel-secret', 'dash-secret', 'spaced-token', 'nested-token', '/webhooks/camel']) {
expect(serialized).not.toContain(forbidden);
}
});
});
+7 -2
View File
@@ -1,11 +1,16 @@
const SECRET_KEYS = /(^|_)(authorization|cookie|password|secret|token)($|_)/i;
const SECRET_TERMS = ['authorization', 'cookie', 'password', 'secret', 'token'];
export function isSecretBearingKey(key: string): boolean {
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
return SECRET_TERMS.some((term) => normalized.includes(term)) || normalized === 'discordwebhookurl';
}
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (!value || typeof value !== 'object') return value;
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (SECRET_KEYS.test(key) || key.toLowerCase() === 'discord_webhook_url') continue;
if (isSecretBearingKey(key)) continue;
result[key] = redact(entry);
}
return result;
@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('config import production path', () => {
it('uses the import sanitizer and the same runtime transition as normal saves', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf("ipcMain.handle('import-config'");
const end = source.indexOf('function isTrustedRendererEvent', start);
const handler = source.slice(start, end);
expect(handler).toContain('sanitizeImportedConfig(parsed)');
expect(handler).toContain('applyConfigTransition(previousConfig, merged)');
expect(handler).not.toContain('sanitizeConfigInput(parsed)');
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { sanitizeConfigInput, sanitizeImportedConfig } from './config-input';
describe('sanitizeConfigInput', () => {
it('keeps valid import fields while dropping unknown, invalid, and secret fields', () => {
expect(sanitizeConfigInput({
language: 'en',
theme: 'system',
download_mode: 'parts',
part_minutes: 60,
parallel_downloads: 2,
streamers: [' Alice ', '@alice', 'bob_1', 'bad/name', '', 42],
download_policy: {
throttle: { maxBytesPerSecond: 500_000 },
windows: [{ start: '22:00', end: '06:00' }],
injected: true,
},
clientSecret: 'secret',
refresh_token: 'refresh',
unknown_setting: true,
})).toEqual({
language: 'en',
theme: 'system',
download_mode: 'parts',
part_minutes: 60,
parallel_downloads: 2,
streamers: ['alice', 'bob_1'],
download_policy: {
throttle: { maxBytesPerSecond: 500_000 },
windows: [{ start: '22:00', end: '06:00' }],
},
});
});
it('omits malformed known fields instead of replacing current settings with coerced values', () => {
expect(sanitizeConfigInput({
language: 'fr',
theme: 'neon',
download_mode: 'archive',
part_minutes: '60',
parallel_downloads: 3,
streamers: 'alice',
streamer_display_names: { alice: ' Alice ', 'bad/name': 'Bad', bob: 42 },
})).toEqual({
streamer_display_names: { alice: 'Alice' },
});
});
it('normalizes bounded strings and rejects oversized or malformed persisted values', () => {
expect(sanitizeConfigInput({
client_id: ' abc_123 ',
download_path: `C:\\${'a'.repeat(32767)}`,
filename_template_vod: '{title}.mp4',
filename_template_parts: 'x'.repeat(4097),
downloaded_vod_ids: ['123', 'valid-id', 'bad/id', '', 'x'.repeat(129)],
})).toEqual({
client_id: 'abc_123',
filename_template_vod: '{title}.mp4',
downloaded_vod_ids: ['123', 'valid-id'],
});
});
it('keeps the globally newest downloaded VOD ids when the history exceeds its limit', () => {
const downloadedVodIds = Array.from({ length: 9000 }, (_, index) => `vod-${index}`);
const sanitized = sanitizeConfigInput({ downloaded_vod_ids: downloadedVodIds });
expect(sanitized.downloaded_vod_ids).toEqual(downloadedVodIds.slice(4904));
});
it('omits malformed policies but accepts an explicit unrestricted reset', () => {
expect(sanitizeConfigInput({
download_policy: { windows: 'invalid', throttle: null },
})).toEqual({});
expect(sanitizeConfigInput({
download_policy: { throttle: null, windows: [] },
})).toEqual({
download_policy: { throttle: null, windows: [] },
});
});
it('never imports a download path without a separately granted folder capability', () => {
expect(sanitizeImportedConfig({
download_path: 'C:\\',
language: 'de',
})).toEqual({ language: 'de' });
});
});
+224
View File
@@ -0,0 +1,224 @@
import {
normalizeDownloadPolicy,
} from './download-policy';
import {
isPlainObject,
normalizeLogin,
VALID_STREAMLINK_QUALITIES,
} from './config-normalize';
import { isSecretBearingKey } from './config-export';
const TEMPLATE_KEYS = new Set(['filename_template_vod', 'filename_template_parts', 'filename_template_clip']);
const MAX_STREAMER_ENTRIES = 4096;
const MAX_TEMPLATE_LENGTH = 4096;
const MAX_WINDOWS_PATH_LENGTH = 32767;
const BOOLEAN_KEYS = new Set([
'sidebar_split_view',
'smart_queue_scheduler',
'prevent_duplicate_downloads',
'persist_queue_on_restart',
'auto_resume_queue_on_startup',
'notify_on_each_completion',
'streamlink_disable_ads',
'download_chat_replay',
'capture_live_chat',
'discord_notify_live_start',
'discord_notify_live_end',
'discord_notify_vod_complete',
'discord_notify_vod_auto_queued',
'auto_cleanup_enabled',
'log_stream_events',
'auto_resume_live_recording',
'auto_merge_resumed_parts',
'delete_parts_after_merge',
]);
const INTEGER_RANGES: Record<string, readonly [number, number]> = {
part_minutes: [10, 480],
metadata_cache_minutes: [1, 120],
parallel_downloads: [1, 2],
auto_record_poll_seconds: [30, 1800],
auto_cleanup_days: [1, 3650],
auto_vod_download_poll_minutes: [5, 360],
auto_vod_max_age_hours: [1, 720],
};
export function normalizeStreamerLogins(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const streamers: string[] = [];
const seen = new Set<string>();
for (const entry of value.slice(0, MAX_STREAMER_ENTRIES)) {
if (typeof entry !== 'string') continue;
const login = normalizeLogin(entry);
if (!/^[a-z0-9_]{1,25}$/.test(login) || seen.has(login)) continue;
seen.add(login);
streamers.push(login);
}
return streamers;
}
function normalizedStringArray(value: unknown, limit: number, isValid: (value: string) => boolean): string[] | null {
if (!Array.isArray(value)) return null;
const values: string[] = [];
const seen = new Set<string>();
for (let index = value.length - 1; index >= 0 && values.length < limit; index -= 1) {
const entry = value[index];
if (typeof entry !== 'string') continue;
const normalized = entry.trim();
if (!isValid(normalized) || seen.has(normalized)) continue;
seen.add(normalized);
values.push(normalized);
}
return values.reverse();
}
function normalizedDisplayNames(value: unknown): Record<string, string> | null {
if (!isPlainObject(value)) return null;
const names: Record<string, string> = {};
for (const [rawLogin, rawDisplayName] of Object.entries(value).slice(0, MAX_STREAMER_ENTRIES)) {
const login = normalizeLogin(rawLogin);
const displayName = typeof rawDisplayName === 'string' ? rawDisplayName.trim() : '';
if (/^[a-z0-9_]{1,25}$/.test(login) && displayName && displayName.length <= 100) names[login] = displayName;
}
return names;
}
function normalizedEnum(value: unknown, allowed: readonly string[]): string | null {
return typeof value === 'string' && allowed.includes(value) ? value : null;
}
function normalizedDownloadPolicy(value: unknown): ReturnType<typeof normalizeDownloadPolicy> | null {
if (!isPlainObject(value)
|| !Object.prototype.hasOwnProperty.call(value, 'throttle')
|| !Object.prototype.hasOwnProperty.call(value, 'windows')
|| !Array.isArray(value.windows)
|| value.windows.length > 32) return null;
if (value.throttle !== null) {
if (!isPlainObject(value.throttle)
|| typeof value.throttle.maxBytesPerSecond !== 'number'
|| !Number.isSafeInteger(value.throttle.maxBytesPerSecond)
|| value.throttle.maxBytesPerSecond <= 0) return null;
}
for (const window of value.windows) {
if (!isPlainObject(window)
|| typeof window.start !== 'string'
|| typeof window.end !== 'string'
|| !/^\d{2}:\d{2}$/.test(window.start)
|| !/^\d{2}:\d{2}$/.test(window.end)) return null;
const normalized = normalizeDownloadPolicy({ throttle: null, windows: [window] });
if (normalized.windows.length !== 1) return null;
}
return normalizeDownloadPolicy(value);
}
export function sanitizeConfigInput(value: unknown): Record<string, unknown> {
if (!isPlainObject(value)) return {};
const sanitized: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (isSecretBearingKey(key)) continue;
if (key === 'client_id') {
if (typeof entry === 'string') {
const clientId = entry.trim();
if (clientId === '' || /^[A-Za-z0-9_-]{1,128}$/.test(clientId)) sanitized[key] = clientId;
}
continue;
}
if (key === 'download_path') {
if (typeof entry === 'string' && entry.length > 0 && entry.length <= MAX_WINDOWS_PATH_LENGTH && !entry.includes('\0')) sanitized[key] = entry;
continue;
}
if (TEMPLATE_KEYS.has(key)) {
if (typeof entry === 'string' && entry.trim().length > 0 && entry.length <= MAX_TEMPLATE_LENGTH) sanitized[key] = entry;
continue;
}
if (BOOLEAN_KEYS.has(key)) {
if (typeof entry === 'boolean') sanitized[key] = entry;
continue;
}
const range = INTEGER_RANGES[key];
if (range) {
if (typeof entry === 'number' && Number.isSafeInteger(entry) && entry >= range[0] && entry <= range[1]) sanitized[key] = entry;
continue;
}
if (key === 'streamers' || key === 'auto_record_streamers' || key === 'auto_vod_download_streamers') {
const streamers = normalizeStreamerLogins(entry);
if (streamers) sanitized[key] = streamers;
continue;
}
if (key === 'downloaded_vod_ids') {
const ids = normalizedStringArray(entry, 4096, (id) => /^[A-Za-z0-9_-]{1,128}$/.test(id));
if (ids) sanitized[key] = ids;
continue;
}
if (key === 'streamer_display_names') {
const displayNames = normalizedDisplayNames(entry);
if (displayNames) sanitized[key] = displayNames;
continue;
}
if (key === 'download_policy') {
const policy = normalizedDownloadPolicy(entry);
if (policy) sanitized[key] = policy;
continue;
}
if (key === 'theme') {
const theme = normalizedEnum(entry, ['twitch', 'discord', 'youtube', 'apple', 'light', 'system']);
if (theme) sanitized[key] = theme;
continue;
}
if (key === 'download_mode') {
const mode = normalizedEnum(entry, ['parts', 'full']);
if (mode) sanitized[key] = mode;
continue;
}
if (key === 'language') {
const language = normalizedEnum(entry, ['de', 'en']);
if (language) sanitized[key] = language;
continue;
}
if (key === 'performance_mode') {
const performanceMode = normalizedEnum(entry, ['stability', 'balanced', 'speed']);
if (performanceMode) sanitized[key] = performanceMode;
continue;
}
if (key === 'streamlink_quality') {
const quality = normalizedEnum(entry, VALID_STREAMLINK_QUALITIES);
if (quality) sanitized[key] = quality;
continue;
}
if (key === 'auto_cleanup_target') {
const target = normalizedEnum(entry, ['live_only', 'all']);
if (target) sanitized[key] = target;
continue;
}
if (key === 'auto_cleanup_action') {
const action = normalizedEnum(entry, ['delete', 'archive']);
if (action) sanitized[key] = action;
}
}
return sanitized;
}
export function sanitizeImportedConfig(value: unknown): Record<string, unknown> {
const sanitized = sanitizeConfigInput(value);
delete sanitized.download_path;
return sanitized;
}
@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('VFR cutter production path', () => {
it('accepts VFR media preparation and keeps timestamp-based video and audio trimming', () => {
const mainSource = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const prepareStart = mainSource.indexOf('async function prepareVideoEditorMedia');
const prepareEnd = mainSource.indexOf('async function prepareVideoEditorWaveform', prepareStart);
const prepare = mainSource.slice(prepareStart, prepareEnd);
const exportSource = readFileSync(join(process.cwd(), 'src', 'main', 'domain', 'cutter-export.ts'), 'utf8');
expect(prepare).not.toContain('info.variableFrameRate');
expect(exportSource).toContain("`trim=start=${start}:end=${end}`");
expect(exportSource).toContain("'setpts=PTS-STARTPTS'");
expect(exportSource).toContain('atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS');
});
});
+20
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
decideDownloadStart,
decideStandaloneDownloadStart,
isWithinLocalDownloadWindow,
normalizeDownloadPolicy,
} from './download-policy';
@@ -84,3 +85,22 @@ describe('manual download policy override', () => {
});
});
});
describe('standalone download policy', () => {
test('blocks an immediate standalone clip outside the configured window and keeps the throttle decision', () => {
const decision = decideStandaloneDownloadStart(
normalizeDownloadPolicy({
throttle: { maxBytesPerSecond: 256_000 },
windows: [{ start: '22:00', end: '06:00' }],
}),
new Date(2026, 0, 13, 13, 0),
);
expect(decision).toEqual({
allowed: false,
reason: 'outside-window',
maxBytesPerSecond: 256_000,
nextStart: new Date(2026, 0, 13, 22, 0),
});
});
});
+4
View File
@@ -107,3 +107,7 @@ export function decideDownloadStart(policy: DownloadPolicy, now: Date, manualOve
}
return { allowed: false, reason: 'outside-window', maxBytesPerSecond, nextStart: nextWindowStart(now, parsedWindows) };
}
export function decideStandaloneDownloadStart(policy: DownloadPolicy, now: Date): DownloadStartDecision {
return decideDownloadStart(policy, now, false);
}
+273
View File
@@ -0,0 +1,273 @@
import { describe, expect, it } from 'vitest';
import { projectExternalError, sanitizeLogDetails } from './external-error';
describe('projectExternalError', () => {
it('projects an Axios-shaped error without request config, response data, or credentials', () => {
const error = {
name: 'AxiosError',
isAxiosError: true,
message: 'Request failed: client_secret=oauth-secret Authorization: Bearer access-token',
code: 'ERR_BAD_REQUEST',
config: {
params: { client_secret: 'oauth-secret' },
headers: { Authorization: 'Bearer access-token', Cookie: 'session-cookie' },
},
response: {
status: 401,
data: { refreshToken: 'refresh-token', html: '<body>provider response</body>' },
},
};
const projected = projectExternalError('twitch-oauth', error);
const serialized = JSON.stringify(projected);
expect(projected).toMatchObject({ provider: 'twitch-oauth', code: 'ERR_BAD_REQUEST', status: 401 });
expect(projected.message).toContain('[REDACTED]');
for (const forbidden of ['oauth-secret', 'access-token', 'session-cookie', 'refresh-token', 'config', 'headers', 'provider response']) {
expect(serialized).not.toContain(forbidden);
}
});
it('redacts URL userinfo and complete Discord webhook URLs from projected messages', () => {
const authenticatedUrl = ['https://service-user', 'service-pass@example.test/private'].join(':');
const webhookUrl = ['https://discord.com/api/webhooks', '123456789012345678', 'super-secret-webhook-token?wait=true'].join('/');
const projected = projectExternalError('discord', new Error(
`POST ${authenticatedUrl} failed for ${webhookUrl}`,
));
expect(projected.message).toContain('example.test/private');
expect(projected.message).not.toContain('service-user');
expect(projected.message).not.toContain('service-pass');
expect(projected.message).not.toContain('123456789012345678');
expect(projected.message).not.toContain('super-secret-webhook-token');
expect(projected.message).not.toContain('discord.com/api/webhooks');
expect(projected.message).toContain('[REDACTED]');
});
it('only retains recognized operational error-code shapes', () => {
const opaqueCredential = ['ghp', '0123456789abcdefghijklmnopqrstuvwxyz'].join('_');
const credentialShapedCode = ['ERR_AKIA', 'IOSFODNN7EXAMPLE'].join('');
for (const code of ['AWS_SECRET_ACCESS_KEY', opaqueCredential, credentialShapedCode]) {
const projected = projectExternalError('external', {
name: 'AxiosError',
message: 'Request failed',
code,
});
expect(projected).toEqual({ provider: 'external', message: 'Request failed' });
}
});
});
describe('sanitizeLogDetails', () => {
it('redacts nested secret variants, sensitive URL parameters, and cyclic Axios errors', () => {
const axiosError: Record<string, unknown> = {
name: 'AxiosError',
isAxiosError: true,
message: 'GET https://example.test/path?access_token=query-token failed',
config: { headers: { Authorization: 'Bearer header-token' } },
response: { status: 503 },
};
axiosError.self = axiosError;
const sanitized = sanitizeLogDetails({
error: axiosError,
clientSecret: 'nested-secret',
safe: 'visible',
callbackUrl: 'https://example.test/callback?refresh_token=url-refresh&state=ok',
});
const serialized = JSON.stringify(sanitized);
expect(sanitized).toMatchObject({ safe: 'visible' });
for (const forbidden of ['query-token', 'header-token', 'nested-secret', 'url-refresh', 'Authorization', 'clientSecret']) {
expect(serialized).not.toContain(forbidden);
}
});
it('redacts secrets embedded as quoted JSON properties in error messages', () => {
const sanitized = sanitizeLogDetails('{"refreshToken":"json-refresh","Authorization":"Bearer json-access","safe":"visible"}');
expect(String(sanitized)).toContain('visible');
expect(String(sanitized)).not.toContain('json-refresh');
expect(String(sanitized)).not.toContain('json-access');
});
it('redacts encoded URL userinfo and legacy Discord webhook hosts in log strings', () => {
const authenticatedUrl = ['https://encoded%2Duser', 'p%40ssword@example.test/path'].join(':');
const webhookUrl = ['https://canary.discordapp.com/api/v10/webhooks', '987654321098765432', 'legacy-secret-token'].join('/');
const sanitized = sanitizeLogDetails(`${authenticatedUrl} ${webhookUrl}`);
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['encoded%2Duser', 'p%40ssword', '987654321098765432', 'legacy-secret-token', 'discordapp.com/api/v10/webhooks']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('example.test/path');
expect(serialized).toContain('[REDACTED]');
});
it('redacts complete authorization and cookie header lines for every authentication scheme', () => {
const sanitized = sanitizeLogDetails([
'Authorization: Digest username="digest-user", nonce="digest-nonce", response="digest-response"',
'Authorization: AWS4-HMAC-SHA256 Credential=aws-credential, SignedHeaders=host, Signature=aws-signature',
'Authorization: Digest username="folded-user",',
' nonce="folded-nonce", response="folded-response"',
'--multipart-boundary',
'Cookie: session=browser-session; csrf=csrf-value',
'X-Safe: visible',
].join('\r\n'));
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['digest-user', 'digest-nonce', 'digest-response', 'aws-credential', 'aws-signature', 'folded-user', 'folded-nonce', 'folded-response', 'browser-session', 'csrf-value']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('multipart-boundary');
expect(serialized).toContain('visible');
});
it('redacts complete authorization and cookie values from equals, tuple, and name-value representations', () => {
const sanitized = sanitizeLogDetails({
text: [
'Authorization=Digest username="equals-user", nonce="equals-nonce", response="equals-response"',
'Cookie=session=equals-session; csrf=equals-csrf',
].join('\n'),
tuples: [
['Authorization', 'Digest username="tuple-user", nonce="tuple-nonce", response="tuple-response"'],
['Cookie', 'session=tuple-session; csrf=tuple-csrf'],
],
header: {
name: 'Authorization',
value: 'AWS4-HMAC-SHA256 Credential=object-credential, SignedHeaders=host, Signature=object-signature',
},
safe: 'visible',
});
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['equals-user', 'equals-nonce', 'equals-response', 'equals-session', 'equals-csrf', 'tuple-user', 'tuple-nonce', 'tuple-response', 'tuple-session', 'tuple-csrf', 'object-credential', 'object-signature']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('visible');
});
it('redacts percent-encoded and escaped authenticated URLs and encoded Discord webhook paths', () => {
const encodedUrl = encodeURIComponent(['https://encoded-user', 'encoded-pass@example.test/encoded'].join(':'));
const escapedUrl = ['https:\\/\\/escaped-user', 'escaped-pass@example.test/escaped'].join(':');
const webhookUrl = ['https://discord.com/api/%77ebhooks', '112233445566778899', 'encoded-webhook-secret'].join('/');
const serialized = JSON.stringify(sanitizeLogDetails(`${encodedUrl} ${escapedUrl} ${webhookUrl}`));
for (const forbidden of ['encoded-user', 'encoded-pass', 'escaped-user', 'escaped-pass', '112233445566778899', 'encoded-webhook-secret']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('example.test/encoded');
expect(serialized).toContain('example.test/escaped');
});
it('redacts multiply encoded URLs, webhook paths, and unicode-escaped URL separators', () => {
const authenticatedUrl = ['https://multi-user', 'multi-pass@example.test/multi?apiKey=multi-query'].join(':');
const doublyEncodedUrl = encodeURIComponent(encodeURIComponent(authenticatedUrl));
const doublyEncodedWebhook = ['https://discord.com/api/%2577ebhooks', '998877665544332211', 'double-webhook-secret'].join('/');
const unicodeEscapedUrl = ['https:', '\\u002f', '\\u002f', 'unicode-user', 'unicode-pass@example.test/unicode'].join('').replace('unicode-userunicode-pass', 'unicode-user:unicode-pass');
const serialized = JSON.stringify(sanitizeLogDetails(`${doublyEncodedUrl} ${doublyEncodedWebhook} ${unicodeEscapedUrl}`));
for (const forbidden of ['multi-user', 'multi-pass', 'multi-query', '998877665544332211', 'double-webhook-secret', 'unicode-user', 'unicode-pass']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('example.test/multi');
expect(serialized).toContain('example.test/unicode');
});
it('removes normalized nested credential keys without stripping descriptive counters and consent fields', () => {
const sanitized = sanitizeLogDetails({
nested: {
'X-Api-Key': 'header-api-key',
apiKey: 'camel-api-key',
sessionId: 'private-session-id',
credentials: { username: 'private-user', password: 'private-password' },
notasecret: 'preserve-not-a-secret',
tokenCount: 4,
cookieConsent: true,
},
});
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['header-api-key', 'camel-api-key', 'private-session-id', 'private-user', 'private-password']) {
expect(serialized).not.toContain(forbidden);
}
expect(sanitized).toMatchObject({
nested: {
notasecret: 'preserve-not-a-secret',
tokenCount: 4,
cookieConsent: true,
},
});
});
it('removes percent-encoded nested credential keys', () => {
const sanitized = sanitizeLogDetails({
'X%2DApi%2DKey': 'encoded-api-key',
'session%49d': 'encoded-session-id',
'credent%69als': 'encoded-credentials',
safe: 'visible',
});
const serialized = JSON.stringify(sanitized);
for (const forbidden of ['encoded-api-key', 'encoded-session-id', 'encoded-credentials']) {
expect(serialized).not.toContain(forbidden);
}
expect(sanitized).toMatchObject({ safe: 'visible' });
});
it('redacts percent-encoded credential keys in unstructured log text', () => {
const serialized = JSON.stringify(sanitizeLogDetails('X%2DApi%2DKey=encoded-text-key safe=visible'));
expect(serialized).not.toContain('encoded-text-key');
expect(serialized).toContain('visible');
});
it('redacts complete values from escaped quoted JSON without leaking authentication suffixes', () => {
const rawJson = JSON.stringify({
Authorization: 'Digest username="escaped-user", nonce="escaped-nonce", response="escaped-response"',
'X-Api-Key': 'escaped-api-key',
safe: 'visible',
});
const escapedJson = JSON.stringify(rawJson).slice(1, -1);
const serialized = JSON.stringify(sanitizeLogDetails(escapedJson));
for (const forbidden of ['escaped-user', 'escaped-nonce', 'escaped-response', 'escaped-api-key']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('visible');
});
it('redacts direct serialized JSON values without leaking quoted authentication suffixes', () => {
const serializedInput = JSON.stringify({
Authorization: 'Digest username="direct-user", nonce="direct-nonce", response="direct-response"',
safe: 'visible',
});
const serialized = JSON.stringify(sanitizeLogDetails(serializedInput));
for (const forbidden of ['direct-user', 'direct-nonce', 'direct-response']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('visible');
});
it('redacts embedded serialized JSON values inside surrounding diagnostic text', () => {
const embedded = JSON.stringify({
Authorization: 'Digest username="embedded-user", nonce="embedded-nonce", response="embedded-response"',
safe: 'visible',
});
const serialized = JSON.stringify(sanitizeLogDetails(`Provider failed with ${embedded} after retry`));
for (const forbidden of ['embedded-user', 'embedded-nonce', 'embedded-response']) {
expect(serialized).not.toContain(forbidden);
}
expect(serialized).toContain('visible');
expect(serialized).toContain('after retry');
});
it('preserves false-positive assignment names in unstructured log text', () => {
const sanitized = sanitizeLogDetails('notasecret=visible tokenCount=4 cookieConsent=true');
expect(sanitized).toBe('notasecret=visible tokenCount=4 cookieConsent=true');
});
});
+289
View File
@@ -0,0 +1,289 @@
export interface SafeExternalError {
provider: string;
message: string;
code?: string;
status?: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function decodeRepeatedURIComponent(value: string): string {
let decoded = value;
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
} catch {
break;
}
}
return decoded;
}
function keyWords(key: string): string[] {
return decodeRepeatedURIComponent(key)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
}
function isSensitiveLogKey(key: string): boolean {
const words = keyWords(key);
const compact = words.join('');
if (compact === 'notasecret' || compact === 'tokencount' || compact === 'cookieconsent') return false;
if (
compact === 'apikey'
|| compact === 'xapikey'
|| compact === 'sessionid'
|| compact === 'discordwebhookurl'
|| compact === 'authorizationheader'
|| compact === 'cookieheader'
|| compact === 'setcookie'
|| compact === 'accesstoken'
|| compact === 'refreshtoken'
|| compact === 'clientsecret'
) return true;
return words.some((word, index) => {
if (word === 'authorization' || word === 'password' || word === 'passwd' || word === 'secret' || word === 'credential' || word === 'credentials') return true;
if (word === 'token') return words[index + 1] !== 'count';
if (word === 'cookie' || word === 'cookies') return words[index + 1] !== 'consent';
if (word === 'api' && words[index + 1] === 'key') return true;
return word === 'session' && words[index + 1] === 'id';
});
}
function decodeUrlSegment(value: string): string {
return decodeRepeatedURIComponent(value).toLowerCase();
}
function normalizeUrl(rawUrl: string): string {
return decodeRepeatedURIComponent(rawUrl);
}
function redactExternalUrl(rawUrl: string): string {
try {
const parsed = new URL(normalizeUrl(rawUrl));
const hostname = parsed.hostname.toLowerCase();
const pathSegments = parsed.pathname.split('/').filter(Boolean).map(decodeUrlSegment);
const webhookSegment = /^v\d+$/.test(pathSegments[1] ?? '') ? 2 : 1;
const isDiscordWebhook = (
hostname === 'discord.com'
|| hostname === 'discordapp.com'
|| hostname === 'canary.discord.com'
|| hostname === 'canary.discordapp.com'
|| hostname === 'ptb.discord.com'
|| hostname === 'ptb.discordapp.com'
) && pathSegments[0]?.toLowerCase() === 'api'
&& pathSegments[webhookSegment]?.toLowerCase() === 'webhooks';
if (isDiscordWebhook) return '[REDACTED]';
for (const key of Array.from(parsed.searchParams.keys())) {
if (isSensitiveLogKey(key)) parsed.searchParams.set(key, '[REDACTED]');
}
const safeUrl = parsed.toString();
if (!parsed.username && !parsed.password) return safeUrl;
const authorityStart = safeUrl.indexOf('//') + 2;
const authorityEnd = ['/', '?', '#']
.map((separator) => safeUrl.indexOf(separator, authorityStart))
.filter((index) => index >= 0)
.reduce((minimum, index) => Math.min(minimum, index), safeUrl.length);
const userInfoEnd = safeUrl.lastIndexOf('@', authorityEnd);
if (userInfoEnd < authorityStart) return safeUrl;
return `${safeUrl.slice(0, authorityStart)}[REDACTED]@${safeUrl.slice(userInfoEnd + 1)}`;
} catch {
return rawUrl;
}
}
function decodeEscapedSyntax(value: string): string {
let normalized = '';
for (let index = 0; index < value.length; index += 1) {
const current = value[index];
const next = value[index + 1];
const unicodeValue = value.slice(index + 2, index + 6);
if (current === '\\' && next === 'u' && /^[0-9a-f]{4}$/i.test(unicodeValue)) {
normalized += String.fromCharCode(Number.parseInt(unicodeValue, 16));
index += 5;
} else if (current === '\\' && (next === '\\' || next === '/')) {
normalized += next;
index += 1;
} else {
normalized += current;
}
}
return normalized;
}
function findQuotedValueEnd(value: string, start: number, quote: string): number {
for (let index = start + 1; index < value.length; index += 1) {
if (value[index] !== quote) continue;
let backslashes = 0;
for (let cursor = index - 1; cursor >= start && value[cursor] === '\\'; cursor -= 1) backslashes += 1;
if (backslashes % 2 === 0) return index + 1;
}
return value.length;
}
function findUnquotedValueEnd(value: string, start: number): number {
for (let index = start; index < value.length; index += 1) {
if (/\s/.test(value[index]) || value[index] === ',' || value[index] === '}') return index;
}
return value.length;
}
function redactAssignments(value: string): string {
const assignment = /(?<![A-Za-z0-9_./%-])([A-Za-z][A-Za-z0-9%_. -]{0,63})(?:["'])?[ \t]*[:=][ \t]*/g;
let output = '';
let copiedUntil = 0;
let match: RegExpExecArray | null;
while ((match = assignment.exec(value)) !== null) {
if (!isSensitiveLogKey(match[1].trim())) continue;
const valueStart = assignment.lastIndex;
const openingQuote = value[valueStart];
const valueEnd = openingQuote === '"' || openingQuote === "'"
? findQuotedValueEnd(value, valueStart, openingQuote)
: findUnquotedValueEnd(value, valueStart);
const replacement = openingQuote === '"' || openingQuote === "'"
? `${openingQuote}[REDACTED]${valueEnd <= value.length && value[valueEnd - 1] === openingQuote ? openingQuote : ''}`
: '[REDACTED]';
output += value.slice(copiedUntil, valueStart) + replacement;
copiedUntil = valueEnd;
assignment.lastIndex = valueEnd;
}
return output + value.slice(copiedUntil);
}
function redactHeaderLines(value: string): string {
const header = /(\b(?:proxy-authorization|authorization|set-cookie|cookie)[ \t]*[:=][ \t]*)/i;
let redactContinuation = false;
return value.split(/(\r\n|\n|\r)/).map((line, index) => {
if (index % 2 === 1) return line;
const match = header.exec(line);
if (match) {
redactContinuation = true;
return `${line.slice(0, match.index)}${match[1]}[REDACTED]`;
}
if (redactContinuation && /^[ \t]+/.test(line)) return `${line.match(/^[ \t]+/)?.[0] ?? ''}[REDACTED]`;
redactContinuation = false;
return line;
}).join('');
}
const SAFE_EXTERNAL_ERROR_CODES = new Set([
'CERT_HAS_EXPIRED',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'EAI_AGAIN',
'ECONNABORTED',
'ECONNREFUSED',
'ECONNRESET',
'EHOSTUNREACH',
'ENETUNREACH',
'ENOTFOUND',
'EPIPE',
'ERR_BAD_OPTION',
'ERR_BAD_OPTION_VALUE',
'ERR_BAD_REQUEST',
'ERR_BAD_RESPONSE',
'ERR_CANCELED',
'ERR_DEPRECATED',
'ERR_FR_TOO_MANY_REDIRECTS',
'ERR_INVALID_URL',
'ERR_NETWORK',
'ERR_NOT_SUPPORT',
'ETIMEDOUT',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UND_ERR_BODY_TIMEOUT',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_HEADERS_TIMEOUT',
]);
function isSafeExternalErrorCode(value: string): boolean {
return SAFE_EXTERNAL_ERROR_CODES.has(value);
}
function sanitizeSerializedText(value: string): string | null {
const candidates = [value];
if (value.includes('\\"') || value.includes('\\/')) candidates.push(`"${value}"`);
for (const candidate of candidates) {
let current: unknown = candidate;
for (let layer = 0; layer < 3 && typeof current === 'string'; layer += 1) {
try {
current = JSON.parse(current) as unknown;
} catch {
break;
}
if (current !== null && typeof current === 'object') {
return JSON.stringify(sanitizeValue(current, new WeakSet<object>(), 0));
}
}
}
return null;
}
export function redactSensitiveText(value: string): string {
const structured = sanitizeSerializedText(value);
if (structured !== null) return structured.slice(0, 1000);
const withRedactedUrls = decodeEscapedSyntax(value)
.replace(/\bhttps%(?:25){0,3}3a%(?:25){0,3}2f%(?:25){0,3}2f[^\s"'<>]+/gi, redactExternalUrl)
.replace(/\bhttps?:\/\/[^\s"'<>]+/gi, redactExternalUrl);
return redactAssignments(redactHeaderLines(withRedactedUrls))
.replace(/\bBearer\s+[^\s"',;]+/gi, 'Bearer [REDACTED]')
.slice(0, 1000);
}
export function projectExternalError(provider: string, error: unknown): SafeExternalError {
const record = asRecord(error);
const response = asRecord(record?.response);
const rawMessage = error instanceof Error
? error.message
: typeof record?.message === 'string'
? record.message
: typeof error === 'string'
? error
: 'External request failed';
const projected: SafeExternalError = {
provider,
message: redactSensitiveText(rawMessage),
};
if (typeof record?.code === 'string' && isSafeExternalErrorCode(record.code)) projected.code = record.code;
if (typeof response?.status === 'number' && Number.isInteger(response.status)) projected.status = response.status;
return projected;
}
function isExternalErrorRecord(value: Record<string, unknown>): boolean {
return value.isAxiosError === true || value.name === 'AxiosError' || value instanceof Error;
}
function sanitizeValue(value: unknown, seen: WeakSet<object>, depth: number): unknown {
if (typeof value === 'string') return redactSensitiveText(value);
if (typeof value === 'number' || typeof value === 'boolean' || value === null || value === undefined) return value;
if (typeof value !== 'object') return String(value);
if (seen.has(value)) return '[Circular]';
if (depth >= 6) return '[Truncated]';
seen.add(value);
if (value instanceof Error) return projectExternalError('external', value);
if (Array.isArray(value)) {
const entries = value.slice(0, 100);
if (typeof entries[0] === 'string' && isSensitiveLogKey(entries[0])) {
return entries.map((entry, index) => index === 1 ? '[REDACTED]' : sanitizeValue(entry, seen, depth + 1));
}
return entries.map((entry) => sanitizeValue(entry, seen, depth + 1));
}
const record = value as Record<string, unknown>;
if (isExternalErrorRecord(record)) return projectExternalError('external', record);
const redactedNamedValue = typeof record.name === 'string' && isSensitiveLogKey(record.name) && Object.hasOwn(record, 'value');
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(record).slice(0, 100)) {
if (isSensitiveLogKey(key)) continue;
result[key] = redactedNamedValue && key === 'value' ? '[REDACTED]' : sanitizeValue(entry, seen, depth + 1);
}
return result;
}
export function sanitizeLogDetails(value: unknown): unknown {
return sanitizeValue(value, new WeakSet<object>(), 0);
}
+9
View File
@@ -41,6 +41,15 @@ describe('tBackend', () => {
}
});
test('describes a blocked standalone download and a phase-boundary pause honestly', () => {
expect(tBackend('downloadOutsideWindow', { nextStart: '22:00' }, 'de')).toBe('Download außerhalb des Zeitfensters blockiert. Nächster Start: 22:00.');
expect(tBackend('downloadOutsideWindow', { nextStart: '22:00' }, 'en')).toBe('Download blocked outside the configured window. Next start: 22:00.');
expect(tBackend('downloadPausePending', undefined, 'de')).toBe('Pause nach dem aktuellen Schritt.');
expect(tBackend('downloadPausePending', undefined, 'en')).toBe('Pausing after the current step.');
expect(tBackend('mergeRecoveryBlocked', undefined, 'de')).toBe('Unterbrochene Merge-Dateien konnten nicht entfernt werden. Entferne den Queue-Eintrag manuell.');
expect(tBackend('mergeRecoveryBlocked', undefined, 'en')).toBe('Interrupted merge files could not be removed. Remove the queue item manually.');
});
test('German backend messages use native umlauts', () => {
const text = Object.values(BACKEND_MESSAGES.de).join('\n').toLocaleLowerCase('de-DE');
const forbidden = ['ungueltig', 'integritaetspruefung', 'fur ', 'benoetigt', 'prufe '];
+6
View File
@@ -21,11 +21,14 @@ export const BACKEND_MESSAGES = {
integrityFailedGeneric: 'Integritätsprüfung fehlgeschlagen.',
downloadCancelled: 'Download wurde abgebrochen.',
downloadPaused: 'Download wurde pausiert.',
downloadPausePending: 'Pause nach dem aktuellen Schritt.',
downloadOutsideWindow: 'Download außerhalb des Zeitfensters blockiert. Nächster Start: {nextStart}.',
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.',
mergeRecoveryBlocked: 'Unterbrochene Merge-Dateien konnten nicht entfernt werden. Entferne den Queue-Eintrag manuell.',
diskSpaceShortFor: 'Zu wenig Speicherplatz für {context}: frei {free}, benötigt ~{required}.',
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
@@ -60,11 +63,14 @@ export const BACKEND_MESSAGES = {
integrityFailedGeneric: 'Integrity check failed.',
downloadCancelled: 'Download was cancelled.',
downloadPaused: 'Download was paused.',
downloadPausePending: 'Pausing after the current step.',
downloadOutsideWindow: 'Download blocked outside the configured window. Next start: {nextStart}.',
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.',
mergeRecoveryBlocked: 'Interrupted merge files could not be removed. Remove the queue item manually.',
diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.',
diskSpaceShortGeneric: 'Not enough disk space.',
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { LastGoodCache } from './last-good-cache';
describe('LastGoodCache', () => {
it('retains the last successful value independently from expiring request caches', () => {
const cache = new LastGoodCache<number[]>(2);
cache.set('a', [1]);
cache.set('a', []);
expect(cache.get('a')).toEqual([]);
});
it('bounds retained values by least-recent insertion and supports authoritative deletion', () => {
const cache = new LastGoodCache<number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBe(2);
expect(cache.delete('b')).toBe(true);
expect(cache.get('b')).toBeUndefined();
});
});
+25
View File
@@ -0,0 +1,25 @@
export class LastGoodCache<T> {
private readonly values = new Map<string, T>();
constructor(private readonly maxEntries: number) {
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new Error('maxEntries must be a positive integer');
}
get(key: string): T | undefined {
return this.values.get(key);
}
set(key: string, value: T): void {
this.values.delete(key);
this.values.set(key, value);
while (this.values.size > this.maxEntries) {
const oldest = this.values.keys().next().value as string | undefined;
if (!oldest) break;
this.values.delete(oldest);
}
}
delete(key: string): boolean {
return this.values.delete(key);
}
}
+177
View File
@@ -0,0 +1,177 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { QueueItem } from '../../types';
import { getInterruptedMergeItemIds, recoverInterruptedMergeArtifacts } from './merge-recovery';
let directory: string;
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-merge-recovery-'));
});
afterEach(() => {
fs.rmSync(directory, { recursive: true, force: true });
});
function queueItem(overrides: Partial<QueueItem> = {}): QueueItem {
return {
id: 'merge-1',
title: 'Merge',
url: 'https://www.twitch.tv/videos/1',
date: '2026-08-13T00:00:00.000Z',
streamer: 'alice',
duration_str: '2h',
status: 'downloading',
progress: 84,
mergeGroup: {
items: [
{ url: 'https://www.twitch.tv/videos/1', title: 'A', date: '2026-08-13T00:00:00.000Z', streamer: 'alice', duration_str: '1h' },
{ url: 'https://www.twitch.tv/videos/2', title: 'B', date: '2026-08-13T00:00:00.000Z', streamer: 'alice', duration_str: '1h' },
],
mergePhase: 'merging',
currentItemIndex: 1,
downloadedFiles: {},
},
...overrides,
};
}
describe('recoverInterruptedMergeArtifacts', () => {
it('removes internal crash artifacts and resets an unfinished merge from the beginning', () => {
const jobDirectory = path.join(directory, 'alice');
fs.mkdirSync(jobDirectory);
const first = path.join(jobDirectory, 'merge_tmp_0_100.mp4');
const second = path.join(jobDirectory, 'merge_tmp_1_200.mp4');
const merged = path.join(jobDirectory, '.merge_output_300_123.mp4');
fs.writeFileSync(first, 'partial-a');
fs.writeFileSync(second, 'partial-b');
fs.writeFileSync(merged, 'partial-merge');
const item = queueItem();
item.mergeGroup!.downloadedFiles = { 0: first, 1: second };
item.mergeGroup!.mergedFile = merged;
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result.changed).toBe(true);
expect(result.removedFiles.sort()).toEqual([first, second, merged].sort());
expect(result.queue[0]).toMatchObject({ status: 'pending', progress: 0 });
expect(result.queue[0].mergeGroup).toMatchObject({
mergePhase: 'downloading',
currentItemIndex: 0,
downloadedFiles: {},
});
expect(result.queue[0].mergeGroup).not.toHaveProperty('mergedFile');
expect(fs.existsSync(first)).toBe(false);
expect(fs.existsSync(second)).toBe(false);
expect(fs.existsSync(merged)).toBe(false);
expect(result.queue[0].artifactRoot).toBe(fs.realpathSync.native(directory));
});
it('uses persisted artifact provenance after the configured download root changes', () => {
const previousRoot = path.join(directory, 'previous');
const currentRoot = path.join(directory, 'current');
fs.mkdirSync(previousRoot);
fs.mkdirSync(currentRoot);
const artifact = path.join(previousRoot, 'merge_tmp_0_100.mp4');
fs.writeFileSync(artifact, 'partial');
const item = queueItem({ artifactRoot: fs.realpathSync.native(previousRoot) });
item.mergeGroup!.downloadedFiles = { 0: artifact };
const result = recoverInterruptedMergeArtifacts([item], currentRoot, new Set([item.id]));
expect(result.failedFiles).toEqual([]);
expect(result.removedFiles).toEqual([artifact]);
expect(fs.existsSync(artifact)).toBe(false);
});
it('never removes a persisted path outside the configured download root', () => {
const outside = path.join(os.tmpdir(), `merge_tmp_0_${Date.now()}.mp4`);
fs.writeFileSync(outside, 'keep');
const item = queueItem();
item.mergeGroup!.downloadedFiles = { 0: outside };
try {
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result.removedFiles).toEqual([]);
expect(result.failedFiles).toEqual([outside]);
expect(result.queue[0]).toMatchObject({ status: 'error', mergeRecoveryBlocked: true });
expect(result.queue[0]).not.toHaveProperty('artifactRoot');
expect(fs.existsSync(outside)).toBe(true);
} finally {
fs.rmSync(outside, { force: true });
}
});
it('leaves completed merge jobs and their published outputs untouched', () => {
const output = path.join(directory, 'published.mp4');
fs.writeFileSync(output, 'complete');
const item = queueItem({ status: 'completed', progress: 100, outputFiles: [output] });
item.mergeGroup!.mergePhase = 'done';
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result).toEqual({ queue: [item], removedFiles: [], failedFiles: [], changed: false });
expect(fs.existsSync(output)).toBe(true);
});
it('leaves a normal failed job untouched because it is not a hard-crash recovery', () => {
const item = queueItem({ status: 'error', progress: 72, last_error: 'ffmpeg failed' });
const result = recoverInterruptedMergeArtifacts([item], directory, new Set());
expect(result).toEqual({ queue: [item], removedFiles: [], failedFiles: [], changed: false });
});
it('removes persisted temp and published split artifacts from an interrupted split', () => {
const jobDirectory = path.join(directory, 'alice');
fs.mkdirSync(jobDirectory);
const temp = path.join(jobDirectory, '.merge_split_123_0.mp4');
const published = path.join(jobDirectory, 'Alice_Part01.mp4');
fs.writeFileSync(temp, 'partial');
fs.writeFileSync(published, 'published-before-crash');
const item = queueItem();
item.mergeGroup!.mergePhase = 'splitting';
item.mergeGroup!.splitTempFiles = [temp];
item.mergeGroup!.splitFiles = [published];
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result.removedFiles.sort()).toEqual([temp, published].sort());
expect(result.failedFiles).toEqual([]);
expect(result.queue[0].mergeGroup).not.toHaveProperty('splitFiles');
expect(result.queue[0].mergeGroup).not.toHaveProperty('splitTempFiles');
expect(fs.existsSync(temp)).toBe(false);
expect(fs.existsSync(published)).toBe(false);
});
it('keeps references and blocks retry when an interrupted artifact cannot be removed', () => {
const jobDirectory = path.join(directory, 'alice');
fs.mkdirSync(jobDirectory);
const locked = path.join(jobDirectory, '.merge_split_123_0.mp4');
fs.mkdirSync(locked);
const item = queueItem();
item.mergeGroup!.mergePhase = 'splitting';
item.mergeGroup!.splitTempFiles = [locked];
const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id]));
expect(result.removedFiles).toEqual([]);
expect(result.failedFiles).toEqual([locked]);
expect(result.queue[0]).toMatchObject({
status: 'error',
mergeRecoveryBlocked: true,
mergeGroup: { splitTempFiles: [locked] },
});
});
it('derives recovery eligibility only from persisted downloading merge jobs', () => {
expect([...getInterruptedMergeItemIds([
{ id: 'active', status: 'downloading', mergeGroup: {} },
{ id: 'failed', status: 'error', mergeGroup: {} },
{ id: 'plain', status: 'downloading' },
null,
])]).toEqual(['active']);
});
});
+197
View File
@@ -0,0 +1,197 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { QueueItem } from '../../types';
export interface MergeRecoveryResult {
queue: QueueItem[];
removedFiles: string[];
failedFiles: string[];
changed: boolean;
}
type ArtifactKind = 'internal' | 'published-split';
type RemovalResult = 'removed' | 'missing' | 'failed';
export interface MergeArtifactRootResolution {
artifactRoot: string | null;
migrated: boolean;
}
function isInside(root: string, candidate: string): boolean {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
}
function canonicalRoot(root: string): string | null {
if (!path.isAbsolute(root)) return null;
const resolved = path.resolve(root);
try {
return fs.statSync(resolved).isDirectory() ? fs.realpathSync.native(resolved) : null;
} catch {
return resolved;
}
}
function collectMergeArtifacts(group: NonNullable<QueueItem['mergeGroup']>): Map<string, { filePath: string; kind: ArtifactKind }> {
const artifacts = new Map<string, { filePath: string; kind: ArtifactKind }>();
const addArtifact = (filePath: string, kind: ArtifactKind): void => {
const resolved = path.resolve(filePath);
const existing = artifacts.get(resolved);
if (!existing || kind === 'internal') artifacts.set(resolved, { filePath, kind });
};
for (const filePath of Object.values(group.downloadedFiles)) addArtifact(filePath, 'internal');
if (group.mergedFile) addArtifact(group.mergedFile, 'internal');
for (const filePath of group.splitTempFiles ?? []) addArtifact(filePath, 'internal');
for (const filePath of group.splitFiles ?? []) addArtifact(filePath, 'published-split');
return artifacts;
}
function isPlausiblyInsideRoot(root: string, candidate: string): boolean {
if (!isInside(root, candidate)) return false;
if (!fs.existsSync(candidate)) return true;
try {
const resolvedRoot = canonicalRoot(root);
if (!resolvedRoot) return false;
return isInside(resolvedRoot, fs.realpathSync.native(candidate));
} catch {
return false;
}
}
export function resolveMergeArtifactRoot(item: QueueItem, currentDownloadRoot: string): MergeArtifactRootResolution {
if (typeof item.artifactRoot === 'string' && item.artifactRoot) {
const resolved = canonicalRoot(item.artifactRoot);
return { artifactRoot: resolved, migrated: resolved !== null && resolved !== item.artifactRoot };
}
const resolved = canonicalRoot(currentDownloadRoot);
if (!resolved || !item.mergeGroup) return { artifactRoot: null, migrated: false };
const plausible = [...collectMergeArtifacts(item.mergeGroup).values()]
.every(({ filePath }) => isPlausiblyInsideRoot(resolved, filePath));
return plausible
? { artifactRoot: resolved, migrated: true }
: { artifactRoot: null, migrated: false };
}
function isInternalMergeArtifact(filePath: string): boolean {
return /^(?:(?:merge_tmp_\d+_\d+|merged_\d+|\.merge_output_\d+_\d+)(?:_\d+)?|\.merge_split_[A-Za-z0-9_-]+)\.mp4$/i.test(path.basename(filePath));
}
function removeArtifact(filePath: string, downloadRoot: string, kind: ArtifactKind): RemovalResult {
if (!isInside(downloadRoot, filePath)) return 'failed';
if (kind === 'internal' && !isInternalMergeArtifact(filePath)) return 'failed';
if (!fs.existsSync(filePath)) return 'missing';
try {
const resolvedRoot = canonicalRoot(downloadRoot);
if (!resolvedRoot || !isInside(resolvedRoot, fs.realpathSync.native(filePath))) return 'failed';
fs.unlinkSync(filePath);
return fs.existsSync(filePath) ? 'failed' : 'removed';
} catch {
return 'failed';
}
}
export function getInterruptedMergeItemIds(rawQueue: unknown[]): Set<string> {
const result = new Set<string>();
for (const raw of rawQueue) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
const item = raw as Record<string, unknown>;
if (item.status !== 'downloading' || typeof item.id !== 'string' || !item.id) continue;
if (!item.mergeGroup || typeof item.mergeGroup !== 'object' || Array.isArray(item.mergeGroup)) continue;
result.add(item.id);
}
return result;
}
function retainFailedArtifacts(group: NonNullable<QueueItem['mergeGroup']>, failed: Set<string>): NonNullable<QueueItem['mergeGroup']> {
const downloadedFiles = Object.fromEntries(
Object.entries(group.downloadedFiles).filter(([, filePath]) => failed.has(path.resolve(filePath)))
) as Record<number, string>;
const retained = { ...group, downloadedFiles };
if (!group.mergedFile || !failed.has(path.resolve(group.mergedFile))) delete retained.mergedFile;
const splitFiles = group.splitFiles?.filter((filePath) => failed.has(path.resolve(filePath)));
const splitTempFiles = group.splitTempFiles?.filter((filePath) => failed.has(path.resolve(filePath)));
if (splitFiles?.length) retained.splitFiles = splitFiles;
else delete retained.splitFiles;
if (splitTempFiles?.length) retained.splitTempFiles = splitTempFiles;
else delete retained.splitTempFiles;
return retained;
}
export function recoverInterruptedMergeArtifacts(
queue: QueueItem[],
downloadRoot: string,
interruptedItemIds: ReadonlySet<string>
): MergeRecoveryResult {
const removedFiles: string[] = [];
const failedFiles: string[] = [];
let changed = false;
const recoveredQueue = queue.map((item) => {
const group = item.mergeGroup;
if (!group || group.mergePhase === 'done' || !interruptedItemIds.has(item.id)) return item;
const artifacts = collectMergeArtifacts(group);
const rootResolution = resolveMergeArtifactRoot(item, downloadRoot);
if (!rootResolution.artifactRoot) {
const failed = new Set(artifacts.keys());
failedFiles.push(...[...artifacts.values()].map(({ filePath }) => filePath));
changed = true;
return {
...item,
status: 'error' as const,
mergeRecoveryBlocked: true,
mergeGroup: retainFailedArtifacts(group, failed),
};
}
const itemWithRoot = item.artifactRoot === rootResolution.artifactRoot
? item
: { ...item, artifactRoot: rootResolution.artifactRoot };
const failed = new Set<string>();
for (const [resolved, artifact] of artifacts) {
const result = removeArtifact(artifact.filePath, rootResolution.artifactRoot, artifact.kind);
if (result === 'removed') removedFiles.push(artifact.filePath);
if (result === 'failed') {
failed.add(resolved);
failedFiles.push(artifact.filePath);
}
}
changed = true;
if (failed.size > 0) {
return {
...itemWithRoot,
status: 'error' as const,
mergeRecoveryBlocked: true,
mergeGroup: retainFailedArtifacts(group, failed),
};
}
const recoveredGroup = {
...group,
mergePhase: 'downloading' as const,
currentItemIndex: 0,
downloadedFiles: {},
};
delete recoveredGroup.mergedFile;
delete recoveredGroup.splitFiles;
delete recoveredGroup.splitTempFiles;
const recoveredItem: QueueItem = {
...itemWithRoot,
status: 'pending',
progress: 0,
mergeGroup: recoveredGroup,
};
delete recoveredItem.currentPart;
delete recoveredItem.totalParts;
delete recoveredItem.speed;
delete recoveredItem.eta;
delete recoveredItem.downloadedBytes;
delete recoveredItem.totalBytes;
delete recoveredItem.progressStatus;
delete recoveredItem.last_error;
delete recoveredItem.mergeRecoveryBlocked;
return recoveredItem;
});
return { queue: recoveredQueue, removedFiles, failedFiles, changed };
}
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('merge split production path', () => {
it('persists a hidden merge output before ffmpeg can write crash data', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function processDownloadMergeGroup');
const merge = source.slice(start, source.indexOf('// ---- PHASE 3: SPLITTING ----', start));
expect(merge).toContain('.merge_output_');
expect(merge.indexOf('mg.mergedFile = mergedFilePath')).toBeLessThan(merge.indexOf('await mergeVideos('));
expect(merge.indexOf('saveQueue(downloadQueue)', merge.indexOf('mg.mergedFile = mergedFilePath'))).toBeLessThan(merge.indexOf('await mergeVideos('));
});
it('encodes each split into a persisted app-owned temp file before atomically publishing it', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function splitMergedFile');
const end = source.indexOf('// ==========================================\n// DOWNLOAD FUNCTIONS', start);
const split = source.slice(start, end);
expect(split).toContain('.merge_split_');
expect(split).toContain('onPartState(i, outputFile, temporaryFile)');
expect(split).toContain('fs.renameSync(temporaryFile, outputFile)');
expect(split).toContain('onPartState(i, outputFile, null)');
expect(split.indexOf('onPartState(i, outputFile, temporaryFile)')).toBeLessThan(split.indexOf("spawn(ffmpeg, args"));
});
it('hydrates interrupted split state and prevents retry while recovery artifacts remain', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
expect(source).toContain('splitTempFiles: Array.isArray(raw.splitTempFiles)');
expect(source).toContain("raw.status === 'downloading' && isPlainObject(raw.mergeGroup)");
expect(source).toContain('const interruptedMergeItemIds = new Set<string>()');
expect(source).toContain('recoverInterruptedMergeArtifacts(downloadQueue, config.download_path, queueLoad.interruptedMergeItemIds)');
expect(source).toContain("item.status === 'error' && !item.mergeRecoveryBlocked");
expect(source).toContain("if (item.status !== 'error' || item.mergeRecoveryBlocked) return downloadQueue");
});
});
+61 -4
View File
@@ -107,6 +107,22 @@ describe('migrateJsonToSqlite', () => {
expect(count?.c).toBe(2);
});
test('scrubs secret aliases reintroduced into legacy files after migration', () => {
const configPath = writeJson('config.json', { language: 'de' });
migrateJsonToSqlite({ db, appDataDir });
fs.writeFileSync(configPath, JSON.stringify({ language: 'en', clientSecret: 'late-client-secret' }), 'utf-8');
fs.writeFileSync(`${configPath}.v4-backup`, JSON.stringify({ language: 'de', accessToken: 'late-access-token' }), 'utf-8');
const second = migrateJsonToSqlite({ db, appDataDir });
expect(second).toMatchObject({ alreadyApplied: true, errors: [] });
const persistedFiles = `${fs.readFileSync(configPath, 'utf-8')}\n${fs.readFileSync(`${configPath}.v4-backup`, 'utf-8')}`;
for (const forbidden of ['late-client-secret', 'late-access-token', 'clientSecret', 'accessToken']) {
expect(persistedFiles).not.toContain(forbidden);
}
expect(JSON.parse(db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language'])!.value)).toBe('de');
});
test('writes .v4-backup of source JSONs', () => {
const configPath = writeJson('config.json', { language: 'en' });
migrateJsonToSqlite({ db, appDataDir });
@@ -114,6 +130,27 @@ describe('migrateJsonToSqlite', () => {
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"');
});
test('does not scrub recoverable plaintext secrets before the SQLite transaction commits', () => {
const configPath = writeJson('config.json', { language: 'de', client_secret: 'must-survive' });
const transactionDb: DbHandle = {
...db,
transaction<R>(fn: () => R): R {
return db.transaction(() => {
const result = fn();
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
return result;
});
},
};
const secrets = createSecretStore(transactionDb, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db: transactionDb, appDataDir, secrets });
expect(result.errors).toEqual([]);
expect(secrets.get('twitch_client_secret')).toBe('must-survive');
expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('must-survive');
});
test('malformed JSON is logged + skipped', () => {
fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8');
const result = migrateJsonToSqlite({ db, appDataDir });
@@ -248,6 +285,26 @@ describe('migrateJsonToSqlite', () => {
expect(db.get('SELECT key FROM config_kv WHERE key = ?', ['discord_webhook_url'])).toBeUndefined();
});
test('migrates camel and separator secret aliases and scrubs every secret-bearing key', () => {
const configPath = writeJson('config.json', {
clientSecret: 'camel-client-secret',
'discord-webhook-url': 'https://discord.com/api/webhooks/camel',
accessToken: 'obsolete-access-token',
language: 'de',
});
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
expect(result.errors).toEqual([]);
expect(secrets.get('twitch_client_secret')).toBe('camel-client-secret');
expect(secrets.get('discord_webhook_url')).toBe('https://discord.com/api/webhooks/camel');
const persistedFiles = `${fs.readFileSync(configPath, 'utf-8')}\n${fs.readFileSync(configPath + '.v4-backup', 'utf-8')}`;
for (const forbidden of ['camel-client-secret', '/webhooks/camel', 'obsolete-access-token', 'clientSecret', 'discord-webhook-url', 'accessToken']) {
expect(persistedFiles).not.toContain(forbidden);
}
});
test('keeps plaintext legacy secrets untouched when production encryption is unavailable', () => {
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
const secrets = createSecretStore(db, new MemorySecureStorage());
@@ -260,7 +317,7 @@ describe('migrateJsonToSqlite', () => {
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
test('keeps plaintext legacy secrets when the sanitized backup cannot be published', () => {
test('commits recovered secrets before reporting a legacy scrub failure', () => {
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
fs.mkdirSync(`${configPath}.v4-backup`);
const secrets = createSecretStore(db, new MemorySecureStorage());
@@ -269,8 +326,8 @@ describe('migrateJsonToSqlite', () => {
expect(result.errors).toHaveLength(1);
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
expect(db.all('SELECT * FROM app_secrets')).toEqual([]);
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
expect(JSON.parse(db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language'])!.value)).toBe('de');
expect(secrets.get('twitch_client_secret')).toBe('must-survive');
expect(db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])?.name).toBe('authoritative-state-v1');
});
});
+58 -23
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import type { DbHandle } from '../infra/db';
import { createAppStateStore } from './app-state-store';
import { isSecretBearingKey } from './config-export';
import type { SecretStore } from './secret-store';
export interface MigratorOptions {
@@ -26,7 +27,19 @@ export interface MigrationResult {
}
const MIGRATION_NAME = 'authoritative-state-v1';
const SECRET_KEYS = new Set(['client_secret', 'discord_webhook_url']);
function normalizeSecretKey(key: string): string {
return key.replace(/[^a-z0-9]/gi, '').toLowerCase();
}
function findLegacySecret(config: Record<string, unknown>, canonicalKey: string): string | null {
const canonicalValue = config[canonicalKey];
if (typeof canonicalValue === 'string' && canonicalValue) return canonicalValue;
const normalizedKey = normalizeSecretKey(canonicalKey);
for (const [key, value] of Object.entries(config)) {
if (normalizeSecretKey(key) === normalizedKey && typeof value === 'string' && value) return value;
}
return null;
}
function readJson<T>(filePath: string, source: string, errors: MigrationError[]): T | undefined {
if (!fs.existsSync(filePath)) return undefined;
@@ -39,7 +52,7 @@ function readJson<T>(filePath: string, source: string, errors: MigrationError[])
}
function withoutSecrets(config: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(config).filter(([key]) => !SECRET_KEYS.has(key)));
return Object.fromEntries(Object.entries(config).filter(([key]) => !isSecretBearingKey(key)));
}
function writeJsonAtomic(filePath: string, value: unknown): void {
@@ -60,9 +73,23 @@ function scrubConfigFiles(configPath: string, config: Record<string, unknown>):
}
function scrubExistingConfig(configPath: string): void {
if (!fs.existsSync(configPath)) return;
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
scrubConfigFiles(configPath, config);
for (const candidate of [configPath, `${configPath}.v4-backup`]) {
if (!fs.existsSync(candidate)) continue;
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
} catch {
fs.rmSync(candidate, { force: true });
continue;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
fs.rmSync(candidate, { force: true });
continue;
}
const config = parsed as Record<string, unknown>;
const sanitized = withoutSecrets(config);
if (JSON.stringify(config) !== JSON.stringify(sanitized)) writeJsonAtomic(candidate, sanitized);
}
}
function emptyResult(alreadyApplied: boolean, errors: MigrationError[] = []): MigrationResult {
@@ -82,6 +109,11 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
const queuePath = path.join(appDataDir, 'download_queue.json');
const existing = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', [MIGRATION_NAME]);
if (existing) {
try {
scrubExistingConfig(configPath);
} catch (error) {
return emptyResult(true, [{ source: 'legacy-config-scrub', message: error instanceof Error ? error.message : String(error) }]);
}
return emptyResult(true);
}
@@ -97,17 +129,18 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
if (configExists && (!config || typeof config !== 'object' || Array.isArray(config))) {
return emptyResult(false, [{ source: 'config.json', message: 'Config JSON must be an object' }]);
}
if (config && !secrets && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key])) {
const clientSecret = config ? findLegacySecret(config, 'client_secret') : null;
const webhookUrl = config ? findLegacySecret(config, 'discord_webhook_url') : null;
if ((clientSecret || webhookUrl) && !secrets) {
return emptyResult(false, [{ source: 'migration', message: 'Secure secret storage is required for plaintext secret migration' }]);
}
if (config && requireEncryption && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key]) && !secrets?.status().encryptionAvailable) {
if ((clientSecret || webhookUrl) && requireEncryption && !secrets?.status().encryptionAvailable) {
return emptyResult(false, [{ source: 'migration', message: 'OS secret encryption is unavailable' }]);
}
const state = createAppStateStore(db);
let downloadedVodsCount = 0;
let streamersCount = 0;
let configScrubbed = false;
try {
db.transaction(() => {
if (configExists && config) {
@@ -115,12 +148,8 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
downloadedVodsCount = Array.isArray(config.downloaded_vod_ids)
? config.downloaded_vod_ids.filter((value) => typeof value === 'string' && value).length
: 0;
if (typeof config.client_secret === 'string' && config.client_secret) {
secrets!.set('twitch_client_secret', config.client_secret);
}
if (typeof config.discord_webhook_url === 'string' && config.discord_webhook_url) {
secrets!.set('discord_webhook_url', config.discord_webhook_url);
}
if (clientSecret) secrets!.set('twitch_client_secret', clientSecret);
if (webhookUrl) secrets!.set('discord_webhook_url', webhookUrl);
}
if (queueExists) state.saveQueue(queue as Array<Record<string, unknown>>);
streamersCount = db.get<{ count: number }>('SELECT COUNT(*) AS count FROM streamers')?.count ?? 0;
@@ -129,20 +158,26 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
[MIGRATION_NAME, JSON.stringify({ configMigrated: configExists, queueMigrated: queueExists, downloadedVodsCount, streamersCount })]
);
if (queueExists) backupJson(queuePath, queue);
if (configExists && config) {
scrubConfigFiles(configPath, config);
configScrubbed = true;
}
});
} catch (error) {
if (configScrubbed && config) {
try {
writeJsonAtomic(configPath, config);
} catch { }
}
return emptyResult(false, [{ source: 'migration', message: error instanceof Error ? error.message : String(error) }]);
}
if (configExists && config) {
try {
scrubConfigFiles(configPath, config);
} catch (error) {
return {
alreadyApplied: false,
configMigrated: true,
queueMigrated: queueExists,
downloadedVodsCount,
streamersCount,
errors: [{ source: 'legacy-config-scrub', message: error instanceof Error ? error.message : String(error) }],
};
}
}
return {
alreadyApplied: false,
configMigrated: configExists,
+18 -1
View File
@@ -4,7 +4,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { openDatabase, type DbHandle } from '../infra/db';
import { createAppStateStore } from './app-state-store';
import { commitQueueMutation, persistStateChange } from './persistence-commit';
import { applyQueueSnapshotPreservingActiveItems, commitQueueMutation, persistStateChange } from './persistence-commit';
let directory: string;
let db: DbHandle;
@@ -20,6 +20,23 @@ afterEach(() => {
});
describe('persistStateChange', () => {
it('preserves active item identity while applying a persisted pause snapshot', () => {
const active = { id: 'q1', status: 'downloading', progress: 72, mergePhase: 'merging' };
const idle = { id: 'q2', status: 'pending', progress: 0 };
const applied = applyQueueSnapshotPreservingActiveItems(
[active, idle],
[
{ id: 'q1', status: 'paused', progress: 72, mergePhase: 'merging' },
{ id: 'q2', status: 'paused', progress: 0 },
],
new Set(['q1']),
);
expect(applied[0]).toBe(active);
expect(active).toMatchObject({ status: 'paused', progress: 72, mergePhase: 'merging' });
expect(applied[1]).not.toBe(idle);
});
it('keeps runtime configuration at the persisted value when a SQLite write fails', () => {
const previous = { language: 'de' };
const next = { language: 'en' };
+10
View File
@@ -4,6 +4,16 @@ export function persistStateChange<T>(current: T, createNext: (current: T) => T,
return next;
}
export function applyQueueSnapshotPreservingActiveItems<T extends { id: string }>(current: T[], next: T[], activeItemIds: ReadonlySet<string>): T[] {
const currentById = new Map(current.map((item) => [item.id, item]));
return next.map((candidate) => {
const active = activeItemIds.has(candidate.id) ? currentById.get(candidate.id) : undefined;
if (!active) return candidate;
Object.assign(active, candidate);
return active;
});
}
export async function commitQueueMutation<T>(
current: T,
createNext: (current: T) => T,
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import { QueueProcessRegistry } from '../queue/process-registry';
import { createPhaseBoundaryProcessResource, waitForPhaseBoundary } from './phase-boundary-process';
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe('phase-boundary queue processes', () => {
it('lets the current process finish on pause and only terminates it on cancellation', async () => {
const registry = new QueueProcessRegistry();
const exited = deferred();
const kill = vi.fn();
const cleanup = vi.fn();
registry.register('item-a', 'merge', createPhaseBoundaryProcessResource({ kill }, () => exited.promise, cleanup));
await registry.pauseItem('item-a');
expect(kill).not.toHaveBeenCalled();
expect(cleanup).not.toHaveBeenCalled();
const cancelling = registry.cancelItem('item-a');
expect(kill).toHaveBeenCalledOnce();
expect(cleanup).not.toHaveBeenCalled();
exited.resolve();
await cancelling;
expect(cleanup).toHaveBeenCalledOnce();
});
it('waits at a safe boundary until the paused item is resumed', async () => {
const registry = new QueueProcessRegistry();
registry.register('item-a', 'split', {});
await registry.pauseItem('item-a');
const transitions: string[] = [];
let settled = false;
const waiting = waitForPhaseBoundary('item-a', registry, {
onPaused: () => { transitions.push('paused'); },
onResumed: () => { transitions.push('resumed'); },
}).then((result) => {
settled = true;
return result;
});
await Promise.resolve();
expect(settled).toBe(false);
expect(transitions).toEqual(['paused']);
await registry.resumeItem('item-a');
await expect(waiting).resolves.toBe(true);
expect(transitions).toEqual(['paused', 'resumed']);
});
it('does not report resumed after cancellation releases a paused boundary', async () => {
const registry = new QueueProcessRegistry();
registry.register('item-a', 'split', {});
await registry.pauseItem('item-a');
const onResumed = vi.fn();
const waiting = waitForPhaseBoundary('item-a', registry, { onResumed });
await registry.cancelItem('item-a');
await expect(waiting).resolves.toBe(false);
expect(onResumed).not.toHaveBeenCalled();
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { QueueProcessResource } from '../queue/process-registry';
export interface KillableProcess {
kill(): unknown;
}
export interface PhaseBoundaryState {
isPaused(itemId: string): boolean;
isCancelled(itemId: string): boolean;
whenResumed(itemId: string): Promise<void>;
}
export interface PhaseBoundaryTransition {
onPaused?: () => unknown | Promise<unknown>;
onResumed?: () => unknown | Promise<unknown>;
}
export function createPhaseBoundaryProcessResource(
process: KillableProcess,
wait: () => Promise<unknown>,
cleanup?: () => unknown | Promise<unknown>,
): QueueProcessResource {
return {
kill: () => process.kill(),
wait,
cleanup,
};
}
export async function waitForPhaseBoundary(itemId: string | null, state: PhaseBoundaryState, transition: PhaseBoundaryTransition = {}): Promise<boolean> {
if (!itemId) return true;
if (state.isPaused(itemId)) {
await transition.onPaused?.();
await state.whenResumed(itemId);
if (state.isCancelled(itemId)) return false;
await transition.onResumed?.();
}
return !state.isCancelled(itemId);
}
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('phase-boundary production path', () => {
it('pauses only at completed boundaries and never reruns a failed concat or copy-merge phase', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const concatStart = source.indexOf('async function concatVideoFiles');
const concatEnd = source.indexOf('async function cutVideo', concatStart);
const concat = source.slice(concatStart, concatEnd);
const mergeStart = source.indexOf('async function mergeVideos');
const mergeEnd = source.indexOf('async function splitMergedFile', mergeStart);
const merge = source.slice(mergeStart, mergeEnd);
expect(source).toContain('async function waitForQueuePhaseBoundary');
expect(concat).not.toContain('while (true)');
expect(concat).toContain('await waitForQueuePhaseBoundary(itemId)');
expect(concat).toContain('fs.rmSync(outputFile, { force: true })');
expect(merge).toContain('const boundaryReady = await waitForQueuePhaseBoundary(itemId)');
expect(merge).toContain('if (appShutdownStarted || !boundaryReady)');
expect(merge).not.toContain('queueProcessRegistry.isCancelled(itemId) || queueProcessRegistry.isPaused(itemId)');
});
});
+1 -1
View File
@@ -11,7 +11,7 @@ describe('privileged IPC behavior', () => {
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
});
it.each(['add-to-queue', 'remove-from-queue', 'download-clip', 'run-preflight', 'get-debug-log'])(
it.each(['add-to-queue', 'add-to-queue-with-result', 'remove-from-queue', 'download-clip', 'run-preflight', 'get-debug-log'])(
'registers %s so an untrusted renderer event cannot execute it',
async (channel) => {
const directory = mkdtempSync(join(tmpdir(), 'tvm-privileged-ipc-'));
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { parseGraphqlDataEnvelope, parseGraphqlUser, parseHelixDataArray } from './provider-payload';
describe('provider payload semantics', () => {
it('accepts only an explicit object-valued GraphQL data envelope', () => {
expect(parseGraphqlDataEnvelope({ data: { user: null } })).toEqual({ status: 'success', value: { user: null } });
expect(parseGraphqlDataEnvelope({})).toEqual({ status: 'unavailable' });
expect(parseGraphqlDataEnvelope({ data: null })).toEqual({ status: 'unavailable' });
expect(parseGraphqlDataEnvelope('<html>failure</html>')).toEqual({ status: 'unavailable' });
});
it('distinguishes an explicit missing GraphQL user from a malformed response', () => {
expect(parseGraphqlUser({ user: null })).toEqual({ status: 'not-found' });
expect(parseGraphqlUser({ user: { id: '1' } })).toEqual({ status: 'success', value: { id: '1' } });
expect(parseGraphqlUser({})).toEqual({ status: 'unavailable' });
expect(parseGraphqlUser({ user: 'invalid' })).toEqual({ status: 'unavailable' });
});
it('accepts an explicit empty Helix data array without accepting missing data', () => {
expect(parseHelixDataArray({ data: [] })).toEqual({ status: 'success', value: [] });
expect(parseHelixDataArray({})).toEqual({ status: 'unavailable' });
expect(parseHelixDataArray({ data: null })).toEqual({ status: 'unavailable' });
expect(parseHelixDataArray({ data: {} })).toEqual({ status: 'unavailable' });
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { RefreshOutcome } from './refresh-result';
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export function parseGraphqlDataEnvelope(value: unknown): RefreshOutcome<Record<string, unknown>> {
const envelope = asRecord(value);
if (!envelope || !Object.prototype.hasOwnProperty.call(envelope, 'data')) return { status: 'unavailable' };
const data = asRecord(envelope.data);
return data ? { status: 'success', value: data } : { status: 'unavailable' };
}
export function parseGraphqlUser(value: unknown): RefreshOutcome<Record<string, unknown>> {
const data = asRecord(value);
if (!data || !Object.prototype.hasOwnProperty.call(data, 'user')) return { status: 'unavailable' };
if (data.user === null) return { status: 'not-found' };
const user = asRecord(data.user);
return user ? { status: 'success', value: user } : { status: 'unavailable' };
}
export function parseHelixDataArray(value: unknown): RefreshOutcome<unknown[]> {
const envelope = asRecord(value);
if (!envelope || !Object.prototype.hasOwnProperty.call(envelope, 'data') || !Array.isArray(envelope.data)) return { status: 'unavailable' };
return { status: 'success', value: envelope.data };
}
@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('queue addition IPC contract', () => {
it('keeps the legacy queue result and exposes the atomic accepted result separately', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
expect(source).toContain("registerTrustedIpcHandler(ipcMain, 'add-to-queue-with-result'");
expect(source).toContain('function addRendererQueueItemWithResult(input: unknown, notifyDuplicate: boolean): QueueAdditionResult<QueueItem>');
expect(source).toContain('return addRendererQueueItemWithResult(input, true).queue;');
expect(source).toContain('return addRendererQueueItemWithResult(input, false);');
expect(source).toContain("reason: 'access-denied' as const");
expect(source).toContain("reason: 'shutting-down'");
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';
import { commitQueueAddition } from './queue-addition';
describe('commitQueueAddition', () => {
it('returns the accepted item id from the same synchronous mutation it persists', () => {
const current = [{ id: 'existing' }];
const persist = vi.fn();
const result = commitQueueAddition(current, { id: 'added' }, () => false, persist);
expect(result).toEqual({
queue: [{ id: 'existing' }, { id: 'added' }],
accepted: true,
addedId: 'added',
});
expect(persist).toHaveBeenCalledOnce();
expect(persist).toHaveBeenCalledWith(result.queue);
});
it('returns an unmodified queue and no id for duplicates or invalid items', () => {
const current = [{ id: 'existing' }];
const persist = vi.fn();
expect(commitQueueAddition(current, { id: 'duplicate' }, () => true, persist)).toEqual({
queue: current,
accepted: false,
reason: 'duplicate',
});
expect(commitQueueAddition(current, null, () => false, persist)).toEqual({
queue: current,
accepted: false,
reason: 'invalid',
});
expect(persist).not.toHaveBeenCalled();
});
it('returns an explicit persistence failure without leaking a replacement queue', () => {
const current = [{ id: 'existing' }];
expect(commitQueueAddition(current, { id: 'added' }, () => false, () => {
throw new Error('disk full');
})).toEqual({
queue: current,
accepted: false,
reason: 'persistence-failed',
});
});
});
+32
View File
@@ -0,0 +1,32 @@
export type QueueAdditionRejectionReason = 'duplicate' | 'invalid' | 'shutting-down' | 'persistence-failed' | 'access-denied';
export interface QueueAdditionAccepted<T> {
queue: T[];
accepted: true;
addedId: string;
}
export interface QueueAdditionRejected<T> {
queue: T[];
accepted: false;
reason: QueueAdditionRejectionReason;
}
export type QueueAdditionResult<T> = QueueAdditionAccepted<T> | QueueAdditionRejected<T>;
export function commitQueueAddition<T extends { id: string }>(
current: T[],
item: T | null,
isDuplicate: (item: T) => boolean,
persist: (next: T[]) => void,
): QueueAdditionResult<T> {
if (!item) return { queue: current, accepted: false, reason: 'invalid' };
if (isDuplicate(item)) return { queue: current, accepted: false, reason: 'duplicate' };
const queue = [...current, item];
try {
persist(queue);
} catch {
return { queue: current, accepted: false, reason: 'persistence-failed' };
}
return { queue, accepted: true, addedId: item.id };
}
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest';
import type { QueueItem } from '../../types';
import {
canonicalQueueItemIdentity,
clearQueueTransferState,
getQueueCreatedAtMs,
isValidPersistedQueueId,
mergeQueueProgressState,
prepareQueueRetryProgress,
} from './queue-runtime';
function queueItem(overrides: Partial<QueueItem> = {}): QueueItem {
return {
id: '1760000000000-1',
url: 'https://www.twitch.tv/videos/1234567890',
title: 'Title',
date: '2026-08-13T10:00:00.000Z',
streamer: 'streamer',
duration_str: '1h',
status: 'pending',
progress: 0,
...overrides,
};
}
describe('queue runtime invariants', () => {
it('accepts every historical generated id shape and rejects markup or selector payloads', () => {
expect(isValidPersistedQueueId('1760000000000-1')).toBe(true);
expect(isValidPersistedQueueId('1760000000000-999')).toBe(true);
expect(isValidPersistedQueueId('1760000000000')).toBe(true);
expect(isValidPersistedQueueId('item" onclick="alert(1)')).toBe(false);
expect(isValidPersistedQueueId('1760000000000-1000')).toBe(false);
expect(isValidPersistedQueueId('not-an-id')).toBe(false);
});
it('uses createdAt first and the timestamp prefix of historical ids as fallback', () => {
expect(getQueueCreatedAtMs(queueItem({ createdAt: '2026-08-13T09:30:00.000Z' }), 1)).toBe(Date.parse('2026-08-13T09:30:00.000Z'));
expect(getQueueCreatedAtMs(queueItem({ id: '1760000000000-27' }), 1)).toBe(1760000000000);
expect(getQueueCreatedAtMs(queueItem({ id: 'invalid', createdAt: 'invalid' }), 123)).toBe(123);
});
it('canonicalizes Twitch VOD identity independently of query, fragment, and metadata', () => {
const first = queueItem({
url: 'https://www.twitch.tv/videos/1234567890?filter=archives#chapter',
streamer: 'Streamer',
date: '2026-01-01',
});
const second = queueItem({
url: 'https://twitch.tv/videos/0001234567890',
streamer: 'renamed',
date: '2025-01-01',
});
expect(canonicalQueueItemIdentity(first)).toBe(canonicalQueueItemIdentity(second));
});
it('uses media clip coordinates but not filename metadata for custom clip identity', () => {
const first = queueItem({
customClip: { startSec: 10, durationSec: 20, startPart: 1, filenameFormat: 'simple' },
});
const renamed = queueItem({
customClip: { startSec: 10, durationSec: 20, startPart: 1, filenameFormat: 'template', filenameTemplate: 'other' },
});
const differentRange = queueItem({
customClip: { startSec: 11, durationSec: 20, startPart: 1, filenameFormat: 'simple' },
});
expect(canonicalQueueItemIdentity(first)).toBe(canonicalQueueItemIdentity(renamed));
expect(canonicalQueueItemIdentity(first)).not.toBe(canonicalQueueItemIdentity(differentRange));
});
it('removes transient transfer state on non-active transitions', () => {
const transitioned = clearQueueTransferState(queueItem({
status: 'paused',
progress: 42,
speed: '12 MB/s',
eta: '10s',
progressStatus: 'Paused',
downloadedBytes: 10,
totalBytes: 20,
recordingHealth: 'stale',
}), 'pending', 0);
expect(transitioned).toEqual(expect.objectContaining({ status: 'pending', progress: 0 }));
expect(transitioned).not.toHaveProperty('speed');
expect(transitioned).not.toHaveProperty('eta');
expect(transitioned).not.toHaveProperty('progressStatus');
expect(transitioned).not.toHaveProperty('downloadedBytes');
expect(transitioned).not.toHaveProperty('totalBytes');
expect(transitioned).not.toHaveProperty('recordingHealth');
});
it('atomically replaces stale transfer state with a retry countdown', () => {
const item = queueItem({
status: 'downloading',
speed: '12 MB/s',
eta: '10s',
progressStatus: 'Downloading',
recordingHealth: 'stale',
});
mergeQueueProgressState(item, {
id: item.id,
progress: -1,
speed: '',
eta: '',
status: 'Retrying in 5 seconds',
}, false);
expect(item.speed).toBe('');
expect(item.eta).toBe('');
expect(item.progressStatus).toBe('Retrying in 5 seconds');
expect(item).not.toHaveProperty('recordingHealth');
});
it('marks a live retry countdown as unknown instead of preserving stale health', () => {
const item = queueItem({
status: 'downloading',
currentPart: 2,
totalParts: 4,
downloadedBytes: 10,
totalBytes: 20,
recordingHealth: 'stale',
});
const retryProgress = prepareQueueRetryProgress(item, 'Retrying in 5 seconds');
expect(item.recordingHealth).toBe('unknown');
expect(item).not.toHaveProperty('downloadedBytes');
expect(item).not.toHaveProperty('totalBytes');
mergeQueueProgressState(item, retryProgress, false);
expect(retryProgress).toEqual({
id: item.id,
progress: -1,
speed: '',
eta: '',
status: 'Retrying in 5 seconds',
currentPart: 2,
totalParts: 4,
recordingHealth: 'unknown',
});
expect(item.recordingHealth).toBe('unknown');
expect(item).not.toHaveProperty('downloadedBytes');
expect(item).not.toHaveProperty('totalBytes');
});
it('does not overwrite pause-pending state with late process progress', () => {
const item = queueItem({
status: 'downloading',
speed: '',
eta: '',
progressStatus: 'Pause pending',
});
mergeQueueProgressState(item, {
id: item.id,
progress: 75,
speed: '12 MB/s',
eta: '10s',
status: 'Downloading',
recordingHealth: 'stale',
}, true);
expect(item).toEqual(expect.objectContaining({
progress: 0,
speed: '',
eta: '',
progressStatus: 'Pause pending',
}));
expect(item).not.toHaveProperty('recordingHealth');
});
});
+124
View File
@@ -0,0 +1,124 @@
import type { DownloadProgress, QueueItem } from '../../types';
type QueueIdentityInput = Pick<QueueItem, 'url' | 'customClip'>;
type QueueTransitionStatus = QueueItem['status'];
function onlyDigits(value: string): boolean {
return value.length > 0 && [...value].every((character) => character >= '0' && character <= '9');
}
function parseHistoricalQueueId(value: string): number | null {
const separator = value.indexOf('-');
if (separator !== -1 && separator !== value.lastIndexOf('-')) return null;
const timestampText = separator === -1 ? value : value.slice(0, separator);
const counterText = separator === -1 ? null : value.slice(separator + 1);
if (timestampText.length !== 13 || !onlyDigits(timestampText)) return null;
if (counterText !== null) {
if (!onlyDigits(counterText) || counterText.length > 3) return null;
if (counterText.length > 1 && counterText.startsWith('0')) return null;
if (Number(counterText) > 999) return null;
}
const timestamp = Number(timestampText);
return Number.isSafeInteger(timestamp) ? timestamp : null;
}
function normalizedUrlIdentity(rawUrl: string): string {
const trimmed = rawUrl.trim();
try {
const parsed = new URL(trimmed);
const hostname = parsed.hostname.toLowerCase().replace(/^www\./, '');
const vod = hostname === 'twitch.tv' ? parsed.pathname.match(/^\/videos\/(\d+)\/?$/i) : null;
if (vod) return `twitch-vod:${vod[1].replace(/^0+(?=\d)/, '')}`;
const clip = hostname === 'clips.twitch.tv'
? parsed.pathname.match(/^\/([A-Za-z0-9_-]+)\/?$/)
: hostname === 'twitch.tv'
? parsed.pathname.match(/^\/[^/]+\/clip\/([A-Za-z0-9_-]+)\/?$/i)
: null;
if (clip) return `twitch-clip:${clip[1]}`;
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
return `${parsed.protocol.toLowerCase()}//${hostname}${pathname}`;
} catch {
return trimmed.split(/[?#]/, 1)[0].replace(/\/+$/, '').toLowerCase();
}
}
export function isValidPersistedQueueId(value: unknown): value is string {
return typeof value === 'string' && parseHistoricalQueueId(value) !== null;
}
export function getQueueCreatedAtMs(item: Pick<QueueItem, 'id' | 'createdAt'>, fallback: number): number {
const explicit = Date.parse(item.createdAt || '');
if (Number.isFinite(explicit)) return explicit;
return parseHistoricalQueueId(item.id) ?? fallback;
}
export function canonicalQueueItemIdentity(item: QueueIdentityInput): string {
const mediaIdentity = normalizedUrlIdentity(item.url);
if (!item.customClip) return `${mediaIdentity}|full`;
return [
mediaIdentity,
'clip',
item.customClip.startSec,
item.customClip.durationSec,
item.customClip.startPart,
].join('|');
}
export function clearQueueTransferState(item: QueueItem, status: QueueTransitionStatus, progress: number): QueueItem {
const stable = { ...item };
delete stable.speed;
delete stable.eta;
delete stable.progressStatus;
delete stable.downloadedBytes;
delete stable.totalBytes;
delete stable.recordingHealth;
return { ...stable, status, progress };
}
export function applyQueueTransferState(item: QueueItem, status: QueueTransitionStatus, progress: number): QueueItem {
delete item.speed;
delete item.eta;
delete item.progressStatus;
delete item.downloadedBytes;
delete item.totalBytes;
delete item.recordingHealth;
item.status = status;
item.progress = progress;
return item;
}
export function prepareQueueRetryProgress(item: QueueItem, status: string): DownloadProgress {
applyQueueTransferState(item, 'downloading', item.progress);
item.recordingHealth = 'unknown';
return {
id: item.id,
progress: -1,
speed: '',
eta: '',
status,
currentPart: item.currentPart,
totalParts: item.totalParts,
recordingHealth: 'unknown',
};
}
export function mergeQueueProgressState(item: QueueItem, progress: DownloadProgress, paused: boolean): QueueItem {
if (paused) return item;
const numericProgress = Number(progress.progress);
if (Number.isFinite(numericProgress) && numericProgress > 0 && numericProgress <= 100) {
item.progress = Math.max(item.progress, numericProgress);
}
item.speed = progress.speed || '';
item.eta = progress.eta || '';
item.progressStatus = progress.status;
if (typeof progress.currentPart === 'number') item.currentPart = progress.currentPart;
if (typeof progress.totalParts === 'number') item.totalParts = progress.totalParts;
if (typeof progress.downloadedBytes === 'number') item.downloadedBytes = progress.downloadedBytes;
if (typeof progress.totalBytes === 'number') item.totalBytes = progress.totalBytes;
if (progress.recordingHealth === 'ok' || progress.recordingHealth === 'stale' || progress.recordingHealth === 'unknown') {
item.recordingHealth = progress.recordingHealth;
} else {
delete item.recordingHealth;
}
return item;
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { resolveRefreshOutcome, type RefreshOutcome } from './refresh-result';
describe('resolveRefreshOutcome', () => {
it('keeps the last good value when a refresh is unavailable without caching the failure', () => {
const previous = [{ id: 'vod-1' }];
const outcome: RefreshOutcome<Array<{ id: string }>> = { status: 'unavailable' };
expect(resolveRefreshOutcome(previous, outcome)).toEqual({
value: previous,
shouldCache: false,
stale: true,
});
});
it('accepts a successful empty collection as fresh authoritative data', () => {
expect(resolveRefreshOutcome([{ id: 'vod-1' }], { status: 'success', value: [] })).toEqual({
value: [],
shouldCache: true,
stale: false,
});
});
it('returns no value when the source is unavailable and no last good value exists', () => {
expect(resolveRefreshOutcome(undefined, { status: 'unavailable' })).toEqual({
value: null,
shouldCache: false,
stale: false,
});
});
it('treats an authoritative not-found result differently from an outage', () => {
expect(resolveRefreshOutcome([{ id: 'vod-1' }], { status: 'not-found' })).toEqual({
value: null,
shouldCache: true,
stale: false,
});
});
});
+17
View File
@@ -0,0 +1,17 @@
export type RefreshOutcome<T> =
| { status: 'success'; value: T }
| { status: 'not-found' }
| { status: 'unavailable' };
export interface ResolvedRefresh<T> {
value: T | null;
shouldCache: boolean;
stale: boolean;
}
export function resolveRefreshOutcome<T>(previous: T | undefined, outcome: RefreshOutcome<T>): ResolvedRefresh<T> {
if (outcome.status === 'success') return { value: outcome.value, shouldCache: true, stale: false };
if (outcome.status === 'not-found') return { value: null, shouldCache: true, stale: false };
if (previous !== undefined) return { value: previous, shouldCache: false, stale: true };
return { value: null, shouldCache: false, stale: false };
}
@@ -87,4 +87,41 @@ describe('renderer queue input', () => {
customClip: { startSec: -1, durationSec: 10, startPart: 1, filenameFormat: 'simple' },
})).toBeNull();
});
it('cleans interrupted merge artifacts without deleting completed published outputs', () => {
const base = createRendererQueueItem({
url: 'https://www.twitch.tv/videos/1',
title: 'Merge',
date: '2026-08-13T00:00:00.000Z',
streamer: 'fixture_streamer',
duration_str: '1h',
}, 'merge-id')!;
const interrupted = {
...base,
status: 'error' as const,
mergeGroup: {
items: [],
mergePhase: 'splitting' as const,
currentItemIndex: 0,
downloadedFiles: { 0: 'C:\\downloads\\merge_tmp_0_1.mp4' },
mergedFile: 'C:\\downloads\\merged_2.mp4',
splitFiles: ['C:\\downloads\\Part01.mp4'],
splitTempFiles: ['C:\\downloads\\.merge_split_2_0.mp4'],
},
};
expect(getMergeGroupCleanupPaths(interrupted)).toEqual([
'C:\\downloads\\merge_tmp_0_1.mp4',
'C:\\downloads\\merged_2.mp4',
'C:\\downloads\\Part01.mp4',
'C:\\downloads\\.merge_split_2_0.mp4',
]);
expect(getMergeGroupCleanupPaths({
...interrupted,
status: 'completed',
mergeGroup: { ...interrupted.mergeGroup, mergePhase: 'done' },
})).toEqual([
'C:\\downloads\\merge_tmp_0_1.mp4',
'C:\\downloads\\merged_2.mp4',
]);
});
});
+4
View File
@@ -60,8 +60,12 @@ export function createRendererQueueItem(value: unknown, id: string): QueueItem |
export function getMergeGroupCleanupPaths(item: QueueItem | undefined): string[] {
if (!item?.mergeGroup) return [];
const interruptedSplitFiles = item.mergeGroup.mergePhase === 'done'
? []
: [...(item.mergeGroup.splitFiles ?? []), ...(item.mergeGroup.splitTempFiles ?? [])];
return [
...Object.values(item.mergeGroup.downloadedFiles),
...(item.mergeGroup.mergedFile ? [item.mergeGroup.mergedFile] : []),
...interruptedSplitFiles,
];
}
+94
View File
@@ -0,0 +1,94 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import {
readSecretSafely,
createManagedToolExecutionTracker,
runResilientSteps,
secureImportedConfigTransition,
} from './runtime-safety';
describe('runtime safety', () => {
it('isolates a secret read failure without invalidating the store', () => {
const onError = vi.fn();
const store = {
get: vi.fn((key: string) => {
if (key === 'broken') throw new Error('foreign DPAPI ciphertext');
return 'usable';
}),
};
expect(readSecretSafely(store, 'broken', onError)).toBe('');
expect(readSecretSafely(store, 'valid', onError)).toBe('usable');
expect(onError).toHaveBeenCalledOnce();
});
it('does not activate an imported all-files delete policy from a safe state', () => {
expect(secureImportedConfigTransition(
{ auto_cleanup_enabled: false, auto_cleanup_target: 'live_only', auto_cleanup_action: 'archive' },
{ auto_cleanup_enabled: true, auto_cleanup_target: 'all', auto_cleanup_action: 'delete' },
)).toEqual({ auto_cleanup_enabled: false, auto_cleanup_target: 'all', auto_cleanup_action: 'delete' });
});
it('does not alter an already active cleanup policy when unrelated values are imported', () => {
expect(secureImportedConfigTransition(
{ auto_cleanup_enabled: true, auto_cleanup_target: 'all', auto_cleanup_action: 'delete' },
{ language: 'en' },
)).toEqual({ language: 'en' });
});
it('runs every cleanup step after earlier failures', async () => {
const calls: string[] = [];
const errors: Array<{ name: string; error: unknown }> = [];
await runResilientSteps([
['persist-config', () => { calls.push('persist-config'); throw new Error('disk full'); }],
['persist-queue', async () => { calls.push('persist-queue'); }],
['cleanup-partial', () => { calls.push('cleanup-partial'); }],
], (name, error) => errors.push({ name, error }));
expect(calls).toEqual(['persist-config', 'persist-queue', 'cleanup-partial']);
expect(errors.map((entry) => entry.name)).toEqual(['persist-config']);
});
it('continues cleanup when failure reporting itself throws', async () => {
const calls: string[] = [];
await runResilientSteps([
['first', () => { calls.push('first'); throw new Error('cleanup failed'); }],
['second', () => { calls.push('second'); }],
], () => {
throw new Error('reporting failed');
});
expect(calls).toEqual(['first', 'second']);
});
it('records native canonical paths and execution counts only while the cutter E2E gate is enabled', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-tool-diagnostics-'));
const nested = path.join(directory, 'nested');
fs.mkdirSync(nested);
const ffmpeg = path.join(directory, 'ffmpeg.exe');
const ffprobe = path.join(directory, 'ffprobe.exe');
const streamlink = path.join(directory, 'streamlink.exe');
for (const filePath of [ffmpeg, ffprobe, streamlink]) fs.writeFileSync(filePath, 'tool');
try {
const disabled = createManagedToolExecutionTracker(false);
disabled.record('ffmpeg', ffmpeg);
expect(disabled.snapshot()).toBeNull();
const tracker = createManagedToolExecutionTracker(true);
tracker.record('ffmpeg', path.join(nested, '..', 'ffmpeg.exe'));
tracker.record('ffmpeg', ffmpeg);
tracker.record('ffprobe', path.join(nested, '..', 'ffprobe.exe'));
tracker.record('streamlink', path.join(nested, '..', 'streamlink.exe'));
expect(tracker.snapshot()).toEqual({
ffmpeg: { path: fs.realpathSync.native(ffmpeg), count: 2 },
ffprobe: { path: fs.realpathSync.native(ffprobe), count: 1 },
streamlink: { path: fs.realpathSync.native(streamlink), count: 1 },
});
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
});
+99
View File
@@ -0,0 +1,99 @@
import * as fs from 'node:fs';
export type CleanupStep = readonly [name: string, run: () => unknown | Promise<unknown>];
export type ManagedToolExecutionKind = 'ffmpeg' | 'ffprobe' | 'streamlink';
export interface ManagedToolExecutionRecord {
path: string | null;
count: number;
}
export interface ManagedToolExecutionDiagnostics {
ffmpeg: ManagedToolExecutionRecord;
ffprobe: ManagedToolExecutionRecord;
streamlink: ManagedToolExecutionRecord;
}
interface SecretReader<K extends string> {
get(key: K): string | null;
}
interface CleanupConfig {
auto_cleanup_enabled: boolean;
auto_cleanup_target: 'live_only' | 'all';
auto_cleanup_action: 'archive' | 'delete';
}
export function readSecretSafely<K extends string>(
store: SecretReader<K>,
key: K,
onError: (error: unknown) => void,
): string {
try {
return store.get(key) ?? '';
} catch (error) {
onError(error);
return '';
}
}
export function secureImportedConfigTransition<T extends Record<string, unknown>>(
current: CleanupConfig,
imported: T,
): T {
const effective = { ...current, ...imported } as CleanupConfig & T;
const currentlyDestructive = current.auto_cleanup_enabled
&& current.auto_cleanup_target === 'all'
&& current.auto_cleanup_action === 'delete';
const activatesDestructiveCleanup = effective.auto_cleanup_enabled
&& effective.auto_cleanup_target === 'all'
&& effective.auto_cleanup_action === 'delete';
if (currentlyDestructive || !activatesDestructiveCleanup) return imported;
return { ...imported, auto_cleanup_enabled: false };
}
export async function runResilientSteps(
steps: ReadonlyArray<CleanupStep>,
onError: (name: string, error: unknown) => void,
): Promise<void> {
for (const [name, run] of steps) {
try {
await run();
} catch (error) {
try {
onError(name, error);
} catch { }
}
}
}
export function createManagedToolExecutionTracker(enabled: boolean): {
record(kind: ManagedToolExecutionKind, command: string): void;
snapshot(): ManagedToolExecutionDiagnostics | null;
} {
const state: ManagedToolExecutionDiagnostics = {
ffmpeg: { path: null, count: 0 },
ffprobe: { path: null, count: 0 },
streamlink: { path: null, count: 0 },
};
return {
record(kind, command) {
if (!enabled) return;
try {
state[kind] = {
path: fs.realpathSync.native(command),
count: state[kind].count + 1,
};
} catch { }
},
snapshot() {
if (!enabled) return null;
return {
ffmpeg: { ...state.ffmpeg },
ffprobe: { ...state.ffprobe },
streamlink: { ...state.streamlink },
};
},
};
}
+25 -6
View File
@@ -15,6 +15,15 @@ function fakeFetch(rows: Array<Record<string, unknown>>, status = 200): typeof f
}) as unknown as typeof fetch;
}
async function captureError(run: () => Promise<unknown>): Promise<Error> {
try {
await run();
} catch (error) {
if (error instanceof Error) return error;
}
throw new Error('Expected the operation to reject with an Error');
}
describe('fetchTopClips', () => {
test('returns parsed clips sorted by view_count desc', async () => {
const fakeRows = [
@@ -98,17 +107,27 @@ describe('fetchTopClips', () => {
});
test('throws on non-2xx response', async () => {
await expect(fetchTopClips({
const responseFetch = (async (): Promise<Response> => new Response('{"Authorization":"Bearer response-token","cookie":"response-cookie"}', { status: 503 })) as unknown as typeof fetch;
const error = await captureError(() => fetchTopClips({
clientId: 'C', accessToken: 'T', broadcasterId: 'b',
fetchImpl: fakeFetch([], 503),
})).rejects.toThrow(/503/);
fetchImpl: responseFetch,
}));
expect(error.message).toBe('top-clips-crawler: helix returned HTTP 503');
expect(error.cause).toBeUndefined();
expect(JSON.stringify(error)).not.toContain('response-token');
expect(JSON.stringify(error)).not.toContain('response-cookie');
});
test('throws on malformed JSON', async () => {
const brokenFetch = (async (): Promise<Response> => new Response('{not-json', { status: 200 })) as unknown as typeof fetch;
await expect(fetchTopClips({
const brokenFetch = (async (): Promise<Response> => new Response('{"accessToken":"parse-token"', { status: 200 })) as unknown as typeof fetch;
const error = await captureError(() => fetchTopClips({
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch,
})).rejects.toThrow(/parse failed/);
}));
expect(error.message).toBe('top-clips-crawler: invalid helix response');
expect(error.cause).toBeUndefined();
expect(`${error.message}${JSON.stringify(error)}`).not.toContain('parse-token');
});
test('empty data returns empty array (not null)', async () => {
+14 -13
View File
@@ -92,22 +92,23 @@ export async function fetchTopClips(opts: FetchTopClipsOptions): Promise<TopClip
if (opts.startedAt) params.set('started_at', opts.startedAt);
if (opts.endedAt) params.set('ended_at', opts.endedAt);
const res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${opts.accessToken}`,
'Client-Id': opts.clientId,
},
});
const text = await res.text();
if (!res.ok) {
throw new Error(`top-clips-crawler: helix ${res.status}: ${text}`);
let res: Response;
try {
res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${opts.accessToken}`,
'Client-Id': opts.clientId,
},
});
} catch {
throw new Error('top-clips-crawler: helix request failed');
}
if (!res.ok) throw new Error(`top-clips-crawler: helix returned HTTP ${res.status}`);
let parsed: HelixClipsResponse;
try {
parsed = JSON.parse(text) as HelixClipsResponse;
} catch (e) {
throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
parsed = JSON.parse(await res.text()) as HelixClipsResponse;
} catch {
throw new Error('top-clips-crawler: invalid helix response');
}
const rows = parsed.data ?? [];
+27 -3
View File
@@ -17,6 +17,15 @@ function httpGet(url: string): Promise<{ status: number }> {
});
}
async function captureError(run: () => Promise<unknown>): Promise<Error> {
try {
await run();
} catch (error) {
if (error instanceof Error) return error;
}
throw new Error('Expected the operation to reject with an Error');
}
describe('startLoginFlow', () => {
test('builds Twitch authorize URL with required params + PKCE + state', async () => {
const flow = await startLoginFlow({
@@ -121,11 +130,16 @@ describe('exchangeCodeForToken', () => {
});
test('throws on non-2xx response', async () => {
const fakeFetch = async (): Promise<Response> => new Response('bad request', { status: 400 });
await expect(exchangeCodeForToken({
const fakeFetch = async (): Promise<Response> => new Response('{"refreshToken":"body-refresh","cookie":"body-cookie"}', { status: 400 });
const error = await captureError(() => exchangeCodeForToken({
clientId: 'cid', code: 'X', codeVerifier: 'V', redirectUri: 'http://x',
fetchImpl: fakeFetch as unknown as typeof fetch,
})).rejects.toThrow(/400/);
}));
expect(error.message).toBe('twitch-oauth: token endpoint returned HTTP 400');
expect(error.cause).toBeUndefined();
expect(JSON.stringify(error)).not.toContain('body-refresh');
expect(JSON.stringify(error)).not.toContain('body-cookie');
});
});
@@ -150,4 +164,14 @@ describe('fetchTwitchUserInfo', () => {
await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch))
.rejects.toThrow(/no user/);
});
test('never exposes a helix response body or request credential in errors', async () => {
const fakeFetch = async (): Promise<Response> => new Response('{"accessToken":"body-access","clientSecret":"body-secret"}', { status: 401 });
const error = await captureError(() => fetchTwitchUserInfo('request-token', 'request-client', fakeFetch as unknown as typeof fetch));
expect(error.message).toBe('twitch-oauth: helix /users returned HTTP 401');
expect(error.cause).toBeUndefined();
const serialized = `${error.message}${JSON.stringify(error)}`;
for (const forbidden of ['body-access', 'body-secret', 'request-token', 'request-client']) expect(serialized).not.toContain(forbidden);
});
});
+35 -23
View File
@@ -93,9 +93,9 @@ export interface CompleteLoginResult {
export async function awaitAuthorizationCode(login: LoginStart, timeoutMs?: number): Promise<CompleteLoginResult> {
const params = await login.server.awaitParams({ timeoutMs });
if (params.has('error')) {
const err = params.get('error') ?? 'unknown_error';
const desc = params.get('error_description') ?? '';
throw new Error(`twitch-oauth: provider error: ${err}${desc ? `${desc}` : ''}`);
const rawError = params.get('error') ?? '';
const errorCode = /^[A-Za-z0-9_.-]{1,80}$/.test(rawError) ? rawError : 'unknown_error';
throw new Error(`twitch-oauth: provider error: ${errorCode}`);
}
const returnedState = params.get('state') ?? '';
if (returnedState !== login.state) {
@@ -126,17 +126,22 @@ export async function exchangeCodeForToken(opts: TokenExchangeOptions): Promise<
redirect_uri: opts.redirectUri,
});
const res = await fetchFn(TWITCH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`twitch-oauth: token endpoint ${res.status}: ${text}`);
let res: Response;
try {
res = await fetchFn(TWITCH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
} catch {
throw new Error('twitch-oauth: token request failed');
}
if (!res.ok) throw new Error(`twitch-oauth: token endpoint returned HTTP ${res.status}`);
try {
return JSON.parse(await res.text()) as TwitchTokenResponse;
} catch {
throw new Error('twitch-oauth: invalid token response');
}
return JSON.parse(text) as TwitchTokenResponse;
}
export async function fetchTwitchUserInfo(
@@ -145,17 +150,24 @@ export async function fetchTwitchUserInfo(
fetchImpl?: typeof fetch
): Promise<TwitchUserInfo> {
const fetchFn = fetchImpl ?? fetch;
const res = await fetchFn(TWITCH_HELIX_USERS_URL, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Client-Id': clientId,
},
});
const text = await res.text();
if (!res.ok) {
throw new Error(`twitch-oauth: helix /users ${res.status}: ${text}`);
let res: Response;
try {
res = await fetchFn(TWITCH_HELIX_USERS_URL, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Client-Id': clientId,
},
});
} catch {
throw new Error('twitch-oauth: helix /users request failed');
}
if (!res.ok) throw new Error(`twitch-oauth: helix /users returned HTTP ${res.status}`);
let json: { data?: TwitchUserInfo[] };
try {
json = JSON.parse(await res.text()) as { data?: TwitchUserInfo[] };
} catch {
throw new Error('twitch-oauth: invalid helix /users response');
}
const json = JSON.parse(text) as { data?: TwitchUserInfo[] };
const first = json.data?.[0];
if (!first) throw new Error('twitch-oauth: helix /users returned no user');
return first;
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('Twitch refresh production path', () => {
it('deduplicates force and normal VOD refreshes and keeps a retained last-good value', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function getVODs');
const end = source.indexOf('interface LiveStreamInfo', start);
const getVods = source.slice(start, end);
expect(getVods).toContain("withInFlightDedup(inFlightVodRequests, cacheKey");
expect(getVods).not.toContain("force' : 'default'");
expect(getVods).toContain('vodListLastGood.get(cacheKey)');
expect(getVods).toContain('requestTwitchHelixVideos(axios');
expect(getVods).toContain('refreshTwitchProviderData(');
});
it('retains profiles outside their expiring cache and deletes an authoritative not-found profile', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function getStreamerProfile');
const end = source.indexOf('// ==========================================\n// VOD STORYBOARD', start);
const getProfile = source.slice(start, end);
expect(getProfile).toContain('streamerProfileLastGood.get(normalized)');
expect(getProfile).toContain('streamerProfileLastGood.delete(normalized)');
expect(getProfile).toContain('streamerProfileLastGood.set(normalized, profile)');
});
});
+2 -2
View File
@@ -84,9 +84,9 @@ describe('formatDateWithPattern', () => {
describe('getMergeGroupPhaseText', () => {
test('known DE phases', () => {
expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen');
expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...');
expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfügen...');
expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt');
expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...');
expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufräumen...');
});
test('known EN phases', () => {
expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD');
+2 -2
View File
@@ -69,9 +69,9 @@ export function getMergeGroupPhaseText(phase: string, language: MergeGroupLangua
const isEnglish = language === 'en';
switch (phase) {
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfügen...';
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufräumen...';
default: return phase;
}
}
+7
View File
@@ -0,0 +1,7 @@
export { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './process-registry';
export { createRendererQueueItem, getMergeGroupCleanupPaths } from '../domain/renderer-queue-input';
export { commitQueueAddition } from '../domain/queue-addition';
export type { QueueAdditionResult } from '../domain/queue-addition';
export { getInterruptedMergeItemIds, recoverInterruptedMergeArtifacts, resolveMergeArtifactRoot } from '../domain/merge-recovery';
export { createPhaseBoundaryProcessResource, waitForPhaseBoundary } from '../domain/phase-boundary-process';
export { applyQueueSnapshotPreservingActiveItems, commitQueueMutation, persistStateChange } from '../domain/persistence-commit';
@@ -3,7 +3,7 @@ import { once } from 'node:events';
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './process-registry';
function waitForExit(process: ReturnType<typeof spawn>): Promise<void> {
@@ -15,6 +15,10 @@ function waitForExit(process: ReturnType<typeof spawn>): Promise<void> {
}
describe('queue process lifecycle integration', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('keeps quick resume behind a real child pause without deleting retry output', async () => {
const directory = mkdtempSync(join(tmpdir(), 'tvm-queue-pause-'));
const retryFile = join(directory, 'merge-retry.mp4');
@@ -25,14 +29,18 @@ describe('queue process lifecycle integration', () => {
try {
writeFileSync(retryFile, 'retry');
await once(child, 'spawn');
const pauseSettled = vi.fn();
const resumeStarted = vi.fn();
registry.register('item-a', 'merge', {
kill: () => child.kill(),
wait: () => waitForChildProcessExit(child, 30),
pause: async () => {
child.kill();
await waitForChildProcessExit(child, 30);
await waitForChildProcessExit(child, 30, 250);
pauseSettled();
},
resume: () => {
resumeStarted();
resumedAfterExit = child.exitCode !== null || child.signalCode !== null;
},
cleanup: () => rmSync(retryFile, { force: true }),
@@ -45,6 +53,7 @@ describe('queue process lifecycle integration', () => {
await Promise.all([pausing, resuming]);
expect(resumedAfterExit).toBe(true);
expect(pauseSettled).toHaveBeenCalledBefore(resumeStarted);
expect(registry.isPaused('item-a')).toBe(false);
expect(existsSync(retryFile)).toBe(true);
} finally {
@@ -69,7 +78,7 @@ describe('queue process lifecycle integration', () => {
lifecycle.schedule(async () => childExited);
registry.register('item-a', 'merge', {
kill: () => undefined,
wait: () => waitForChildProcessExit(child, 30),
wait: () => waitForChildProcessExit(child, 30, 250),
cleanup: () => {
expect(child.exitCode !== null || child.signalCode !== null).toBe(true);
rmSync(partialFile, { force: true });
+132 -8
View File
@@ -67,7 +67,7 @@ describe('waitForChildProcessExit', () => {
}
});
it('settles and releases resources when close never arrives after forced termination', async () => {
it('settles on process exit without waiting for delayed stream closure', async () => {
vi.useFakeTimers();
try {
const child = Object.assign(new EventEmitter(), {
@@ -75,23 +75,41 @@ describe('waitForChildProcessExit', () => {
signalCode: null,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
let settled = false;
const waiting = waitForChildProcessExit(child, 25).then(() => {
settled = true;
});
const waiting = waitForChildProcessExit(child, 25);
child.emit('exit', null, 'SIGTERM');
await waiting;
expect(child.kill).not.toHaveBeenCalled();
expect(child.listenerCount('close')).toBe(0);
expect(child.listenerCount('exit')).toBe(0);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it('rejects within a bounded deadline when close never arrives after forced termination', async () => {
vi.useFakeTimers();
try {
const child = Object.assign(new EventEmitter(), {
exitCode: null,
signalCode: null,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
const waiting = waitForChildProcessExit(child, 25);
const rejected = expect(waiting).rejects.toThrow('Child process did not exit after forced termination');
await vi.advanceTimersByTimeAsync(25);
expect(child.kill).toHaveBeenCalledOnce();
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(25);
expect(settled).toBe(true);
expect(child.listenerCount('close')).toBe(0);
expect(vi.getTimerCount()).toBe(0);
await waiting;
await rejected;
} finally {
vi.useRealTimers();
}
@@ -179,6 +197,57 @@ describe('QueueProcessRegistry', () => {
expect(registry.isPaused('item-a')).toBe(false);
});
it('does not resume resources when a pause operation fails', async () => {
const registry = new QueueProcessRegistry();
const pauseError = new Error('process exit was not confirmed');
const resource = createResource();
resource.pause = vi.fn(() => Promise.reject(pauseError));
registry.register('item-a', 'merge', resource);
const pausing = registry.pauseItem('item-a');
const resuming = registry.resumeItem('item-a');
await expect(pausing).rejects.toBe(pauseError);
await expect(resuming).rejects.toBe(pauseError);
expect(resource.resume).not.toHaveBeenCalled();
expect(registry.isPaused('item-a')).toBe(true);
});
it('resumes after a later pause retry succeeds', async () => {
const registry = new QueueProcessRegistry();
const resource = createResource();
resource.pause = vi.fn()
.mockRejectedValueOnce(new Error('process exit was not confirmed'))
.mockResolvedValueOnce(undefined);
registry.register('item-a', 'merge', resource);
await expect(registry.pauseItem('item-a')).rejects.toThrow('process exit was not confirmed');
await expect(registry.pauseItem('item-a')).resolves.toBeUndefined();
await expect(registry.resumeItem('item-a')).resolves.toBeUndefined();
expect(resource.pause).toHaveBeenCalledTimes(2);
expect(resource.resume).toHaveBeenCalledOnce();
expect(registry.isPaused('item-a')).toBe(false);
});
it('keeps a paused boundary controllable after the completed process registration releases', async () => {
const registry = new QueueProcessRegistry();
const registration = registry.register('item-a', 'merge', createResource());
await registry.pauseItem('item-a');
registration.release();
expect(registry.activeItemIds()).toEqual(['item-a']);
let resumed = false;
const waiting = registry.whenResumed('item-a').then(() => { resumed = true; });
await registry.resumeItem('item-a');
await waiting;
expect(resumed).toBe(true);
expect(registry.activeItemIds()).toEqual([]);
});
it.each(['merge', 'split'] as const)('waits for %s termination before removing partial output', async (phase) => {
const registry = new QueueProcessRegistry();
const closed = deferred();
@@ -199,6 +268,18 @@ describe('QueueProcessRegistry', () => {
expect(registry.activeItemIds()).toEqual([]);
});
it('retains partial output when process exit cannot be confirmed', async () => {
const registry = new QueueProcessRegistry();
const resource = createResource(Promise.reject(new Error('exit timeout')));
registry.register('item-a', 'merge', resource);
await registry.cancelItem('item-a');
expect(resource.kill).toHaveBeenCalledOnce();
expect(resource.cleanup).not.toHaveBeenCalled();
expect(registry.activeItemIds()).toEqual([]);
});
it('allows an explicitly reset item to retry without affecting another item', async () => {
const registry = new QueueProcessRegistry();
const firstAttempt = createResource();
@@ -266,4 +347,47 @@ describe('QueueRunLifecycle', () => {
expect(persist).toHaveBeenCalledOnce();
expect(registry.activeItemIds()).toEqual([]);
});
it('reports a persistence failure without rejecting shutdown', async () => {
const registry = new QueueProcessRegistry();
const lifecycle = new QueueRunLifecycle(registry);
const persistenceError = new Error('disk unavailable');
const reportError = vi.fn();
await expect(lifecycle.shutdown(
() => undefined,
() => { throw persistenceError; },
reportError,
)).resolves.toBeUndefined();
expect(reportError).toHaveBeenCalledOnce();
expect(reportError).toHaveBeenCalledWith(persistenceError);
});
it('finishes shutdown when process exit and the scheduled run never settle', async () => {
vi.useFakeTimers();
try {
const registry = new QueueProcessRegistry();
const lifecycle = new QueueRunLifecycle(registry, 25);
const neverFinishes = deferred();
const resource = createResource(Promise.reject(new Error('exit timeout')));
const persist = vi.fn(async () => undefined);
const reportTimeout = vi.fn();
lifecycle.schedule(async () => neverFinishes.promise);
registry.register('item-a', 'merge', resource);
const shutdown = lifecycle.shutdown(() => undefined, persist, undefined, reportTimeout);
await vi.advanceTimersByTimeAsync(25);
await shutdown;
expect(resource.kill).toHaveBeenCalledOnce();
expect(resource.cleanup).not.toHaveBeenCalled();
expect(persist).toHaveBeenCalledOnce();
expect(reportTimeout).toHaveBeenCalledWith(expect.objectContaining({ message: 'Queue run did not settle after process cancellation' }));
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
});
+65 -16
View File
@@ -2,28 +2,39 @@ import type { ChildProcess } from 'node:child_process';
export type QueueProcessPhase = 'streamlink' | 'merge' | 'split' | 'post-processing';
export function waitForChildProcessExit(process: ChildProcess | null, forceKillAfterMs = 5000): Promise<void> {
export function waitForChildProcessExit(process: ChildProcess | null, forceKillAfterMs = 5000, confirmExitAfterKillMs = forceKillAfterMs): Promise<void> {
if (!process || process.exitCode !== null || process.signalCode !== null) return Promise.resolve();
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let settled = false;
const finish = (): void => {
if (settled) return;
settled = true;
const release = (): void => {
if (forceKillTimer) clearTimeout(forceKillTimer);
if (settleTimer) clearTimeout(settleTimer);
process.removeListener('close', finish);
process.removeListener('exit', finish);
};
const finish = (): void => {
if (settled) return;
settled = true;
release();
resolve();
};
const fail = (): void => {
if (settled) return;
settled = true;
release();
reject(new Error('Child process did not exit after forced termination'));
};
process.once('close', finish);
process.once('exit', finish);
forceKillTimer = setTimeout(() => {
forceKillTimer = null;
if (process.exitCode !== null || process.signalCode !== null) {
finish();
return;
}
settleTimer = setTimeout(finish, forceKillAfterMs);
settleTimer = setTimeout(fail, confirmExitAfterKillMs);
try { process.kill('SIGKILL'); } catch { }
}, forceKillAfterMs);
});
@@ -43,6 +54,20 @@ export interface QueueProcessRegistration {
release: () => void;
}
async function waitForSettlementWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
let settled = false;
const finish = (completed: boolean): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(completed);
};
const timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
promise.then(() => finish(true), () => finish(true));
});
}
interface RegisteredResource {
itemId: string;
phase: QueueProcessPhase;
@@ -162,9 +187,11 @@ export class QueueProcessRegistry {
}
activeItemIds(): string[] {
return [...this.groups.entries()]
const active = new Set([...this.groups.entries()]
.filter(([, entries]) => entries.size > 0)
.map(([itemId]) => itemId);
.map(([itemId]) => itemId));
for (const itemId of this.pausedItems) active.add(itemId);
return [...active];
}
private async invokeItem(itemId: string, operation: 'pause' | 'resume'): Promise<void> {
@@ -179,8 +206,11 @@ export class QueueProcessRegistry {
entry.stopping = (async () => {
try { entry.resource.kill?.(); } catch { }
try { await entry.resource.cancel?.(); } catch { }
try { await entry.resource.wait?.(); } catch { }
try { await entry.resource.cleanup?.(); } catch { }
let exited = true;
try { await entry.resource.wait?.(); } catch { exited = false; }
if (exited) {
try { await entry.resource.cleanup?.(); } catch { }
}
this.release(entry);
})();
return entry.stopping;
@@ -192,13 +222,17 @@ export class QueueProcessRegistry {
}
private enqueuePause(itemId: string, entries: RegisteredResource[]): Promise<void> {
const previous = this.pauseRuns.get(itemId) || Promise.resolve();
const previous = this.pauseRuns.get(itemId)?.catch(() => undefined) || Promise.resolve();
const pauseRun = Promise.allSettled([
previous,
...entries.map(async ({ resource }) => {
await resource.pause?.();
}),
]).then(() => undefined);
]).then((results) => {
for (const result of results) {
if (result.status === 'rejected') throw result.reason;
}
});
this.pauseRuns.set(itemId, pauseRun);
return pauseRun;
}
@@ -233,7 +267,10 @@ export class QueueRunLifecycle {
private currentRun: Promise<void> | null = null;
private shutdownRun: Promise<void> | null = null;
constructor(private readonly registry: QueueProcessRegistry) { }
constructor(
private readonly registry: QueueProcessRegistry,
private readonly currentRunShutdownTimeoutMs = 5000,
) { }
schedule(run: () => Promise<void>, onError?: (error: unknown) => void): boolean {
if (this.shutdownRun || this.currentRun) return false;
@@ -247,15 +284,27 @@ export class QueueRunLifecycle {
return true;
}
shutdown(beforeCancel: () => unknown | Promise<unknown>, persist: () => unknown | Promise<unknown>): Promise<void> {
shutdown(
beforeCancel: () => unknown | Promise<unknown>,
persist: () => unknown | Promise<unknown>,
onPersistError?: (error: unknown) => void,
onRunTimeout?: (error: unknown) => void,
): Promise<void> {
if (this.shutdownRun) return this.shutdownRun;
this.registry.beginShutdown();
this.shutdownRun = (async () => {
try { await beforeCancel(); } catch { }
await this.registry.cancelAll();
if (this.currentRun) await this.currentRun;
const currentRun = this.currentRun;
if (currentRun && !(await waitForSettlementWithin(currentRun, this.currentRunShutdownTimeoutMs))) {
onRunTimeout?.(new Error('Queue run did not settle after process cancellation'));
}
await this.registry.waitForIdle();
await persist();
try {
await persist();
} catch (error) {
onPersistError?.(error);
}
})();
return this.shutdownRun;
}
+11
View File
@@ -0,0 +1,11 @@
export { openDatabase } from '../infra/db';
export type { DbHandle } from '../infra/db';
export { createAppStateStore } from '../domain/app-state-store';
export type { AppStateStore } from '../domain/app-state-store';
export { createExportableConfig } from '../domain/config-export';
export { normalizeStreamerLogins, sanitizeConfigInput, sanitizeImportedConfig } from '../domain/config-input';
export { resolveSecretInputUpdate } from '../domain/secret-input';
export { createSecretStore } from '../domain/secret-store';
export type { SecretStore } from '../domain/secret-store';
export { migrateJsonToSqlite } from '../domain/migrator';
export { createElectronSecureStorage } from '../infra/secure-storage';
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest';
import { requestTwitchAppAccessToken, TwitchAppTokenService, type TwitchAppTokenCredentials } from './app-token';
function credentials(clientId = 'client-id', clientSecret = 'client-secret'): TwitchAppTokenCredentials {
return { clientId, clientSecret };
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((accept, decline) => {
resolve = accept;
reject = decline;
});
return { promise, resolve, reject };
}
describe('TwitchAppTokenService', () => {
it('caches a successful token for the active credentials', async () => {
const requestToken = vi.fn().mockResolvedValue('token-one');
const service = new TwitchAppTokenService(requestToken);
expect(await service.ensure(credentials())).toBe('token-one');
expect(await service.ensure(credentials())).toBe('token-one');
expect(service.currentToken).toBe('token-one');
expect(requestToken).toHaveBeenCalledTimes(1);
});
it('deduplicates parallel requests for the same credentials', async () => {
const pending = deferred<string>();
const requestToken = vi.fn().mockReturnValue(pending.promise);
const service = new TwitchAppTokenService(requestToken);
const first = service.ensure(credentials());
const second = service.ensure(credentials());
pending.resolve('shared-token');
await expect(Promise.all([first, second])).resolves.toEqual(['shared-token', 'shared-token']);
expect(requestToken).toHaveBeenCalledTimes(1);
});
it('refreshes a cached token once and deduplicates parallel forced refreshes', async () => {
const refresh = deferred<string>();
const requestToken = vi.fn()
.mockResolvedValueOnce('token-one')
.mockReturnValueOnce(refresh.promise);
const service = new TwitchAppTokenService(requestToken);
await service.ensure(credentials());
const first = service.ensure(credentials(), true);
const second = service.ensure(credentials(), true);
refresh.resolve('token-two');
await expect(Promise.all([first, second])).resolves.toEqual(['token-two', 'token-two']);
expect(service.currentToken).toBe('token-two');
expect(requestToken).toHaveBeenCalledTimes(2);
});
it('discards an in-flight token after clear', async () => {
const stale = deferred<string>();
const requestToken = vi.fn()
.mockReturnValueOnce(stale.promise)
.mockResolvedValueOnce('fresh-token');
const service = new TwitchAppTokenService(requestToken);
const first = service.ensure(credentials());
service.clear();
stale.resolve('stale-token');
await expect(first).resolves.toBeNull();
expect(service.currentToken).toBeNull();
await expect(service.ensure(credentials())).resolves.toBe('fresh-token');
});
it('ignores an obsolete request error after clear', async () => {
const stale = deferred<string>();
const errors: unknown[] = [];
const service = new TwitchAppTokenService(() => stale.promise, (error) => errors.push(error));
const first = service.ensure(credentials());
service.clear();
stale.reject(new Error('obsolete request failed'));
await expect(first).resolves.toBeNull();
expect(errors).toEqual([]);
});
it('clears the cache and skips requests when credentials are missing', async () => {
const requestToken = vi.fn().mockResolvedValue('token-one');
const service = new TwitchAppTokenService(requestToken);
await service.ensure(credentials());
await expect(service.ensure(credentials('', ''))).resolves.toBeNull();
expect(service.currentToken).toBeNull();
expect(requestToken).toHaveBeenCalledTimes(1);
});
it('returns null and reports only a projected safe error', async () => {
const errors: unknown[] = [];
const requestToken = vi.fn().mockRejectedValue({
name: 'AxiosError',
isAxiosError: true,
message: 'client_secret=provider-secret Authorization: Bearer provider-token',
config: { params: { client_secret: 'provider-secret' } },
response: { status: 401, data: { access_token: 'response-token' } },
});
const service = new TwitchAppTokenService(requestToken, (error) => errors.push(error));
await expect(service.ensure(credentials())).resolves.toBeNull();
expect(service.currentToken).toBeNull();
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({ provider: 'twitch-oauth', status: 401 });
expect(JSON.stringify(errors[0])).not.toMatch(/provider-secret|provider-token|response-token|config|response/);
});
it('rejects malformed token responses without exposing them', async () => {
const errors: unknown[] = [];
const requestToken = vi.fn().mockResolvedValue(' ');
const service = new TwitchAppTokenService(requestToken, (error) => errors.push(error));
await expect(service.ensure(credentials())).resolves.toBeNull();
expect(errors).toEqual([{
provider: 'twitch-oauth',
message: 'Twitch app token response was invalid',
}]);
});
});
describe('requestTwitchAppAccessToken', () => {
it('uses the Twitch client-credentials endpoint and parses its token', async () => {
const post = vi.fn().mockResolvedValue({ data: { access_token: ' live-token ' } });
await expect(requestTwitchAppAccessToken({ post }, credentials(), 1234)).resolves.toBe('live-token');
expect(post).toHaveBeenCalledWith('https://id.twitch.tv/oauth2/token', null, {
params: {
client_id: 'client-id',
client_secret: 'client-secret',
grant_type: 'client_credentials',
},
timeout: 1234,
});
});
it('rejects malformed response envelopes', async () => {
await expect(requestTwitchAppAccessToken({ post: vi.fn().mockResolvedValue({ data: {} }) }, credentials(), 1000))
.rejects.toThrow('Twitch app token response was invalid');
});
});
+121
View File
@@ -0,0 +1,121 @@
import { projectExternalError, type SafeExternalError } from '../domain/external-error';
export interface TwitchAppTokenCredentials {
clientId: string;
clientSecret: string;
}
export type TwitchAppTokenRequester = (credentials: TwitchAppTokenCredentials) => Promise<unknown>;
export type TwitchAppTokenErrorHandler = (error: SafeExternalError) => void;
export interface TwitchAppTokenHttpClient {
post(url: string, data: null, config: {
params: { client_id: string; client_secret: string; grant_type: 'client_credentials' };
timeout: number;
}): Promise<unknown>;
}
export async function requestTwitchAppAccessToken(
client: TwitchAppTokenHttpClient,
credentials: TwitchAppTokenCredentials,
timeoutMs: number,
): Promise<string> {
const response = await client.post('https://id.twitch.tv/oauth2/token', null, {
params: {
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
grant_type: 'client_credentials',
},
timeout: timeoutMs,
});
if (!response || typeof response !== 'object' || Array.isArray(response)) {
throw new Error('Twitch app token response was invalid');
}
const data = (response as Record<string, unknown>).data;
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('Twitch app token response was invalid');
}
const token = (data as Record<string, unknown>).access_token;
if (typeof token !== 'string' || !token.trim()) {
throw new Error('Twitch app token response was invalid');
}
return token.trim();
}
function hasCredentials(credentials: TwitchAppTokenCredentials): boolean {
return credentials.clientId.trim().length > 0 && credentials.clientSecret.trim().length > 0;
}
function sameCredentials(left: TwitchAppTokenCredentials | null, right: TwitchAppTokenCredentials): boolean {
return left?.clientId === right.clientId && left.clientSecret === right.clientSecret;
}
export class TwitchAppTokenService {
private token: string | null = null;
private credentials: TwitchAppTokenCredentials | null = null;
private activeRequest: Promise<string | null> | null = null;
private generation = 0;
constructor(
private readonly requestToken: TwitchAppTokenRequester,
private readonly onError?: TwitchAppTokenErrorHandler,
) { }
get currentToken(): string | null {
return this.token;
}
ensure(credentials: TwitchAppTokenCredentials, forceRefresh = false): Promise<string | null> {
if (!hasCredentials(credentials)) {
this.clear();
return Promise.resolve(null);
}
if (!sameCredentials(this.credentials, credentials)) {
this.invalidate();
this.credentials = { ...credentials };
}
if (this.activeRequest) return this.activeRequest;
if (!forceRefresh && this.token) return Promise.resolve(this.token);
const requestGeneration = this.generation;
const requestCredentials = { ...credentials };
const request = this.resolveRequest(requestCredentials, requestGeneration);
const tracked = request.finally(() => {
if (this.activeRequest === tracked) this.activeRequest = null;
});
this.activeRequest = tracked;
return tracked;
}
clear(): void {
this.invalidate();
this.credentials = null;
}
private invalidate(): void {
this.generation += 1;
this.token = null;
this.activeRequest = null;
}
private async resolveRequest(credentials: TwitchAppTokenCredentials, generation: number): Promise<string | null> {
try {
const response = await this.requestToken(credentials);
if (typeof response !== 'string' || !response.trim()) {
throw new Error('Twitch app token response was invalid');
}
if (this.generation !== generation) return null;
this.token = response.trim();
return this.token;
} catch (error) {
if (this.generation !== generation) return null;
this.token = null;
try {
this.onError?.(projectExternalError('twitch-oauth', error));
} catch { }
return null;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
export { requestTwitchAppAccessToken, TwitchAppTokenService } from './app-token';
export type { TwitchAppTokenCredentials, TwitchAppTokenHttpClient } from './app-token';
export { createTwitchProviderRefreshService, refreshTwitchProviderData, requestPublicTwitchGraphql, requestPublicTwitchVodsByLogin, requestTwitchHelixUsers, requestTwitchHelixVideos } from './provider-refresh';
export type { TwitchGraphqlHttpClient, TwitchHelixAuth, TwitchHelixHttpClient, TwitchHelixRefreshOutcome, TwitchHelixUser, TwitchProviderRefreshDependencies, TwitchProviderRefreshResult, TwitchVod } from './provider-refresh';
export { buildVodPreviewFrameUrls } from '../domain/vod-preview';
export { resolveRefreshOutcome } from '../domain/refresh-result';
export type { RefreshOutcome } from '../domain/refresh-result';
export { parseGraphqlDataEnvelope, parseGraphqlUser, parseHelixDataArray } from '../domain/provider-payload';
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import { createTwitchProviderRefreshService, requestPublicTwitchGraphql, requestPublicTwitchVodsByLogin, requestTwitchHelixUsers, requestTwitchHelixVideos } from './provider-refresh';
describe('Twitch provider refresh product path', () => {
it('requests and parses a public GraphQL data envelope', async () => {
const post = vi.fn().mockResolvedValue({ data: { data: { user: { id: '42' } } } });
await expect(requestPublicTwitchGraphql({ post }, 'query', { login: 'alice' }, 1200, 1))
.resolves.toEqual({ status: 'success', value: { user: { id: '42' } } });
expect(post).toHaveBeenCalledWith('https://gql.twitch.tv/gql', { query: 'query', variables: { login: 'alice' } }, {
headers: { 'Client-ID': 'kimne78kx3ncx6brgo4mv6wki5h1ko', 'Content-Type': 'application/json' },
timeout: 1200,
});
});
it('encapsulates the product VOD query and projects public rows', async () => {
const post = vi.fn().mockResolvedValue({ data: { data: { user: { videos: { edges: [{ node: { id: '42', title: 'Archive', publishedAt: '2026-01-01T00:00:00Z', lengthSeconds: 3661, viewCount: 7, previewThumbnailURL: 'https://example.com/42.jpg' } }] } } } } });
await expect(requestPublicTwitchVodsByLogin({ post }, 'alice', 1, 1200, 1)).resolves.toEqual({
status: 'success',
value: [{ id: '42', title: 'Archive', created_at: '2026-01-01T00:00:00Z', duration: '1h1m1s', thumbnail_url: 'https://example.com/42.jpg', url: 'https://www.twitch.tv/videos/42', view_count: 7, stream_id: '', user_login: 'alice' }],
});
expect(post.mock.calls[0][1].query).toContain('videos(first:$first, type:ARCHIVE, sort:TIME)');
expect(post.mock.calls[0][1].variables).toEqual({ login: 'alice', first: 1 });
});
it.each([
{ lengthSeconds: null, viewCount: 7 },
{ lengthSeconds: '', viewCount: 7 },
{ lengthSeconds: 3661, viewCount: null },
{ lengthSeconds: 3661, viewCount: '7' },
])('rejects non-numeric public VOD metrics: %o', async ({ lengthSeconds, viewCount }) => {
const post = vi.fn().mockResolvedValue({
data: {
data: {
user: {
videos: {
edges: [{ node: { id: '42', lengthSeconds, viewCount } }],
},
},
},
},
});
await expect(requestPublicTwitchVodsByLogin({ post }, 'alice', 1, 1200, 1))
.resolves.toEqual({ status: 'unavailable' });
});
it('refreshes Helix after a 401, falls back to public, and retains last-good on provider outage', async () => {
const publicValues = [
{ status: 'success' as const, value: [{ id: 'public-1' }] },
{ status: 'unavailable' as const },
];
const helixValues = [
{ status: 'success' as const, value: [{ id: 'helix-1' }] },
{ status: 'unauthorized' as const },
{ status: 'unavailable' as const },
{ status: 'unavailable' as const },
];
const refreshToken = vi.fn().mockResolvedValue(true);
const service = createTwitchProviderRefreshService<{ id: string }>({
requestPublic: vi.fn(async () => publicValues.shift() ?? { status: 'unavailable' as const }),
requestHelix: vi.fn(async () => helixValues.shift() ?? { status: 'unavailable' as const }),
refreshToken,
maxLastGoodEntries: 4,
});
await expect(service.refresh('alice')).resolves.toEqual({ value: [{ id: 'helix-1' }], source: 'helix', stale: false });
await expect(service.refresh('alice')).resolves.toEqual({ value: [{ id: 'public-1' }], source: 'public', stale: false });
await expect(service.refresh('alice')).resolves.toEqual({ value: [{ id: 'public-1' }], source: 'last-good', stale: true });
expect(refreshToken).toHaveBeenCalledOnce();
});
it('requests and validates Helix users and paginated archive videos', async () => {
const get = vi.fn()
.mockResolvedValueOnce({ data: { data: [{ id: '42', login: 'alice', display_name: 'Alice', description: '', profile_image_url: 'https://example.com/a.png', broadcaster_type: 'partner' }] } })
.mockResolvedValueOnce({ data: { data: [{ id: '1', title: 'One', created_at: '2026-01-01T00:00:00Z', duration: '1h', thumbnail_url: '', url: 'https://www.twitch.tv/videos/1', view_count: 2, stream_id: '', user_login: 'alice' }], pagination: { cursor: 'next' } } })
.mockResolvedValueOnce({ data: { data: [{ id: '2', title: 'Two', created_at: '2026-01-02T00:00:00Z', duration: '2h', thumbnail_url: '', url: 'https://www.twitch.tv/videos/2', view_count: 3, stream_id: '', user_login: 'alice' }], pagination: {} } });
const auth = { clientId: 'client', accessToken: 'token' };
await expect(requestTwitchHelixUsers({ get }, 'alice', auth, 1000)).resolves.toMatchObject({ status: 'success', value: [{ id: '42' }] });
await expect(requestTwitchHelixVideos({ get }, '42', auth, 1000)).resolves.toMatchObject({ status: 'success', value: [{ id: '1' }, { id: '2' }] });
expect(get).toHaveBeenNthCalledWith(3, 'https://api.twitch.tv/helix/videos', expect.objectContaining({ params: expect.objectContaining({ after: 'next' }) }));
});
it('reports Helix authorization expiry without projecting provider payloads', async () => {
const get = vi.fn().mockRejectedValue({ response: { status: 401, data: { token: 'secret' } } });
await expect(requestTwitchHelixUsers({ get }, 'alice', { clientId: 'client', accessToken: 'token' }, 1000))
.resolves.toEqual({ status: 'unauthorized' });
});
});
+282
View File
@@ -0,0 +1,282 @@
import { LastGoodCache } from '../domain/last-good-cache';
import { parseGraphqlDataEnvelope, parseHelixDataArray } from '../domain/provider-payload';
import type { RefreshOutcome } from '../domain/refresh-result';
const TWITCH_PUBLIC_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
const TWITCH_PUBLIC_VODS_QUERY = 'query($login:String!,$first:Int!){ user(login:$login){ videos(first:$first, type:ARCHIVE, sort:TIME){ edges{ node{ id title publishedAt lengthSeconds viewCount previewThumbnailURL(width:320,height:180) } } } } }';
export interface TwitchGraphqlHttpClient {
post(url: string, body: { query: string; variables: Record<string, unknown> }, config: {
headers: { 'Client-ID': string; 'Content-Type': 'application/json' };
timeout: number;
}): Promise<{ data?: unknown }>;
}
export interface TwitchHelixHttpClient {
get(url: string, config: {
params: Record<string, string | number>;
headers: { 'Client-ID': string; Authorization: string };
timeout: number;
}): Promise<{ data?: unknown }>;
}
export interface TwitchHelixAuth {
clientId: string;
accessToken: string;
}
export interface TwitchHelixUser {
id: string;
login: string;
display_name: string;
description: string;
profile_image_url: string;
broadcaster_type: string;
}
export interface TwitchVod {
id: string;
title: string;
created_at: string;
duration: string;
thumbnail_url: string;
url: string;
view_count: number;
stream_id: string;
user_login?: string;
}
export type TwitchHelixRefreshOutcome<T> = RefreshOutcome<T[]> | { status: 'unauthorized' };
export interface TwitchProviderRefreshDependencies<T> {
requestPublic(key: string): Promise<RefreshOutcome<T[]>>;
requestHelix(key: string): Promise<TwitchHelixRefreshOutcome<T>>;
refreshToken(): Promise<boolean>;
maxLastGoodEntries: number;
}
export interface TwitchProviderRefreshResult<T> {
value: T[] | null;
source: 'helix' | 'public' | 'last-good' | 'not-found' | 'unavailable';
stale: boolean;
}
type TwitchProviderRefreshOperations<T> = Omit<TwitchProviderRefreshDependencies<T>, 'maxLastGoodEntries'>;
function isTransientHttpError(error: unknown): boolean {
if (!error || typeof error !== 'object') return true;
const response = (error as { response?: { status?: unknown } }).response;
const status = Number(response?.status);
return !Number.isFinite(status) || status === 408 || status === 429 || (status >= 500 && status < 600);
}
function httpStatus(error: unknown): number | null {
if (!error || typeof error !== 'object') return null;
const status = Number((error as { response?: { status?: unknown } }).response?.status);
return Number.isFinite(status) ? status : null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function helixConfig(auth: TwitchHelixAuth, params: Record<string, string | number>, timeout: number) {
return {
params,
headers: { 'Client-ID': auth.clientId, Authorization: `Bearer ${auth.accessToken}` },
timeout,
};
}
export async function requestTwitchHelixUsers(
client: TwitchHelixHttpClient,
login: string,
auth: TwitchHelixAuth,
timeoutMs: number,
): Promise<TwitchHelixRefreshOutcome<TwitchHelixUser>> {
try {
const response = await client.get('https://api.twitch.tv/helix/users', helixConfig(auth, { login }, timeoutMs));
const parsed = parseHelixDataArray(response.data);
if (parsed.status !== 'success') return parsed;
if (parsed.value.length === 0) return { status: 'not-found' };
const users: TwitchHelixUser[] = [];
for (const value of parsed.value) {
const user = asRecord(value);
if (!user
|| typeof user.id !== 'string'
|| typeof user.login !== 'string'
|| typeof user.display_name !== 'string'
|| typeof user.description !== 'string'
|| typeof user.profile_image_url !== 'string'
|| typeof user.broadcaster_type !== 'string') return { status: 'unavailable' };
users.push(user as unknown as TwitchHelixUser);
}
return { status: 'success', value: users };
} catch (error) {
return httpStatus(error) === 401 ? { status: 'unauthorized' } : { status: 'unavailable' };
}
}
export async function requestTwitchHelixVideos(
client: TwitchHelixHttpClient,
userId: string,
auth: TwitchHelixAuth,
timeoutMs: number,
maxPages = 50,
): Promise<TwitchHelixRefreshOutcome<TwitchVod>> {
const videos: TwitchVod[] = [];
let cursor = '';
try {
for (let page = 0; page < maxPages; page++) {
const params: Record<string, string | number> = { user_id: userId, type: 'archive', first: 100 };
if (cursor) params.after = cursor;
const response = await client.get('https://api.twitch.tv/helix/videos', helixConfig(auth, params, timeoutMs));
const parsed = parseHelixDataArray(response.data);
if (parsed.status !== 'success') return parsed;
for (const value of parsed.value) {
const video = asRecord(value);
if (!video
|| typeof video.id !== 'string'
|| typeof video.title !== 'string'
|| typeof video.created_at !== 'string'
|| typeof video.duration !== 'string'
|| typeof video.thumbnail_url !== 'string'
|| typeof video.url !== 'string'
|| typeof video.view_count !== 'number'
|| typeof video.stream_id !== 'string') return { status: 'unavailable' };
videos.push(video as unknown as TwitchVod);
}
const envelope = asRecord(response.data);
const pagination = asRecord(envelope?.pagination);
if (!pagination) return { status: 'unavailable' };
if (pagination.cursor !== undefined && typeof pagination.cursor !== 'string') return { status: 'unavailable' };
cursor = typeof pagination.cursor === 'string' ? pagination.cursor : '';
if (!cursor) break;
}
return { status: 'success', value: videos };
} catch (error) {
return httpStatus(error) === 401 ? { status: 'unauthorized' } : { status: 'unavailable' };
}
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
export async function requestPublicTwitchGraphql<T>(
client: TwitchGraphqlHttpClient,
query: string,
variables: Record<string, unknown>,
timeoutMs: number,
attempts = 3,
): Promise<RefreshOutcome<T>> {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const response = await client.post('https://gql.twitch.tv/gql', { query, variables }, {
headers: { 'Client-ID': TWITCH_PUBLIC_WEB_CLIENT_ID, 'Content-Type': 'application/json' },
timeout: timeoutMs,
});
if (response.data && typeof response.data === 'object' && !Array.isArray(response.data)) {
const errors = (response.data as Record<string, unknown>).errors;
if (Array.isArray(errors) && errors.length > 0) return { status: 'unavailable' };
}
const parsed = parseGraphqlDataEnvelope(response.data);
return parsed.status === 'success'
? { status: 'success', value: parsed.value as T }
: parsed;
} catch (error) {
if (!isTransientHttpError(error) || attempt === attempts) return { status: 'unavailable' };
await delay(400 * Math.pow(2, attempt - 1));
}
}
return { status: 'unavailable' };
}
function formatTwitchDuration(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(totalSeconds));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = seconds % 60;
return `${hours > 0 ? `${hours}h` : ''}${minutes > 0 ? `${minutes}m` : ''}${remainder > 0 || (hours === 0 && minutes === 0) ? `${remainder}s` : ''}`;
}
export async function requestPublicTwitchVodsByLogin(
client: TwitchGraphqlHttpClient,
login: string,
first = 100,
timeoutMs = 10000,
attempts = 3,
): Promise<RefreshOutcome<TwitchVod[]>> {
if (!login || !Number.isSafeInteger(first) || first < 1 || first > 100) return { status: 'not-found' };
const outcome = await requestPublicTwitchGraphql<Record<string, unknown>>(
client,
TWITCH_PUBLIC_VODS_QUERY,
{ login, first },
timeoutMs,
attempts,
);
if (outcome.status !== 'success') return outcome;
const user = asRecord(outcome.value.user);
if (outcome.value.user === null) return { status: 'not-found' };
const videos = asRecord(user?.videos);
if (!videos || !Array.isArray(videos.edges)) return { status: 'unavailable' };
const vods: TwitchVod[] = [];
for (const edgeValue of videos.edges) {
const node = asRecord(asRecord(edgeValue)?.node);
if (!node
|| typeof node.id !== 'string'
|| !node.id
|| typeof node.lengthSeconds !== 'number'
|| !Number.isFinite(node.lengthSeconds)
|| typeof node.viewCount !== 'number'
|| !Number.isFinite(node.viewCount)) {
return { status: 'unavailable' };
}
vods.push({
id: node.id,
title: typeof node.title === 'string' && node.title ? node.title : 'Untitled VOD',
created_at: typeof node.publishedAt === 'string' && node.publishedAt ? node.publishedAt : new Date(0).toISOString(),
duration: formatTwitchDuration(node.lengthSeconds),
thumbnail_url: typeof node.previewThumbnailURL === 'string' ? node.previewThumbnailURL : '',
url: `https://www.twitch.tv/videos/${node.id}`,
view_count: node.viewCount,
stream_id: '',
user_login: login,
});
}
return { status: 'success', value: vods };
}
export async function refreshTwitchProviderData<T>(
key: string,
previous: T[] | undefined,
operations: TwitchProviderRefreshOperations<T>,
): Promise<TwitchProviderRefreshResult<T>> {
let helix = await operations.requestHelix(key);
if (helix.status === 'unauthorized' && await operations.refreshToken()) {
helix = await operations.requestHelix(key);
}
if (helix.status === 'success') return { value: helix.value, source: 'helix', stale: false };
const publicOutcome = await operations.requestPublic(key);
if (publicOutcome.status === 'success') return { value: publicOutcome.value, source: 'public', stale: false };
if (helix.status === 'not-found' || publicOutcome.status === 'not-found') {
return { value: null, source: 'not-found', stale: false };
}
return previous
? { value: previous, source: 'last-good', stale: true }
: { value: null, source: 'unavailable', stale: false };
}
export function createTwitchProviderRefreshService<T>(dependencies: TwitchProviderRefreshDependencies<T>): {
refresh(key: string): Promise<TwitchProviderRefreshResult<T>>;
} {
const lastGood = new LastGoodCache<T[]>(dependencies.maxLastGoodEntries);
return {
async refresh(key: string): Promise<TwitchProviderRefreshResult<T>> {
const result = await refreshTwitchProviderData(key, lastGood.get(key), dependencies);
if (result.source === 'helix' || result.source === 'public') lastGood.set(key, result.value ?? []);
if (result.source === 'not-found') lastGood.delete(key);
return result;
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export { compareUpdateVersions, normalizeUpdateVersion } from '../domain/update-version-utils';
export { createUpdateCheckCoordinator } from '../domain/update-check-operation';
export { UpdateLifecycle } from './update-lifecycle';
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('main update lifecycle production path', () => {
it('uses one lifecycle for checks, downloads, terminals and typed errors', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('async function requestUpdateCheck');
const end = source.indexOf('// ==========================================\n// IPC HANDLERS', start);
const updateSource = source.slice(start, end);
expect(source).toMatch(/import\s*\{[\s\S]*UpdateLifecycle[\s\S]*\}\s*from '\.\/main\/updates'/);
expect(source).toContain('const autoUpdateLifecycle = new UpdateLifecycle()');
expect(updateSource).toContain('autoUpdateLifecycle.beginCheck()');
expect(updateSource).toContain('autoUpdateLifecycle.beginDownload(version)');
expect(updateSource).toContain('autoUpdateLifecycle.completeCheckAvailable(incomingVersion)');
expect(updateSource).toContain('autoUpdateLifecycle.completeCheckNotAvailable()');
expect(updateSource).toContain('autoUpdateLifecycle.completeDownload(downloadedVersion)');
const timeoutBranch = updateSource.slice(
updateSource.indexOf("if (result.state === 'timed-out')"),
updateSource.indexOf("if (result.state === 'in-progress')")
);
expect(timeoutBranch).toContain('autoUpdateLifecycle.failCheck()');
expect(updateSource).toContain("kind: 'check'");
expect(updateSource).toContain("kind: 'download'");
expect(updateSource).not.toContain('autoUpdateDownloadInProgress = false');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { UpdateLifecycle } from './update-lifecycle';
describe('UpdateLifecycle', () => {
it('serializes checks and downloads through ready state', () => {
const lifecycle = new UpdateLifecycle();
expect(lifecycle.beginCheck()).toEqual({ started: true });
expect(lifecycle.completeCheckAvailable('1.2.3')).toBe(true);
expect(lifecycle.beginDownload('1.2.3')).toEqual({ started: true });
expect(lifecycle.beginCheck()).toEqual({ started: false, reason: 'downloading' });
expect(lifecycle.completeDownload('1.2.3')).toBe(true);
expect(lifecycle.beginCheck()).toEqual({ started: false, reason: 'ready-to-install' });
expect(lifecycle.snapshot).toEqual({ phase: 'ready', version: '1.2.3' });
});
it('ignores check events that arrive during a download', () => {
const lifecycle = new UpdateLifecycle();
lifecycle.beginCheck();
lifecycle.completeCheckAvailable('1.2.3');
lifecycle.beginDownload('1.2.3');
expect(lifecycle.completeCheckAvailable('1.2.4')).toBe(false);
expect(lifecycle.completeCheckNotAvailable()).toBe(false);
expect(lifecycle.failCheck()).toBe(false);
expect(lifecycle.snapshot).toEqual({ phase: 'downloading', version: '1.2.3' });
});
it('restores the available version after a download failure', () => {
const lifecycle = new UpdateLifecycle();
lifecycle.beginCheck();
lifecycle.completeCheckAvailable('1.2.3');
lifecycle.beginDownload('1.2.3');
expect(lifecycle.failDownload('1.2.3')).toBe(true);
expect(lifecycle.snapshot).toEqual({ phase: 'available', version: '1.2.3' });
expect(lifecycle.failDownload('1.2.3')).toBe(false);
});
it('restores the previous available version after a failed refresh check', () => {
const lifecycle = new UpdateLifecycle();
lifecycle.beginCheck();
lifecycle.completeCheckAvailable('1.2.3');
lifecycle.beginCheck();
expect(lifecycle.failCheck()).toBe(true);
expect(lifecycle.snapshot).toEqual({ phase: 'available', version: '1.2.3' });
});
it('rejects unavailable or mismatched download transitions', () => {
const lifecycle = new UpdateLifecycle();
expect(lifecycle.beginDownload('1.2.3')).toEqual({ started: false, reason: 'not-available' });
lifecycle.beginCheck();
lifecycle.completeCheckAvailable('1.2.3');
expect(lifecycle.beginDownload('1.2.4')).toEqual({ started: false, reason: 'stale-version' });
expect(lifecycle.beginDownload('1.2.3')).toEqual({ started: true });
expect(lifecycle.completeDownload('1.2.4')).toBe(false);
expect(lifecycle.snapshot).toEqual({ phase: 'downloading', version: '1.2.3' });
});
});
+71
View File
@@ -0,0 +1,71 @@
export type UpdateLifecycleSnapshot =
| { phase: 'idle' }
| { phase: 'checking' }
| { phase: 'available'; version: string }
| { phase: 'downloading'; version: string }
| { phase: 'ready'; version: string };
export type UpdateLifecycleStartResult =
| { started: true }
| { started: false; reason: 'in-progress' | 'downloading' | 'ready-to-install' | 'not-available' | 'stale-version' };
export class UpdateLifecycle {
private state: UpdateLifecycleSnapshot = { phase: 'idle' };
private checkFallback: UpdateLifecycleSnapshot | null = null;
get snapshot(): UpdateLifecycleSnapshot {
return { ...this.state };
}
beginCheck(): UpdateLifecycleStartResult {
if (this.state.phase === 'checking') return { started: false, reason: 'in-progress' };
if (this.state.phase === 'downloading') return { started: false, reason: 'downloading' };
if (this.state.phase === 'ready') return { started: false, reason: 'ready-to-install' };
this.checkFallback = this.state.phase === 'available' ? this.state : { phase: 'idle' };
this.state = { phase: 'checking' };
return { started: true };
}
completeCheckAvailable(version: string): boolean {
if (this.state.phase !== 'checking' || !version) return false;
this.state = { phase: 'available', version };
this.checkFallback = null;
return true;
}
completeCheckNotAvailable(): boolean {
if (this.state.phase !== 'checking') return false;
this.state = { phase: 'idle' };
this.checkFallback = null;
return true;
}
failCheck(): boolean {
if (this.state.phase !== 'checking') return false;
this.state = this.checkFallback ?? { phase: 'idle' };
this.checkFallback = null;
return true;
}
beginDownload(version: string): UpdateLifecycleStartResult {
if (this.state.phase === 'downloading') return { started: false, reason: 'in-progress' };
if (this.state.phase === 'ready') return { started: false, reason: 'ready-to-install' };
if (this.state.phase !== 'available') return { started: false, reason: 'not-available' };
if (!version || this.state.version !== version) return { started: false, reason: 'stale-version' };
this.state = { phase: 'downloading', version };
return { started: true };
}
completeDownload(version: string): boolean {
if (this.state.phase !== 'downloading' || this.state.version !== version) return false;
this.state = { phase: 'ready', version };
return true;
}
failDownload(version?: string): boolean {
if (this.state.phase !== 'downloading') return false;
if (version && this.state.version !== version) return false;
this.state = { phase: 'available', version: this.state.version };
return true;
}
}
+4 -8
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
import type { DownloadProgress, QueueAdditionResult, QueueItem } from './types';
let chatReadSequence = 0;
@@ -93,12 +93,6 @@ interface VideoEditExportRequest {
cuts: Array<{ id: string; start: number; end: number }>;
}
interface FileCapabilityReference {
token: string;
name: string;
displayPath?: string;
}
// Expose protected methods to renderer
contextBridge.exposeInMainWorld('api', {
// Config
@@ -121,6 +115,7 @@ contextBridge.exposeInMainWorld('api', {
// Queue
getQueue: () => ipcRenderer.invoke('get-queue'),
addToQueue: (item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>) => ipcRenderer.invoke('add-to-queue', item),
addToQueueWithResult: (item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>): Promise<QueueAdditionResult> => ipcRenderer.invoke('add-to-queue-with-result', item),
startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName),
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
@@ -216,6 +211,7 @@ contextBridge.exposeInMainWorld('api', {
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
getManagedToolStatus: () => ipcRenderer.invoke('get-managed-tool-status'),
getManagedToolExecutionDiagnostics: (): Promise<{ ffmpeg: { path: string | null; count: number }; ffprobe: { path: string | null; count: number }; streamlink: { path: string | null; count: number } } | null> => ipcRenderer.invoke('get-managed-tool-execution-diagnostics'),
repairManagedTools: () => ipcRenderer.invoke('repair-managed-tools'),
resetManagedTools: () => ipcRenderer.invoke('reset-managed-tools'),
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
@@ -276,7 +272,7 @@ contextBridge.exposeInMainWorld('api', {
onUpdateDownloaded: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => {
ipcRenderer.on('update-downloaded', (_, info) => callback(info));
},
onUpdateError: (callback: (payload: { message: string }) => void) => {
onUpdateError: (callback: (payload: { message: string; kind: 'check' | 'download'; version?: string }) => void) => {
ipcRenderer.on('update-error', (_, payload) => callback(payload));
}
});
+1 -1
View File
@@ -112,7 +112,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
<div class="archive-result-size">${escapeHtml(formatBytes(hit.size))}</div>
</div>
<div class="archive-result-actions">
<button type="button" class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
<button type="button" class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveOpen || 'Öffnen')}</button>
<button type="button" class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
${chatBtn}
${eventsBtn}
+174
View File
@@ -62,7 +62,178 @@ function createCutterSelects(): Map<string, FakeSelect> {
]);
}
const englishCutterChoiceTexts = {
noAudio: 'No audio track',
audioStream: 'Audio track {index}',
channelSingular: 'channel',
channelPlural: 'channels',
profileQuality: 'Quality',
profileBalanced: 'Balanced',
profileFast: 'Fast',
profileArchive: 'Archive',
encoderSoftware: 'Software',
encoderNvenc: 'NVIDIA NVENC',
encoderQsv: 'Intel Quick Sync',
encoderAmf: 'AMD AMF',
};
describe('cutter production paths', () => {
test('uses unambiguous frame timecodes and accepts pasted HH:MM:SS values', () => {
const context = {
cutterEditorState: { fps: 30, duration: 90 },
cutterVideoInfo: null,
snapCutterTime: (value: number) => value,
};
const api = evaluate(
sourceFragment('function formatCutterTimecode', 'function getCutterVideo'),
context,
'formatCutterTimecode, parseCutterTimecode',
);
expect(api.formatCutterTimecode(1.5)).toBe('00:00:01:15');
expect(api.parseCutterTimecode('00:01:15')).toBe(75);
expect(api.parseCutterTimecode('00:00:01:15')).toBe(1.5);
});
test.each([
{
language: 'de',
texts: {
recoveryFound: 'Gespeicherte Bearbeitung gefunden',
noAudio: 'Keine Audiospur',
audioStream: 'Audiospur {index}',
channelSingular: 'Kanal',
channelPlural: 'Kanäle',
profileQuality: 'Qualität',
profileBalanced: 'Ausgewogen',
profileFast: 'Schnell',
profileArchive: 'Archiv',
encoderSoftware: 'Software',
encoderNvenc: 'NVIDIA NVENC',
encoderQsv: 'Intel Quick Sync',
encoderAmf: 'AMD AMF',
},
expectedAudio: ['Audiospur 1 (deu · aac · 1 Kanal)', 'Audiospur 3 (eng · opus · 2 Kanäle)'],
expectedProfiles: ['Qualität', 'Ausgewogen', 'Schnell', 'Archiv'],
},
{
language: 'en',
texts: {
recoveryFound: 'Saved edit found',
noAudio: 'No audio track',
audioStream: 'Audio track {index}',
channelSingular: 'channel',
channelPlural: 'channels',
profileQuality: 'Quality',
profileBalanced: 'Balanced',
profileFast: 'Fast',
profileArchive: 'Archive',
encoderSoftware: 'Software',
encoderNvenc: 'NVIDIA NVENC',
encoderQsv: 'Intel Quick Sync',
encoderAmf: 'AMD AMF',
},
expectedAudio: ['Audio track 1 (deu · aac · 1 channel)', 'Audio track 3 (eng · opus · 2 channels)'],
expectedProfiles: ['Quality', 'Balanced', 'Fast', 'Archive'],
},
])('renders $language recovery, audio and export choices from the active cutter locale', ({ texts, expectedAudio, expectedProfiles }) => {
const selects = createCutterSelects();
const recoveryPanel = { hidden: true };
const recoveryText = { textContent: '' };
const context: Record<string, unknown> = {
cutterPendingProject: null,
cutterVideoInfo: {
audioStreams: [
{ index: 0, language: 'deu', codec: 'aac', channels: 1 },
{ index: 2, language: 'eng', codec: 'opus', channels: 2 },
],
},
cutterAudioStreamIndex: 0,
cutterExportProfile: 'balanced',
cutterExportEncoder: 'software',
UI_TEXT: { cutter: texts },
byId: (id: string) => id === 'cutterRecoveryPanel'
? recoveryPanel
: id === 'cutterRecoveryText'
? recoveryText
: selects.get(id),
document: { createElement: () => ({ value: '', textContent: '' }) },
};
const api = evaluate(sourceFragment('function renderCutterProjectRecovery', 'async function loadCutterExportOptions'), context, 'renderCutterProjectRecovery, updateCutterAudioStreams, updateCutterExportControls');
api.renderCutterProjectRecovery({ trimStart: 12 });
api.updateCutterAudioStreams();
api.updateCutterExportControls({
profiles: [
{ id: 'quality', label: 'Quality', container: 'mp4' },
{ id: 'balanced', label: 'Balanced', container: 'mp4' },
{ id: 'fast', label: 'Fast', container: 'mp4' },
{ id: 'archive', label: 'Archive', container: 'mkv' },
],
hardwareEncoders: ['h264_nvenc', 'h264_qsv', 'h264_amf'],
});
expect(recoveryPanel.hidden).toBe(false);
expect(recoveryText.textContent).toBe(texts.recoveryFound);
expect(selects.get('cutterAudioStream')?.options.map((option) => option.textContent)).toEqual(expectedAudio);
expect(selects.get('cutterExportProfile')?.options.map((option) => option.textContent)).toEqual(expectedProfiles);
expect(selects.get('cutterExportEncoder')?.options.map((option) => option.textContent)).toEqual([
texts.encoderSoftware,
texts.encoderNvenc,
texts.encoderQsv,
texts.encoderAmf,
]);
});
test('uses active English project feedback for save, recovery and manual open actions', async () => {
const toasts: Array<[string, string]> = [];
const project = { duration: 90, fps: 30, trimStart: 5, trimEnd: 80, cuts: [], profile: 'balanced', encoder: 'software', audioStreamIndex: 0 };
let openResult: unknown = project;
const context: Record<string, unknown> = {
cutterEditorState: { duration: 90, fps: 30, trimStart: 0, trimEnd: 90, cuts: [] },
cutterFile: { token: 'source-capability' },
cutterExportProfile: 'balanced',
cutterExportEncoder: 'software',
cutterAudioStreamIndex: 0,
cutterPendingProject: project,
cutterRecoveryDecisionPending: true,
UI_TEXT: {
cutter: {
projectSaved: 'Project saved',
projectSaveFailed: 'Project could not be saved',
projectRecoveryFailed: 'Project could not be restored',
projectRecovered: 'Project restored',
projectNotFound: 'No matching project found',
projectOpened: 'Project opened',
},
},
applyCutterProject: () => true,
renderCutterProjectRecovery: () => undefined,
showAppToast: (message: string, type: string) => toasts.push([message, type]),
api: {
saveCutterProject: async () => true,
openCutterProject: async () => openResult,
},
};
context.getCutterProjectPayload = () => ({ trimStart: 0, trimEnd: 90, cuts: [], profile: 'balanced', encoder: 'software', audioStreamIndex: 0 });
const persistence = evaluate(sourceFragment('async function persistCutterProject', 'function scheduleCutterAutosave'), context, 'persistCutterProject');
context.persistCutterProject = persistence.persistCutterProject;
const actions = evaluate(sourceFragment('async function recoverCutterProject', 'function setCutterExportProfile'), context, 'recoverCutterProject, openCutterProject');
await persistence.persistCutterProject(true);
await actions.recoverCutterProject();
await actions.openCutterProject();
openResult = null;
await actions.openCutterProject();
expect(toasts).toEqual([
['Project saved', 'info'],
['Project restored', 'info'],
['Project opened', 'info'],
['No matching project found', 'warn'],
]);
});
test('rejects a PNG drop before requesting a capability or loader', async () => {
const listeners = new Map<string, (event: Record<string, unknown>) => Promise<void> | void>();
let capabilityRequests = 0;
@@ -105,6 +276,7 @@ describe('cutter production paths', () => {
applyCutterProject: () => true,
renderCutterProjectRecovery: () => undefined,
showAppToast: () => undefined,
UI_TEXT: { cutter: { projectNotFound: 'No matching project found', projectOpened: 'Project opened' } },
api: {
openCutterProject: async () => {
opens += 1;
@@ -158,6 +330,7 @@ describe('cutter production paths', () => {
cutterHistoryPast: [],
cutterHistoryFuture: [],
cutterActiveCutId: null,
UI_TEXT: { cutter: englishCutterChoiceTexts },
byId: (id: string) => selects.get(id),
document: { createElement: () => ({ value: '', textContent: '' }) },
renderCutterEditor: () => undefined,
@@ -206,6 +379,7 @@ describe('cutter production paths', () => {
cutterExportOptions: undefined,
cutterLoadGeneration: 4,
cutterFile: file,
UI_TEXT: { cutter: englishCutterChoiceTexts },
byId: (id: string) => selects.get(id),
document: { createElement: () => ({ value: '', textContent: '' }) },
api: { getCutterExportOptions: async () => { throw new Error('probe failed'); } },
+32 -19
View File
@@ -118,11 +118,7 @@ function formatCutterTimecode(time: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
const useHours = (cutterEditorState?.duration || cutterVideoInfo?.duration || time) >= 3600;
const fields = useHours
? [hours, minutes, remainingSeconds, frames]
: [minutes, remainingSeconds, frames];
return fields.map((field) => String(field).padStart(2, '0')).join(':');
return [hours, minutes, remainingSeconds, frames].map((field) => String(field).padStart(2, '0')).join(':');
}
function parseCutterTimecode(value: string): number | null {
@@ -131,7 +127,7 @@ function parseCutterTimecode(value: string): number | null {
if ((fields.length !== 3 && fields.length !== 4) || fields.some((field) => !Number.isInteger(field) || field < 0)) return null;
const [hours, minutes, seconds, frames] = fields.length === 4
? fields
: [0, fields[0], fields[1], fields[2]];
: [fields[0], fields[1], fields[2], 0];
if (minutes >= 60 || seconds >= 60 || frames >= Math.max(1, Math.round(cutterEditorState.fps))) return null;
return snapCutterTime(hours * 3600 + minutes * 60 + seconds + frames / cutterEditorState.fps);
}
@@ -166,7 +162,7 @@ async function persistCutterProject(showResult: boolean): Promise<boolean> {
try {
saved = await window.api.saveCutterProject(file.token, project);
} catch { }
if (showResult) showAppToast(saved ? 'Projekt gespeichert' : 'Projekt konnte nicht gespeichert werden', saved ? 'info' : 'warn');
if (showResult) showAppToast(saved ? UI_TEXT.cutter.projectSaved : UI_TEXT.cutter.projectSaveFailed, saved ? 'info' : 'warn');
return saved;
}
@@ -184,7 +180,7 @@ function renderCutterProjectRecovery(project: CutterProject | null): void {
cutterPendingProject = project;
const panel = byId<HTMLElement>('cutterRecoveryPanel');
panel.hidden = !project;
if (project) byId('cutterRecoveryText').textContent = 'Gespeicherte Bearbeitung gefunden';
if (project) byId('cutterRecoveryText').textContent = UI_TEXT.cutter.recoveryFound;
}
function updateCutterAudioStreams(): void {
@@ -194,7 +190,7 @@ function updateCutterAudioStreams(): void {
if (streams.length === 0) {
const option = document.createElement('option');
option.value = '0';
option.textContent = 'Keine Audiospur';
option.textContent = UI_TEXT.cutter.noAudio;
select.append(option);
select.disabled = true;
cutterAudioStreamIndex = 0;
@@ -203,8 +199,10 @@ function updateCutterAudioStreams(): void {
streams.forEach((stream) => {
const option = document.createElement('option');
option.value = String(stream.index);
const details = [stream.language, stream.codec, stream.channels > 0 ? `${stream.channels} Kanäle` : ''].filter(Boolean).join(' · ');
option.textContent = `Audiospur ${stream.index + 1}${details ? ` (${details})` : ''}`;
const channelLabel = stream.channels === 1 ? UI_TEXT.cutter.channelSingular : UI_TEXT.cutter.channelPlural;
const details = [stream.language, stream.codec, stream.channels > 0 ? `${stream.channels} ${channelLabel}` : ''].filter(Boolean).join(' · ');
const label = UI_TEXT.cutter.audioStream.replace('{index}', String(stream.index + 1));
option.textContent = `${label}${details ? ` (${details})` : ''}`;
select.append(option);
});
if (!streams.some((stream) => stream.index === cutterAudioStreamIndex)) cutterAudioStreamIndex = streams[0].index;
@@ -219,7 +217,12 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi
profile.replaceChildren(...options.profiles.map((entry) => {
const option = document.createElement('option');
option.value = entry.id;
option.textContent = entry.label;
option.textContent = {
quality: UI_TEXT.cutter.profileQuality,
balanced: UI_TEXT.cutter.profileBalanced,
fast: UI_TEXT.cutter.profileFast,
archive: UI_TEXT.cutter.profileArchive,
}[entry.id];
return option;
}));
}
@@ -227,7 +230,7 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi
encoder.replaceChildren();
const software = document.createElement('option');
software.value = 'software';
software.textContent = 'Software';
software.textContent = UI_TEXT.cutter.encoderSoftware;
encoder.append(software);
if (cutterExportProfile !== 'archive') {
const hardwareEncoders = options?.hardwareEncoders
@@ -235,7 +238,11 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi
hardwareEncoders.forEach((value) => {
const option = document.createElement('option');
option.value = value;
option.textContent = value === 'h264_nvenc' ? 'NVIDIA NVENC' : value === 'h264_qsv' ? 'Intel Quick Sync' : 'AMD AMF';
option.textContent = value === 'h264_nvenc'
? UI_TEXT.cutter.encoderNvenc
: value === 'h264_qsv'
? UI_TEXT.cutter.encoderQsv
: UI_TEXT.cutter.encoderAmf;
encoder.append(option);
});
}
@@ -244,6 +251,12 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi
encoder.disabled = !options || cutterExportProfile === 'archive';
}
function refreshCutterLocalizedUi(): void {
renderCutterProjectRecovery(cutterPendingProject);
updateCutterAudioStreams();
updateCutterExportControls(cutterExportOptions);
}
async function loadCutterExportOptions(file: FileCapabilityReference, generation: number): Promise<void> {
let options: CutterExportOptions | null = null;
try {
@@ -279,12 +292,12 @@ function applyCutterProject(project: CutterProject): boolean {
async function recoverCutterProject(): Promise<void> {
if (!cutterPendingProject || !applyCutterProject(cutterPendingProject)) {
showAppToast('Projekt konnte nicht wiederhergestellt werden', 'warn');
showAppToast(UI_TEXT.cutter.projectRecoveryFailed, 'warn');
return;
}
cutterRecoveryDecisionPending = false;
renderCutterProjectRecovery(null);
showAppToast('Projekt wiederhergestellt', 'info');
showAppToast(UI_TEXT.cutter.projectRecovered, 'info');
}
async function discardCutterProject(): Promise<void> {
@@ -304,12 +317,12 @@ async function openCutterProject(): Promise<void> {
let project: CutterProject | null = null;
try { project = await window.api.openCutterProject(cutterFile.token); } catch { }
if (!project || !applyCutterProject(project)) {
showAppToast('Kein passendes Projekt gefunden', 'warn');
showAppToast(UI_TEXT.cutter.projectNotFound, 'warn');
return;
}
cutterRecoveryDecisionPending = false;
renderCutterProjectRecovery(null);
showAppToast('Projekt geöffnet', 'info');
showAppToast(UI_TEXT.cutter.projectOpened, 'info');
}
function setCutterExportProfile(value: string): void {
@@ -1156,7 +1169,7 @@ async function requestCutterVideoReplacement(file: FileCapabilityReference): Pro
if (!file || isCutting) return;
if (!await confirmCutterReplacement(file)) return;
if (cutterEditorState && !cutterRecoveryDecisionPending && !await persistCutterProject(false)) {
showAppToast('Projekt konnte nicht gespeichert werden', 'warn');
showAppToast(UI_TEXT.cutter.projectSaveFailed, 'warn');
return;
}
await loadCutterFromPath(file);
+9 -1
View File
@@ -79,11 +79,13 @@ interface MergeGroup {
downloadedFiles: Record<number, string>;
mergedFile?: string;
splitFiles?: string[];
splitTempFiles?: string[];
totalDurationSec?: number;
}
interface QueueItem {
id: string;
createdAt?: string;
title: string;
url: string;
date: string;
@@ -101,6 +103,8 @@ interface QueueItem {
last_error?: string;
customClip?: CustomClip;
mergeGroup?: MergeGroup;
mergeRecoveryBlocked?: boolean;
artifactRoot?: string;
outputFiles?: string[];
isLive?: boolean;
recordingHealth?: 'ok' | 'stale' | 'unknown';
@@ -173,6 +177,8 @@ interface VideoInfo {
audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>;
}
type QueueAdditionResult = import('./main/domain/queue-addition').QueueAdditionResult<QueueItem>;
interface DownloadPolicy {
throttle: { maxBytesPerSecond: number } | null;
windows: Array<{ start: string; end: string }>;
@@ -433,6 +439,7 @@ interface ApiBridge {
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
getQueue(): Promise<QueueItem[]>;
addToQueue(item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>): Promise<QueueItem[]>;
addToQueueWithResult(item: Pick<QueueItem, 'url' | 'title' | 'date' | 'streamer' | 'duration_str' | 'customClip'>): Promise<QueueAdditionResult>;
startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>;
removeFromQueue(id: string): Promise<QueueItem[]>;
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
@@ -502,6 +509,7 @@ interface ApiBridge {
openExternal(url: string): Promise<void>;
runPreflight(autoFix: boolean): Promise<PreflightResult>;
getManagedToolStatus(): Promise<ManagedToolStatuses | null>;
getManagedToolExecutionDiagnostics(): Promise<{ ffmpeg: { path: string | null; count: number }; ffprobe: { path: string | null; count: number }; streamlink: { path: string | null; count: number } } | null>;
repairManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>;
resetManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>;
getDebugLog(lines: number): Promise<string>;
@@ -525,7 +533,7 @@ interface ApiBridge {
onUpdateNotAvailable(callback: () => void): void;
onUpdateDownloadProgress(callback: (progress: UpdateDownloadProgress) => void): void;
onUpdateDownloaded(callback: (info: UpdateInfo) => void): void;
onUpdateError(callback: (payload: { message: string }) => void): void;
onUpdateError(callback: (payload: { message: string; kind: 'check' | 'download'; version?: string }) => void): void;
}
interface Window {
+58 -12
View File
@@ -35,7 +35,7 @@ const UI_TEXT_DE = {
streamerPlaceholder: 'Streamer hinzufügen…',
clipsHeading: 'Twitch Clip-Download',
clipsInfoTitle: 'Info',
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
clipsInfoText: 'Unterstützte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
cutterSelectTitle: 'Video auswählen',
cutterPreviewPlaceholder: 'Video auswählen, um eine Vorschau zu sehen',
cutterBrowse: 'Durchsuchen',
@@ -76,11 +76,11 @@ const UI_TEXT_DE = {
recordingMetadataTitle: 'Aufnahmen und Metadaten',
storageLabel: 'Speicherort',
selectFolder: 'Ordner',
openFolder: 'Offnen',
openFolder: 'Öffnen',
modeLabel: 'Download-Modus',
modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)',
partMinutesLabel: 'Teil-Länge (Minuten)',
parallelDownloadsLabel: 'Parallele Downloads',
parallelDownloads1: '1 (Standard)',
parallelDownloads2: '2 (Parallel)',
@@ -195,7 +195,7 @@ const UI_TEXT_DE = {
resetDownloadedIds: 'Downloaded-VODs zurücksetzen',
configExported: 'Konfiguration exportiert.',
configExportFailed: 'Export der Konfiguration fehlgeschlagen.',
configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.',
configImported: 'Konfiguration importiert. Einige Änderungen erfordern evtl. einen Neustart.',
configImportFailed: 'Import der Konfiguration fehlgeschlagen.',
resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zurücksetzen? Karten verlieren das grüne Häkchen, es werden aber keine Dateien gelöscht.',
resetDownloadedDone: '{count} Einträge aus der Downloaded-Liste entfernt.',
@@ -210,7 +210,7 @@ const UI_TEXT_DE = {
downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)',
downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der öffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behält Chat-Replays nur solange wie das VOD selbst.',
captureLiveChatLabel: 'Live-Chat während der Aufnahme mitschneiden (.chat.jsonl)',
captureLiveChatHint: 'Oeffnet während einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch früheren Inhalt nicht korrumpiert).',
captureLiveChatHint: 'Öffnet während einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch früheren Inhalt nicht korrumpiert).',
logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)',
logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr günstig — ein zusätzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.',
streamlinkQualityLabel: 'Stream-Qualität',
@@ -316,6 +316,7 @@ const UI_TEXT_DE = {
preflightRun: 'Check ausführen',
preflightFix: 'Auto-Fix Tools',
preflightEmpty: 'Noch kein Check ausgeführt.',
preflightError: 'System-Check fehlgeschlagen.',
preflightChecking: 'Prüfe...',
preflightFixing: 'Fixe...',
preflightReady: 'Alles bereit.',
@@ -416,6 +417,7 @@ const UI_TEXT_DE = {
ctxCopyUrl: 'URL kopieren',
ctxOpenOnTwitch: 'Auf Twitch öffnen',
ctxRemove: 'Aus Queue entfernen',
ctxCopyFailed: 'URL konnte nicht kopiert werden.',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
liveRecordingTitle: 'Live-Aufnahme - läuft bis der Stream endet',
recordingHealth: {
@@ -501,17 +503,34 @@ const UI_TEXT_DE = {
bulkAdding: 'Füge hinzu...',
bulkClear: 'Löschen',
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefügt.',
bulkAddedToQueueOne: '1 VOD zur Warteschlange hinzugefügt.',
bulkAddSkipped: 'Keine VODs hinzugefügt (bereits in Queue oder ungültig).',
bulkAddPartial: '{added} VODs hinzugefügt; {skipped} übersprungen (bereits in Queue oder ungültig).',
bulkAddDuplicate: '{count} VODs sind bereits in der Warteschlange.',
bulkAddDuplicateOne: 'Dieses VOD ist bereits in der Warteschlange.',
bulkAddInvalid: '{count} VODs enthalten ungültige Daten und wurden übersprungen.',
bulkAddInvalidOne: 'Dieses VOD enthält ungültige Daten und wurde übersprungen.',
bulkAddFailed: '{count} VODs konnten nicht hinzugefügt werden und bleiben für einen erneuten Versuch ausgewählt.',
bulkAddFailedOne: 'Dieses VOD konnte nicht hinzugefügt werden und bleibt für einen erneuten Versuch ausgewählt.',
bulkAddResult: '{added} hinzugefügt; {duplicates} bereits vorhanden; {invalid} ungültig; {failed} fehlgeschlagen.',
bulkMarkDownloaded: 'Als heruntergeladen markieren',
bulkUnmark: 'Markierung entfernen',
bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.',
bulkMarkedDownloadedOne: '1 VOD als heruntergeladen markiert.',
bulkUnmarkedDownloaded: 'Markierung von {count} VODs entfernt.',
bulkUnmarkedDownloadedOne: 'Markierung von 1 VOD entfernt.',
bulkMarkFailed: '{count} VODs konnten nicht aktualisiert werden und bleiben für einen erneuten Versuch ausgewählt.',
bulkMarkFailedOne: 'Dieses VOD konnte nicht aktualisiert werden und bleibt für einen erneuten Versuch ausgewählt.',
bulkMarkResult: '{updated} aktualisiert; {failed} fehlgeschlagen.',
alreadyDownloaded: 'Bereits heruntergeladen',
hideDownloaded: 'Bereits geladene ausblenden',
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind',
hideDownloadedEmptyTitle: 'Alle VODs ausgeblendet',
hideDownloadedEmptyText: 'Alle VODs sind bereits als heruntergeladen markiert. Deaktiviere den Filter, um sie anzuzeigen.',
openOnTwitch: 'Auf Twitch öffnen',
ctxOpenOnTwitch: 'Auf Twitch öffnen',
ctxCopyUrl: 'VOD-URL kopieren',
ctxCopyFailed: 'URL konnte nicht kopiert werden.',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
ctxMarkDownloaded: 'Als heruntergeladen markieren',
ctxUnmarkDownloaded: 'Markierung entfernen'
@@ -527,7 +546,7 @@ const UI_TEXT_DE = {
dialogPartHint: 'Leer lassen = Teil 1',
dialogFormatLabel: 'Dateinamen-Format:',
dialogConfirm: 'Zur Queue hinzufügen',
invalidDuration: 'Ungultig!',
invalidDuration: 'Ungültig!',
invalidTime: 'Ungültige Zeitangaben',
endBeforeStart: 'Endzeit muss größer als Startzeit sein!',
outOfRange: 'Zeit außerhalb des VOD-Bereichs!',
@@ -576,6 +595,33 @@ const UI_TEXT_DE = {
videoTrack: 'VIDEO',
audioTrack: 'AUDIO',
noAudio: 'Keine Audiospur',
newVideo: 'Neues Video',
openProject: 'Projekt öffnen',
saveProject: 'Projekt speichern',
recoveryFound: 'Gespeicherte Bearbeitung gefunden',
recoverProject: 'Wiederherstellen',
discardProject: 'Verwerfen',
exportProfileLabel: 'Exportprofil',
exportEncoderLabel: 'Encoder',
audioStreamLabel: 'Audiospur',
profileQuality: 'Qualität',
profileBalanced: 'Ausgewogen',
profileFast: 'Schnell',
profileArchive: 'Archiv',
encoderSoftware: 'Software',
encoderNvenc: 'NVIDIA NVENC',
encoderQsv: 'Intel Quick Sync',
encoderAmf: 'AMD AMF',
audioStream: 'Audiospur {index}',
channelSingular: 'Kanal',
channelPlural: 'Kanäle',
speedNormal: 'Normal',
projectSaved: 'Projekt gespeichert',
projectSaveFailed: 'Projekt konnte nicht gespeichert werden',
projectRecoveryFailed: 'Projekt konnte nicht wiederhergestellt werden',
projectRecovered: 'Projekt wiederhergestellt',
projectNotFound: 'Kein passendes Projekt gefunden',
projectOpened: 'Projekt geöffnet',
loadingMedia: 'Video wird vorbereitet…',
speedLabel: 'Geschwindigkeit',
play: 'Abspielen',
@@ -607,10 +653,10 @@ const UI_TEXT_DE = {
discardConfirm: 'Verwerfen und öffnen'
},
merge: {
empty: 'Keine Videos ausgewahlt',
empty: 'Keine Videos ausgewählt',
merging: 'Zusammenfügen...',
merge: 'Zusammenfügen',
success: 'Videos erfolgreich zusammengefugt!',
success: 'Videos erfolgreich zusammengefügt!',
failed: 'Fehler beim Zusammenfügen der Videos.',
moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben',
@@ -621,7 +667,7 @@ const UI_TEXT_DE = {
phaseDownloading: 'VOD wird heruntergeladen',
phaseMerging: 'Zusammenfügen...',
phaseSplitting: 'Part wird erstellt',
phaseCleanup: 'Aufraumen...',
phaseCleanup: 'Aufräumen...',
needMinTwo: 'Mindestens 2 VODs auswählen',
titleTwo: 'Merge: {title1} + {title2}',
titleMany: 'Merge: {title1} + {count} weitere',
@@ -631,11 +677,11 @@ const UI_TEXT_DE = {
bannerDefault: 'Neue Version verfügbar!',
latest: 'Du hast die neueste Version!',
checking: 'Suche nach Updates...',
checkInProgress: 'Update-Prufung lauft bereits.',
checkInProgress: 'Update-Prüfung läuft bereits.',
readyToInstall: 'Update ist bereit zur Installation.',
checkFailed: 'Update-Prufung fehlgeschlagen.',
checkFailed: 'Update-Prüfung fehlgeschlagen.',
downloading: 'Wird heruntergeladen...',
downloadInProgress: 'Update-Download lauft bereits.',
downloadInProgress: 'Update-Download läuft bereits.',
downloadFailed: 'Update-Download fehlgeschlagen.',
available: 'verfügbar!',
downloadNow: 'Jetzt herunterladen',
+46
View File
@@ -316,6 +316,7 @@ const UI_TEXT_EN = {
preflightRun: 'Run check',
preflightFix: 'Auto-fix tools',
preflightEmpty: 'No checks run yet.',
preflightError: 'System check failed.',
preflightChecking: 'Checking...',
preflightFixing: 'Fixing...',
preflightReady: 'Everything is ready.',
@@ -416,6 +417,7 @@ const UI_TEXT_EN = {
ctxCopyUrl: 'Copy URL',
ctxOpenOnTwitch: 'Open on Twitch',
ctxRemove: 'Remove from queue',
ctxCopyFailed: 'Could not copy URL.',
ctxCopiedUrl: 'URL copied to clipboard.',
liveRecordingTitle: 'Live recording — captures until the stream ends',
recordingHealth: {
@@ -501,17 +503,34 @@ const UI_TEXT_EN = {
bulkAdding: 'Adding...',
bulkClear: 'Clear',
bulkAddedToQueue: 'Added {count} VODs to the queue.',
bulkAddedToQueueOne: 'Added 1 VOD to the queue.',
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
bulkAddPartial: '{added} VODs added; {skipped} skipped (already in queue or invalid).',
bulkAddDuplicate: '{count} VODs are already in the queue.',
bulkAddDuplicateOne: 'This VOD is already in the queue.',
bulkAddInvalid: '{count} VODs have invalid data and were skipped.',
bulkAddInvalidOne: 'This VOD has invalid data and was skipped.',
bulkAddFailed: '{count} VODs could not be added and remain selected for retry.',
bulkAddFailedOne: 'This VOD could not be added and remains selected for retry.',
bulkAddResult: '{added} added; {duplicates} already queued; {invalid} invalid; {failed} failed.',
bulkMarkDownloaded: 'Mark as downloaded',
bulkUnmark: 'Unmark',
bulkMarkedDownloaded: 'Marked {count} VODs as downloaded.',
bulkMarkedDownloadedOne: 'Marked 1 VOD as downloaded.',
bulkUnmarkedDownloaded: 'Removed {count} VODs from the downloaded list.',
bulkUnmarkedDownloadedOne: 'Removed 1 VOD from the downloaded list.',
bulkMarkFailed: '{count} VODs could not be updated and remain selected for retry.',
bulkMarkFailedOne: 'This VOD could not be updated and remains selected for retry.',
bulkMarkResult: '{updated} updated; {failed} failed.',
alreadyDownloaded: 'Already downloaded',
hideDownloaded: 'Hide downloaded',
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded',
hideDownloadedEmptyTitle: 'All VODs hidden',
hideDownloadedEmptyText: 'All VODs are marked as downloaded. Turn off the filter to show them.',
openOnTwitch: 'Open on Twitch',
ctxOpenOnTwitch: 'Open on Twitch',
ctxCopyUrl: 'Copy VOD URL',
ctxCopyFailed: 'Could not copy URL.',
ctxCopiedUrl: 'URL copied to clipboard.',
ctxMarkDownloaded: 'Mark as downloaded',
ctxUnmarkDownloaded: 'Unmark downloaded'
@@ -576,6 +595,33 @@ const UI_TEXT_EN = {
videoTrack: 'VIDEO',
audioTrack: 'AUDIO',
noAudio: 'No audio track',
newVideo: 'New video',
openProject: 'Open project',
saveProject: 'Save project',
recoveryFound: 'Saved edit found',
recoverProject: 'Restore',
discardProject: 'Discard',
exportProfileLabel: 'Export profile',
exportEncoderLabel: 'Encoder',
audioStreamLabel: 'Audio track',
profileQuality: 'Quality',
profileBalanced: 'Balanced',
profileFast: 'Fast',
profileArchive: 'Archive',
encoderSoftware: 'Software',
encoderNvenc: 'NVIDIA NVENC',
encoderQsv: 'Intel Quick Sync',
encoderAmf: 'AMD AMF',
audioStream: 'Audio track {index}',
channelSingular: 'channel',
channelPlural: 'channels',
speedNormal: 'Normal',
projectSaved: 'Project saved',
projectSaveFailed: 'Project could not be saved',
projectRecoveryFailed: 'Project could not be restored',
projectRecovered: 'Project restored',
projectNotFound: 'No matching project found',
projectOpened: 'Project opened',
loadingMedia: 'Preparing video…',
speedLabel: 'Playback speed',
play: 'Play',
@@ -0,0 +1,63 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
import { describe, expect, it, vi } from 'vitest';
const source = readFileSync(join(__dirname, 'renderer-profile.ts'), 'utf8');
describe('renderer profile production paths', () => {
it('invalidates an in-flight profile request when the active profile is hidden', () => {
const from = source.indexOf('let activeProfileRequestId');
const to = source.indexOf('function renderStreamerProfileSkeleton', from);
expect(from).toBeGreaterThanOrEqual(0);
expect(to).toBeGreaterThan(from);
const code = transpileModule(
`${source.slice(from, to)}\nglobalThis.profilePath = { hideStreamerProfileHeader, getRequestId: () => activeProfileRequestId };`,
{ compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } }
).outputText;
const context = {
document: { getElementById: () => null },
Map,
applyHtml: () => undefined
} as Record<string, unknown>;
runInNewContext(code, context);
const profilePath = context.profilePath as { hideStreamerProfileHeader(): void; getRequestId(): number };
expect(profilePath.getRequestId()).toBe(0);
profilePath.hideStreamerProfileHeader();
expect(profilePath.getRequestId()).toBe(1);
});
it.each(['unavailable', 'rejected'] as const)('keeps the last good profile visible when refresh is %s', async (outcome) => {
const from = source.indexOf('async function loadStreamerProfile');
const to = source.indexOf('async function fetchStreamerProfile', from);
expect(from).toBeGreaterThanOrEqual(0);
expect(to).toBeGreaterThan(from);
const code = transpileModule(
`let activeProfileLogin = ''; let activeProfileRequestId = 0; const streamerProfileCache = globalThis.profileCache; ${source.slice(from, to)}\nglobalThis.profilePath = { loadStreamerProfile };`,
{ compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } }
).outputText;
const hide = vi.fn();
const renderCard = vi.fn();
const cachedProfile = { login: 'fixture-alpha', displayName: 'Fixture Alpha' };
const context = {
profileCache: new Map([['fixture-alpha', cachedProfile]]),
hideStreamerProfileHeader: hide,
renderStreamerProfileCard: renderCard,
renderStreamerProfileSkeleton: vi.fn(),
fetchStreamerProfile: outcome === 'unavailable'
? async () => null
: async () => { throw new Error('offline'); },
streamerProfilesMatch: () => true,
window: {}
} as Record<string, unknown>;
runInNewContext(code, context);
const profilePath = context.profilePath as { loadStreamerProfile(login: string, forceRefresh?: boolean): Promise<void> };
await profilePath.loadStreamerProfile('fixture-alpha', true);
expect(renderCard).toHaveBeenCalledWith(cachedProfile);
expect(hide).not.toHaveBeenCalled();
});
});
+3 -2
View File
@@ -52,6 +52,7 @@ function streamerProfilesMatch(left: StreamerProfile, right: StreamerProfile): b
}
function hideStreamerProfileHeader(): void {
activeProfileRequestId += 1;
activeProfileLogin = '';
const el = document.getElementById('streamerProfileHeader');
if (!el) return;
@@ -203,14 +204,14 @@ async function loadStreamerProfile(login: string, forceRefresh = false): Promise
// while we were waiting on the API.
if (reqId !== activeProfileRequestId) return;
if (!profile) {
hideStreamerProfileHeader();
if (!cached) hideStreamerProfileHeader();
return;
}
const rememberDisplayName = (window as unknown as { rememberStreamerDisplayName?: (login: string, displayName: string) => void }).rememberStreamerDisplayName;
if (typeof rememberDisplayName === 'function') rememberDisplayName(profile.login, profile.displayName);
if (!cached || !streamerProfilesMatch(cached, profile)) renderStreamerProfileCard(profile);
} catch (_) {
if (reqId === activeProfileRequestId) hideStreamerProfileHeader();
if (reqId === activeProfileRequestId && !cached) hideStreamerProfileHeader();
}
}
+572
View File
@@ -0,0 +1,572 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
import { describe, expect, it, vi } from 'vitest';
const source = readFileSync(join(__dirname, 'renderer-queue.ts'), 'utf8');
const rendererSource = readFileSync(join(__dirname, 'renderer.ts'), 'utf8');
function fragment(start: string, end: string): string {
const from = source.indexOf(start);
const to = source.indexOf(end, from);
if (from < 0 || to < 0) throw new Error(`Missing renderer queue fragment: ${start}`);
return source.slice(from, to);
}
function evaluate<T extends Record<string, unknown>>(code: string, names: string, context: T): T & { exposed: Record<string, (...args: unknown[]) => unknown> } {
const compiled = transpileModule(`${code}\nglobalThis.exposed = { ${names} };`, {
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 }
}).outputText;
runInNewContext(compiled, context);
return context as T & { exposed: Record<string, (...args: unknown[]) => unknown> };
}
const queueText = {
openFile: 'Open file',
showInFolder: 'Show in folder',
viewChat: 'View chat',
viewEvents: 'View events',
outputFilesLabel: '{count} files',
openFileFailed: 'Could not open file.',
ctxCopiedUrl: 'URL copied.',
ctxCopyFailed: 'Could not copy URL.',
readyToDownload: 'Ready',
statusPaused: 'Paused',
statusDone: 'Done',
started: 'Started',
done: 'Done',
failed: 'Failed',
part: 'Part'
};
class HealthElement {
className = '';
title = '';
parent: HealthElement | null = null;
children: HealthElement[] = [];
attributes = new Map<string, string>();
constructor(className = '') {
this.className = className;
}
querySelector(selector: string): HealthElement | null {
const className = selector.startsWith('.') ? selector.slice(1) : '';
for (const child of this.children) {
if (child.className.split(/\s+/).includes(className)) return child;
const nested = child.querySelector(selector);
if (nested) return nested;
}
return null;
}
append(child: HealthElement): void {
child.parent = this;
this.children.push(child);
}
prepend(child: HealthElement): void {
child.parent = this;
this.children.unshift(child);
}
insertAdjacentElement(position: string, child: HealthElement): void {
if (position !== 'afterend' || !this.parent) return;
const index = this.parent.children.indexOf(this);
child.parent = this.parent;
this.parent.children.splice(index + 1, 0, child);
}
setAttribute(name: string, value: string): void {
this.attributes.set(name, value);
}
remove(): void {
if (!this.parent) return;
const index = this.parent.children.indexOf(this);
if (index >= 0) this.parent.children.splice(index, 1);
this.parent = null;
}
}
class DelegatedElement {
parent: DelegatedElement | null = null;
dataset: Record<string, string> = {};
clicked = false;
constructor(readonly selectors: string[] = []) { }
closest(selector: string): DelegatedElement | null {
if (selector.split(',').some((entry) => this.selectors.includes(entry.trim()))) return this;
return this.parent?.closest(selector) ?? null;
}
click(): void {
this.clicked = true;
}
contains(candidate: DelegatedElement): boolean {
let current: DelegatedElement | null = candidate;
while (current) {
if (current === this) return true;
current = current.parent;
}
return false;
}
}
class DelegatedList extends DelegatedElement {
private listeners = new Map<string, Array<(event: { target: DelegatedElement; key?: string; preventDefault(): void }) => void>>();
addEventListener(type: string, listener: (event: { target: DelegatedElement; key?: string; preventDefault(): void }) => void): void {
this.listeners.set(type, [...(this.listeners.get(type) || []), listener]);
}
dispatch(type: string, target: DelegatedElement, key?: string): boolean {
let prevented = false;
for (const listener of this.listeners.get(type) || []) {
listener({ target, key, preventDefault: () => { prevented = true; } });
}
return prevented;
}
}
function decodeHtmlAttribute(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
describe('renderer queue production paths', () => {
it('routes rendered item controls through delegated data actions without inline JavaScript', () => {
expect(source).not.toMatch(/\son(?:click|keydown)=/);
expect(source).toContain('data-queue-action="details"');
expect(source).toContain('data-queue-action="remove"');
expect(source).toContain('data-queue-action="retry"');
expect(source).toContain('data-id="${escapeHtml(item.id)}"');
expect(source).toContain("list.addEventListener('click'");
expect(source).toContain("list.addEventListener('keydown'");
});
it('resolves nested SVG click targets through the Element closest path', () => {
const runtime = evaluate(
fragment('function resolveQueueControl', 'function initQueueActions'),
'resolveQueueControl',
{ Element: DelegatedElement }
);
const control = new DelegatedElement(['[data-queue-action]']);
const svg = new DelegatedElement();
const path = new DelegatedElement();
svg.parent = control;
path.parent = svg;
expect(runtime.exposed.resolveQueueControl(path)).toBe(control);
expect(runtime.exposed.resolveQueueControl({})).toBeNull();
});
it('contains rejected delegated queue actions and reports them without an unhandled rejection', async () => {
const toasts: Array<[string, string]> = [];
const runtime = evaluate(
fragment('async function invokeQueueItemAction', 'function resolveQueueControl'),
'activateQueueControl',
{
window: { showAppToast: (message: string, kind: string) => toasts.push([message, kind]) },
UI_TEXT: { queue: queueText },
invokeQueueFileAction: async () => { throw new Error('viewer rejected'); },
toggleQueueDetails: () => undefined,
removeFromQueue: async () => { throw new Error('remove rejected'); },
retryQueueItem: async () => { throw new Error('retry rejected'); }
}
);
const list = new DelegatedList();
const item = new DelegatedElement(['.queue-item']);
item.dataset.id = 'dangerous-id';
item.parent = list;
const remove = new DelegatedElement(['[data-queue-action]']);
remove.dataset.queueAction = 'remove';
remove.parent = item;
await runtime.exposed.activateQueueControl(remove);
expect(toasts).toEqual([['Failed', 'warn']]);
});
it('preserves an exact Windows path from rendered dataset through delegated click dispatch', async () => {
const rendered = evaluate(
fragment('function renderQueueItemFileActions', 'async function invokeOpenFile'),
'renderQueueItemFileActions',
{
UI_TEXT: { queue: queueText },
escapeHtml: (value: unknown) => String(value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
);
const windowsPath = "C:\\Users\\O'Brien & Söhne\\new cut.mp4";
const html = rendered.exposed.renderQueueItemFileActions({
status: 'completed',
outputFiles: [windowsPath],
title: 'A "quoted" title'
}) as string;
expect(html).not.toContain('onclick=');
expect(html).toContain('data-queue-file-action="open"');
expect(html).toContain('data-queue-file-action="folder"');
expect(html).toContain('C:\\Users\\O&#39;Brien &amp; Söhne\\new cut.mp4');
const openButtonMatch = html.match(/<button[^>]+data-queue-file-action="open"[^>]+data-queue-file-path="([^"]+)"/);
expect(openButtonMatch).not.toBeNull();
const browserDatasetPath = decodeHtmlAttribute(openButtonMatch![1]);
const calls: string[] = [];
const list = new DelegatedList();
const control = new DelegatedElement(['[data-queue-file-action]']);
const svg = new DelegatedElement();
const path = new DelegatedElement();
control.dataset.queueFileAction = 'open';
control.dataset.queueFilePath = browserDatasetPath;
control.parent = list;
svg.parent = control;
path.parent = svg;
const dispatched = evaluate(
fragment('async function invokeOpenFile', 'async function copyQueueUrl'),
'initQueueActions',
{
Element: DelegatedElement,
byId: () => list,
window: {
api: {
openFile: async (filePath: string) => { calls.push(filePath); return true; },
showInFolder: async () => true
}
},
UI_TEXT: { queue: queueText },
openChatViewer: async () => undefined,
openEventsViewer: async () => undefined,
toggleQueueDetails: () => undefined,
removeFromQueue: async () => undefined,
retryQueueItem: async () => undefined
}
);
dispatched.exposed.initQueueActions();
list.dispatch('click', path);
await Promise.resolve();
await Promise.resolve();
expect(calls).toEqual([windowsPath]);
});
it('reports clipboard success only after fulfillment and reports rejection as a warning', async () => {
let resolveWrite: (() => void) | undefined;
const writeText = vi.fn(() => new Promise<void>((resolve) => { resolveWrite = resolve; }));
const toasts: Array<[string, string]> = [];
const runtime = evaluate(
fragment('async function copyQueueUrl', 'function buildQueueFingerprint'),
'copyQueueUrl',
{
navigator: { clipboard: { writeText } },
window: { showAppToast: (message: string, kind: string) => toasts.push([message, kind]) },
UI_TEXT: { queue: queueText }
}
);
const pending = runtime.exposed.copyQueueUrl('https://twitch.example/vod') as Promise<void>;
expect(toasts).toEqual([]);
expect(resolveWrite).toBeTypeOf('function');
(resolveWrite as () => void)();
await pending;
expect(toasts).toEqual([['URL copied.', 'info']]);
runtime.navigator.clipboard.writeText = vi.fn(async () => { throw new Error('denied'); });
await runtime.exposed.copyQueueUrl('https://twitch.example/vod');
expect(toasts.at(-1)).toEqual(['Could not copy URL.', 'warn']);
});
it('reports rejected and negative file open operations through the shared safe wrappers', async () => {
const toasts: Array<[string, string]> = [];
const openFile = vi.fn()
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error('open denied'));
const showInFolder = vi.fn()
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error('folder denied'));
const runtime = evaluate(
fragment('async function invokeOpenFile', 'async function invokeQueueFileAction'),
'invokeOpenFile, invokeShowInFolder',
{
window: {
api: { openFile, showInFolder },
showAppToast: (message: string, kind: string) => toasts.push([message, kind])
},
UI_TEXT: { queue: queueText }
}
);
await runtime.exposed.invokeOpenFile('C:\\media\\video.mp4');
await runtime.exposed.invokeOpenFile('C:\\media\\video.mp4');
await runtime.exposed.invokeShowInFolder('C:\\media\\video.mp4');
await runtime.exposed.invokeShowInFolder('C:\\media\\video.mp4');
expect(toasts).toEqual([
['Could not open file.', 'warn'],
['Could not open file.', 'warn'],
['Could not open file.', 'warn'],
['Could not open file.', 'warn']
]);
const contextMenuPath = fragment('function showQueueContextMenu', 'async function moveQueueItemTo');
expect(contextMenuPath).toContain('() => invokeOpenFile(first)');
expect(contextMenuPath).toContain('() => invokeShowInFolder(first)');
expect(contextMenuPath).not.toContain('window.api.openFile(first)');
expect(contextMenuPath).not.toContain('window.api.showInFolder(first)');
});
it('awaits rejected context menu actions through the shared warning path', () => {
const menuPath = fragment('function showQueueContextMenu', 'async function moveQueueItemTo');
expect(menuPath).toContain("const makeItem = (label: string, onClick: () => void | Promise<void>");
expect(menuPath).toContain('void invokeQueueActionSafely(onClick)');
expect(menuPath).toContain('() => moveQueueItemTo(item.id');
expect(menuPath).toContain('() => retryQueueItem(item.id)');
expect(menuPath).toContain('() => window.api.openExternal(item.url)');
expect(menuPath).toContain('() => removeFromQueue(item.id)');
expect(menuPath).not.toContain('() => { void moveQueueItemTo');
expect(menuPath).not.toContain('() => { void retryQueueItem');
expect(menuPath).not.toContain('() => { void window.api.openExternal');
expect(menuPath).not.toContain('() => { void removeFromQueue');
});
it('removes the exact document listeners before replacing an open context menu', () => {
const lifecyclePath = fragment('let queueContextMenuInitialized', 'function initQueueContextMenu');
const calls: Array<[string, string, unknown, boolean]> = [];
const firstCleanup = vi.fn();
const runtime = evaluate(
lifecyclePath,
'closeQueueContextMenu, installQueueContextMenuDismissal, setActiveCleanup: (cleanup) => { activeQueueContextMenuCleanup = cleanup; }, getActiveCleanup: () => activeQueueContextMenuCleanup',
{
activeQueueContextMenu: null,
activeQueueContextMenuInvoker: null,
document: {
addEventListener: (type: string, listener: unknown, capture: boolean) => calls.push(['add', type, listener, capture]),
removeEventListener: (type: string, listener: unknown, capture: boolean) => calls.push(['remove', type, listener, capture])
},
Node: DelegatedElement
}
);
const firstMenu = { contains: () => false };
const installed = runtime.exposed.installQueueContextMenuDismissal(firstMenu, firstCleanup) as (restoreFocus?: boolean) => void;
runtime.exposed.setActiveCleanup(installed);
runtime.exposed.closeQueueContextMenu(true);
expect(firstCleanup).toHaveBeenCalledWith(true);
const adds = calls.filter(([operation]) => operation === 'add');
const removes = calls.filter(([operation]) => operation === 'remove');
expect(adds).toHaveLength(2);
expect(removes).toHaveLength(2);
expect(removes[0]).toEqual(['remove', adds[0][1], adds[0][2], adds[0][3]]);
expect(removes[1]).toEqual(['remove', adds[1][1], adds[1][2], adds[1][3]]);
expect(runtime.exposed.getActiveCleanup()).toBeNull();
const secondCleanup = vi.fn();
const secondInstalled = runtime.exposed.installQueueContextMenuDismissal(firstMenu, secondCleanup) as (restoreFocus?: boolean) => void;
runtime.exposed.setActiveCleanup(secondInstalled);
runtime.exposed.closeQueueContextMenu(false);
expect(secondCleanup).toHaveBeenCalledWith(false);
const allAdds = calls.filter(([operation]) => operation === 'add');
const allRemoves = calls.filter(([operation]) => operation === 'remove');
expect(allAdds).toHaveLength(4);
expect(allRemoves).toHaveLength(4);
expect(allRemoves.slice(2)).toEqual(allAdds.slice(2).map(([, type, listener, capture]) => ['remove', type, listener, capture]));
});
it('shows terminal and paused states before multipart progress', () => {
const runtime = evaluate(
fragment('function getQueueProgressStatusText', 'function getQueueProgressMetricsText'),
'getQueueProgressStatusText',
{ UI_TEXT: { queue: queueText } }
);
const status = runtime.exposed.getQueueProgressStatusText;
expect(status({ status: 'paused', currentPart: 3, totalParts: 8 })).toBe('Paused');
expect(status({ status: 'completed', currentPart: 8, totalParts: 8 })).toBe('Done');
expect(status({ status: 'error', currentPart: 3, totalParts: 8, last_error: 'Disk full' })).toBe('Disk full');
expect(status({ status: 'downloading', currentPart: 3, totalParts: 8, progressStatus: 'Pause pending' })).toBe('Pause pending');
expect(status({ status: 'downloading', currentPart: 3, totalParts: 8 })).toBe('Part 3/8');
});
it('shows speed and ETA only while an item is actively downloading', () => {
const runtime = evaluate(
fragment('function getQueueProgressMetricsText', 'function toggleQueueSelection'),
'getQueueProgressMetricsText',
{}
);
const metrics = runtime.exposed.getQueueProgressMetricsText;
expect(metrics({ status: 'downloading', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('12.3% | 4 MB/s | 2m');
expect(metrics({ status: 'pending', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('');
expect(metrics({ status: 'paused', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('');
expect(metrics({ status: 'error', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('');
expect(metrics({ status: 'completed', progress: 100, speed: '4 MB/s', eta: '2m' })).toBe('100%');
});
it('keeps monotonic progress while treating explicit empty telemetry as an authoritative reset', () => {
const mergePath = rendererSource.slice(
rendererSource.indexOf('function mergeQueueState'),
rendererSource.indexOf('function getQueueStateFingerprint')
);
const runtime = evaluate(
`${mergePath}\n${fragment('function getQueueProgressMetricsText', 'function toggleQueueSelection')}`,
'mergeQueueState, getQueueProgressMetricsText',
{
queue: [{
id: 'active',
status: 'downloading',
progress: 70,
speed: '4 MB/s',
eta: '2m',
currentPart: 2,
totalParts: 5,
downloadedBytes: 700,
totalBytes: 1000,
progressStatus: '70%',
recordingHealth: 'ok'
}]
}
);
const pausePending = runtime.exposed.mergeQueueState([{
id: 'active',
status: 'downloading',
progress: 10,
speed: '',
eta: '',
currentPart: 0,
totalParts: 0,
downloadedBytes: 0,
totalBytes: 0,
progressStatus: 'Pause pending',
recordingHealth: 'stale'
}]) as Array<Record<string, unknown>>;
expect(pausePending[0]).toMatchObject({
progress: 70,
speed: '',
eta: '',
currentPart: 0,
totalParts: 0,
downloadedBytes: 0,
totalBytes: 0,
progressStatus: 'Pause pending',
recordingHealth: 'stale'
});
expect(runtime.exposed.getQueueProgressMetricsText(pausePending[0])).toBe('70.0%');
const retrying = runtime.exposed.mergeQueueState([{
id: 'active',
status: 'downloading',
progress: -1,
speed: '',
eta: '',
progressStatus: 'Retrying in 5 seconds'
}]) as Array<Record<string, unknown>>;
expect(retrying[0]).toMatchObject({
progress: 70,
speed: '',
eta: '',
progressStatus: 'Retrying in 5 seconds',
recordingHealth: 'ok'
});
expect(runtime.exposed.getQueueProgressMetricsText(retrying[0])).toBe('70.0%');
const missingTelemetry = runtime.exposed.mergeQueueState([{
id: 'active',
status: 'downloading',
progress: 20
}]) as Array<Record<string, unknown>>;
expect(missingTelemetry[0]).toMatchObject({
progress: 70,
speed: '4 MB/s',
eta: '2m',
currentPart: 2,
totalParts: 5,
downloadedBytes: 700,
totalBytes: 1000,
progressStatus: '70%',
recordingHealth: 'ok'
});
});
it('includes recording health in render invalidation and updates its visible badge in place', () => {
const fingerprints = evaluate(
fragment('function getQueueRenderFingerprint', 'function hasActiveQueueDuplicate'),
'getQueueRenderFingerprint',
{ currentLanguage: 'en', selectedQueueIds: [], expandedQueueIds: new Set<string>() }
);
const base = { id: 'live-1', status: 'downloading', progress: 1, isLive: true };
const unknown = fingerprints.exposed.getQueueRenderFingerprint([{ ...base, recordingHealth: 'unknown' }]) as string;
const ok = fingerprints.exposed.getQueueRenderFingerprint([{ ...base, recordingHealth: 'ok' }]) as string;
const stale = fingerprints.exposed.getQueueRenderFingerprint([{ ...base, recordingHealth: 'stale' }]) as string;
expect(new Set([unknown, ok, stale]).size).toBe(3);
const healthPath = fragment('function syncQueueRecordingHealth', 'function updateQueueItemProgress');
expect(healthPath).toContain("health === 'ok'");
expect(healthPath).toContain("health === 'stale'");
expect(fragment('function updateQueueItemProgress', 'function toggleQueueDetails')).toContain('syncQueueRecordingHealth(el, item)');
const runtime = evaluate(
healthPath,
'syncQueueRecordingHealth',
{
UI_TEXT: { queue: { recordingHealth: { unknown: 'Pending', ok: 'Healthy', stale: 'Stalled' } } },
document: { createElement: () => new HealthElement() }
}
);
const root = new HealthElement('queue-item');
const title = new HealthElement('title');
const live = new HealthElement('queue-live-badge');
title.append(live);
root.append(title);
runtime.exposed.syncQueueRecordingHealth(root, { isLive: true, status: 'downloading', recordingHealth: 'unknown' });
const badge = root.querySelector('.queue-health-dot');
expect(badge?.className).toBe('queue-health-dot health-unknown');
expect(badge?.title).toBe('Pending');
expect(badge?.attributes.get('aria-label')).toBe('Pending');
runtime.exposed.syncQueueRecordingHealth(root, { isLive: true, status: 'downloading', recordingHealth: 'ok' });
expect(root.querySelector('.queue-health-dot')).toBe(badge);
expect(badge?.className).toBe('queue-health-dot health-ok');
expect(badge?.title).toBe('Healthy');
runtime.exposed.syncQueueRecordingHealth(root, { isLive: true, status: 'downloading', recordingHealth: 'stale' });
expect(badge?.className).toBe('queue-health-dot health-stale');
expect(badge?.title).toBe('Stalled');
runtime.exposed.syncQueueRecordingHealth(root, { isLive: true, status: 'paused', recordingHealth: 'stale' });
expect(root.querySelector('.queue-health-dot')).toBeNull();
const mergePath = rendererSource.slice(
rendererSource.indexOf('function mergeQueueState'),
rendererSource.indexOf('function getQueueStateFingerprint')
);
expect(mergePath).toContain('recordingHealth: item.recordingHealth === undefined ? prev.recordingHealth : item.recordingHealth');
});
it('matches progress elements by exact dataset identity without constructing a CSS selector from the queue id', () => {
const progressPath = fragment('function updateQueueItemProgress', 'function toggleQueueDetails');
expect(progressPath).toContain("querySelectorAll<HTMLElement>('.queue-item')");
expect(progressPath).toContain('candidate.dataset.id === progressId');
expect(progressPath).not.toContain('`[data-id="${');
expect(progressPath).not.toContain("replace(/\"/g");
});
it('does not offer retry actions while interrupted merge artifacts remain', () => {
expect(source).toContain("queue.some((item) => item.status === 'error' && !item.mergeRecoveryBlocked)");
expect(source).toContain("const isFailed = item.status === 'error' && !item.mergeRecoveryBlocked");
expect(source).toContain("item.status === 'error' && !item.mergeRecoveryBlocked ?");
expect(source).toContain("item.mergeRecoveryBlocked ? 'blocked' : ''");
});
});
+207 -67
View File
@@ -14,30 +14,27 @@ function renderQueueItemFileActions(item: QueueItem): string {
const first = item.outputFiles[0];
if (typeof first !== 'string' || !first) return '';
const safeFirst = escapeHtml(first);
const safeFirstAttr = first.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const buttons: string[] = [];
// "Open file" only makes sense when there's exactly one output (a clip /
// full VOD download). For multi-part downloads "open the first part" is
// surprising — the user almost always wants the folder.
if (item.outputFiles.length === 1) {
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
buttons.push(`<button type="button" class="queue-detail-btn" data-queue-file-action="open" data-queue-file-path="${escapeHtml(first)}">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
}
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
buttons.push(`<button type="button" class="queue-detail-btn" data-queue-file-action="folder" data-queue-file-path="${escapeHtml(first)}">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
// Surface a "View chat" button when a sibling chat file exists in the
// outputs list. Single click opens the in-app viewer modal.
const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f));
if (chatFile) {
const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '&quot;');
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
buttons.push(`<button type="button" class="queue-detail-btn" data-queue-file-action="chat" data-queue-file-path="${escapeHtml(chatFile)}" data-queue-file-title="${escapeHtml(item.title || item.streamer || '')}">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
}
// Same pattern for the .events.jsonl sidecar — title/game change timeline.
const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f));
if (eventsFile) {
const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '&quot;');
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
buttons.push(`<button type="button" class="queue-detail-btn" data-queue-file-action="events" data-queue-file-path="${escapeHtml(eventsFile)}" data-queue-file-title="${escapeHtml(item.title || item.streamer || '')}">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
}
const fileLabel = item.outputFiles.length === 1
@@ -53,18 +50,109 @@ function renderQueueItemFileActions(item: QueueItem): string {
}
async function invokeOpenFile(filePath: string): Promise<void> {
const ok = await window.api.openFile(filePath);
if (!ok) {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
let ok: boolean;
try {
ok = await window.api.openFile(filePath);
} catch {
ok = false;
}
if (ok) return;
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
}
async function invokeShowInFolder(filePath: string): Promise<void> {
const ok = await window.api.showInFolder(filePath);
if (!ok) {
let ok: boolean;
try {
ok = await window.api.showInFolder(filePath);
} catch {
ok = false;
}
if (ok) return;
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
}
async function invokeQueueFileAction(action: string, filePath: string, title = ''): Promise<void> {
if (action === 'open') {
await invokeOpenFile(filePath);
} else if (action === 'folder') {
await invokeShowInFolder(filePath);
} else if (action === 'chat') {
await openChatViewer(filePath, title);
} else if (action === 'events') {
await openEventsViewer(filePath, title);
}
}
let queueActionsInitialized = false;
async function invokeQueueItemAction(action: string, id: string): Promise<void> {
if (action === 'details') {
toggleQueueDetails(id);
} else if (action === 'remove') {
await removeFromQueue(id);
} else if (action === 'retry') {
await retryQueueItem(id);
}
}
async function invokeQueueActionSafely(action: () => void | Promise<void>): Promise<void> {
try {
await action();
} catch {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
if (toast) toast(UI_TEXT.queue.failed, 'warn');
}
}
async function activateQueueControl(control: HTMLElement): Promise<void> {
await invokeQueueActionSafely(async () => {
const fileAction = control.dataset.queueFileAction;
const filePath = control.dataset.queueFilePath;
if (fileAction && filePath) {
await invokeQueueFileAction(fileAction, filePath, control.dataset.queueFileTitle || '');
return;
}
const action = control.dataset.queueAction;
const item = control.closest<HTMLElement>('.queue-item');
const id = item?.dataset.id;
if (!action || !id) return;
await invokeQueueItemAction(action, id);
});
}
function resolveQueueControl(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) return null;
return target.closest<HTMLElement>('[data-queue-action], [data-queue-file-action]');
}
function initQueueActions(): void {
if (queueActionsInitialized) return;
queueActionsInitialized = true;
const list = byId('queueList');
list.addEventListener('click', (event: MouseEvent) => {
const control = resolveQueueControl(event.target);
if (!control || !list.contains(control)) return;
void activateQueueControl(control);
});
list.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const control = resolveQueueControl(event.target);
if (!control || !list.contains(control)) return;
event.preventDefault();
control.click();
});
}
async function copyQueueUrl(url: string): Promise<void> {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
try {
await navigator.clipboard.writeText(url);
if (toast) toast(UI_TEXT.queue.ctxCopiedUrl, 'info');
} catch {
if (toast) toast(UI_TEXT.queue.ctxCopyFailed, 'warn');
}
}
@@ -101,7 +189,9 @@ function getQueueRenderFingerprint(items: QueueItem[]): string {
item.speed || '',
item.eta || '',
item.progressStatus || '',
item.recordingHealth || '',
item.last_error || '',
item.mergeRecoveryBlocked ? 'blocked' : '',
item.mergeGroup?.mergePhase || ''
].join(':'));
@@ -158,8 +248,15 @@ async function retryQueueItem(id: string): Promise<void> {
let queueContextMenuInitialized = false;
let activeQueueContextMenu: HTMLElement | null = null;
let activeQueueContextMenuInvoker: HTMLElement | null = null;
let activeQueueContextMenuCleanup: ((restoreFocus?: boolean) => void) | null = null;
function closeQueueContextMenu(restoreFocus = false): void {
const cleanup = activeQueueContextMenuCleanup;
if (cleanup) {
activeQueueContextMenuCleanup = null;
cleanup(restoreFocus);
return;
}
if (!activeQueueContextMenu) return;
activeQueueContextMenu.remove();
activeQueueContextMenu = null;
@@ -168,6 +265,26 @@ function closeQueueContextMenu(restoreFocus = false): void {
if (restoreFocus && invoker?.isConnected) invoker.focus();
}
function installQueueContextMenuDismissal(menu: HTMLElement, cleanupMenu: (restoreFocus: boolean) => void): (restoreFocus?: boolean) => void {
let cleaned = false;
let cleanup: (restoreFocus?: boolean) => void;
const dismissOnClick = (event: MouseEvent) => {
if (event.target instanceof Node && menu.contains(event.target)) return;
cleanup();
};
const dismissOnScroll = () => cleanup();
cleanup = (restoreFocus = false): void => {
if (cleaned) return;
cleaned = true;
document.removeEventListener('mousedown', dismissOnClick, true);
document.removeEventListener('scroll', dismissOnScroll, true);
cleanupMenu(restoreFocus);
};
document.addEventListener('mousedown', dismissOnClick, true);
document.addEventListener('scroll', dismissOnScroll, true);
return cleanup;
}
function initQueueContextMenu(): void {
if (queueContextMenuInitialized) return;
queueContextMenuInitialized = true;
@@ -203,8 +320,7 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
let cleanup = (restoreFocus = false): void => closeQueueContextMenu(restoreFocus);
const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => {
const makeItem = (label: string, onClick: () => void | Promise<void>, disabled = false): HTMLElement => {
const el = document.createElement('button');
el.type = 'button';
el.textContent = label;
@@ -216,7 +332,8 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT
}
if (!disabled) {
el.addEventListener('click', () => {
try { onClick(); } finally { cleanup(); }
closeQueueContextMenu();
void invokeQueueActionSafely(onClick);
});
}
return el;
@@ -230,7 +347,7 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT
};
const isPending = item.status === 'pending' || item.status === 'paused';
const isFailed = item.status === 'error';
const isFailed = item.status === 'error' && !item.mergeRecoveryBlocked;
const isCompleted = item.status === 'completed';
const canSelectForMerge = item.status === 'pending' && !item.mergeGroup && !item.isLive;
@@ -246,37 +363,29 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT
}
if (isPending) {
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => { void moveQueueItemTo(item.id, 'top'); }));
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveBottom, () => { void moveQueueItemTo(item.id, 'bottom'); }));
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => moveQueueItemTo(item.id, 'top')));
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveBottom, () => moveQueueItemTo(item.id, 'bottom')));
menu.appendChild(makeSeparator());
}
if (isFailed) {
menu.appendChild(makeItem(UI_TEXT.queue.retryItem, () => { void retryQueueItem(item.id); }));
menu.appendChild(makeItem(UI_TEXT.queue.retryItem, () => retryQueueItem(item.id)));
menu.appendChild(makeSeparator());
}
if (isCompleted && item.outputFiles && item.outputFiles.length > 0) {
const first = item.outputFiles[0];
if (item.outputFiles.length === 1) {
menu.appendChild(makeItem(UI_TEXT.queue.openFile, () => { void window.api.openFile(first); }));
menu.appendChild(makeItem(UI_TEXT.queue.openFile, () => invokeOpenFile(first)));
}
menu.appendChild(makeItem(UI_TEXT.queue.showInFolder, () => { void window.api.showInFolder(first); }));
menu.appendChild(makeItem(UI_TEXT.queue.showInFolder, () => invokeShowInFolder(first)));
menu.appendChild(makeSeparator());
}
menu.appendChild(makeItem(UI_TEXT.queue.ctxCopyUrl, () => {
try {
void navigator.clipboard.writeText(item.url);
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.queue.ctxCopiedUrl, 'info');
} catch { /* ignore */ }
}));
menu.appendChild(makeItem(UI_TEXT.queue.ctxOpenOnTwitch, () => {
void window.api.openExternal(item.url);
}));
menu.appendChild(makeItem(UI_TEXT.queue.ctxCopyUrl, () => copyQueueUrl(item.url)));
menu.appendChild(makeItem(UI_TEXT.queue.ctxOpenOnTwitch, () => window.api.openExternal(item.url)));
menu.appendChild(makeSeparator());
menu.appendChild(makeItem(UI_TEXT.queue.ctxRemove, () => { void removeFromQueue(item.id); }));
menu.appendChild(makeItem(UI_TEXT.queue.ctxRemove, () => removeFromQueue(item.id)));
document.body.appendChild(menu);
activeQueueContextMenu = menu;
@@ -290,20 +399,21 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
const dismissOnClick = (ev: MouseEvent) => {
if (!activeQueueContextMenu) return;
if (ev.target instanceof Node && activeQueueContextMenu.contains(ev.target)) return;
cleanup();
};
const dismissOnScroll = () => cleanup();
cleanup = (restoreFocus = false): void => {
closeQueueContextMenu(restoreFocus);
document.removeEventListener('mousedown', dismissOnClick, true);
document.removeEventListener('scroll', dismissOnScroll, true);
};
document.addEventListener('mousedown', dismissOnClick, true);
document.addEventListener('scroll', dismissOnScroll, true);
RendererAccessibility.installMenuKeyboardNavigation(menu, () => cleanup(true));
let cleanup: (restoreFocus?: boolean) => void;
cleanup = installQueueContextMenuDismissal(menu, (restoreFocus) => {
if (activeQueueContextMenuCleanup === cleanup) activeQueueContextMenuCleanup = null;
if (activeQueueContextMenu === menu) {
activeQueueContextMenu = null;
const currentInvoker = activeQueueContextMenuInvoker;
activeQueueContextMenuInvoker = null;
menu.remove();
if (restoreFocus && currentInvoker?.isConnected) currentInvoker.focus();
return;
}
menu.remove();
});
activeQueueContextMenuCleanup = cleanup;
RendererAccessibility.installMenuKeyboardNavigation(menu, () => closeQueueContextMenu(true));
RendererAccessibility.focusFirstMenuItem(menu);
}
@@ -332,14 +442,15 @@ function getQueueProgressStatusText(item: QueueItem): string {
return item.last_error;
}
if (item.status === 'pending') return UI_TEXT.queue.readyToDownload;
if (item.status === 'paused') return UI_TEXT.queue.statusPaused;
if (item.status === 'completed') return UI_TEXT.queue.done;
if (item.status === 'error') return UI_TEXT.queue.failed;
if (item.status === 'downloading' && item.progressStatus) return item.progressStatus;
if (item.currentPart && item.totalParts) {
return `${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`;
}
if (item.status === 'pending') return UI_TEXT.queue.readyToDownload;
if (item.status === 'paused') return UI_TEXT.queue.statusPaused;
if (item.status === 'downloading') return item.progressStatus || UI_TEXT.queue.started;
if (item.status === 'completed') return UI_TEXT.queue.done;
if (item.status === 'downloading') return UI_TEXT.queue.started;
return UI_TEXT.queue.failed;
}
@@ -349,8 +460,8 @@ function getQueueProgressMetricsText(item: QueueItem): string {
if (item.status === 'downloading' && item.progress > 0) {
parts.push(`${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`);
}
if (item.speed) parts.push(item.speed);
if (item.eta) parts.push(item.eta);
if (item.status === 'downloading' && item.speed) parts.push(item.speed);
if (item.status === 'downloading' && item.eta) parts.push(item.eta);
return parts.join(' | ');
}
@@ -394,14 +505,40 @@ async function createMergeGroupFromSelection(): Promise<void> {
updateMergeGroupButton();
}
function syncQueueRecordingHealth(el: HTMLElement, item: QueueItem): void {
const current = el.querySelector<HTMLElement>('.queue-health-dot');
const health = item.isLive && item.status === 'downloading' ? item.recordingHealth : undefined;
if (!health) {
current?.remove();
return;
}
const labels = UI_TEXT.queue.recordingHealth || { ok: 'Healthy', stale: 'Stalled', unknown: 'Pending data' };
const className = health === 'ok' ? 'health-ok' : (health === 'stale' ? 'health-stale' : 'health-unknown');
const label = labels[health] || '';
let badge = current;
if (!badge) {
const title = el.querySelector<HTMLElement>('.title');
if (!title) return;
badge = document.createElement('span');
const liveBadge = title.querySelector<HTMLElement>('.queue-live-badge');
if (liveBadge) liveBadge.insertAdjacentElement('afterend', badge);
else title.prepend(badge);
}
badge.className = `queue-health-dot ${className}`;
badge.title = label;
badge.setAttribute('aria-label', label);
}
function updateQueueItemProgress(progress: DownloadProgress): void {
// Lookup by data-id attribute, not array index — survives queue mutation between renders
const safeId = String(progress.id ?? '').replace(/"/g, '\\"');
if (!safeId) return;
const el = byId('queueList').querySelector(`[data-id="${safeId}"]`) as HTMLElement | null;
const progressId = String(progress.id ?? '');
if (!progressId) return;
const list = byId<HTMLElement>('queueList');
const el = Array.from(list.querySelectorAll<HTMLElement>('.queue-item'))
.find((candidate) => candidate.dataset.id === progressId) || null;
if (!el) return;
const item = queue.find(i => i.id === progress.id);
const item = queue.find(i => String(i.id) === progressId);
if (!item) return;
const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null;
@@ -418,6 +555,7 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
}
if (status) status.textContent = getQueueProgressStatusText(item);
if (metrics) metrics.textContent = getQueueProgressMetricsText(item);
syncQueueRecordingHealth(el, item);
}
function toggleQueueDetails(id: string): void {
@@ -488,10 +626,11 @@ function renderQueue(): void {
}
const list = byId('queueList');
initQueueActions();
byId('queueCount').textContent = String(queue.length);
const retryBtn = byId<HTMLButtonElement>('btnRetryFailed');
const clearBtn = byId<HTMLButtonElement>('btnClear');
const hasFailed = queue.some((item) => item.status === 'error');
const hasFailed = queue.some((item) => item.status === 'error' && !item.mergeRecoveryBlocked);
const hasCompleted = queue.some((item) => item.status === 'completed');
retryBtn.disabled = !hasFailed;
clearBtn.disabled = !hasCompleted;
@@ -516,7 +655,7 @@ function renderQueue(): void {
return;
}
list.innerHTML = queue.map((item: QueueItem) => {
list.innerHTML = queue.map((item: QueueItem, itemIndex: number) => {
const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled);
const safeStatusLabel = escapeHtml(getQueueStatusLabel(item));
const safeProgressStatus = escapeHtml(getQueueProgressStatusText(item));
@@ -546,16 +685,17 @@ function renderQueue(): void {
const mergeMetaExtra = isMergeGroup
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
: '';
const detailsId = `queue-details-${itemIndex}`;
return `
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}${isSelected ? ' merge-selected' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}${isSelected ? ' merge-selected' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${escapeHtml(item.id)}">
${isSelected ? `<span class="queue-selection-order" title="${selectionTitle}" aria-label="${selectionTitle}">${selectionPosition}</span>` : ''}
<div class="status ${item.status}"></div>
<div class="queue-main">
<div class="queue-title-row">
<div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="details-${item.id}" onclick="toggleQueueDetails('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueDetails('${item.id}');}">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
<div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="${detailsId}" data-queue-action="details">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div>
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span>
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" data-queue-action="remove">x</span>
</div>
<div class="queue-meta"><span class="queue-date">${safeDate}</span>${mergeMetaExtra}</div>
<div class="queue-progress-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(progressValue)}" aria-label="${escapeHtml(safeStatusLabel)}">
@@ -565,7 +705,7 @@ function renderQueue(): void {
<span class="queue-progress-status${progressStatusClass}">${safeProgressStatus}</span>
<span class="queue-progress-metrics">${safeProgressMetrics}</span>
</div>
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="details-${item.id}">
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="${detailsId}">
<div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div>
@@ -573,7 +713,7 @@ function renderQueue(): void {
${renderQueueItemFileActions(item)}
</div>
</div>
${item.status === 'error' ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">&#x21bb;</button>` : ''}
${item.status === 'error' && !item.mergeRecoveryBlocked ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" data-queue-action="retry">&#x21bb;</button>` : ''}
</div>
`;
}).join('');
+149
View File
@@ -25,6 +25,121 @@ function createInput(value = '', checked = false): Input {
return { value, checked };
}
class InteractiveInput {
value = '';
checked = false;
disabled = false;
textContent = '';
className = '';
readonly classList = {
add: (..._tokens: string[]) => undefined,
remove: (..._tokens: string[]) => undefined,
contains: (_token: string) => false,
toggle: (_token: string, force?: boolean) => force ?? false
};
private readonly listeners = new Map<string, Array<() => void>>();
addEventListener(type: string, listener: () => void): void {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}
dispatch(type: string): void {
for (const listener of this.listeners.get(type) ?? []) listener();
}
setAttribute(_name: string, _value: string): void { }
select(): void { }
}
type AutosaveRuntime = {
inputs: Map<string, InteractiveInput>;
saveConfigCalls: Array<Record<string, unknown>>;
scheduled: Array<() => void>;
};
function createAutosaveRuntime(): AutosaveRuntime {
const inputs = new Map(inputIds.map((id) => [id, new InteractiveInput()]));
for (const id of ['partMinutesLabel', 'downloadPolicyStatus', 'templateLint']) {
inputs.set(id, new InteractiveInput());
}
const saveConfigCalls: Array<Record<string, unknown>> = [];
const scheduled: Array<() => void> = [];
const config = {
download_policy: { throttle: null, windows: [] },
auto_resume_live_recording: true,
auto_merge_resumed_parts: false,
delete_parts_after_merge: false,
discord_notify_vod_auto_queued: false,
auto_vod_download_poll_minutes: 15,
auto_vod_max_age_hours: 24
};
const window = {
api: {
setClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }),
clearClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }),
setDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }),
clearDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }),
getDownloadPolicyStatus: () => Promise.resolve({ waiting: false, nextStart: null }),
onDownloadPolicyStatus: () => undefined,
saveConfig(payload: Record<string, unknown>) {
saveConfigCalls.push(payload);
return Promise.resolve(payload);
}
},
setTimeout(callback: () => void) {
scheduled.push(callback);
return scheduled.length;
},
clearTimeout: () => undefined,
addEventListener: () => undefined
};
const sandbox = {
window,
config,
UI_TEXT: {
status: {},
static: {
downloadThrottleInvalid: 'Invalid rate',
downloadWindowsInvalid: 'Invalid window',
downloadPolicyReady: 'Ready',
downloadPolicyWaiting: 'Waiting until {time}',
templateLintOk: 'Valid',
templateLintWarn: 'Invalid'
},
streamers: {}
},
byId: (id: string) => {
if (!inputs.has(id)) inputs.set(id, new InteractiveInput());
return inputs.get(id);
},
collectUnknownTemplatePlaceholders: () => [],
applySidebarLayoutPreference: () => undefined,
formatUiDateTime: (value: string) => value,
document: {
hidden: false,
querySelector: () => null,
getElementById: () => null,
addEventListener: () => undefined
},
setTimeout,
clearTimeout,
console
};
const context = vm.createContext(sandbox);
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None },
}).outputText;
vm.runInContext(compiled, context);
vm.runInContext('initSettingsAutoSave()', context);
return { inputs, saveConfigCalls, scheduled };
}
async function settleAutosave(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
function loadRuntimeErrorFormatter(language: 'de' | 'en'): (errorClass: string | null) => string {
const localeName = language === 'de' ? 'UI_TEXT_DE' : 'UI_TEXT_EN';
const localeSource = fs.readFileSync(path.join(process.cwd(), 'src', `renderer-locale-${language}.ts`), 'utf8');
@@ -39,6 +154,40 @@ function loadRuntimeErrorFormatter(language: 'de' | 'en'): (errorClass: string |
}
describe('renderer settings autosave orchestration', () => {
it.each([
['autoResumeLiveRecordingToggle', 'auto_resume_live_recording', false],
['autoMergeResumedPartsToggle', 'auto_merge_resumed_parts', true],
['deletePartsAfterMergeToggle', 'delete_parts_after_merge', true],
['discordNotifyVodAutoQueuedToggle', 'discord_notify_vod_auto_queued', true]
] as const)('persists %s through its change listener', async (controlId, configKey, nextValue) => {
const runtime = createAutosaveRuntime();
const control = runtime.inputs.get(controlId)!;
control.checked = nextValue;
control.dispatch('change');
await settleAutosave();
expect(runtime.saveConfigCalls).toHaveLength(1);
expect(runtime.saveConfigCalls[0][configKey]).toBe(nextValue);
});
it.each([
['autoVodPollMinutes', 'auto_vod_download_poll_minutes', '30', 30],
['autoVodMaxAgeHours', 'auto_vod_max_age_hours', '48', 48]
] as const)('persists %s through its debounced input listener', async (controlId, configKey, nextValue, expectedValue) => {
const runtime = createAutosaveRuntime();
const control = runtime.inputs.get(controlId)!;
control.value = nextValue;
control.dispatch('input');
expect(runtime.scheduled).toHaveLength(1);
runtime.scheduled[0]();
await settleAutosave();
expect(runtime.saveConfigCalls).toHaveLength(1);
expect(runtime.saveConfigCalls[0][configKey]).toBe(expectedValue);
});
it('persists a pure download policy change through the real autosave fingerprint', async () => {
const inputs = new Map(inputIds.map((id) => [id, createInput()]));
inputs.get('downloadThrottleMiBps')!.value = '1';
@@ -0,0 +1,323 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
import { describe, expect, it } from 'vitest';
class FakeClassList {
private readonly values = new Set<string>();
add(...tokens: string[]): void {
tokens.forEach((token) => this.values.add(token));
}
remove(...tokens: string[]): void {
tokens.forEach((token) => this.values.delete(token));
}
contains(token: string): boolean {
return this.values.has(token);
}
toggle(token: string, force?: boolean): boolean {
const enabled = force ?? !this.values.has(token);
if (enabled) this.values.add(token);
else this.values.delete(token);
return enabled;
}
}
class FakeElement {
textContent = '';
value = '';
checked = false;
disabled = false;
className = '';
title = '';
readonly classList = new FakeClassList();
readonly dataset: Record<string, string> = {};
readonly attributes = new Map<string, string>();
setAttribute(name: string, value: string): void {
this.attributes.set(name, value);
}
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
removeAttribute(name: string): void {
this.attributes.delete(name);
}
}
const settingsSource = readFileSync(join(__dirname, 'renderer-settings.ts'), 'utf8');
function sourceFragment(start: string, end: string): string {
const from = settingsSource.indexOf(start);
const to = settingsSource.indexOf(end, from);
if (from < 0 || to < 0) throw new Error(`Missing renderer settings fragment: ${start}`);
return settingsSource.slice(from, to);
}
function evaluate(
source: string,
context: Record<string, unknown>,
exposedNames: string
): Record<string, (...args: unknown[]) => unknown> {
const compiled = transpileModule(`${source}\nObject.assign(globalThis, { __settingsProductionPath: { ${exposedNames} } });`, {
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 },
}).outputText;
runInNewContext(compiled, context);
return (context as { __settingsProductionPath: Record<string, (...args: unknown[]) => unknown> }).__settingsProductionPath;
}
function createElements(...ids: string[]): Map<string, FakeElement> {
return new Map(ids.map((id) => [id, new FakeElement()]));
}
describe('renderer settings production diagnostics paths', () => {
it('ends every consecutive runtime metrics rejection in the localized error state', async () => {
const elements = createElements('runtimeMetricsOutput');
const context = {
UI_TEXT: { static: { runtimeMetricsLoading: 'Loading metrics...', runtimeMetricsError: 'Could not load runtime metrics.' } },
byId: (id: string) => elements.get(id),
window: { api: { getRuntimeMetrics: () => Promise.reject(new Error('IPC unavailable')) } },
lastRuntimeMetricsOutput: '',
};
const api = evaluate(
sourceFragment('async function refreshRuntimeMetrics', 'async function exportRuntimeMetrics'),
context,
'refreshRuntimeMetrics'
);
await api.refreshRuntimeMetrics();
expect(elements.get('runtimeMetricsOutput')?.textContent).toBe('Could not load runtime metrics.');
await api.refreshRuntimeMetrics();
expect(elements.get('runtimeMetricsOutput')?.textContent).toBe('Could not load runtime metrics.');
});
it('invalidates a prior green preflight result when the next IPC check rejects', async () => {
const elements = createElements('btnPreflightRun', 'btnPreflightFix', 'preflightResult', 'healthBadge');
const context = {
UI_TEXT: {
static: {
preflightChecking: 'Checking...',
preflightRun: 'Run check',
preflightFixing: 'Fixing...',
preflightFix: 'Auto-fix tools',
preflightEmpty: 'No checks run yet.',
preflightError: 'System check failed.',
preflightInternet: 'Internet',
preflightStreamlink: 'Streamlink',
preflightFfmpeg: 'FFmpeg',
preflightFfprobe: 'FFprobe',
preflightPath: 'Download path',
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.',
preflightReady: 'Everything is ready.',
healthGood: 'System: Stable',
healthWarn: 'System: Limited',
healthBad: 'System: Problems',
healthUnknown: 'System: Unknown',
},
},
byId: (id: string) => elements.get(id),
window: { api: { runPreflight: () => Promise.reject(new Error('IPC unavailable')) } },
preflightGeneration: 0,
lastPreflightResult: null,
};
const api = evaluate(
sourceFragment('function renderPreflightButtonLabels', 'function getManagedToolStateLabel'),
context,
'renderPreflightResult, runPreflight, refreshLocalizedPreflightUi'
);
api.renderPreflightResult({
checks: {
internet: true,
streamlink: true,
ffmpeg: true,
ffprobe: true,
downloadPathWritable: true,
},
});
await Promise.resolve(api.runPreflight(false)).catch(() => undefined);
expect(elements.get('preflightResult')?.textContent).toBe('System check failed.');
expect(elements.get('healthBadge')?.textContent).toBe('System: Unknown');
expect(elements.get('healthBadge')?.classList.contains('unknown')).toBe(true);
expect(elements.get('healthBadge')?.classList.contains('good')).toBe(false);
context.UI_TEXT.static.preflightError = 'System-Check fehlgeschlagen.';
context.UI_TEXT.static.healthUnknown = 'System: Unbekannt';
api.refreshLocalizedPreflightUi();
expect(elements.get('preflightResult')?.textContent).toBe('System-Check fehlgeschlagen.');
expect(elements.get('healthBadge')?.textContent).toBe('System: Unbekannt');
});
});
type ImportRuntime = {
context: Record<string, unknown>;
elements: Map<string, FakeElement>;
themeButtons: FakeElement[];
queueLabel: FakeElement;
};
function createImportRuntime(nextConfig: Record<string, unknown>, initialConfig: Record<string, unknown>): ImportRuntime {
const elements = createElements(
'btnPreflightRun',
'btnPreflightFix',
'preflightResult',
'healthBadge',
'languageSelect',
'langOptionDe',
'langOptionEn',
'languagePicker',
'themeSelect',
'statusText',
'statusDot',
'settingsSearchInput',
'pageTitle'
);
const themeButtons = ['light', 'twitch', 'system'].map((theme) => {
const button = new FakeElement();
button.dataset.theme = theme;
return button;
});
const queueLabel = new FakeElement();
const body = new FakeElement();
body.className = `theme-${String(initialConfig.theme ?? 'twitch')}`;
elements.get('languageSelect')!.value = String(initialConfig.language ?? 'en');
elements.get('themeSelect')!.value = String(initialConfig.theme ?? 'twitch');
const englishText = {
appName: 'Twitch VOD Manager',
tabs: { settings: 'Settings' },
static: {
preflightChecking: 'Checking...',
preflightRun: 'Run check',
preflightFixing: 'Fixing...',
preflightFix: 'Auto-fix tools',
preflightEmpty: 'No checks run yet.',
healthUnknown: 'System: Unknown',
configImported: 'Configuration imported.',
},
queue: { title: 'Queue' },
};
const germanText = {
appName: 'Twitch VOD Manager',
tabs: { settings: 'Einstellungen' },
static: {
preflightChecking: 'Prüfe...',
preflightRun: 'Check ausführen',
preflightFixing: 'Fixe...',
preflightFix: 'Tools reparieren',
preflightEmpty: 'Noch kein Check ausgeführt.',
healthUnknown: 'System: Unbekannt',
configImported: 'Konfiguration importiert.',
},
queue: { title: 'Warteschlange' },
};
const toasts: string[] = [];
const document = {
body,
querySelector: (selector: string) => selector === '.tab-content.active' ? { id: 'settingsTab' } : null,
querySelectorAll: (selector: string) => selector === '#workspaceThemePicker [data-theme]' ? themeButtons : [],
};
const window = {
api: {
importConfig: () => Promise.resolve({ success: true }),
getConfig: () => Promise.resolve(nextConfig),
saveConfig: () => Promise.resolve(nextConfig),
},
showAppToast: (message: string) => toasts.push(message),
};
const context: Record<string, unknown> = {
window,
document,
config: { ...initialConfig },
currentLanguage: initialConfig.language === 'de' ? 'de' : 'en',
UI_TEXT: initialConfig.language === 'de' ? germanText : englishText,
isConnected: false,
currentStreamer: '',
lastLoadedStreamer: '',
lastPreflightResult: null,
preflightFailed: false,
preflightGeneration: 0,
byId: (id: string) => {
if (!elements.has(id)) elements.set(id, new FakeElement());
return elements.get(id);
},
setLanguage: (language: string) => {
const normalized = language === 'en' ? 'en' : 'de';
context.currentLanguage = normalized;
context.UI_TEXT = normalized === 'de' ? germanText : englishText;
return normalized;
},
localizeCurrentStatusText: (status: string) => status,
updateStatus: () => undefined,
renderQueue: () => {
queueLabel.textContent = (context.UI_TEXT as typeof englishText).queue.title;
},
renderStreamers: () => undefined,
renderVodGridFromCurrentState: () => undefined,
refreshVodSortSelectLabels: () => undefined,
refreshRuntimeMetrics: () => Promise.resolve(),
refreshAutomationStatusLine: () => Promise.resolve(),
validateFilenameTemplates: () => true,
filterSettings: () => undefined,
syncSettingsFormFromConfig: () => undefined,
scheduleSegmentedIndicatorSync: () => undefined,
};
return { context, elements, themeButtons, queueLabel };
}
function evaluateImportRuntime(runtime: ImportRuntime): Record<string, (...args: unknown[]) => unknown> {
return evaluate(
[
sourceFragment('function changeLanguage', 'function getManagedToolStateLabel'),
sourceFragment('async function importConfigFromFile', 'async function resetDownloadedIds'),
sourceFragment('function syncWorkspaceThemePicker', 'function formatRelativeTime'),
].join('\n'),
runtime.context,
'importConfigFromFile'
);
}
describe('renderer settings config import production path', () => {
it('applies imported language and theme to controls and dependent dynamic content immediately', async () => {
const runtime = createImportRuntime(
{ language: 'de', theme: 'light', client_id: 'imported' },
{ language: 'en', theme: 'twitch', client_id: 'current' }
);
const api = evaluateImportRuntime(runtime);
await api.importConfigFromFile();
expect(runtime.elements.get('languageSelect')?.value).toBe('de');
expect(runtime.elements.get('langOptionDe')?.classList.contains('active')).toBe(true);
expect(runtime.queueLabel.textContent).toBe('Warteschlange');
expect(runtime.elements.get('themeSelect')?.value).toBe('light');
expect((runtime.context.document as { body: FakeElement }).body.className).toBe('theme-light');
expect(runtime.themeButtons.find((button) => button.dataset.theme === 'light')?.getAttribute('aria-pressed')).toBe('true');
});
it('preserves the current renderer language and theme when imported config omits them', async () => {
const runtime = createImportRuntime(
{ client_id: 'imported' },
{ language: 'de', theme: 'light', client_id: 'current' }
);
const api = evaluateImportRuntime(runtime);
await api.importConfigFromFile();
expect(runtime.context.config).toMatchObject({ client_id: 'imported', language: 'de', theme: 'light' });
expect(runtime.elements.get('languageSelect')?.value).toBe('de');
expect(runtime.elements.get('themeSelect')?.value).toBe('light');
expect((runtime.context.document as { body: FakeElement }).body.className).toBe('theme-light');
});
});
+54 -19
View File
@@ -8,6 +8,7 @@ let pendingCredentialsReconnect = false;
let lastPersistedSettingsFingerprint = '';
let settingsInputGeneration = 0;
let lastPreflightResult: PreflightResult | null = null;
let preflightFailed = false;
let preflightGeneration = 0;
const SECRET_INPUT_MASK = '••••••••';
let secretStatus: SecretStatus = {
@@ -209,10 +210,8 @@ async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
lastRuntimeMetricsOutput = nextOutput;
}
} catch {
if (lastRuntimeMetricsOutput !== UI_TEXT.static.runtimeMetricsError) {
output.textContent = UI_TEXT.static.runtimeMetricsError;
lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError;
}
output.textContent = UI_TEXT.static.runtimeMetricsError;
lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError;
}
}
@@ -314,11 +313,15 @@ function setSettingsPane(pane: string, source?: HTMLElement): void {
}
function changeLanguage(lang: string): void {
const normalized = applyRendererLanguage(lang);
void window.api.saveConfig({ language: normalized });
}
function applyRendererLanguage(lang: string): LanguageCode {
const normalized = setLanguage(lang);
byId<HTMLSelectElement>('languageSelect').value = normalized;
updateLanguagePicker(normalized);
config.language = normalized;
void window.api.saveConfig({ language: normalized });
const currentStatus = byId('statusText').textContent?.trim() || '';
const statusTone: ConnectionStatusTone = isConnected
@@ -349,6 +352,7 @@ function changeLanguage(lang: string): void {
refreshLocalizedPreflightUi();
validateFilenameTemplates();
filterSettings(byId<HTMLInputElement>('settingsSearchInput').value);
return normalized;
}
function updateLanguagePicker(lang: string): void {
@@ -377,20 +381,33 @@ function renderPreflightButtonLabels(): void {
function refreshLocalizedPreflightUi(): void {
renderPreflightButtonLabels();
if (lastPreflightResult) renderPreflightResult(lastPreflightResult);
else if (preflightFailed) renderPreflightError();
}
function invalidatePreflightResult(): void {
preflightGeneration += 1;
lastPreflightResult = null;
byId('preflightResult').textContent = UI_TEXT.static.preflightEmpty;
preflightFailed = false;
renderUnknownPreflightState(UI_TEXT.static.preflightEmpty);
}
function renderUnknownPreflightState(message: string): void {
byId('preflightResult').textContent = message;
const badge = byId('healthBadge');
badge.classList.remove('good', 'warn', 'bad', 'unknown');
badge.classList.add('unknown');
badge.textContent = UI_TEXT.static.healthUnknown;
}
function renderPreflightError(): void {
lastPreflightResult = null;
preflightFailed = true;
renderUnknownPreflightState(UI_TEXT.static.preflightError);
}
function renderPreflightResult(result: PreflightResult): void {
lastPreflightResult = result;
preflightFailed = false;
const entries: Array<[string, boolean, string]> = [
[UI_TEXT.static.preflightInternet, result.checks.internet, UI_TEXT.static.preflightNoInternet],
[UI_TEXT.static.preflightStreamlink, result.checks.streamlink, UI_TEXT.static.preflightStreamlinkMissing],
@@ -432,6 +449,8 @@ async function runPreflight(autoFix = false): Promise<void> {
try {
const result = await window.api.runPreflight(autoFix);
if (generation === preflightGeneration) renderPreflightResult(result);
} catch {
if (generation === preflightGeneration) renderPreflightError();
} finally {
btn.disabled = false;
renderPreflightButtonLabels();
@@ -662,17 +681,23 @@ async function importConfigFromFile(): Promise<void> {
invalidatePreflightResult();
// Reload local config copy + refresh forms / streamer list / VOD grid
try {
config = await window.api.getConfig();
if (typeof setLanguage === 'function' && typeof config.language === 'string') {
setLanguage(config.language);
}
if (typeof renderStreamers === 'function') renderStreamers();
const currentConfig = config;
const importedConfig = await window.api.getConfig();
const language = typeof importedConfig.language === 'string'
? importedConfig.language
: typeof currentConfig.language === 'string'
? currentConfig.language
: currentLanguage;
const theme = typeof importedConfig.theme === 'string'
? importedConfig.theme
: typeof currentConfig.theme === 'string'
? currentConfig.theme
: byId<HTMLSelectElement>('themeSelect').value || 'twitch';
config = { ...currentConfig, ...importedConfig, language, theme };
if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig();
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
renderVodGridFromCurrentState();
}
applyRendererTheme(theme);
applyRendererLanguage(language);
} catch { /* ignore — next refresh will catch up */ }
refreshLocalizedPreflightUi();
if (toast) toast(UI_TEXT.static.configImported, 'info');
} else if (result.cancelled) {
// User cancelled the dialog — no toast needed.
@@ -1131,9 +1156,13 @@ function initSettingsAutoSave(): void {
'downloadChatReplayToggle',
'captureLiveChatToggle',
'logStreamEventsToggle',
'autoResumeLiveRecordingToggle',
'autoMergeResumedPartsToggle',
'deletePartsAfterMergeToggle',
'discordNotifyLiveStartToggle',
'discordNotifyLiveEndToggle',
'discordNotifyVodCompleteToggle',
'discordNotifyVodAutoQueuedToggle',
'autoCleanupEnabledToggle',
'autoCleanupTarget',
'autoCleanupAction',
@@ -1147,6 +1176,8 @@ function initSettingsAutoSave(): void {
'partsFilenameTemplate',
'defaultClipFilenameTemplate',
'discordWebhookUrl',
'autoVodPollMinutes',
'autoVodMaxAgeHours',
'autoCleanupDays',
'downloadThrottleMiBps',
'downloadWindows'
@@ -1276,15 +1307,19 @@ function syncWorkspaceThemePicker(theme: string): void {
});
}
function selectWorkspaceTheme(theme: string): void {
function applyRendererTheme(theme: string): void {
byId<HTMLSelectElement>('themeSelect').value = theme;
document.body.className = `theme-${theme}`;
config.theme = theme;
syncWorkspaceThemePicker(theme);
}
function selectWorkspaceTheme(theme: string): void {
changeTheme(theme);
}
function changeTheme(theme: string): void {
document.body.className = `theme-${theme}`;
config.theme = theme;
syncWorkspaceThemePicker(theme);
applyRendererTheme(theme);
void window.api.saveConfig({ theme });
}
File diff suppressed because it is too large Load Diff
+267 -101
View File
@@ -29,6 +29,12 @@ function scheduleStreamerActiveIndicatorSync(): void {
const liveStatusByLogin = new Map<string, boolean>();
const streamerDisplayNames = new Map<string, string>();
function getStreamerDisplayName(login: string): string {
return streamerDisplayNames.get(login.trim().toLowerCase()) || login;
}
(window as unknown as { getStreamerDisplayName: typeof getStreamerDisplayName }).getStreamerDisplayName = getStreamerDisplayName;
function rememberStreamerDisplayName(login: string, displayName: string): void {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
@@ -44,6 +50,14 @@ function rememberStreamerDisplayName(login: string, displayName: string): void {
(window as unknown as { rememberStreamerDisplayName: typeof rememberStreamerDisplayName }).rememberStreamerDisplayName = rememberStreamerDisplayName;
function renderHydratedStreamerDisplayNames(): void {
if (currentStreamer) {
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(getStreamerDisplayName(currentStreamer));
}
renderStreamers();
}
async function hydrateStreamerDisplayNames(): Promise<void> {
const configuredNames = config.streamer_display_names || {};
let changed = false;
@@ -55,12 +69,13 @@ async function hydrateStreamerDisplayNames(): Promise<void> {
changed = true;
}
}
if (changed) {
renderHydratedStreamerDisplayNames();
changed = false;
}
const streamers = (config.streamers ?? []) as string[];
if (streamers.length === 0) {
if (changed) renderStreamers();
return;
}
if (streamers.length === 0) return;
try {
const resolvedNames = await window.api.getStreamerDisplayNames(streamers);
@@ -74,14 +89,7 @@ async function hydrateStreamerDisplayNames(): Promise<void> {
}
} catch { }
if (changed) {
if (currentStreamer) {
const displayName = streamerDisplayNames.get(currentStreamer.toLowerCase());
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (displayName && typeof setTitle === 'function') setTitle(displayName);
}
renderStreamers();
}
if (changed) renderHydratedStreamerDisplayNames();
}
(window as unknown as { hydrateStreamerDisplayNames: typeof hydrateStreamerDisplayNames }).hydrateStreamerDisplayNames = hydrateStreamerDisplayNames;
@@ -131,6 +139,9 @@ const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
// on streamer switch (selection is per-streamer mental model). NOT persisted
// because a stale selection across reloads is more confusing than helpful.
const selectedVodUrls = new Set<string>();
const selectedVodUrlRevisions = new Map<string, number>();
let vodSelectionRevision = 0;
let vodBulkOperationInFlight = false;
let vodGridDelegationInitialized = false;
// Hide-downloaded toggle: when enabled, the VOD grid skips entries whose
@@ -397,6 +408,7 @@ let streamerListFilterQuery = '';
const VOD_SCROLL_POSITIONS_KEY = 'twitch-vod-manager:vod-scroll-positions';
let vodScrollPositions: Record<string, number> = {};
let pendingScrollRestore: { streamer: string; y: number } | null = null;
let vodScrollRestoreTimer: number | null = null;
function loadVodScrollPositions(): void {
try {
@@ -523,7 +535,7 @@ function showStreamerContextMenu(event: MouseEvent, streamer: string): void {
const menu = document.createElement('div');
menu.className = 'streamer-context-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', streamerDisplayNames.get(streamer.toLowerCase()) || streamer);
menu.setAttribute('aria-label', getStreamerDisplayName(streamer));
const appendAction = (action: 'auto' | 'vod' | 'record', label: string, active: boolean, handler: () => void): void => {
const button = document.createElement('button');
@@ -643,7 +655,7 @@ function renderStreamers(): void {
const nameSpan = document.createElement('span');
nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : '');
nameSpan.textContent = streamerDisplayNames.get(streamer.toLowerCase()) || streamer;
nameSpan.textContent = getStreamerDisplayName(streamer);
const removeSpan = document.createElement('span');
removeSpan.className = 'remove';
removeSpan.textContent = 'x';
@@ -710,6 +722,38 @@ function onStreamerListFilterChange(): void {
renderStreamers();
}
function clearActiveVodHoverPreview(): void {
const clear = (window as unknown as { clearVodHoverPreview?: () => void }).clearVodHoverPreview;
if (typeof clear === 'function') clear();
}
function cancelVodScrollRestore(): void {
pendingScrollRestore = null;
if (vodScrollRestoreTimer === null) return;
window.clearTimeout(vodScrollRestoreTimer);
vodScrollRestoreTimer = null;
}
function clearActiveStreamerSelection(): void {
selectStreamerRequestId += 1;
vodRenderTaskId += 1;
currentStreamer = null;
lastLoadedVods = [];
lastLoadedStreamer = null;
cancelVodScrollRestore();
selectedVodUrls.clear();
selectedVodUrlRevisions.clear();
clearActiveVodHoverPreview();
closeVodContextMenu();
const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader;
if (typeof hide === 'function') hide();
updateVodBulkBar();
updateVodFilterCount(0, 0);
setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText);
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(UI_TEXT.tabs.vods);
}
async function bulkRemoveStreamers(): Promise<void> {
const all = (config.streamers ?? []) as string[];
if (all.length === 0) return;
@@ -726,9 +770,7 @@ async function bulkRemoveStreamers(): Promise<void> {
config.streamers = remaining;
config = await window.api.saveConfig({ streamers: remaining });
if (currentStreamer && targets.includes(currentStreamer)) {
currentStreamer = null;
const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader;
if (typeof hide === 'function') hide();
clearActiveStreamerSelection();
}
streamerListFilterQuery = '';
const input = document.getElementById('streamerListFilter') as HTMLInputElement | null;
@@ -821,16 +863,8 @@ async function addStreamer(): Promise<void> {
async function removeStreamer(name: string): Promise<void> {
config.streamers = (config.streamers ?? []).filter((s: string) => s !== name);
config = await window.api.saveConfig({ streamers: config.streamers });
if (currentStreamer === name) clearActiveStreamerSelection();
renderStreamers();
if (currentStreamer !== name) {
return;
}
currentStreamer = null;
const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader;
if (typeof hide === 'function') hide();
setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText);
}
function normalizeStreamerCacheKey(name: string): string {
@@ -904,13 +938,40 @@ function startStreamerBackgroundRefresh(): void {
}, STREAMER_BACKGROUND_REFRESH_MS);
}
function renderVodGridLoadingState(): void {
byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => `
<div class="vod-card vod-card-skeleton">
<div class="vod-skel-thumb"></div>
<div class="vod-info">
<div class="vod-skel-line title"></div>
<div class="vod-skel-line meta-1"></div>
<div class="vod-skel-line meta-2"></div>
</div>
</div>
`).join('');
}
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
clearActiveVodHoverPreview();
// Save where we were on the OLD streamer before navigating away.
rememberCurrentVodScroll();
cancelVodScrollRestore();
const requestId = ++selectStreamerRequestId;
const isStaleRequest = () => requestId !== selectStreamerRequestId || currentStreamer !== name;
if (currentStreamer !== name) {
vodRenderTaskId += 1;
lastLoadedStreamer = null;
lastLoadedVods = [];
closeVodContextMenu();
renderVodGridLoadingState();
if (selectedVodUrls.size > 0) {
selectedVodUrls.clear();
selectedVodUrlRevisions.clear();
updateVodBulkBar();
}
}
currentStreamer = name;
// Schedule a scroll-restore once the VOD grid renders. The actual
// restore runs after renderVODs replaces the grid.
@@ -918,7 +979,7 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
pendingScrollRestore = (typeof savedY === 'number' && savedY > 0) ? { streamer: name, y: savedY } : null;
renderStreamers();
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
const displayName = streamerDisplayNames.get(name.toLowerCase()) || name;
const displayName = getStreamerDisplayName(name);
if (typeof setTitle === 'function') setTitle(displayName);
else byId('pageTitle').textContent = displayName;
@@ -946,16 +1007,7 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
if (cached) {
renderVODs(cached.vods, name);
} else {
byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => `
<div class="vod-card vod-card-skeleton">
<div class="vod-skel-thumb"></div>
<div class="vod-info">
<div class="vod-skel-line title"></div>
<div class="vod-skel-line meta-1"></div>
<div class="vod-skel-line meta-2"></div>
</div>
</div>
`).join('');
renderVodGridLoadingState();
}
const loaded = await loadStreamerVods(name, forceRefresh);
@@ -1003,6 +1055,7 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string, animateCha
// Clear bulk-selection on streamer switch — selection is per-streamer
if (lastLoadedStreamer && lastLoadedStreamer !== streamer && selectedVodUrls.size > 0) {
selectedVodUrls.clear();
selectedVodUrlRevisions.clear();
updateVodBulkBar();
}
const motion = animateChanges ? captureVodGridMotion() : undefined;
@@ -1016,7 +1069,9 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string, animateCha
if (pendingScrollRestore && pendingScrollRestore.streamer === streamer) {
const target = pendingScrollRestore;
pendingScrollRestore = null;
window.setTimeout(() => {
vodScrollRestoreTimer = window.setTimeout(() => {
vodScrollRestoreTimer = null;
if (lastLoadedStreamer !== target.streamer) return;
const grid = document.getElementById('vodGrid');
if (!grid) return;
const scrollable = (grid.closest('.content') as HTMLElement | null) || grid;
@@ -1095,8 +1150,16 @@ function setVodCardSelection(card: HTMLElement, selected: boolean): void {
if (!checkbox || !url) return;
checkbox.checked = selected;
card.classList.toggle('selected', selected);
if (selected) selectedVodUrls.add(url);
else selectedVodUrls.delete(url);
if (selected !== selectedVodUrls.has(url)) {
vodSelectionRevision += 1;
if (selected) {
selectedVodUrls.add(url);
selectedVodUrlRevisions.set(url, vodSelectionRevision);
} else {
selectedVodUrls.delete(url);
selectedVodUrlRevisions.delete(url);
}
}
updateVodBulkBar();
}
@@ -1108,8 +1171,12 @@ function toggleVodCardSelection(card: HTMLElement): void {
let activeVodContextMenu: HTMLElement | null = null;
let activeVodContextMenuInvoker: HTMLElement | null = null;
let activeVodContextMenuCleanup: (() => void) | null = null;
function closeVodContextMenu(restoreFocus = false): void {
const cleanup = activeVodContextMenuCleanup;
activeVodContextMenuCleanup = null;
cleanup?.();
if (!activeVodContextMenu) return;
activeVodContextMenu.remove();
activeVodContextMenu = null;
@@ -1118,6 +1185,16 @@ function closeVodContextMenu(restoreFocus = false): void {
if (restoreFocus && invoker?.isConnected) invoker.focus();
}
async function copyVodUrl(url: string): Promise<void> {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
try {
await navigator.clipboard.writeText(url);
if (toast) toast(UI_TEXT.vods.ctxCopiedUrl, 'info');
} catch {
if (toast) toast(UI_TEXT.vods.ctxCopyFailed, 'warn');
}
}
function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker: HTMLElement | null): void {
closeVodContextMenu();
@@ -1132,7 +1209,7 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker:
);
const isMarkedDownloaded = downloadedIds.has(ctx.id);
let cleanup = (restoreFocus = false): void => closeVodContextMenu(restoreFocus);
const cleanup = (restoreFocus = false): void => closeVodContextMenu(restoreFocus);
const makeItem = (label: string, onClick: () => void): HTMLElement => {
const el = document.createElement('button');
el.type = 'button';
@@ -1149,11 +1226,7 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker:
void window.api.openExternal(ctx.url);
}));
menu.appendChild(makeItem(UI_TEXT.vods.ctxCopyUrl, () => {
try {
void navigator.clipboard.writeText(ctx.url);
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.vods.ctxCopiedUrl, 'info');
} catch { /* ignore */ }
void copyVodUrl(ctx.url);
}));
menu.appendChild(makeItem(UI_TEXT.vods.trimButton, () => {
openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration);
@@ -1185,8 +1258,7 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker:
cleanup();
};
const dismissOnScroll = () => cleanup();
cleanup = (restoreFocus = false): void => {
closeVodContextMenu(restoreFocus);
activeVodContextMenuCleanup = () => {
document.removeEventListener('mousedown', dismissOnClick, true);
document.removeEventListener('scroll', dismissOnScroll, true);
};
@@ -1219,10 +1291,36 @@ function updateVodBulkBar(): void {
function clearVodSelection(): void {
if (selectedVodUrls.size === 0) return;
selectedVodUrls.clear();
selectedVodUrlRevisions.clear();
updateVodBulkBar();
if (lastLoadedStreamer) renderVodGridFromCurrentState();
}
function removeVodSelectionIfUnchanged(url: string, revision: number | undefined): void {
if (!selectedVodUrls.has(url) || selectedVodUrlRevisions.get(url) !== revision) return;
selectedVodUrls.delete(url);
selectedVodUrlRevisions.delete(url);
}
function setVodBulkActionsDisabled(disabled: boolean): void {
for (const id of ['vodBulkAddBtn', 'vodBulkMarkBtn', 'vodBulkUnmarkBtn']) {
const button = document.getElementById(id) as HTMLButtonElement | null;
if (button) button.disabled = disabled;
}
}
function beginVodBulkOperation(): boolean {
if (vodBulkOperationInFlight) return false;
vodBulkOperationInFlight = true;
setVodBulkActionsDisabled(true);
return true;
}
function endVodBulkOperation(): void {
vodBulkOperationInFlight = false;
setVodBulkActionsDisabled(false);
}
async function toggleAutoRecord(streamer: string): Promise<void> {
const current = ((config.auto_record_streamers as string[]) || []).slice();
const idx = current.indexOf(streamer);
@@ -1283,76 +1381,137 @@ async function triggerLiveRecording(streamer: string): Promise<void> {
async function bulkMarkSelectedDownloaded(mark: boolean): Promise<void> {
const urls = Array.from(selectedVodUrls);
if (urls.length === 0) return;
if (!beginVodBulkOperation()) return;
const vods = new Map(lastLoadedVods.map((vod) => [vod.url, { id: vod.id }]));
const selectionRevisions = new Map(urls.map((url) => [url, selectedVodUrlRevisions.get(url)]));
let updated = 0;
for (const url of urls) {
const vod = lastLoadedVods.find((v) => v.url === url);
if (!vod || !vod.id) continue;
try {
const result = await window.api.markVodDownloaded(vod.id, mark);
if (result?.success) updated++;
} catch { /* keep going */ }
}
try {
let updated = 0;
let failed = 0;
for (const url of urls) {
const vod = vods.get(url);
if (!vod || !vod.id) {
failed++;
continue;
}
try {
const result = await window.api.markVodDownloaded(vod.id, mark);
if (result?.success) {
updated++;
removeVodSelectionIfUnchanged(url, selectionRevisions.get(url));
} else {
failed++;
}
} catch {
failed++;
}
}
if (updated === 0) return;
updateVodBulkBar();
if (updated > 0) {
try { config = await window.api.getConfig(); } catch { /* ignore */ }
if (lastLoadedStreamer) renderVodGridFromCurrentState();
}
try { config = await window.api.getConfig(); } catch { /* ignore */ }
selectedVodUrls.clear();
updateVodBulkBar();
if (lastLoadedStreamer) renderVodGridFromCurrentState();
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) {
const template = mark ? UI_TEXT.vods.bulkMarkedDownloaded : UI_TEXT.vods.bulkUnmarkedDownloaded;
toast(template.replace('{count}', String(updated)), 'info');
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast && updated > 0 && failed === 0) {
const template = updated === 1
? (mark ? UI_TEXT.vods.bulkMarkedDownloadedOne : UI_TEXT.vods.bulkUnmarkedDownloadedOne)
: (mark ? UI_TEXT.vods.bulkMarkedDownloaded : UI_TEXT.vods.bulkUnmarkedDownloaded);
toast(template.replace('{count}', String(updated)), 'info');
} else if (toast && updated === 0 && failed > 0) {
const template = failed === 1 ? UI_TEXT.vods.bulkMarkFailedOne : UI_TEXT.vods.bulkMarkFailed;
toast(template.replace('{count}', String(failed)), 'warn');
} else if (toast && updated > 0 && failed > 0) {
toast(UI_TEXT.vods.bulkMarkResult
.replace('{updated}', String(updated))
.replace('{failed}', String(failed)), 'warn');
}
} finally {
endVodBulkOperation();
}
}
async function bulkAddSelectedVodsToQueue(): Promise<void> {
const urls = Array.from(selectedVodUrls);
if (urls.length === 0 || !lastLoadedStreamer) return;
if (!beginVodBulkOperation()) return;
const streamer = lastLoadedStreamer;
const vods = new Map(lastLoadedVods.map((vod) => [vod.url, {
url: vod.url,
title: vod.title,
date: vod.created_at,
streamer,
duration_str: vod.duration
}]));
const selectionRevisions = new Map(urls.map((url) => [url, selectedVodUrlRevisions.get(url)]));
const btn = document.getElementById('vodBulkAddBtn') as HTMLButtonElement | null;
const originalText = btn?.textContent || '';
if (btn) {
btn.disabled = true;
btn.textContent = UI_TEXT.vods.bulkAdding;
}
if (btn) btn.textContent = UI_TEXT.vods.bulkAdding;
let added = 0;
let skipped = 0;
for (const url of urls) {
const vod = lastLoadedVods.find((v) => v.url === url);
if (!vod) { skipped++; continue; }
try {
queue = await window.api.addToQueue({
url: vod.url,
title: vod.title,
date: vod.created_at,
streamer,
duration_str: vod.duration
});
added++;
} catch {
skipped++;
try {
let added = 0;
let duplicates = 0;
let invalid = 0;
let failed = 0;
for (const [index, url] of urls.entries()) {
const vod = vods.get(url);
if (!vod) {
invalid++;
removeVodSelectionIfUnchanged(url, selectionRevisions.get(url));
continue;
}
try {
const result = await window.api.addToQueueWithResult(vod);
if (result.accepted) {
added++;
} else if (result.reason === 'duplicate') {
duplicates++;
} else if (result.reason === 'invalid') {
invalid++;
} else {
failed++;
if (result.reason === 'shutting-down' || result.reason === 'access-denied') {
failed += urls.length - index - 1;
break;
}
continue;
}
removeVodSelectionIfUnchanged(url, selectionRevisions.get(url));
} catch {
failed++;
}
}
}
selectedVodUrls.clear();
if (btn) {
btn.disabled = false;
btn.textContent = originalText;
}
updateVodBulkBar();
renderQueue();
renderVodGridFromCurrentState();
updateVodBulkBar();
renderQueue();
if (lastLoadedStreamer === streamer) renderVodGridFromCurrentState();
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast && added > 0) {
toast(UI_TEXT.vods.bulkAddedToQueue.replace('{count}', String(added)), 'info');
} else if (toast && skipped > 0) {
toast(UI_TEXT.vods.bulkAddSkipped, 'warn');
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
const categoryCount = Number(added > 0) + Number(duplicates > 0) + Number(invalid > 0) + Number(failed > 0);
if (toast && categoryCount === 1 && added > 0) {
const template = added === 1 ? UI_TEXT.vods.bulkAddedToQueueOne : UI_TEXT.vods.bulkAddedToQueue;
toast(template.replace('{count}', String(added)), 'info');
} else if (toast && categoryCount === 1 && duplicates > 0) {
const template = duplicates === 1 ? UI_TEXT.vods.bulkAddDuplicateOne : UI_TEXT.vods.bulkAddDuplicate;
toast(template.replace('{count}', String(duplicates)), 'warn');
} else if (toast && categoryCount === 1 && invalid > 0) {
const template = invalid === 1 ? UI_TEXT.vods.bulkAddInvalidOne : UI_TEXT.vods.bulkAddInvalid;
toast(template.replace('{count}', String(invalid)), 'warn');
} else if (toast && categoryCount === 1 && failed > 0) {
const template = failed === 1 ? UI_TEXT.vods.bulkAddFailedOne : UI_TEXT.vods.bulkAddFailed;
toast(template.replace('{count}', String(failed)), 'warn');
} else if (toast && categoryCount > 1) {
toast(UI_TEXT.vods.bulkAddResult
.replace('{added}', String(added))
.replace('{duplicates}', String(duplicates))
.replace('{invalid}', String(invalid))
.replace('{failed}', String(failed)), 'warn');
}
} finally {
if (btn) btn.textContent = originalText;
endVodBulkOperation();
}
}
@@ -1398,6 +1557,7 @@ function animateVodGridMotion(motion: VodGridMotion): void {
}
function renderVodGridFromCurrentState(motion?: VodGridMotion): void {
clearActiveVodHoverPreview();
if (!lastLoadedStreamer) return;
const grid = byId('vodGrid');
@@ -1421,6 +1581,12 @@ function renderVodGridFromCurrentState(motion?: VodGridMotion): void {
: sorted;
const filtered = filterVodsByQuery(sortedAndHidden, vodFilterQuery);
if (filtered.length === 0 && vodHideDownloaded && sortedAndHidden.length === 0 && !vodFilterQuery.trim()) {
setVodGridEmptyState(grid, UI_TEXT.vods.hideDownloadedEmptyTitle, UI_TEXT.vods.hideDownloadedEmptyText);
updateVodFilterCount(0, total);
return;
}
if (filtered.length === 0 && vodFilterQuery.trim()) {
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);
updateVodFilterCount(0, total);
+20
View File
@@ -168,6 +168,11 @@ function applyLanguageToStaticUI(): void {
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('cutterNewVideoText', UI_TEXT.cutter.newVideo);
setAriaLabel('cutterOpenProjectBtn', UI_TEXT.cutter.openProject);
setTitle('cutterOpenProjectBtn', UI_TEXT.cutter.openProject);
setAriaLabel('cutterSaveProjectBtn', UI_TEXT.cutter.saveProject);
setTitle('cutterSaveProjectBtn', UI_TEXT.cutter.saveProject);
setText('commandPaletteTitle', UI_TEXT.static.commandPaletteTitle);
setAriaLabel('commandPaletteInput', UI_TEXT.static.commandPaletteAria);
setAriaLabel('commandPaletteList', UI_TEXT.static.commandPaletteResultsAria);
@@ -193,6 +198,19 @@ function applyLanguageToStaticUI(): void {
setText('cutterVideoTrackLabel', UI_TEXT.cutter.videoTrack);
setText('cutterAudioTrackLabel', UI_TEXT.cutter.audioTrack);
setText('cutterAudioEmpty', UI_TEXT.cutter.noAudio);
setText('cutterRecoveryText', UI_TEXT.cutter.recoveryFound);
setText('cutterRecoveryRestoreBtn', UI_TEXT.cutter.recoverProject);
setText('cutterRecoveryDiscardBtn', UI_TEXT.cutter.discardProject);
setText('cutterExportProfileLabel', UI_TEXT.cutter.exportProfileLabel);
setText('cutterExportEncoderLabel', UI_TEXT.cutter.exportEncoderLabel);
setText('cutterAudioStreamLabel', UI_TEXT.cutter.audioStreamLabel);
setText('cutterProfileQualityOption', UI_TEXT.cutter.profileQuality);
setText('cutterProfileBalancedOption', UI_TEXT.cutter.profileBalanced);
setText('cutterProfileFastOption', UI_TEXT.cutter.profileFast);
setText('cutterProfileArchiveOption', UI_TEXT.cutter.profileArchive);
setText('cutterEncoderSoftwareOption', UI_TEXT.cutter.encoderSoftware);
setText('cutterAudioStreamEmptyOption', UI_TEXT.cutter.noAudio);
setText('cutterSpeedNormalBtn', UI_TEXT.cutter.speedNormal);
setText('cutterLoadingLabel', UI_TEXT.cutter.loadingMedia);
setText('cutterSpeedLabel', UI_TEXT.cutter.speedLabel);
setAriaLabel('cutterPlayBtn', UI_TEXT.cutter.play);
@@ -325,6 +343,7 @@ function applyLanguageToStaticUI(): void {
setText('btnPreflightRun', UI_TEXT.static.preflightRun);
setText('btnPreflightFix', UI_TEXT.static.preflightFix);
setText('preflightResult', UI_TEXT.static.preflightEmpty);
if (typeof refreshLocalizedPreflightUi === 'function') refreshLocalizedPreflightUi();
setText('managedToolsTitle', UI_TEXT.static.managedToolsTitle);
setText('btnRefreshManagedTools', UI_TEXT.static.managedToolsRefresh);
setText('btnRepairManagedTools', UI_TEXT.static.managedToolsRepair);
@@ -460,6 +479,7 @@ function applyLanguageToStaticUI(): void {
}
if (typeof updateCutterPlayUi === 'function') updateCutterPlayUi();
if (typeof updateCutterMuteUi === 'function') updateCutterMuteUi();
if (typeof refreshCutterLocalizedUi === 'function') refreshCutterLocalizedUi();
if (typeof renderCutterEditor === 'function') renderCutterEditor();
}
+545 -14
View File
@@ -4,30 +4,561 @@ import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
import { describe, expect, test } from 'vitest';
function sourceFragment(start: string, end: string): string {
const source = readFileSync(join(__dirname, 'renderer-updates.ts'), 'utf8');
const from = source.indexOf(start);
const to = source.indexOf(end, from);
if (from < 0 || to < 0) throw new Error('Missing renderer updates production fragment');
return source.slice(from, to);
type UpdateInfoFixture = {
version?: string;
releaseName?: string;
releaseDate?: string;
releaseNotes?: string;
};
type DownloadProgressFixture = {
percent: number;
transferred: number;
total: number;
};
class FakeClassList {
private readonly values = new Set<string>();
add(...tokens: string[]): void {
tokens.forEach((token) => this.values.add(token));
}
remove(...tokens: string[]): void {
tokens.forEach((token) => this.values.delete(token));
}
contains(token: string): boolean {
return this.values.has(token);
}
toggle(token: string, force?: boolean): boolean {
const enabled = force ?? !this.values.has(token);
if (enabled) this.values.add(token);
else this.values.delete(token);
return enabled;
}
}
function evaluate(source: string, context: Record<string, unknown>): { rememberUpdateInfo: (info?: { version?: string } | null) => unknown } {
class FakeElement {
readonly classList = new FakeClassList();
readonly dataset: Record<string, string> = {};
readonly style: Record<string, string> = {};
readonly attributes = new Map<string, string>();
readonly children: FakeElement[] = [];
hidden = false;
disabled = false;
textContent = '';
innerHTML = '';
title = '';
constructor(readonly id: string, private readonly document: FakeDocument) { }
get childNodes(): FakeElement[] {
return this.children;
}
appendChild(child: FakeElement): FakeElement {
if (child.id === 'fragment') {
child.children.forEach((entry) => this.children.push(entry));
return child;
}
this.children.push(child);
return child;
}
setAttribute(name: string, value: string): void {
this.attributes.set(name, value);
}
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
removeAttribute(name: string): void {
this.attributes.delete(name);
}
addEventListener(): void { }
matches(): boolean {
return false;
}
focus(): void {
this.document.activeElement = this;
}
}
class FakeDocument {
readonly body = new FakeElement('body', this);
activeElement: FakeElement = this.body;
activeNavigationItem: FakeElement | null = null;
constructor(private readonly elements: Map<string, FakeElement>) { }
createElement(tagName: string): FakeElement {
return new FakeElement(tagName, this);
}
createTextNode(text: string): FakeElement {
const node = new FakeElement('text', this);
node.textContent = text;
return node;
}
createDocumentFragment(): FakeElement {
return new FakeElement('fragment', this);
}
querySelector<T extends FakeElement>(selector: string): T | null {
if (selector === '.top-nav-item[aria-current="page"]') {
return this.activeNavigationItem as T | null;
}
return null;
}
addEventListener(): void { }
}
interface UpdateCallbacks {
checking: () => void;
available: (info: UpdateInfoFixture) => void;
notAvailable: () => void;
progress: (progress: DownloadProgressFixture) => void;
downloaded: (info: UpdateInfoFixture) => void;
error: (payload: { message?: string; kind: 'check' | 'download'; version?: string }) => void;
}
interface ProductionApi {
rememberUpdateInfo(info?: UpdateInfoFixture | null): UpdateInfoFixture | null;
checkUpdate(): Promise<void>;
downloadUpdate(): void;
postponeWorkspaceUpdatePopover(): void;
dismissWorkspaceUpdatePopover(): void;
getState(): {
updateBannerState: string;
updateDownloadInProgress: boolean;
workspaceUpdatePopoverPostponed: boolean;
};
}
interface Runtime {
api: ProductionApi;
callbacks: UpdateCallbacks;
document: FakeDocument;
elements: Map<string, FakeElement>;
notifications: Array<{ message: string; type: string }>;
download: {
resolve(result?: Record<string, unknown>): void;
reject(error: Error): void;
};
check: {
resolve(result?: Record<string, unknown>): void;
reject(error: Error): void;
};
}
const elementIds = [
'checkUpdateBtn',
'workspaceUpdateButton',
'updateBanner',
'workspaceUpdateLabel',
'updateText',
'workspaceUpdateLater',
'workspaceUpdateDismiss',
'updateProgress',
'updateProgressBar',
'updateProgressGauge',
'updateButton',
'updateModal',
'updateModalTitle',
'updateModalMessage',
'updateModalDismissBtn',
'updateModalConfirmBtn',
'updateModalSkipBtn',
'updateChangelogLabel',
'updateChangelogEmpty',
'updateModalMeta',
'updateChangelogCard',
'updateChangelogPanel',
'updateChangelogContent',
'updateChangelogToggle',
];
function createRuntime(): Runtime {
const elements = new Map<string, FakeElement>();
const document = new FakeDocument(elements);
elementIds.forEach((id) => elements.set(id, new FakeElement(id, document)));
const activeNavigationItem = new FakeElement('activeNavigationItem', document);
activeNavigationItem.setAttribute('aria-current', 'page');
document.activeNavigationItem = activeNavigationItem;
const callbacks = {} as UpdateCallbacks;
const notifications: Array<{ message: string; type: string }> = [];
let resolveDownload!: (result?: Record<string, unknown>) => void;
let rejectDownload!: (error: Error) => void;
let resolveCheck!: (result?: Record<string, unknown>) => void;
let rejectCheck!: (error: Error) => void;
const downloadPromise = new Promise<Record<string, unknown> | undefined>((resolve, reject) => {
resolveDownload = resolve;
rejectDownload = reject;
});
const checkPromise = new Promise<Record<string, unknown> | undefined>((resolve, reject) => {
resolveCheck = resolve;
rejectCheck = reject;
});
const context: Record<string, unknown> = {
console,
document,
updateReady: false,
UI_TEXT: {
static: { checkUpdates: 'Check for updates' },
updates: {
checking: 'Checking...',
installNow: 'Install now',
downloadNow: 'Download now',
downloading: 'Downloading...',
downloadLabel: 'Download',
ready: 'ready to install',
available: 'available',
checkFailed: 'Update check failed.',
downloadFailed: 'Update download failed.',
downloadInProgress: 'Update download is already running.',
readyToInstall: 'Update is ready to install.',
checkInProgress: 'Update check is already running.',
latest: 'You are on the latest version.',
modalReadyTitle: 'Ready',
modalAvailableTitle: 'Available',
modalReadyMessage: 'Version {version} is ready.',
modalAvailableMessage: 'Version {version} is available.',
modalDismiss: 'Later',
modalInstallConfirm: 'Install',
modalDownloadConfirm: 'Download',
modalSkipVersion: 'Skip',
releasedLabel: 'Release',
changelogLabel: 'Changelog',
noChangelog: 'No changelog',
hideChangelog: 'Hide changelog',
showChangelog: 'Show changelog',
},
},
RendererAccessibility: {
openDialog: (id: string) => elements.get(id)?.classList.add('show'),
closeDialog: (id: string) => elements.get(id)?.classList.remove('show'),
},
getIntlLocale: () => 'en-US',
safeLocalStorageGet: () => '',
safeLocalStorageSet: () => undefined,
safeLocalStorageRemove: () => undefined,
alert: (message: string) => notifications.push({ message, type: 'warn' }),
requestAnimationFrame: (callback: () => void) => callback(),
setTimeout,
clearTimeout,
};
const windowApi = {
checkUpdate: () => checkPromise,
downloadUpdate: () => downloadPromise,
installUpdate: () => Promise.resolve(),
onUpdateChecking: (callback: () => void) => { callbacks.checking = callback; },
onUpdateAvailable: (callback: (info: UpdateInfoFixture) => void) => { callbacks.available = callback; },
onUpdateNotAvailable: (callback: () => void) => { callbacks.notAvailable = callback; },
onUpdateDownloadProgress: (callback: (progress: DownloadProgressFixture) => void) => { callbacks.progress = callback; },
onUpdateDownloaded: (callback: (info: UpdateInfoFixture) => void) => { callbacks.downloaded = callback; },
onUpdateError: (callback: (payload: { message?: string; kind: 'check' | 'download'; version?: string }) => void) => { callbacks.error = callback; },
};
context.api = windowApi;
context.showAppToast = (message: string, type = 'info') => notifications.push({ message, type });
context.window = context;
context.globalThis = context;
const compiled = transpileModule(`${source}\nObject.assign(globalThis, { __updatesProductionPath: { rememberUpdateInfo } });`, {
context.byId = (id: string) => {
const element = elements.get(id);
if (!element) throw new Error(`Missing element ${id}`);
return element;
};
const source = readFileSync(join(__dirname, 'renderer-updates.ts'), 'utf8');
const exposed = `
Object.assign(globalThis, {
__updatesProductionPath: {
rememberUpdateInfo,
checkUpdate,
downloadUpdate,
postponeWorkspaceUpdatePopover,
dismissWorkspaceUpdatePopover,
getState: () => ({ updateBannerState, updateDownloadInProgress, workspaceUpdatePopoverPostponed })
}
});
`;
const compiled = transpileModule(`${source}\n${exposed}`, {
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 },
}).outputText;
runInNewContext(compiled, context);
return (context as { __updatesProductionPath: { rememberUpdateInfo: (info?: { version?: string } | null) => unknown } }).__updatesProductionPath;
return {
api: context.__updatesProductionPath as ProductionApi,
callbacks,
document,
elements,
notifications,
download: { resolve: resolveDownload, reject: rejectDownload },
check: { resolve: resolveCheck, reject: rejectCheck },
};
}
async function flushPromises(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
describe('renderer update production paths', () => {
test('does not create an update state without a version', () => {
const api = evaluate(sourceFragment('function rememberUpdateInfo', 'function getActiveUpdateInfo'), {
latestUpdateVersion: '',
latestUpdateInfo: null,
});
const runtime = createRuntime();
expect(api.rememberUpdateInfo({})).toBeNull();
expect(runtime.api.rememberUpdateInfo({})).toBeNull();
});
test('renders localized pending copy without a fabricated version', () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
expect(runtime.elements.get('updateText')?.textContent).toBe('Downloading...');
});
test('moves focus to the current navigation control after Later hides the update trigger', () => {
const runtime = createRuntime();
runtime.callbacks.available({ version: '1.2.3' });
runtime.elements.get('workspaceUpdateLater')?.focus();
runtime.api.postponeWorkspaceUpdatePopover();
expect(runtime.document.activeElement).toBe(runtime.document.activeNavigationItem);
expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false);
});
test('moves focus to the current navigation control after Dismiss hides the update trigger', () => {
const runtime = createRuntime();
runtime.callbacks.available({ version: '1.2.3' });
runtime.elements.get('workspaceUpdateDismiss')?.focus();
runtime.api.dismissWorkspaceUpdatePopover();
expect(runtime.document.activeElement).toBe(runtime.document.activeNavigationItem);
expect(runtime.elements.get('updateBanner')?.hidden).toBe(true);
});
test('keeps dismissed download progress hidden until the ready state is reached', () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
runtime.api.dismissWorkspaceUpdatePopover();
runtime.callbacks.progress({ percent: 50, transferred: 1024 * 1024, total: 2 * 1024 * 1024 });
expect(runtime.api.getState().updateBannerState).toBe('downloading');
expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false);
expect(runtime.elements.get('updateText')?.textContent).toBe('Download: 1.0 / 2.0 MB (50%)');
expect(runtime.elements.get('updateProgressGauge')?.getAttribute('aria-valuenow')).toBe('50');
runtime.callbacks.downloaded({ version: '1.2.3' });
expect(runtime.api.getState().updateBannerState).toBe('ready');
expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(true);
});
test('deduplicates a main error followed by a rejected download operation', async () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
runtime.callbacks.error({ kind: 'download' });
runtime.download.reject(new Error('download failed'));
await flushPromises();
expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']);
});
test('deduplicates a main error followed by the production error result', async () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
runtime.callbacks.error({ kind: 'download' });
runtime.download.resolve({ error: true });
await flushPromises();
expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']);
});
test('deduplicates a typed check error followed by the IPC error result', async () => {
const runtime = createRuntime();
const pending = runtime.api.checkUpdate();
runtime.callbacks.error({ kind: 'check' });
runtime.check.resolve({ error: true });
await pending;
expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update check failed.']);
});
test('reports a blocked manual check as an active download', async () => {
const runtime = createRuntime();
const pending = runtime.api.checkUpdate();
runtime.check.resolve({ checking: true, skipped: 'downloading' });
await pending;
expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download is already running.']);
});
test('keeps download failure deduplication scoped to its operation while a new check begins', async () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
runtime.callbacks.error({ kind: 'download' });
runtime.callbacks.checking();
runtime.download.reject(new Error('download failed'));
await flushPromises();
expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']);
});
test('reports a later independent error without relying on a checking event', async () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
runtime.callbacks.error({ kind: 'download' });
runtime.download.resolve({ error: true });
await flushPromises();
runtime.callbacks.error({ kind: 'check' });
expect(runtime.notifications.map(({ message }) => message)).toEqual([
'Update download failed.',
'Update check failed.',
]);
});
test('ignores stale check events while a download is active', () => {
const runtime = createRuntime();
runtime.callbacks.available({ version: '1.2.3' });
runtime.api.downloadUpdate();
runtime.callbacks.checking();
runtime.callbacks.available({ version: '1.2.4' });
runtime.callbacks.notAvailable();
runtime.callbacks.error({ kind: 'check' });
expect(runtime.notifications).toEqual([]);
expect(runtime.api.getState().updateBannerState).toBe('downloading');
expect(runtime.api.getState().updateDownloadInProgress).toBe(true);
});
test('reports a download failure when only the main error channel fires', () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
runtime.callbacks.error({ kind: 'download' });
expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']);
});
test('ignores a download terminal for another version', () => {
const runtime = createRuntime();
runtime.callbacks.available({ version: '1.2.3' });
runtime.api.downloadUpdate();
runtime.callbacks.error({ kind: 'download', version: '1.2.4' });
expect(runtime.notifications).toEqual([]);
expect(runtime.api.getState().updateBannerState).toBe('downloading');
expect(runtime.api.getState().updateDownloadInProgress).toBe(true);
});
test('reports a download failure when only the rejected operation channel fires', async () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
runtime.download.reject(new Error('download failed'));
await flushPromises();
expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']);
});
test('reports a later independent check error after a handled download failure', async () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
runtime.download.reject(new Error('download failed'));
await flushPromises();
runtime.callbacks.checking();
runtime.callbacks.error({ kind: 'check' });
expect(runtime.notifications.map(({ message }) => message)).toEqual([
'Update download failed.',
'Update check failed.',
]);
});
test('restores the available state after download failure without reopening a dismissed popover', async () => {
const runtime = createRuntime();
runtime.callbacks.available({ version: '1.2.3' });
runtime.api.downloadUpdate();
runtime.api.dismissWorkspaceUpdatePopover();
runtime.download.reject(new Error('download failed'));
await flushPromises();
expect(runtime.api.getState().updateBannerState).toBe('available');
expect(runtime.api.getState().workspaceUpdatePopoverPostponed).toBe(true);
expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false);
});
test('restores and reveals the available state after an ordinary download failure', () => {
const runtime = createRuntime();
runtime.callbacks.available({ version: '1.2.3' });
runtime.api.downloadUpdate();
runtime.callbacks.error({ kind: 'download', version: '1.2.3' });
expect(runtime.api.getState().updateBannerState).toBe('available');
expect(runtime.api.getState().workspaceUpdatePopoverPostponed).toBe(false);
expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(true);
});
test('leaves downloading state after an error when no update version is cached', () => {
const runtime = createRuntime();
runtime.api.downloadUpdate();
runtime.callbacks.progress({ percent: 25, transferred: 512, total: 2048 });
runtime.callbacks.error({ kind: 'download' });
expect(runtime.api.getState().updateBannerState).toBe('idle');
expect(runtime.api.getState().updateDownloadInProgress).toBe(false);
expect(runtime.elements.get('updateBanner')?.hidden).toBe(true);
expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false);
expect(runtime.elements.get('updateProgress')?.classList.contains('is-hidden')).toBe(true);
expect(runtime.elements.get('updateProgressBar')?.style.width).toBe('0%');
expect(runtime.elements.get('updateProgressGauge')?.getAttribute('aria-valuenow')).toBe('0');
});
test('does not reopen a Later update when an unrelated check later fails', () => {
const runtime = createRuntime();
runtime.callbacks.available({ version: '1.2.3' });
runtime.api.postponeWorkspaceUpdatePopover();
runtime.callbacks.checking();
runtime.callbacks.error({ kind: 'check' });
expect(runtime.api.getState().updateBannerState).toBe('available');
expect(runtime.api.getState().workspaceUpdatePopoverPostponed).toBe(true);
expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false);
});
test('does not reopen a dismissed update when an unrelated check later fails', () => {
const runtime = createRuntime();
runtime.callbacks.available({ version: '1.2.3' });
runtime.api.dismissWorkspaceUpdatePopover();
runtime.callbacks.checking();
runtime.callbacks.error({ kind: 'check' });
expect(runtime.api.getState().updateBannerState).toBe('idle');
expect(runtime.elements.get('updateBanner')?.hidden).toBe(true);
expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false);
});
});
+79 -26
View File
@@ -1,5 +1,6 @@
let updateCheckInProgress = false;
let updateDownloadInProgress = false;
let updateDownloadOperation: { failureHandled: boolean } | null = null;
let manualUpdateCheckPending = false;
let manualUpdateOutcomeHandled = false;
let latestUpdateVersion = '';
@@ -137,6 +138,10 @@ function showUpdateBanner(): void {
syncWorkspaceUpdateState(updateBannerState);
}
function focusWorkspaceAfterUpdateHidden(): void {
document.querySelector<HTMLElement>('.top-nav-item[aria-current="page"]')?.focus();
}
function hideUpdateBanner(): void {
updateBannerState = 'idle';
workspaceUpdatePopoverPostponed = false;
@@ -163,12 +168,17 @@ function postponeWorkspaceUpdatePopover(): void {
banner.classList.remove('show');
banner.classList.add('popover-dismissed');
byId<HTMLButtonElement>('workspaceUpdateButton').setAttribute('aria-expanded', 'false');
byId<HTMLButtonElement>('workspaceUpdateButton').focus();
focusWorkspaceAfterUpdateHidden();
}
function dismissWorkspaceUpdatePopover(): void {
if (updateBannerState === 'downloading') {
postponeWorkspaceUpdatePopover();
return;
}
hideUpdateBanner();
byId<HTMLButtonElement>('workspaceUpdateButton').focus();
focusWorkspaceAfterUpdateHidden();
}
for (const eventName of ['mouseenter', 'mouseleave', 'focusin', 'focusout']) {
@@ -217,11 +227,13 @@ function setUpdateBannerAvailableUi(info: UpdateInfo, reveal = true): void {
syncWorkspaceUpdateState('available');
}
function setDownloadPendingUi(): void {
function setDownloadPendingUi(reveal = true): void {
updateReady = false;
updateBannerState = 'downloading';
workspaceUpdatePopoverPostponed = false;
byId('updateBanner').classList.remove('popover-dismissed');
if (reveal) {
workspaceUpdatePopoverPostponed = false;
byId('updateBanner').classList.remove('popover-dismissed');
}
showUpdateBanner();
const button = byId<HTMLButtonElement>('updateButton');
@@ -236,7 +248,9 @@ function setDownloadPendingUi(): void {
byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(pendingPct)));
if (!latestDownloadProgress) {
byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.downloading}`;
byId('updateText').textContent = latestUpdateVersion
? `Version ${latestUpdateVersion} ${UI_TEXT.updates.downloading}`
: UI_TEXT.updates.downloading;
}
syncWorkspaceUpdateState('downloading');
}
@@ -246,6 +260,7 @@ function setDownloadReadyUi(info?: UpdateInfo): void {
if (!activeInfo) return;
updateReady = true;
updateDownloadInProgress = false;
updateDownloadOperation = null;
updateBannerState = 'ready';
workspaceUpdatePopoverPostponed = false;
byId('updateBanner').classList.remove('popover-dismissed');
@@ -497,7 +512,7 @@ function refreshUpdateUiTexts(): void {
const totalMb = (latestDownloadProgress.total / 1024 / 1024).toFixed(1);
byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${latestDownloadProgress.percent.toFixed(0)}%)`;
} else {
setDownloadPendingUi();
setDownloadPendingUi(false);
}
} else if (updateBannerState === 'ready' && latestUpdateInfo) {
setDownloadReadyUi(latestUpdateInfo);
@@ -534,12 +549,13 @@ async function checkUpdate(): Promise<void> {
const result = await window.api.checkUpdate();
if (result?.error) {
const alreadyHandled = manualUpdateOutcomeHandled;
shouldOpenUpdateModalOnAvailable = false;
manualUpdateOutcomeHandled = true;
manualUpdateCheckPending = false;
updateCheckInProgress = false;
setCheckButtonCheckingState(false);
notifyUpdate(UI_TEXT.updates.checkFailed, 'warn');
if (!alreadyHandled) notifyUpdate(UI_TEXT.updates.checkFailed, 'warn');
return;
}
@@ -559,6 +575,16 @@ async function checkUpdate(): Promise<void> {
return;
}
if (skippedReason === 'downloading') {
shouldOpenUpdateModalOnAvailable = false;
manualUpdateOutcomeHandled = true;
manualUpdateCheckPending = false;
updateCheckInProgress = false;
setCheckButtonCheckingState(false);
notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info');
return;
}
if (skippedReason === 'in-progress' || skippedReason === 'throttled' || skippedReason === 'timed-out') {
shouldOpenUpdateModalOnAvailable = false;
manualUpdateOutcomeHandled = true;
@@ -589,6 +615,27 @@ async function checkUpdate(): Promise<void> {
}
}
function handleUpdateDownloadFailure(operation: { failureHandled: boolean }): void {
if (operation.failureHandled) {
return;
}
operation.failureHandled = true;
if (operation !== updateDownloadOperation) {
return;
}
updateDownloadOperation = null;
updateDownloadInProgress = false;
latestDownloadProgress = null;
if (latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo, false);
} else {
hideUpdateBanner();
}
notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn');
}
function downloadUpdate(): void {
if (updateReady) {
dismissUpdateModal();
@@ -602,17 +649,15 @@ function downloadUpdate(): void {
}
updateDownloadInProgress = true;
const operation = { failureHandled: false };
updateDownloadOperation = operation;
latestDownloadProgress = null;
dismissUpdateModal();
setDownloadPendingUi();
void window.api.downloadUpdate().then((result) => {
if (result?.error) {
updateDownloadInProgress = false;
if (latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo);
}
notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn');
handleUpdateDownloadFailure(operation);
return;
}
@@ -629,15 +674,12 @@ function downloadUpdate(): void {
notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info');
}
}).catch(() => {
updateDownloadInProgress = false;
if (latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo);
}
notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn');
handleUpdateDownloadFailure(operation);
});
}
window.api.onUpdateChecking(() => {
if (updateDownloadInProgress || updateBannerState === 'downloading' || updateReady || updateBannerState === 'ready') return;
updateCheckInProgress = true;
if (manualUpdateCheckPending) {
setCheckButtonCheckingState(true);
@@ -645,6 +687,7 @@ window.api.onUpdateChecking(() => {
});
window.api.onUpdateAvailable((info: UpdateInfo) => {
if (updateDownloadInProgress || updateBannerState === 'downloading' || updateReady || updateBannerState === 'ready') return;
const activeInfo = rememberUpdateInfo(info);
updateCheckInProgress = false;
updateReady = false;
@@ -681,6 +724,7 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
window.api.onUpdateNotAvailable(() => {
if (updateDownloadInProgress || updateBannerState === 'downloading' || updateReady || updateBannerState === 'ready') return;
updateCheckInProgress = false;
setCheckButtonCheckingState(false);
manualUpdateOutcomeHandled = true;
@@ -726,20 +770,29 @@ window.api.onUpdateDownloaded((info: UpdateInfo) => {
openUpdateModal(activeInfo);
});
window.api.onUpdateError(() => {
window.api.onUpdateError((payload) => {
if (payload.kind === 'check') {
if (updateDownloadInProgress || updateBannerState === 'downloading' || updateReady || updateBannerState === 'ready') return;
updateCheckInProgress = false;
manualUpdateCheckPending = false;
manualUpdateOutcomeHandled = true;
shouldOpenUpdateModalOnAvailable = false;
setCheckButtonCheckingState(false);
notifyUpdate(UI_TEXT.updates.checkFailed, 'warn');
return;
}
const operation = updateDownloadOperation;
if (!updateDownloadInProgress || operation === null) return;
const activeVersion = (latestUpdateInfo?.version || latestUpdateVersion || '').trim();
if (payload.version && activeVersion && payload.version !== activeVersion) return;
updateCheckInProgress = false;
const wasDownloading = updateDownloadInProgress;
updateDownloadInProgress = false;
manualUpdateCheckPending = false;
manualUpdateOutcomeHandled = true;
shouldOpenUpdateModalOnAvailable = false;
setCheckButtonCheckingState(false);
if (!updateReady && latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo);
}
notifyUpdate(wasDownloading ? UI_TEXT.updates.downloadFailed : UI_TEXT.updates.checkFailed, 'warn');
handleUpdateDownloadFailure(operation);
});
document.addEventListener('keydown', (event) => {
+242
View File
@@ -0,0 +1,242 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
import { describe, expect, it, vi } from 'vitest';
class FakeClassList {
private readonly values = new Set<string>();
add(...tokens: string[]): void {
tokens.forEach((token) => this.values.add(token));
}
remove(...tokens: string[]): void {
tokens.forEach((token) => this.values.delete(token));
}
contains(token: string): boolean {
return this.values.has(token);
}
}
class FakeElement {
readonly classList = new FakeClassList();
readonly dataset: Record<string, string> = {};
readonly style: Record<string, string> = {};
readonly children: FakeElement[] = [];
readonly listeners = new Map<string, Array<(event: { target: FakeElement; relatedTarget: FakeElement | null }) => void>>();
className = '';
parentElement: FakeElement | null = null;
constructor(readonly tagName: string) { }
appendChild(child: FakeElement): FakeElement {
child.parentElement = this;
this.children.push(child);
return child;
}
addEventListener(type: string, listener: (event: { target: FakeElement; relatedTarget: FakeElement | null }) => void): void {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
dispatch(type: string, target: FakeElement, relatedTarget: FakeElement | null): void {
for (const listener of this.listeners.get(type) ?? []) listener({ target, relatedTarget });
}
closest(selector: string): FakeElement | null {
if (selector === '.vod-card' && this.className.split(/\s+/).includes('vod-card')) return this;
return this.parentElement?.closest(selector) ?? null;
}
contains(node: FakeElement): boolean {
return node === this || this.children.some((child) => child.contains(node));
}
remove(): void {
if (!this.parentElement) return;
const index = this.parentElement.children.indexOf(this);
if (index >= 0) this.parentElement.children.splice(index, 1);
this.parentElement = null;
}
querySelector(selector: string): FakeElement | null {
if (selector === '.vod-thumb-wrap') return this.children.find((child) => child.className === 'vod-thumb-wrap') ?? null;
return null;
}
getBoundingClientRect(): { width: number; height: number } {
return { width: 320, height: 180 };
}
}
describe('renderer VOD hover lifecycle', () => {
it('does not restart an active preview while moving between elements of the same card', async () => {
const source = readFileSync(join(__dirname, 'renderer-vod-hover.ts'), 'utf8');
const grid = new FakeElement('section');
const card = createCard();
const title = new FakeElement('h3');
card.appendChild(title);
grid.appendChild(card);
const context = createHoverContext([Promise.resolve(storyboard())], grid);
evaluateHover(source, context);
const bind = (context.window as Record<string, unknown>).ensureVodHoverHandlersBound as (() => void);
bind();
grid.dispatch('mouseover', card.children[0], null);
context.scheduledTimers.shift()?.();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(context.intervalCount).toBe(1);
expect(context.scheduledTimers).toEqual([]);
grid.dispatch('mouseover', title, card.children[0]);
expect(context.scheduledTimers).toEqual([]);
expect(context.intervalCount).toBe(1);
});
it('does not let an older same-ID fetch steal the newer card activation', async () => {
const source = readFileSync(join(__dirname, 'renderer-vod-hover.ts'), 'utf8');
const requests = [deferredStoryboard(), deferredStoryboard()];
const cardA = createCard();
const cardB = createCard();
const context = createHoverContext(requests.map((request) => request.promise));
const exposed = evaluateHover(source, context);
exposed.scheduleHoverPreview(cardA, 'vod-1');
context.scheduledTimers.shift()?.();
exposed.clearHoverPreview();
exposed.scheduleHoverPreview(cardB, 'vod-1');
context.scheduledTimers.shift()?.();
requests[0].resolve(storyboard());
await new Promise<void>((resolve) => setImmediate(resolve));
expect(cardA.children[0].children).toEqual([]);
requests[1].resolve(storyboard());
await new Promise<void>((resolve) => setImmediate(resolve));
expect(cardA.children[0].children).toEqual([]);
expect(cardB.children[0].children).toHaveLength(1);
expect(context.intervalCount).toBe(1);
});
it('exports cleanup that cancels the interval, removes the preview and invalidates a queued activation frame', async () => {
const source = readFileSync(join(__dirname, 'renderer-vod-hover.ts'), 'utf8');
const scheduledFrames: Array<() => void> = [];
const scheduledTimers: Array<() => void> = [];
const clearedIntervals: number[] = [];
const card = new FakeElement('article');
const thumbnail = new FakeElement('div');
thumbnail.className = 'vod-thumb-wrap';
card.appendChild(thumbnail);
const context: Record<string, unknown> = {
document: {
readyState: 'loading',
addEventListener: () => undefined,
getElementById: () => null,
createElement: (tagName: string) => new FakeElement(tagName)
},
HTMLElement: FakeElement,
requestAnimationFrame: (callback: () => void) => { scheduledFrames.push(callback); return scheduledFrames.length; },
setTimeout: (callback: () => void) => { scheduledTimers.push(callback); return scheduledTimers.length; },
setInterval: () => 73,
clearInterval: (id: number) => clearedIntervals.push(id),
api: {
getVodStoryboard: vi.fn(async () => ({
framesInSprite: 4,
cols: 2,
rows: 2,
cellWidth: 160,
cellHeight: 90,
spriteDataUrl: 'data:image/jpeg;base64,preview',
frameDataUrls: []
}))
}
};
context.window = context;
const compiled = transpileModule(`${source}\nglobalThis.exposed = { scheduleHoverPreview };`, {
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 }
}).outputText;
runInNewContext(compiled, context);
const exposed = (context as { exposed: { scheduleHoverPreview(card: FakeElement, vodId: string): void } }).exposed;
exposed.scheduleHoverPreview(card, 'vod-1');
scheduledTimers.shift()?.();
await new Promise<void>((resolve) => setImmediate(resolve));
const cleanup = (context.window as Record<string, unknown>).clearVodHoverPreview as (() => void) | undefined;
expect(cleanup).toBeTypeOf('function');
cleanup?.();
scheduledFrames.forEach((callback) => callback());
expect(clearedIntervals).toEqual([73]);
expect(card.classList.contains('preview-active')).toBe(false);
expect(thumbnail.children[0]?.style.opacity).toBe('0');
});
});
function storyboard(): Record<string, unknown> {
return {
framesInSprite: 4,
cols: 2,
rows: 2,
cellWidth: 160,
cellHeight: 90,
spriteDataUrl: 'data:image/jpeg;base64,preview',
frameDataUrls: []
};
}
function deferredStoryboard(): { promise: Promise<Record<string, unknown>>; resolve(value: Record<string, unknown>): void } {
let resolvePromise!: (value: Record<string, unknown>) => void;
return {
promise: new Promise((resolve) => { resolvePromise = resolve; }),
resolve: resolvePromise
};
}
function createCard(): FakeElement {
const card = new FakeElement('article');
card.className = 'vod-card';
card.dataset.vodId = 'vod-1';
const thumbnail = new FakeElement('div');
thumbnail.className = 'vod-thumb-wrap';
card.appendChild(thumbnail);
return card;
}
interface HoverTestContext extends Record<string, unknown> {
scheduledTimers: Array<() => void>;
intervalCount: number;
}
function createHoverContext(requests: Array<Promise<Record<string, unknown>>>, grid: FakeElement | null = null): HoverTestContext {
const scheduledTimers: Array<() => void> = [];
const context: HoverTestContext = {
scheduledTimers,
intervalCount: 0,
document: {
readyState: 'loading',
addEventListener: () => undefined,
getElementById: () => grid,
createElement: (tagName: string) => new FakeElement(tagName)
},
HTMLElement: FakeElement,
requestAnimationFrame: () => 1,
setTimeout: (callback: () => void) => { scheduledTimers.push(callback); return scheduledTimers.length; },
setInterval: () => { context.intervalCount += 1; return context.intervalCount; },
clearInterval: () => undefined,
api: { getVodStoryboard: vi.fn(() => requests.shift()) }
};
context.window = context;
return context;
}
function evaluateHover(source: string, context: HoverTestContext): { scheduleHoverPreview(card: FakeElement, vodId: string): void; clearHoverPreview(): void } {
const compiled = transpileModule(`${source}\nglobalThis.exposed = { scheduleHoverPreview, clearHoverPreview };`, {
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 }
}).outputText;
runInNewContext(compiled, context);
return (context as unknown as { exposed: { scheduleHoverPreview(card: FakeElement, vodId: string): void; clearHoverPreview(): void } }).exposed;
}
+14 -8
View File
@@ -18,6 +18,7 @@ interface ActiveHover {
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
let activeHover: ActiveHover | null = null;
let pendingHoverVodId: string | null = null;
let hoverRequestGeneration = 0;
const HOVER_DEBOUNCE_MS = 220;
const FRAME_INTERVAL_MS = 600;
@@ -49,6 +50,8 @@ function ensureVodHoverHandlersBound(): void {
const target = e.target as HTMLElement | null;
const card = target?.closest('.vod-card') as HTMLElement | null;
if (!card) return;
const related = e.relatedTarget as HTMLElement | null;
if (related && card.contains(related)) return;
const vodId = card.dataset.vodId;
if (!vodId) return;
scheduleHoverPreview(card, vodId);
@@ -68,15 +71,17 @@ function ensureVodHoverHandlersBound(): void {
function scheduleHoverPreview(card: HTMLElement, vodId: string): void {
if (pendingHoverVodId === vodId) return;
pendingHoverVodId = vodId;
const generation = ++hoverRequestGeneration;
// Debounce so rapid mouse passes (scrolling, dragging across cards)
// don't trigger a download for every card brushed.
window.setTimeout(() => {
if (pendingHoverVodId !== vodId) return;
void activateHoverPreview(card, vodId);
if (pendingHoverVodId !== vodId || generation !== hoverRequestGeneration) return;
void activateHoverPreview(card, vodId, generation);
}, HOVER_DEBOUNCE_MS);
}
function clearHoverPreview(): void {
hoverRequestGeneration += 1;
pendingHoverVodId = null;
if (!activeHover) return;
window.clearInterval(activeHover.intervalId);
@@ -88,9 +93,9 @@ function clearHoverPreview(): void {
activeHover = null;
}
async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<void> {
async function activateHoverPreview(card: HTMLElement, vodId: string, generation: number): Promise<void> {
// Stale-guard: user might have moved off the card in the debounce window.
if (pendingHoverVodId !== vodId) return;
if (pendingHoverVodId !== vodId || generation !== hoverRequestGeneration) return;
let storyboard: VodStoryboard | null | undefined = vodStoryboardClientCache.get(vodId);
if (storyboard === undefined) {
@@ -103,7 +108,7 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
}
// Cursor may have moved on while we awaited; re-check guard.
if (pendingHoverVodId !== vodId) return;
if (pendingHoverVodId !== vodId || generation !== hoverRequestGeneration) return;
if (!storyboard) return;
clearHoverPreview();
@@ -168,9 +173,6 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
advanceFrame(0);
host.appendChild(overlay);
// Trigger CSS transition to opacity:1 on the next frame.
requestAnimationFrame(() => { card.classList.add('preview-active'); });
let frameIdx = 1;
const intervalId = window.setInterval(() => {
advanceFrame(frameIdx);
@@ -178,9 +180,13 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
}, FRAME_INTERVAL_MS);
activeHover = { vodId, intervalId, overlay, card };
requestAnimationFrame(() => {
if (activeHover?.overlay === overlay) card.classList.add('preview-active');
});
}
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;
(window as unknown as { clearVodHoverPreview: typeof clearHoverPreview }).clearVodHoverPreview = clearHoverPreview;
// Bind once the grid exists. Tab switches don't re-create the grid, so
// one-time binding via DOMContentLoaded is enough.
+10 -9
View File
@@ -1069,13 +1069,14 @@ function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
return {
...item,
progress: bestProgress,
speed: item.speed || prev.speed,
eta: item.eta || prev.eta,
currentPart: item.currentPart || prev.currentPart,
totalParts: item.totalParts || prev.totalParts,
downloadedBytes: item.downloadedBytes || prev.downloadedBytes,
totalBytes: item.totalBytes || prev.totalBytes,
progressStatus: item.progressStatus || prev.progressStatus
speed: item.speed === undefined ? prev.speed : item.speed,
eta: item.eta === undefined ? prev.eta : item.eta,
currentPart: item.currentPart === undefined ? prev.currentPart : item.currentPart,
totalParts: item.totalParts === undefined ? prev.totalParts : item.totalParts,
downloadedBytes: item.downloadedBytes === undefined ? prev.downloadedBytes : item.downloadedBytes,
totalBytes: item.totalBytes === undefined ? prev.totalBytes : item.totalBytes,
progressStatus: item.progressStatus === undefined ? prev.progressStatus : item.progressStatus,
recordingHealth: item.recordingHealth === undefined ? prev.recordingHealth : item.recordingHealth
};
});
}
@@ -1291,7 +1292,7 @@ function showTab(tab: string): void {
// Only show the streamer name on the VODs tab — otherwise the title would
// mismatch the tab content (e.g. "streamer X" while on Settings)
const pageTitleText = (tab === 'vods' && currentStreamer)
? currentStreamer
? getStreamerDisplayName(currentStreamer)
: (titles[tab] || UI_TEXT.appName);
setPageTitle(pageTitleText);
@@ -1547,7 +1548,7 @@ function getTemplateVariableDocs(): TemplateVariableDoc[] {
{ placeholder: '{part_padded}', description: text('Teilnummer mit 2 Stellen', 'Part number padded to 2 digits'), exampleTemplate: '{part_padded}' },
{ placeholder: '{trim_start}', description: text('Startzeit des Ausschnitts', 'Trim start time'), exampleTemplate: '{trim_start}' },
{ placeholder: '{trim_end}', description: text('Endzeit des Ausschnitts', 'Trim end time'), exampleTemplate: '{trim_end}' },
{ placeholder: '{trim_length}', description: text('Lange des Ausschnitts', 'Trimmed duration'), exampleTemplate: '{trim_length}' },
{ placeholder: '{trim_length}', description: text('Länge des Ausschnitts', 'Trimmed duration'), exampleTemplate: '{trim_length}' },
{ placeholder: '{length}', description: text('Gesamtdauer', 'Total duration'), exampleTemplate: '{length}' },
{ placeholder: '{ext}', description: text('Dateiendung', 'File extension'), exampleTemplate: '{ext}' },
{ placeholder: '{random_string}', description: text('Zufallsstring (8 Zeichen)', 'Random string (8 chars)'), exampleTemplate: '{random_string}' },
+70
View File
@@ -0,0 +1,70 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import { isRendererReloadTarget } from './main/dev-reload';
const styleFiles = [
'styles.css',
'styles-workflows.css',
'styles-overlays.css',
'workspace.css',
'workspace-refinements.css',
];
describe('production style modules', () => {
test('preserves the complete stylesheet byte sequence across module boundaries', () => {
const content = Buffer.from(styleFiles
.map((fileName) => readFileSync(join(__dirname, fileName), 'utf8'))
.join('')
.replace(/\r\n/g, '\n'));
const digest = createHash('sha256').update(content).digest('hex');
expect(digest).toBe('143fb0cc3e6c2ca3f04ded7e3b83175db424b34b6ca9ed4ef32e337b4a60a898');
});
test('derives the Windows hot-development executable version from package metadata', () => {
const script = readFileSync(join(__dirname, '../scripts/dev.mjs'), 'utf8');
expect(script).toContain("readFileSync(resolve(rootDirectory, 'package.json'), 'utf8')");
expect(script).toMatch(/version:\s*developmentAppVersion/);
expect(script).not.toMatch(/version:\s*['"]\d+\.\d+\.\d+['"]/);
});
test('reloads every production stylesheet during hot development', () => {
for (const fileName of styleFiles) {
expect(isRendererReloadTarget(fileName), fileName).toBe(true);
}
expect(isRendererReloadTarget('future-workspace-surface.css')).toBe(true);
});
test('loads and packages every stylesheet in cascade order', () => {
const html = readFileSync(join(__dirname, 'index.html'), 'utf8');
const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')) as {
build?: { files?: string[] };
};
const links = Array.from(html.matchAll(/<link rel="stylesheet" href="\.\/([^"?]+)"/g), (match) => match[1]);
expect(links).toEqual(styleFiles);
for (const fileName of styleFiles) {
expect(packageJson.build?.files).toContain(`src/${fileName}`);
}
expect(packageJson.build?.files).toContain('!dist/main/dev-executable.js');
expect(packageJson.build?.files).toContain('!dist/main/index.js');
expect(packageJson.build?.files).toContain('!dist/types.js');
});
test('animates queue progress only while downloading and visibly marks paused items', () => {
const styles = readFileSync(join(__dirname, 'styles-workflows.css'), 'utf8');
const baseShimmer = styles.match(/\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? '';
const activeShimmer = styles.match(/\.status\.downloading\s*~\s*\.queue-main\s+\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? '';
const pausedStatus = styles.match(/\.queue-item\s+\.status\.paused\s*\{([\s\S]*?)\}/)?.[1] ?? '';
expect(baseShimmer).toMatch(/display:\s*none/);
expect(baseShimmer).toMatch(/animation:\s*none/);
expect(activeShimmer).toMatch(/display:\s*block/);
expect(activeShimmer).toMatch(/animation:\s*queue-progress-shimmer/);
expect(pausedStatus).toMatch(/background:/);
expect(pausedStatus).toMatch(/box-shadow:/);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-5027
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -83,6 +83,12 @@ function getCommandCacheKey(command: string, args: string[]): string {
return [command, ...args].join('\u0000');
}
let managedToolExecutionObserver: ((command: string) => void) | null = null;
export function setManagedToolExecutionObserver(observer: ((command: string) => void) | null): void {
managedToolExecutionObserver = observer;
}
export function canExecute(cmd: string): boolean {
try {
execSync(cmd, { stdio: 'ignore', windowsHide: true });
@@ -94,6 +100,7 @@ export function canExecute(cmd: string): boolean {
export function canExecuteCommand(command: string, args: string[]): boolean {
try {
managedToolExecutionObserver?.(command);
const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true });
return result.status === 0;
} catch {
+7
View File
@@ -21,11 +21,13 @@ export interface MergeGroup {
downloadedFiles: Record<number, string>;
mergedFile?: string;
splitFiles?: string[];
splitTempFiles?: string[];
totalDurationSec?: number;
}
export interface QueueItem {
id: string;
createdAt?: string;
title: string;
url: string;
date: string;
@@ -43,6 +45,8 @@ export interface QueueItem {
last_error?: string;
customClip?: CustomClip;
mergeGroup?: MergeGroup;
mergeRecoveryBlocked?: boolean;
artifactRoot?: string;
// File paths produced by the download (single file for VOD/clip, multiple
// for parts/merge-group splits). Persisted with the queue so completed
// items keep their "Open file" / "Show in folder" actions across restarts.
@@ -80,3 +84,6 @@ export interface DownloadResult {
error?: string;
outputFiles?: string[];
}
export type QueueAdditionRejectionReason = import('./main/domain/queue-addition').QueueAdditionRejectionReason;
export type QueueAdditionResult = import('./main/domain/queue-addition').QueueAdditionResult<QueueItem>;
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More