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:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 '];
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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-'));
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Vendored
+9
-1
@@ -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
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
);
|
||||
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'Brien & 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
@@ -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, '"');
|
||||
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, '"');
|
||||
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, '"');
|
||||
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}')">↻</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">↻</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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}' },
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user