Compare commits

...

8 Commits

Author SHA1 Message Date
xRangerDE
6b97039471 release: 4.5.0 ETA, shortcuts, stats, drag&drop, expandable items, light theme, parallel downloads, code split
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:02:32 +01:00
xRangerDE
66afaba0ea refactor: extract tool discovery functions to src/tools.ts
Move streamlink/ffmpeg path discovery, bundled tool management,
auto-install logic, and related caches (~430 lines) into a
dedicated tools module. main.ts uses dependency injection for
debug logging and directory paths to keep the module decoupled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:00:51 +01:00
xRangerDE
54d04d4f73 feat: support parallel downloads (up to 2 simultaneous)
Add parallel_downloads config option (1 or 2) with Settings UI dropdown.
Refactor processQueue to run concurrent download slots using Promise.race,
extracting per-item logic into processOneQueueItem. Add per-item process
tracking via activeDownloads Map and cancelledItemIds Set so cancel/pause
correctly terminates all active downloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:54:20 +01:00
xRangerDE
63aafae85d feat: add light theme with toggle in settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:46:26 +01:00
xRangerDE
424b312551 refactor: extract shared interfaces to src/types.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:44:37 +01:00
xRangerDE
fbcf3935d0 feat: add drag & drop queue reordering and expandable queue item details
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:42:35 +01:00
xRangerDE
2481230983 feat: add keyboard shortcuts (Del/S) and download statistics bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:40:39 +01:00
xRangerDE
c96fd13aff feat: add ETA calculation for download progress display
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:39:27 +01:00
15 changed files with 1091 additions and 692 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.4.0", "version": "4.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.4.0", "version": "4.5.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.4.0", "version": "4.5.0",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",

View File

@ -218,6 +218,7 @@
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button> <button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
</div> </div>
</div> </div>
<div class="stats-bar" id="statsBar"></div>
</aside> </aside>
<main class="main"> <main class="main">
@ -379,6 +380,7 @@
<option value="discord">Discord</option> <option value="discord">Discord</option>
<option value="youtube">YouTube</option> <option value="youtube">YouTube</option>
<option value="apple">Apple</option> <option value="apple">Apple</option>
<option value="light" id="themeLightOption">Light</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -434,6 +436,13 @@
<label id="partMinutesLabel">Teil-Lange (Minuten)</label> <label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480"> <input type="number" id="partMinutes" value="120" min="10" max="480">
</div> </div>
<div class="form-group">
<label id="parallelDownloadsLabel">Parallele Downloads</label>
<select id="parallelDownloads">
<option value="1" id="parallelDownloads1">1 (Standard)</option>
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label id="performanceModeLabel">Performance-Profil</label> <label id="performanceModeLabel">Performance-Profil</label>
<select id="performanceMode"> <select id="performanceMode">

File diff suppressed because it is too large Load Diff

View File

