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:
parent
63aafae85d
commit
54d04d4f73
@ -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">
|
||||||
|
|||||||
313
src/main.ts
313
src/main.ts
@ -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,137 +3210,165 @@ async function processDownloadMergeGroup(
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||||
|
appendDebugLog('queue-item-start', {
|
||||||
|
itemId: item.id,
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
runtimeMetrics.downloadsStarted += 1;
|
||||||
|
runtimeMetrics.activeItemId = item.id;
|
||||||
|
runtimeMetrics.activeItemTitle = item.title;
|
||||||
|
activeQueueItemId = item.id;
|
||||||
|
|
||||||
|
cancelledItemIds.delete(item.id);
|
||||||
|
item.status = 'downloading';
|
||||||
|
saveQueue(downloadQueue);
|
||||||
|
emitQueueUpdated();
|
||||||
|
|
||||||
|
item.last_error = '';
|
||||||
|
|
||||||
|
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
|
||||||
|
const maxAttempts = getRetryAttemptLimit();
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
|
||||||
|
|
||||||
|
const result = item.mergeGroup
|
||||||
|
? await processDownloadMergeGroup(item, (progress) => {
|
||||||
|
mainWindow?.webContents.send('download-progress', progress);
|
||||||
|
})
|
||||||
|
: await downloadVOD(item, (progress) => {
|
||||||
|
mainWindow?.webContents.send('download-progress', progress);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
finalResult = result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalResult = result;
|
||||||
|
|
||||||
|
if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) {
|
||||||
|
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorClass = classifyDownloadError(result.error || '');
|
||||||
|
runtimeMetrics.lastErrorClass = errorClass;
|
||||||
|
|
||||||
|
if (errorClass === 'tooling' || errorClass === 'validation') {
|
||||||
|
appendDebugLog('queue-item-no-retry', {
|
||||||
|
itemId: item.id,
|
||||||
|
errorClass,
|
||||||
|
error: result.error || 'unknown'
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt);
|
||||||
|
runtimeMetrics.retriesScheduled += 1;
|
||||||
|
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
|
||||||
|
|
||||||
|
item.last_error = `Versuch ${attempt}/${maxAttempts} fehlgeschlagen (${errorClass}): ${result.error || 'Unbekannter Fehler'}`;
|
||||||
|
mainWindow?.webContents.send('download-progress', {
|
||||||
|
id: item.id,
|
||||||
|
progress: -1,
|
||||||
|
speed: '',
|
||||||
|
eta: '',
|
||||||
|
status: `Neuer Versuch in ${retryDelaySeconds}s (${errorClass})...`,
|
||||||
|
currentPart: item.currentPart,
|
||||||
|
totalParts: item.totalParts
|
||||||
|
} as DownloadProgress);
|
||||||
|
saveQueue(downloadQueue);
|
||||||
|
emitQueueUpdated();
|
||||||
|
await sleep(retryDelaySeconds * 1000);
|
||||||
|
} else {
|
||||||
|
runtimeMetrics.retriesExhausted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasQueueItemId(item.id)) {
|
||||||
|
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
|
||||||
|
activeDownloads.delete(item.id);
|
||||||
|
cancelledItemIds.delete(item.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
|
||||||
|
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
|
||||||
|
item.progress = finalResult.success ? 100 : item.progress;
|
||||||
|
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
|
||||||
|
|
||||||
|
if (finalResult.success) {
|
||||||
|
runtimeMetrics.downloadsCompleted += 1;
|
||||||
|
} else if (!wasPaused) {
|
||||||
|
runtimeMetrics.downloadsFailed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDownloads.delete(item.id);
|
||||||
|
cancelledItemIds.delete(item.id);
|
||||||
|
|
||||||
|
appendDebugLog('queue-item-finished', {
|
||||||
|
itemId: item.id,
|
||||||
|
status: item.status,
|
||||||
|
error: item.last_error
|
||||||
|
});
|
||||||
|
|
||||||
|
saveQueue(downloadQueue);
|
||||||
|
emitQueueUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
async function processQueue(): Promise<void> {
|
async function processQueue(): Promise<void> {
|
||||||
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
|
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
|
||||||
|
|
||||||
appendDebugLog('queue-start', {
|
appendDebugLog('queue-start', {
|
||||||
items: downloadQueue.length,
|
items: downloadQueue.length,
|
||||||
smartScheduler: config.smart_queue_scheduler,
|
smartScheduler: config.smart_queue_scheduler,
|
||||||
performanceMode: config.performance_mode
|
performanceMode: config.performance_mode,
|
||||||
|
parallelDownloads: config.parallel_downloads || 1
|
||||||
});
|
});
|
||||||
|
|
||||||
isDownloading = true;
|
isDownloading = true;
|
||||||
pauseRequested = false;
|
pauseRequested = false;
|
||||||
|
currentDownloadCancelled = false;
|
||||||
|
cancelledItemIds.clear();
|
||||||
mainWindow?.webContents.send('download-started');
|
mainWindow?.webContents.send('download-started');
|
||||||
emitQueueUpdated();
|
emitQueueUpdated();
|
||||||
|
|
||||||
|
const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2);
|
||||||
|
const activePromises = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
while (isDownloading && !pauseRequested) {
|
while (isDownloading && !pauseRequested) {
|
||||||
const item = pickNextPendingQueueItem();
|
// Clean up finished promises
|
||||||
if (!item) {
|
for (const [id] of activePromises) {
|
||||||
break;
|
const queueItem = downloadQueue.find(i => i.id === id);
|
||||||
}
|
if (!queueItem || queueItem.status !== 'downloading') {
|
||||||
|
activePromises.delete(id);
|
||||||
appendDebugLog('queue-item-start', {
|
|
||||||
itemId: item.id,
|
|
||||||
title: item.title,
|
|
||||||
url: item.url,
|
|
||||||
smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0
|
|
||||||
});
|
|
||||||
|
|
||||||
runtimeMetrics.downloadsStarted += 1;
|
|
||||||
runtimeMetrics.activeItemId = item.id;
|
|
||||||
runtimeMetrics.activeItemTitle = item.title;
|
|
||||||
activeQueueItemId = item.id;
|
|
||||||
|
|
||||||
currentDownloadCancelled = false;
|
|
||||||
item.status = 'downloading';
|
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
|
||||||
|
|
||||||
item.last_error = '';
|
|
||||||
|
|
||||||
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
|
|
||||||
const maxAttempts = getRetryAttemptLimit();
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
|
|
||||||
|
|
||||||
const result = item.mergeGroup
|
|
||||||
? await processDownloadMergeGroup(item, (progress) => {
|
|
||||||
mainWindow?.webContents.send('download-progress', progress);
|
|
||||||
})
|
|
||||||
: await downloadVOD(item, (progress) => {
|
|
||||||
mainWindow?.webContents.send('download-progress', progress);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
finalResult = result;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
finalResult = result;
|
|
||||||
|
|
||||||
if (!isDownloading || currentDownloadCancelled || pauseRequested) {
|
|
||||||
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorClass = classifyDownloadError(result.error || '');
|
|
||||||
runtimeMetrics.lastErrorClass = errorClass;
|
|
||||||
|
|
||||||
if (errorClass === 'tooling' || errorClass === 'validation') {
|
|
||||||
appendDebugLog('queue-item-no-retry', {
|
|
||||||
itemId: item.id,
|
|
||||||
errorClass,
|
|
||||||
error: result.error || 'unknown'
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt < maxAttempts) {
|
|
||||||
const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt);
|
|
||||||
runtimeMetrics.retriesScheduled += 1;
|
|
||||||
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
|
|
||||||
|
|
||||||
item.last_error = `Versuch ${attempt}/${maxAttempts} fehlgeschlagen (${errorClass}): ${result.error || 'Unbekannter Fehler'}`;
|
|
||||||
mainWindow?.webContents.send('download-progress', {
|
|
||||||
id: item.id,
|
|
||||||
progress: -1,
|
|
||||||
speed: '',
|
|
||||||
eta: '',
|
|
||||||
status: `Neuer Versuch in ${retryDelaySeconds}s (${errorClass})...`,
|
|
||||||
currentPart: item.currentPart,
|
|
||||||
totalParts: item.totalParts
|
|
||||||
} as DownloadProgress);
|
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
|
||||||
await sleep(retryDelaySeconds * 1000);
|
|
||||||
} else {
|
|
||||||
runtimeMetrics.retriesExhausted += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasQueueItemId(item.id)) {
|
// Fill available slots
|
||||||
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
|
while (activePromises.size < maxSlots && !pauseRequested) {
|
||||||
runtimeMetrics.activeItemId = null;
|
const item = pickNextPendingQueueItem();
|
||||||
runtimeMetrics.activeItemTitle = null;
|
if (!item) break;
|
||||||
activeQueueItemId = null;
|
|
||||||
continue;
|
const itemPromise = processOneQueueItem(item);
|
||||||
|
activePromises.set(item.id, itemPromise);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
|
if (activePromises.size === 0) break;
|
||||||
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
|
|
||||||
item.progress = finalResult.success ? 100 : item.progress;
|
|
||||||
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
|
|
||||||
|
|
||||||
if (finalResult.success) {
|
// Wait for any one download to finish before re-checking
|
||||||
runtimeMetrics.downloadsCompleted += 1;
|
await Promise.race([...activePromises.values()]);
|
||||||
} else if (!wasPaused) {
|
}
|
||||||
runtimeMetrics.downloadsFailed += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
runtimeMetrics.activeItemId = null;
|
// Wait for all remaining active downloads to complete
|
||||||
runtimeMetrics.activeItemTitle = null;
|
if (activePromises.size > 0) {
|
||||||
activeQueueItemId = null;
|
await Promise.allSettled([...activePromises.values()]);
|
||||||
|
|
||||||
appendDebugLog('queue-item-finished', {
|
|
||||||
itemId: item.id,
|
|
||||||
status: item.status,
|
|
||||||
error: item.last_error
|
|
||||||
});
|
|
||||||
|
|
||||||
saveQueue(downloadQueue);
|
|
||||||
emitQueueUpdated();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isDownloading = false;
|
isDownloading = false;
|
||||||
@ -3333,6 +3376,8 @@ async function processQueue(): Promise<void> {
|
|||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/renderer-globals.d.ts
vendored
1
src/renderer-globals.d.ts
vendored
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user