Compare commits

..

No commits in common. "39fa5065d23348b55b8aec51aa644d05e50dda79" and "1e81b889f9a4a35ba7f1419dbf7a4a5a76f05b78" have entirely different histories.

7 changed files with 92 additions and 129 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@ -685,26 +685,20 @@ function formatDurationDashed(seconds: number): string {
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
}
const claimedFilenames = new Set<string>();
function ensureUniqueFilename(filePath: string): string {
if (!fs.existsSync(filePath)) return filePath;
const dir = path.dirname(filePath);
const ext = path.extname(filePath);
const base = path.basename(filePath, ext);
let counter = 1;
let candidate = filePath;
let counter = 0;
while (fs.existsSync(candidate) || claimedFilenames.has(candidate)) {
counter++;
while (fs.existsSync(candidate)) {
candidate = path.join(dir, `${base}_${counter}${ext}`);
counter++;
}
claimedFilenames.add(candidate);
return candidate;
}
function releaseClaimedFilename(filePath: string): void {
claimedFilenames.delete(filePath);
}
function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
const cleaned = (input || '')
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
@ -2210,15 +2204,13 @@ function downloadVODPart(
if (speed > 0 && downloadedBytes > 0) {
const itemStartTime = itemTracking.startTime;
const elapsedSec = (Date.now() - itemStartTime) / 1000;
if (elapsedSec > 5) { // Wait at least 5 seconds before showing ETA
if (elapsedSec > 3) {
const avgSpeed = downloadedBytes / elapsedSec;
if (expectedDurationSeconds && expectedDurationSeconds > 0) {
const estimatedTotalBytes = avgSpeed * expectedDurationSeconds;
if (estimatedTotalBytes > downloadedBytes) {
const remainingSec = (estimatedTotalBytes - downloadedBytes) / avgSpeed;
if (remainingSec > 0 && remainingSec < 86400) { // Between 0 and 24 hours
etaStr = formatETA(remainingSec);
}
etaStr = formatETA(remainingSec);
}
}
}
@ -2806,98 +2798,96 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
item.last_error = '';
try {
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
const maxAttempts = getRetryAttemptLimit();
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 });
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;
}
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;
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;
}
break;
}
if (!hasQueueItemId(item.id)) {
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
return;
finalResult = result;
if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) {
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
break;
}
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');
const errorClass = classifyDownloadError(result.error || '');
runtimeMetrics.lastErrorClass = errorClass;
if (finalResult.success) {
runtimeMetrics.downloadsCompleted += 1;
} else if (!wasPaused) {
runtimeMetrics.downloadsFailed += 1;
if (errorClass === 'tooling' || errorClass === 'validation') {
appendDebugLog('queue-item-no-retry', {
itemId: item.id,
errorClass,
error: result.error || 'unknown'
});
break;
}
appendDebugLog('queue-item-finished', {
itemId: item.id,
status: item.status,
error: item.last_error
});
if (attempt < maxAttempts) {
const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt);
runtimeMetrics.retriesScheduled += 1;
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
saveQueue(downloadQueue);
emitQueueUpdated();
} finally {
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);
// Release any filenames claimed during this download (prevents stale claims blocking re-downloads)
claimedFilenames.clear();
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> {

View File

@ -35,7 +35,7 @@ function getQueueRenderFingerprint(items: QueueItem[]): string {
item.mergeGroup?.mergePhase || ''
].join(':'));
return `${lang}|${selectedQueueIds.join(',')}|${[...expandedQueueIds].join(',')}|${pieces.join('|')}`;
return `${lang}|${selectedQueueIds.join(',')}|${pieces.join('|')}`;
}
function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean {
@ -199,35 +199,16 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
}
function toggleQueueDetails(id: string): void {
if (expandedQueueIds.has(id)) {
expandedQueueIds.delete(id);
} else {
expandedQueueIds.add(id);
}
renderQueue();
const el = document.getElementById(`details-${id}`);
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function initQueueDragDrop(): void {
if (queueDragDropInitialized) return;
queueDragDropInitialized = true;
const list = byId('queueList');
list.addEventListener('dragstart', (e: DragEvent) => {
const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
if (!el) return;
// Prevent dragging items that are no longer pending (race window between status change and re-render)
const itemId = el.dataset.id;
if (itemId) {
const item = queue.find(i => i.id === itemId);
if (!item || item.status !== 'pending') {
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'none';
e.dataTransfer.clearData();
}
return;
}
}
draggedQueueItemId = el.dataset.id || null;
el.classList.add('dragging');
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
@ -322,7 +303,7 @@ function renderQueue(): void {
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div>
<div class="queue-progress-text">${safeProgressText}</div>
<div class="queue-details" id="details-${item.id}" style="display:${expandedQueueIds.has(item.id) ? 'block' : 'none'}">
<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>

View File

@ -25,8 +25,6 @@ let isConnected = false;
let downloading = false;
let queue: QueueItem[] = [];
let selectedQueueIds: string[] = [];
let expandedQueueIds: Set<string> = new Set();
let queueDragDropInitialized = false;
let cutterFile: string | null = null;
let cutterVideoInfo: VideoInfo | null = null;

View File

@ -99,7 +99,7 @@ async function init(): Promise<void> {
// Update stats bar
updateStatsBar();
const _statsInterval = setInterval(updateStatsBar, 5000);
setInterval(updateStatsBar, 5000);
if (config.client_id && config.client_secret) {
await connect();
@ -275,14 +275,9 @@ function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
return item;
}
// Keep the higher progress value to prevent backward jumps from stale data
const bestProgress = (prev.status === 'downloading' && prev.progress > item.progress)
? prev.progress
: (item.progress > 0 ? item.progress : prev.progress);
return {
...item,
progress: bestProgress,
progress: item.progress > 0 ? item.progress : prev.progress,
speed: item.speed || prev.speed,
eta: item.eta || prev.eta,
currentPart: item.currentPart || prev.currentPart,

View File

@ -358,9 +358,8 @@ body {
}
.queue-selector {
min-width: 22px;
width: 22px;
height: 22px;
padding: 0 3px;
border: 2px solid var(--text-secondary);
border-radius: 4px;
cursor: pointer;
@ -368,7 +367,7 @@ body {
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-size: 12px;
font-weight: 700;
color: var(--bg-primary);
user-select: none;