@ -1,62 +1,7 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
// Types // Types
interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
filenameFormat: 'simple' | 'timestamp' | 'template';
filenameTemplate?: string;
}
interface MergeGroupItem {
url: string;
title: string;
date: string;
streamer: string;
duration_str: string;
}
interface MergeGroup {
items: MergeGroupItem[];
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
currentItemIndex: number;
downloadedFiles: Record<number, string>;
mergedFile?: string;
splitFiles?: string[];
totalDurationSec?: number;
}
interface QueueItem {
id: string;
title: string;
url: string;
date: string;
streamer: string;
duration_str: string;
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
progress: number;
currentPart?: number;
totalParts?: number;
speed?: string;
eta?: string;
customClip?: CustomClip;
mergeGroup?: MergeGroup;
}
interface DownloadProgress {
id: string;
progress: number;
speed: string;
speedBytesPerSec?: number;
eta: string;
status: string;
currentPart?: number;
totalParts?: number;
downloadedBytes?: number;
totalBytes?: number;
}
interface RuntimeMetricsSnapshot { interface RuntimeMetricsSnapshot {
cacheHits: number; cacheHits: number;
cacheMisses: number; cacheMisses: number;

View File

@ -15,6 +15,7 @@ interface AppConfig {
prevent_duplicate_downloads?: boolean; prevent_duplicate_downloads?: boolean;
persist_queue_on_restart?: boolean; persist_queue_on_restart?: boolean;
metadata_cache_minutes?: number; metadata_cache_minutes?: number;
parallel_downloads?: number;
[key: string]: unknown; [key: string]: unknown;
} }

View File

@ -26,6 +26,7 @@ const UI_TEXT_DE = {
mergeAdd: '+ Videos hinzufugen', mergeAdd: '+ Videos hinzufugen',
designTitle: 'Design', designTitle: 'Design',
themeLabel: 'Theme', themeLabel: 'Theme',
themeLight: 'Hell',
languageLabel: 'Sprache', languageLabel: 'Sprache',
languageDe: 'Deutsch', languageDe: 'Deutsch',
languageEn: 'Englisch', languageEn: 'Englisch',
@ -40,6 +41,9 @@ const UI_TEXT_DE = {
modeFull: 'Ganzes VOD', modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten', modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)', partMinutesLabel: 'Teil-Lange (Minuten)',
parallelDownloadsLabel: 'Parallele Downloads',
parallelDownloads1: '1 (Standard)',
parallelDownloads2: '2 (Parallel)',
performanceModeLabel: 'Performance-Profil', performanceModeLabel: 'Performance-Profil',
performanceModeStability: 'Max Stabilitat', performanceModeStability: 'Max Stabilitat',
performanceModeBalanced: 'Ausgewogen', performanceModeBalanced: 'Ausgewogen',

View File

@ -26,6 +26,7 @@ const UI_TEXT_EN = {
mergeAdd: '+ Add videos', mergeAdd: '+ Add videos',
designTitle: 'Design', designTitle: 'Design',
themeLabel: 'Theme', themeLabel: 'Theme',
themeLight: 'Light',
languageLabel: 'Language', languageLabel: 'Language',
languageDe: 'German', languageDe: 'German',
languageEn: 'English', languageEn: 'English',
@ -40,6 +41,9 @@ const UI_TEXT_EN = {
modeFull: 'Full VOD', modeFull: 'Full VOD',
modeParts: 'Split into parts', modeParts: 'Split into parts',
partMinutesLabel: 'Part Length (Minutes)', partMinutesLabel: 'Part Length (Minutes)',
parallelDownloadsLabel: 'Parallel Downloads',
parallelDownloads1: '1 (Default)',
parallelDownloads2: '2 (Parallel)',
performanceModeLabel: 'Performance Profile', performanceModeLabel: 'Performance Profile',
performanceModeStability: 'Max Stability', performanceModeStability: 'Max Stability',
performanceModeBalanced: 'Balanced', performanceModeBalanced: 'Balanced',

View File

@ -198,6 +198,49 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
if (meta) meta.textContent = getQueueMetaText(queue[idx]); if (meta) meta.textContent = getQueueMetaText(queue[idx]);
} }
function toggleQueueDetails(id: string): void {
const el = document.getElementById(`details-${id}`);
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function initQueueDragDrop(): void {
const list = byId('queueList');
list.addEventListener('dragstart', (e: DragEvent) => {
const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
if (!el) return;
draggedQueueItemId = el.dataset.id || null;
el.classList.add('dragging');
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragover', (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
});
list.addEventListener('drop', (e: DragEvent) => {
e.preventDefault();
const target = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
if (!target || !draggedQueueItemId) return;
const targetId = target.dataset.id;
if (!targetId || targetId === draggedQueueItemId) return;
const fromIdx = queue.findIndex(i => i.id === draggedQueueItemId);
const toIdx = queue.findIndex(i => i.id === targetId);
if (fromIdx < 0 || toIdx < 0) return;
const [moved] = queue.splice(fromIdx, 1);
queue.splice(toIdx, 0, moved);
window.api.reorderQueue(queue.map(i => i.id));
renderQueue();
});
list.addEventListener('dragend', () => {
draggedQueueItemId = null;
document.querySelectorAll('.queue-item.dragging').forEach(el => el.classList.remove('dragging'));
});
}
function renderQueue(): void { function renderQueue(): void {
if (!Array.isArray(queue)) { if (!Array.isArray(queue)) {
queue = []; queue = [];
@ -244,7 +287,7 @@ function renderQueue(): void {
: ''; : '';
return ` return `
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}"> <div class="queue-item${isMergeGroup ? ' merge-group' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
${showSelector ${showSelector
? `<div class="queue-selector${isSelected ? ' selected' : ''}" onclick="toggleQueueSelection('${item.id}')">${isSelected ? selectionIndex + 1 : ''}</div>` ? `<div class="queue-selector${isSelected ? ' selected' : ''}" onclick="toggleQueueSelection('${item.id}')">${isSelected ? selectionIndex + 1 : ''}</div>`
: '' : ''
@ -252,7 +295,7 @@ function renderQueue(): void {
<div class="status ${item.status}"></div> <div class="status ${item.status}"></div>
<div class="queue-main"> <div class="queue-main">
<div class="queue-title-row"> <div class="queue-title-row">
<div class="title" title="${safeTitle}">${mergeIcon}${isClip}${safeTitle}</div> <div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div> <div class="queue-status-label">${safeStatusLabel}</div>
</div> </div>
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div> <div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
@ -260,6 +303,12 @@ function renderQueue(): void {
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div> <div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div> </div>
<div class="queue-progress-text">${safeProgressText}</div> <div class="queue-progress-text">${safeProgressText}</div>
<div class="queue-details" id="details-${item.id}" style="display:none">
<div>URL: ${escapeHtml(item.url)}</div>
<div>Streamer: ${escapeHtml(item.streamer)}</div>
<div>Dauer: ${escapeHtml(item.duration_str)}</div>
<div>Datum: ${escapeHtml(new Date(item.date).toLocaleString())}</div>
</div>
</div> </div>
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span> <span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
</div> </div>

View File

@ -315,6 +315,7 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
return { return {
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full', download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120, part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed', performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked, smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked, prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
@ -356,6 +357,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.client_secret ?? '', effective.client_secret ?? '',
effective.download_mode ?? 'full', effective.download_mode ?? 'full',
effective.part_minutes ?? 120, effective.part_minutes ?? 120,
effective.parallel_downloads ?? 1,
effective.performance_mode ?? 'balanced', effective.performance_mode ?? 'balanced',
effective.smart_queue_scheduler !== false, effective.smart_queue_scheduler !== false,
effective.prevent_duplicate_downloads !== false, effective.prevent_duplicate_downloads !== false,
@ -372,6 +374,7 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? ''; byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full'; byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120); byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced'; byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
@ -482,6 +485,7 @@ function initSettingsAutoSave(): void {
const immediateSaveIds = [ const immediateSaveIds = [
'downloadMode', 'downloadMode',
'parallelDownloads',
'performanceMode', 'performanceMode',
'smartSchedulerToggle', 'smartSchedulerToggle',
'duplicatePreventionToggle', 'duplicatePreventionToggle',
@ -582,5 +586,6 @@ function openFolder(): void {
function changeTheme(theme: string): void { function changeTheme(theme: string): void {
document.body.className = `theme-${theme}`; document.body.className = `theme-${theme}`;
config.theme = theme;
void window.api.saveConfig({ theme }); void window.api.saveConfig({ theme });
} }

View File

@ -68,6 +68,7 @@ function applyLanguageToStaticUI(): void {
setText('mergeAddBtn', UI_TEXT.static.mergeAdd); setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
setText('designTitle', UI_TEXT.static.designTitle); setText('designTitle', UI_TEXT.static.designTitle);
setText('themeLabel', UI_TEXT.static.themeLabel); setText('themeLabel', UI_TEXT.static.themeLabel);
setText('themeLightOption', UI_TEXT.static.themeLight);
setText('languageLabel', UI_TEXT.static.languageLabel); setText('languageLabel', UI_TEXT.static.languageLabel);
setText('languageDeText', UI_TEXT.static.languageDe); setText('languageDeText', UI_TEXT.static.languageDe);
setText('languageEnText', UI_TEXT.static.languageEn); setText('languageEnText', UI_TEXT.static.languageEn);
@ -82,6 +83,9 @@ function applyLanguageToStaticUI(): void {
setText('modeFullText', UI_TEXT.static.modeFull); setText('modeFullText', UI_TEXT.static.modeFull);
setText('modePartsText', UI_TEXT.static.modeParts); setText('modePartsText', UI_TEXT.static.modeParts);
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel); setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
setText('parallelDownloadsLabel', UI_TEXT.static.parallelDownloadsLabel);
setText('parallelDownloads1', UI_TEXT.static.parallelDownloads1);
setText('parallelDownloads2', UI_TEXT.static.parallelDownloads2);
setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel); setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel);
setText('performanceModeStability', UI_TEXT.static.performanceModeStability); setText('performanceModeStability', UI_TEXT.static.performanceModeStability);
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced); setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);

View File

@ -42,6 +42,7 @@ async function init(): Promise<void> {
changeTheme(config.theme ?? 'twitch'); changeTheme(config.theme ?? 'twitch');
renderStreamers(); renderStreamers();
renderQueue(); renderQueue();
initQueueDragDrop();
updateDownloadButtonState(); updateDownloadButtonState();
window.api.onQueueUpdated((q: QueueItem[]) => { window.api.onQueueUpdated((q: QueueItem[]) => {
@ -96,6 +97,10 @@ async function init(): Promise<void> {
byId('mergeProgressText').textContent = Math.round(percent) + '%'; byId('mergeProgressText').textContent = Math.round(percent) + '%';
}); });
// Update stats bar
updateStatsBar();
setInterval(updateStatsBar, 5000);
if (config.client_id && config.client_secret) { if (config.client_id && config.client_secret) {
await connect(); await connect();
} else { } else {
@ -119,9 +124,56 @@ async function init(): Promise<void> {
scheduleQueueSync(document.hidden ? 600 : 150); scheduleQueueSync(document.hidden ? 600 : 150);
}); });
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Skip if user is typing in an input field
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
if (e.key === 'Delete' && selectedQueueIds.length > 0) {
// Delete selected queue items
const idsToRemove = [...selectedQueueIds];
selectedQueueIds = [];
(async () => {
for (const id of idsToRemove) {
queue = await window.api.removeFromQueue(id);
}
renderQueue();
})();
}
if ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
toggleDownload();
}
});
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS); scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS);
} }
function formatBytesRenderer(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatSpeedRenderer(bytesPerSec: number): string {
if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`;
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
}
async function updateStatsBar(): Promise<void> {
try {
const metrics = await window.api.getRuntimeMetrics();
const bar = byId('statsBar');
if (!bar) return;
const totalDL = formatBytesRenderer(metrics.downloadedBytesTotal);
const avgSpeed = metrics.avgSpeedBytesPerSec > 0 ? formatSpeedRenderer(metrics.avgSpeedBytesPerSec) : '-';
bar.textContent = `${totalDL} | ${avgSpeed} avg | ${metrics.downloadsCompleted} done | ${metrics.downloadsFailed} failed`;
} catch { }
}
let toastHideTimer: number | null = null; let toastHideTimer: number | null = null;
let queueSyncTimer: number | null = null; let queueSyncTimer: number | null = null;
let queueSyncInFlight = false; let queueSyncInFlight = false;

View File

@ -334,6 +334,29 @@ body {
opacity: 1; opacity: 1;
} }
.queue-item[draggable="true"] {
cursor: grab;
}
.queue-item[draggable="true"]:active {
cursor: grabbing;
}
.queue-item.dragging {
opacity: 0.4;
}
.queue-details {
font-size: 10px;
color: var(--text-secondary);
padding: 4px 0;
word-break: break-all;
}
.queue-details div {
margin-bottom: 2px;
}
.queue-selector { .queue-selector {
width: 22px; width: 22px;
height: 22px; height: 22px;
@ -386,6 +409,14 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
.stats-bar {
padding: 6px 15px;
font-size: 10px;
color: var(--text-secondary);
border-top: 1px solid rgba(255,255,255,0.1);
flex-shrink: 0;
}
.btn { .btn {
flex: 1; flex: 1;
padding: 10px; padding: 10px;
@ -1315,6 +1346,146 @@ body.theme-apple {
--accent-hover: #0071e3; --accent-hover: #0071e3;
} }
body.theme-light {
--bg-main: #f0f2f5;
--bg-sidebar: #ffffff;
--bg-card: #e4e6ea;
--text: #1a1a2e;
--text-secondary: #65676b;
--accent: #9146ff;
--accent-hover: #772ce8;
--success: #00c853;
--error: #e41e3f;
--warning: #e68a00;
}
/* Light theme: swap white-alpha borders/backgrounds to black-alpha */
body.theme-light .sidebar,
body.theme-light .queue-section,
body.theme-light .logo,
body.theme-light .stats-bar,
body.theme-light .header,
body.theme-light .status-bar {
border-color: rgba(0,0,0,0.1);
}
body.theme-light .add-streamer input,
body.theme-light .form-group input:not([type="checkbox"]):not([type="radio"]),
body.theme-light .form-group select,
body.theme-light .clip-input input,
body.theme-light .time-input-group input,
body.theme-light .part-number-group input,
body.theme-light .btn-secondary,
body.theme-light .lang-option,
body.theme-light .log-panel,
body.theme-light .template-guide-table-wrap,
body.theme-light .template-guide-preview-box {
border-color: rgba(0,0,0,0.12);
}
body.theme-light .lang-option:hover {
border-color: rgba(0,0,0,0.26);
}
body.theme-light .streamer-item:hover {
background: rgba(0,0,0,0.05);
}
body.theme-light .vod-btn.secondary {
background: rgba(0,0,0,0.08);
}
body.theme-light .vod-btn.secondary:hover {
background: rgba(0,0,0,0.12);
}
body.theme-light .nav-item:hover {
background: rgba(145, 71, 255, 0.1);
}
body.theme-light ::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.15);
}
body.theme-light ::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.25);
}
body.theme-light .update-modal {
border-color: rgba(0,0,0,0.1);
background:
linear-gradient(180deg, rgba(145, 70, 255, 0.12) 0%, rgba(145, 70, 255, 0.03) 24%, rgba(240, 242, 245, 0.98) 100%),
var(--bg-card);
}
body.theme-light .update-modal-eyebrow {
color: #4a2a8a;
}
body.theme-light .update-changelog-card {
border-color: rgba(0,0,0,0.08);
background: rgba(255, 255, 255, 0.6);
}
body.theme-light .update-changelog-header {
border-bottom-color: rgba(0,0,0,0.06);
}
body.theme-light .update-changelog-toggle {
color: #4a2a8a;
}
body.theme-light .update-changelog-toggle:hover {
color: #1a1a2e;
}
body.theme-light .update-changelog-heading,
body.theme-light .update-changelog-content strong {
color: #1a1a2e;
}
body.theme-light .template-guide-preview-box {
background: rgba(0, 0, 0, 0.04);
}
body.theme-light .template-guide-output {
background: rgba(0, 0, 0, 0.06);
}
body.theme-light .template-guide-table th,
body.theme-light .template-guide-table td {
border-bottom-color: rgba(0,0,0,0.08);
}
body.theme-light .log-panel {
background: #f8f9fb;
color: #2c3e50;
}
body.theme-light .app-toast {
background: rgba(255, 255, 255, 0.96);
color: #1a1a2e;
border-color: rgba(0,0,0,0.12);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
body.theme-light .btn-retry {
background: #dce4f0;
color: #2a3344;
}
body.theme-light .btn-retry:hover {
background: #c8d4e8;
}
body.theme-light .btn-clear:hover {
background: #d0d2d6;
}
body.theme-light .modal {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
/* Modal Styles */ /* Modal Styles */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;

494
src/tools.ts Normal file
View File

@ -0,0 +1,494 @@
import * as path from 'path';
import * as fs from 'fs';
import { spawn, execSync, spawnSync } from 'child_process';
import axios from 'axios';
// ==========================================
// CONSTANTS
// ==========================================
const TOOL_PATH_REFRESH_TTL_MS = 10 * 1000;
// ==========================================
// DEBUG LOG CALLBACK
// ==========================================
let _appendDebugLog: (message: string, details?: unknown) => void = () => {};
export function setDebugLogFn(fn: (message: string, details?: unknown) => void): void {
_appendDebugLog = fn;
}
// ==========================================
// TOOL DIRECTORIES (set once from main)
// ==========================================
let TOOLS_STREAMLINK_DIR = '';
let TOOLS_FFMPEG_DIR = '';
let _getTempPath: () => string = () => '';
export function initToolDirs(streamlinkDir: string, ffmpegDir: string, getTempPath: () => string): void {
TOOLS_STREAMLINK_DIR = streamlinkDir;
TOOLS_FFMPEG_DIR = ffmpegDir;
_getTempPath = getTempPath;
}
// ==========================================
// CACHE STATE
// ==========================================
let streamlinkPathCache: string | null = null;
let streamlinkCommandCache: { command: string; prefixArgs: string[] } | null = null;
let ffmpegPathCache: string | null = null;
let ffprobePathCache: string | null = null;
let bundledStreamlinkPath: string | null = null;
let bundledFFmpegPath: string | null = null;
let bundledFFprobePath: string | null = null;
let verifiedStreamlinkCommandKey: string | null = null;
let verifiedFfmpegCommandKey: string | null = null;
let bundledToolPathSignature = '';
let bundledToolPathRefreshedAt = 0;
// ==========================================
// INTERNAL HELPERS
// ==========================================
function findFileRecursive(rootDir: string, fileName: string): string | null {
if (!fs.existsSync(rootDir)) return null;
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
return fullPath;
}
if (entry.isDirectory()) {
const nested = findFileRecursive(fullPath, fileName);
if (nested) return nested;
}
}
return null;
}
function getDirectoryMtimeMs(directoryPath: string): number {
try {
return fs.statSync(directoryPath).mtimeMs;
} catch {
return 0;
}
}
function getCommandCacheKey(command: string, args: string[]): string {
return [command, ...args].join('\u0000');
}
export function canExecute(cmd: string): boolean {
try {
execSync(cmd, { stdio: 'ignore', windowsHide: true });
return true;
} catch {
return false;
}
}
export function canExecuteCommand(command: string, args: string[]): boolean {
try {
const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true });
return result.status === 0;
} catch {
return false;
}
}
// ==========================================
// VERIFIED COMMAND CACHES
// ==========================================
export function cacheVerifiedStreamlinkCommand(command: string, args: string[]): void {
verifiedStreamlinkCommandKey = getCommandCacheKey(command, args);
}
export function isVerifiedStreamlinkCommand(command: string, args: string[]): boolean {
return verifiedStreamlinkCommandKey === getCommandCacheKey(command, args);
}
export function cacheVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): void {
verifiedFfmpegCommandKey = getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
}
export function isVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): boolean {
return verifiedFfmpegCommandKey === getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
}
export function invalidateVerifiedToolCaches(): void {
verifiedStreamlinkCommandKey = null;
verifiedFfmpegCommandKey = null;
}
// ==========================================
// TOOL PATH DISCOVERY
// ==========================================
export function getStreamlinkPath(): string {
if (streamlinkPathCache) {
if (streamlinkPathCache === 'streamlink' || fs.existsSync(streamlinkPathCache)) {
return streamlinkPathCache;
}
streamlinkPathCache = null;
}
if (bundledStreamlinkPath && fs.existsSync(bundledStreamlinkPath)) {
streamlinkPathCache = bundledStreamlinkPath;
return streamlinkPathCache;
}
try {
if (process.platform === 'win32') {
const result = execSync('where streamlink', { encoding: 'utf-8' });
const paths = result.trim().split('\n');
if (paths.length > 0) {
streamlinkPathCache = paths[0].trim();
return streamlinkPathCache;
}
} else {
const result = execSync('which streamlink', { encoding: 'utf-8' });
streamlinkPathCache = result.trim();
return streamlinkPathCache;
}
} catch { }
const commonPaths = [
'C:\\Program Files\\Streamlink\\bin\\streamlink.exe',
'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe',
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe')
];
for (const p of commonPaths) {
if (fs.existsSync(p)) {
streamlinkPathCache = p;
return streamlinkPathCache;
}
}
streamlinkPathCache = 'streamlink';
return streamlinkPathCache;
}
export function getStreamlinkCommand(): { command: string; prefixArgs: string[] } {
if (streamlinkCommandCache) {
return streamlinkCommandCache;
}
const directPath = getStreamlinkPath();
if (directPath !== 'streamlink' || canExecute('streamlink --version')) {
streamlinkCommandCache = { command: directPath, prefixArgs: [] };
return streamlinkCommandCache;
}
if (process.platform === 'win32') {
if (canExecute('py -3 -m streamlink --version')) {
streamlinkCommandCache = { command: 'py', prefixArgs: ['-3', '-m', 'streamlink'] };
return streamlinkCommandCache;
}
if (canExecute('python -m streamlink --version')) {
streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] };
return streamlinkCommandCache;
}
} else {
if (canExecute('python3 -m streamlink --version')) {
streamlinkCommandCache = { command: 'python3', prefixArgs: ['-m', 'streamlink'] };
return streamlinkCommandCache;
}
if (canExecute('python -m streamlink --version')) {
streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] };
return streamlinkCommandCache;
}
}
streamlinkCommandCache = { command: directPath, prefixArgs: [] };
return streamlinkCommandCache;
}
export function getFFmpegPath(): string {
if (ffmpegPathCache) {
if (ffmpegPathCache === 'ffmpeg' || fs.existsSync(ffmpegPathCache)) {
return ffmpegPathCache;
}
ffmpegPathCache = null;
}
if (bundledFFmpegPath && fs.existsSync(bundledFFmpegPath)) {
ffmpegPathCache = bundledFFmpegPath;
return ffmpegPathCache;
}
try {
if (process.platform === 'win32') {
const result = execSync('where ffmpeg', { encoding: 'utf-8' });
const paths = result.trim().split('\n');
if (paths.length > 0) {
ffmpegPathCache = paths[0].trim();
return ffmpegPathCache;
}
} else {
const result = execSync('which ffmpeg', { encoding: 'utf-8' });
ffmpegPathCache = result.trim();
return ffmpegPathCache;
}
} catch { }
const commonPaths = [
'C:\\ffmpeg\\bin\\ffmpeg.exe',
'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ffmpeg', 'bin', 'ffmpeg.exe')
];
for (const p of commonPaths) {
if (fs.existsSync(p)) {
ffmpegPathCache = p;
return ffmpegPathCache;
}
}
ffmpegPathCache = 'ffmpeg';
return ffmpegPathCache;
}
export function getFFprobePath(): string {
if (ffprobePathCache) {
if (ffprobePathCache === 'ffprobe' || ffprobePathCache === 'ffprobe.exe' || fs.existsSync(ffprobePathCache)) {
return ffprobePathCache;
}
ffprobePathCache = null;
}
if (bundledFFprobePath && fs.existsSync(bundledFFprobePath)) {
ffprobePathCache = bundledFFprobePath;
return ffprobePathCache;
}
const ffmpegPath = getFFmpegPath();
const ffprobeExe = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe';
if (ffmpegPath === 'ffmpeg') {
ffprobePathCache = ffprobeExe;
return ffprobePathCache;
}
const derivedFfprobePath = path.join(path.dirname(ffmpegPath), ffprobeExe);
if (fs.existsSync(derivedFfprobePath)) {
ffprobePathCache = derivedFfprobePath;
return ffprobePathCache;
}
ffprobePathCache = ffprobeExe;
return ffprobePathCache;
}
// ==========================================
// BUNDLED TOOL PATH REFRESH
// ==========================================
export function refreshBundledToolPaths(force = false): void {
const now = Date.now();
const signature = `${getDirectoryMtimeMs(TOOLS_STREAMLINK_DIR)}|${getDirectoryMtimeMs(TOOLS_FFMPEG_DIR)}`;
if (!force && signature === bundledToolPathSignature && (now - bundledToolPathRefreshedAt) < TOOL_PATH_REFRESH_TTL_MS) {
return;
}
bundledToolPathSignature = signature;
bundledToolPathRefreshedAt = now;
const nextBundledStreamlinkPath = findFileRecursive(TOOLS_STREAMLINK_DIR, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink');
const nextBundledFFmpegPath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
const nextBundledFFprobePath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe');
const changed =
nextBundledStreamlinkPath !== bundledStreamlinkPath ||
nextBundledFFmpegPath !== bundledFFmpegPath ||
nextBundledFFprobePath !== bundledFFprobePath;
bundledStreamlinkPath = nextBundledStreamlinkPath;
bundledFFmpegPath = nextBundledFFmpegPath;
bundledFFprobePath = nextBundledFFprobePath;
if (changed) {
streamlinkPathCache = null;
ffmpegPathCache = null;
ffprobePathCache = null;
streamlinkCommandCache = null;
invalidateVerifiedToolCaches();
}
}
// ==========================================
// DOWNLOAD & EXTRACT HELPERS
// ==========================================
async function downloadFile(url: string, destinationPath: string): Promise<boolean> {
try {
const response = await axios.get(url, { responseType: 'stream', timeout: 120000 });
await new Promise<void>((resolve, reject) => {
const writer = fs.createWriteStream(destinationPath);
response.data.pipe(writer);
writer.on('finish', () => resolve());
writer.on('error', (err) => reject(err));
});
return true;
} catch (e) {
_appendDebugLog('download-file-failed', { url, destinationPath, error: String(e) });
return false;
}
}
async function extractZip(zipPath: string, destinationDir: string): Promise<boolean> {
try {
fs.mkdirSync(destinationDir, { recursive: true });
const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`;
await new Promise<void>((resolve, reject) => {
const proc = spawn('powershell', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-Command',
command
], { windowsHide: true });
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Expand-Archive exit code ${code}: ${stderr.trim()}`));
}
});
proc.on('error', (err) => reject(err));
});
return true;
} catch (e) {
_appendDebugLog('extract-zip-failed', { zipPath, destinationDir, error: String(e) });
return false;
}
}
// ==========================================
// AUTO-INSTALL TOOLS
// ==========================================
export async function ensureStreamlinkInstalled(): Promise<boolean> {
refreshBundledToolPaths();
const current = getStreamlinkCommand();
const versionArgs = [...current.prefixArgs, '--version'];
if (isVerifiedStreamlinkCommand(current.command, versionArgs)) {
return true;
}
if (canExecuteCommand(current.command, versionArgs)) {
cacheVerifiedStreamlinkCommand(current.command, versionArgs);
return true;
}
if (process.platform !== 'win32') {
return false;
}
_appendDebugLog('streamlink-install-start');
try {
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
const release = await axios.get('https://api.github.com/repos/streamlink/windows-builds/releases/latest', {
timeout: 120000,
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'Twitch-VOD-Manager'
}
});
const assets = release.data?.assets || [];
const zipAsset = assets.find((a: any) => typeof a?.name === 'string' && /x86_64\.zip$/i.test(a.name));
if (!zipAsset?.browser_download_url) {
_appendDebugLog('streamlink-install-no-asset-found');
return false;
}
const zipPath = path.join(_getTempPath(), `streamlink_portable_${Date.now()}.zip`);
const downloadOk = await downloadFile(zipAsset.browser_download_url, zipPath);
if (!downloadOk) return false;
fs.rmSync(TOOLS_STREAMLINK_DIR, { recursive: true, force: true });
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
const extractOk = await extractZip(zipPath, TOOLS_STREAMLINK_DIR);
try { fs.unlinkSync(zipPath); } catch { }
if (!extractOk) return false;
refreshBundledToolPaths(true);
streamlinkCommandCache = null;
const cmd = getStreamlinkCommand();
const installedVersionArgs = [...cmd.prefixArgs, '--version'];
const works = canExecuteCommand(cmd.command, installedVersionArgs);
if (works) {
cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs);
}
_appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs });
return works;
} catch (e) {
_appendDebugLog('streamlink-install-failed', String(e));
return false;
}
}
export async function ensureFfmpegInstalled(): Promise<boolean> {
refreshBundledToolPaths();
const ffmpegPath = getFFmpegPath();
const ffprobePath = getFFprobePath();
if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) {
return true;
}
if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) {
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
return true;
}
if (process.platform !== 'win32') {
return false;
}
_appendDebugLog('ffmpeg-install-start');
try {
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
const zipPath = path.join(_getTempPath(), `ffmpeg_essentials_${Date.now()}.zip`);
const downloadOk = await downloadFile('https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', zipPath);
if (!downloadOk) return false;
fs.rmSync(TOOLS_FFMPEG_DIR, { recursive: true, force: true });
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
const extractOk = await extractZip(zipPath, TOOLS_FFMPEG_DIR);
try { fs.unlinkSync(zipPath); } catch { }
if (!extractOk) return false;
refreshBundledToolPaths(true);
const newFfmpegPath = getFFmpegPath();
const newFfprobePath = getFFprobePath();
const works = canExecuteCommand(newFfmpegPath, ['-version']) && canExecuteCommand(newFfprobePath, ['-version']);
if (works) {
cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath);
}
_appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath });
return works;
} catch (e) {
_appendDebugLog('ffmpeg-install-failed', String(e));
return false;
}
}

63
src/types.ts Normal file
View File

@ -0,0 +1,63 @@
export interface CustomClip {
startSec: number;
durationSec: number;
startPart: number;
filenameFormat: 'simple' | 'timestamp' | 'template';
filenameTemplate?: string;
}
export interface MergeGroupItem {
url: string;
title: string;
date: string;
streamer: string;
duration_str: string;
}
export interface MergeGroup {
items: MergeGroupItem[];
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
currentItemIndex: number;
downloadedFiles: Record<number, string>;
mergedFile?: string;
splitFiles?: string[];
totalDurationSec?: number;
}
export interface QueueItem {
id: string;
title: string;
url: string;
date: string;
streamer: string;
duration_str: string;
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
progress: number;
currentPart?: number;
totalParts?: number;
speed?: string;
eta?: string;
downloadedBytes?: number;
totalBytes?: number;
last_error?: string;
customClip?: CustomClip;
mergeGroup?: MergeGroup;
}
export interface DownloadProgress {
id: string;
progress: number;
speed: string;
speedBytesPerSec?: number;
eta: string;
status: string;
currentPart?: number;
totalParts?: number;
downloadedBytes?: number;
totalBytes?: number;
}
export interface DownloadResult {
success: boolean;
error?: string;
}