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>
This commit is contained in:
xRangerDE 2026-03-20 09:54:20 +01:00
parent 63aafae85d
commit 54d04d4f73
7 changed files with 214 additions and 120 deletions

View File

@ -436,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">

View File

@ -97,6 +97,7 @@ interface Config {
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;
} }
interface RuntimeMetrics { interface RuntimeMetrics {
@ -207,7 +208,8 @@ const defaultConfig: Config = {
performance_mode: DEFAULT_PERFORMANCE_MODE, performance_mode: DEFAULT_PERFORMANCE_MODE,
prevent_duplicate_downloads: true, prevent_duplicate_downloads: true,
persist_queue_on_restart: true, persist_queue_on_restart: true,
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES,
parallel_downloads: 1
}; };
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string { function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
@ -379,6 +381,9 @@ let pauseRequested = false;
let activeQueueItemId: string | null = null; let activeQueueItemId: string | null = null;
let downloadStartTime = 0; let downloadStartTime = 0;
let downloadedBytes = 0; let downloadedBytes = 0;
// Per-item tracking for parallel downloads
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>();
const cancelledItemIds = new Set<string>();
const userIdLoginCache = new Map<string, string>(); const userIdLoginCache = new Map<string, string>();
const loginToUserIdCache = new Map<string, CacheEntry<string>>(); const loginToUserIdCache = new Map<string, CacheEntry<string>>();
const vodListCache = new Map<string, CacheEntry<VOD[]>>(); const vodListCache = new Map<string, CacheEntry<VOD[]>>();
@ -2595,7 +2600,11 @@ function downloadVODPart(
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true }); const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
currentProcess = proc; currentProcess = proc;
downloadStartTime = Date.now(); // Register in per-item tracking map for parallel downloads
const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 };
activeDownloads.set(itemId, itemTracking);
downloadStartTime = itemTracking.startTime;
downloadedBytes = 0; downloadedBytes = 0;
let lastBytes = 0; let lastBytes = 0;
let lastTime = Date.now(); let lastTime = Date.now();
@ -2606,6 +2615,7 @@ function downloadVODPart(
try { try {
const stats = fs.statSync(filename); const stats = fs.statSync(filename);
downloadedBytes = stats.size; downloadedBytes = stats.size;
itemTracking.bytes = stats.size;
const now = Date.now(); const now = Date.now();
const timeDiff = (now - lastTime) / 1000; const timeDiff = (now - lastTime) / 1000;
@ -2624,7 +2634,8 @@ function downloadVODPart(
let etaStr = ''; let etaStr = '';
if (speed > 0 && downloadedBytes > 0) { if (speed > 0 && downloadedBytes > 0) {
const elapsedSec = (Date.now() - downloadStartTime) / 1000; const itemStartTime = itemTracking.startTime;
const elapsedSec = (Date.now() - itemStartTime) / 1000;
if (elapsedSec > 3) { if (elapsedSec > 3) {
const avgSpeed = downloadedBytes / elapsedSec; const avgSpeed = downloadedBytes / elapsedSec;
if (expectedDurationSeconds && expectedDurationSeconds > 0) { if (expectedDurationSeconds && expectedDurationSeconds > 0) {
@ -2684,8 +2695,10 @@ function downloadVODPart(
proc.on('close', async (code) => { proc.on('close', async (code) => {
clearInterval(progressInterval); clearInterval(progressInterval);
currentProcess = null; currentProcess = null;
activeDownloads.delete(itemId);
if (currentDownloadCancelled) { if (currentDownloadCancelled || cancelledItemIds.has(itemId)) {
cancelledItemIds.delete(itemId);
appendDebugLog('download-part-cancelled', { itemId, filename }); appendDebugLog('download-part-cancelled', { itemId, filename });
resolve({ success: false, error: 'Download wurde abgebrochen.' }); resolve({ success: false, error: 'Download wurde abgebrochen.' });
return; return;
@ -2727,6 +2740,7 @@ function downloadVODPart(
clearInterval(progressInterval); clearInterval(progressInterval);
console.error('Process error:', err); console.error('Process error:', err);
currentProcess = null; currentProcess = null;
activeDownloads.delete(itemId);
const rawError = String(err); const rawError = String(err);
const errorMessage = rawError.includes('ENOENT') const errorMessage = rawError.includes('ENOENT')
? 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).' ? 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).'
@ -2856,7 +2870,7 @@ async function downloadVOD(
const downloadedFiles: string[] = []; const downloadedFiles: string[] = [];
for (let i = 0; i < numParts; i++) { for (let i = 0; i < numParts; i++) {
if (currentDownloadCancelled) break; if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break;
const partNum = clip.startPart + i; const partNum = clip.startPart + i;
const startOffset = clip.startSec + (i * partDuration); const startOffset = clip.startSec + (i * partDuration);
@ -2918,7 +2932,7 @@ async function downloadVOD(
const downloadedFiles: string[] = []; const downloadedFiles: string[] = [];
for (let i = 0; i < numParts; i++) { for (let i = 0; i < numParts; i++) {
if (currentDownloadCancelled) break; if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break;
const startSec = i * partDuration; const startSec = i * partDuration;
const endSec = Math.min((i + 1) * partDuration, totalDuration); const endSec = Math.min((i + 1) * partDuration, totalDuration);
@ -2998,7 +3012,7 @@ async function processDownloadMergeGroup(
} }
for (let i = 0; i < mg.items.length; i++) { for (let i = 0; i < mg.items.length; i++) {
if (currentDownloadCancelled) { if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
return { success: false, error: 'Download wurde abgebrochen.' }; return { success: false, error: 'Download wurde abgebrochen.' };
} }
@ -3008,7 +3022,8 @@ async function processDownloadMergeGroup(
continue; continue;
} }
currentDownloadCancelled = false; // Reset stale cancel state // Reset stale per-item cancel state (global cancel already checked above)
cancelledItemIds.delete(item.id);
mg.currentItemIndex = i; mg.currentItemIndex = i;
mg.mergePhase = 'downloading'; mg.mergePhase = 'downloading';
saveQueue(downloadQueue); saveQueue(downloadQueue);
@ -3109,7 +3124,7 @@ async function processDownloadMergeGroup(
saveQueue(downloadQueue); saveQueue(downloadQueue);
emitQueueUpdated(); emitQueueUpdated();
if (currentDownloadCancelled) { if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
return { success: false, error: 'Download wurde abgebrochen.' }; return { success: false, error: 'Download wurde abgebrochen.' };
} }
@ -3195,26 +3210,7 @@ async function processDownloadMergeGroup(
return { success: true }; return { success: true };
} }
async function processQueue(): Promise<void> { async function processOneQueueItem(item: QueueItem): Promise<void> {
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
appendDebugLog('queue-start', {
items: downloadQueue.length,
smartScheduler: config.smart_queue_scheduler,
performanceMode: config.performance_mode
});
isDownloading = true;
pauseRequested = false;
mainWindow?.webContents.send('download-started');
emitQueueUpdated();
while (isDownloading && !pauseRequested) {
const item = pickNextPendingQueueItem();
if (!item) {
break;
}
appendDebugLog('queue-item-start', { appendDebugLog('queue-item-start', {
itemId: item.id, itemId: item.id,
title: item.title, title: item.title,
@ -3227,7 +3223,7 @@ async function processQueue(): Promise<void> {
runtimeMetrics.activeItemTitle = item.title; runtimeMetrics.activeItemTitle = item.title;
activeQueueItemId = item.id; activeQueueItemId = item.id;
currentDownloadCancelled = false; cancelledItemIds.delete(item.id);
item.status = 'downloading'; item.status = 'downloading';
saveQueue(downloadQueue); saveQueue(downloadQueue);
emitQueueUpdated(); emitQueueUpdated();
@ -3255,7 +3251,7 @@ async function processQueue(): Promise<void> {
finalResult = result; finalResult = result;
if (!isDownloading || currentDownloadCancelled || pauseRequested) { if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) {
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' }; finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
break; break;
} }
@ -3297,10 +3293,9 @@ async function processQueue(): Promise<void> {
if (!hasQueueItemId(item.id)) { if (!hasQueueItemId(item.id)) {
appendDebugLog('queue-item-finished-removed', { itemId: item.id }); appendDebugLog('queue-item-finished-removed', { itemId: item.id });
runtimeMetrics.activeItemId = null; activeDownloads.delete(item.id);
runtimeMetrics.activeItemTitle = null; cancelledItemIds.delete(item.id);
activeQueueItemId = null; return;
continue;
} }
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert'); const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
@ -3314,9 +3309,8 @@ async function processQueue(): Promise<void> {
runtimeMetrics.downloadsFailed += 1; runtimeMetrics.downloadsFailed += 1;
} }
runtimeMetrics.activeItemId = null; activeDownloads.delete(item.id);
runtimeMetrics.activeItemTitle = null; cancelledItemIds.delete(item.id);
activeQueueItemId = null;
appendDebugLog('queue-item-finished', { appendDebugLog('queue-item-finished', {
itemId: item.id, itemId: item.id,
@ -3328,11 +3322,62 @@ async function processQueue(): Promise<void> {
emitQueueUpdated(); emitQueueUpdated();
} }
async function processQueue(): Promise<void> {
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
appendDebugLog('queue-start', {
items: downloadQueue.length,
smartScheduler: config.smart_queue_scheduler,
performanceMode: config.performance_mode,
parallelDownloads: config.parallel_downloads || 1
});
isDownloading = true;
pauseRequested = false;
currentDownloadCancelled = false;
cancelledItemIds.clear();
mainWindow?.webContents.send('download-started');
emitQueueUpdated();
const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2);
const activePromises = new Map<string, Promise<void>>();
while (isDownloading && !pauseRequested) {
// Clean up finished promises
for (const [id] of activePromises) {
const queueItem = downloadQueue.find(i => i.id === id);
if (!queueItem || queueItem.status !== 'downloading') {
activePromises.delete(id);
}
}
// Fill available slots
while (activePromises.size < maxSlots && !pauseRequested) {
const item = pickNextPendingQueueItem();
if (!item) break;
const itemPromise = processOneQueueItem(item);
activePromises.set(item.id, itemPromise);
}
if (activePromises.size === 0) break;
// Wait for any one download to finish before re-checking
await Promise.race([...activePromises.values()]);
}
// Wait for all remaining active downloads to complete
if (activePromises.size > 0) {
await Promise.allSettled([...activePromises.values()]);
}
isDownloading = false; isDownloading = false;
pauseRequested = false; pauseRequested = false;
runtimeMetrics.activeItemId = null; runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null; runtimeMetrics.activeItemTitle = null;
activeQueueItemId = null; activeQueueItemId = null;
activeDownloads.clear();
cancelledItemIds.clear();
saveQueue(downloadQueue); saveQueue(downloadQueue);
emitQueueUpdated(); emitQueueUpdated();
@ -3774,13 +3819,21 @@ ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'prog
}); });
ipcMain.handle('remove-from-queue', (_, id: string) => { ipcMain.handle('remove-from-queue', (_, id: string) => {
const wasActiveItem = activeQueueItemId === id; const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id);
if (wasActiveItem) { if (wasActiveItem) {
// Cancel via per-item tracking
cancelledItemIds.add(id);
const tracking = activeDownloads.get(id);
if (tracking?.process) {
tracking.process.kill();
}
// Also set global for backwards compat
currentDownloadCancelled = true; currentDownloadCancelled = true;
if (currentProcess) { if (currentProcess) {
currentProcess.kill(); currentProcess.kill();
} }
activeDownloads.delete(id);
activeQueueItemId = null; activeQueueItemId = null;
runtimeMetrics.activeItemId = null; runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null; runtimeMetrics.activeItemTitle = null;
@ -3948,6 +4001,13 @@ ipcMain.handle('pause-download', () => {
pauseRequested = true; pauseRequested = true;
currentDownloadCancelled = true; currentDownloadCancelled = true;
// Kill all active download processes
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) { if (currentProcess) {
currentProcess.kill(); currentProcess.kill();
} }
@ -3958,6 +4018,13 @@ ipcMain.handle('cancel-download', () => {
isDownloading = false; isDownloading = false;
pauseRequested = false; pauseRequested = false;
currentDownloadCancelled = true; currentDownloadCancelled = true;
// Kill all active download processes
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) { if (currentProcess) {
currentProcess.kill(); currentProcess.kill();
} }
@ -4190,6 +4257,12 @@ app.on('window-all-closed', () => {
stopDebugLogFlushTimer(true); stopDebugLogFlushTimer(true);
stopAutoUpdatePolling(); stopAutoUpdatePolling();
// Kill all active download processes
for (const [, tracking] of activeDownloads) {
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) { if (currentProcess) {
currentProcess.kill(); currentProcess.kill();
} }

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

@ -41,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

@ -41,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

@ -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',

View File

@ -83,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);