Improve live download status and add debug logging (v3.7.8)

Keep queue visibility synced while downloads run, show richer per-item progress/error states in the sidebar, and write backend downloader diagnostics to ProgramData debug.log so instant start/stop failures can be traced on user systems.
This commit is contained in:
xRangerDE 2026-02-13 12:10:11 +01:00
parent 7f208cf369
commit 78378b9812
9 changed files with 227 additions and 26 deletions

View File

@ -54,3 +54,11 @@ Release must include all of:
- `Twitch-VOD-Manager-Setup-<version>.exe.blockmap` - `Twitch-VOD-Manager-Setup-<version>.exe.blockmap`
Tag version must match app version (example: `v3.7.6`). Tag version must match app version (example: `v3.7.6`).
## Debug log for failed downloads
From `v3.7.8`, detailed downloader logs are written to:
`C:\ProgramData\Twitch_VOD_Manager\debug.log`
If a download instantly switches from `Stoppen` back to `Start`, check the latest lines in that file for streamlink exit reasons.

View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "3.7.7", "version": "3.7.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "3.7.7", "version": "3.7.8",
"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": "3.7.7", "version": "3.7.8",
"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

@ -335,7 +335,7 @@
<div class="settings-card"> <div class="settings-card">
<h3>Updates</h3> <h3>Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.7.7</p> <p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.7.8</p>
<button class="btn-secondary" onclick="checkUpdate()">Nach Updates suchen</button> <button class="btn-secondary" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
</div> </div>
@ -346,7 +346,7 @@
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span> <span id="statusText">Nicht verbunden</span>
</div> </div>
<span id="versionText">v3.7.7</span> <span id="versionText">v3.7.8</span>
</div> </div>
</main> </main>
</div> </div>

View File

@ -8,13 +8,14 @@ import { autoUpdater } from 'electron-updater';
// ========================================== // ==========================================
// CONFIG & CONSTANTS // CONFIG & CONSTANTS
// ========================================== // ==========================================
const APP_VERSION = '3.7.7'; const APP_VERSION = '3.7.8';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths // Paths
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json'); const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json');
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs'); const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs');
// Timeouts // Timeouts
@ -74,9 +75,15 @@ interface QueueItem {
eta?: string; eta?: string;
downloadedBytes?: number; downloadedBytes?: number;
totalBytes?: number; totalBytes?: number;
last_error?: string;
customClip?: CustomClip; customClip?: CustomClip;
} }
interface DownloadResult {
success: boolean;
error?: string;
}
interface DownloadProgress { interface DownloadProgress {
id: string; id: string;
progress: number; progress: number;
@ -225,6 +232,18 @@ function getFFprobePath(): string {
return path.join(path.dirname(ffmpegPath), ffprobeExe); return path.join(path.dirname(ffmpegPath), ffprobeExe);
} }
function appendDebugLog(message: string, details?: unknown): void {
try {
const ts = new Date().toISOString();
const payload = details === undefined
? ''
: ` | ${typeof details === 'string' ? details : JSON.stringify(details)}`;
fs.appendFileSync(DEBUG_LOG_FILE, `[${ts}] ${message}${payload}\n`);
} catch {
// ignore debug log errors
}
}
// ========================================== // ==========================================
// DURATION HELPERS // DURATION HELPERS
// ========================================== // ==========================================
@ -756,10 +775,11 @@ function downloadVODPart(
itemId: string, itemId: string,
partNum: number, partNum: number,
totalParts: number totalParts: number
): Promise<boolean> { ): Promise<DownloadResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const streamlinkPath = getStreamlinkPath(); const streamlinkPath = getStreamlinkPath();
const args = [url, 'best', '-o', filename, '--force']; const args = [url, 'best', '-o', filename, '--force'];
let lastErrorLine = '';
if (startTime) { if (startTime) {
args.push('--hls-start-offset', startTime); args.push('--hls-start-offset', startTime);
@ -769,6 +789,7 @@ function downloadVODPart(
} }
console.log('Starting download:', streamlinkPath, args); console.log('Starting download:', streamlinkPath, args);
appendDebugLog('download-part-start', { itemId, streamlinkPath, filename, args });
const proc = spawn(streamlinkPath, args, { windowsHide: true }); const proc = spawn(streamlinkPath, args, { windowsHide: true });
currentProcess = proc; currentProcess = proc;
@ -828,7 +849,12 @@ function downloadVODPart(
}); });
proc.stderr?.on('data', (data: Buffer) => { proc.stderr?.on('data', (data: Buffer) => {
console.error('Streamlink error:', data.toString()); const message = data.toString().trim();
if (message) {
lastErrorLine = message.split('\n').pop() || message;
appendDebugLog('download-part-stderr', { itemId, message: lastErrorLine });
console.error('Streamlink error:', message);
}
}); });
proc.on('close', (code) => { proc.on('close', (code) => {
@ -836,26 +862,36 @@ function downloadVODPart(
currentProcess = null; currentProcess = null;
if (currentDownloadCancelled) { if (currentDownloadCancelled) {
resolve(false); appendDebugLog('download-part-cancelled', { itemId, filename });
resolve({ success: false, error: 'Download wurde abgebrochen.' });
return; return;
} }
if (code === 0 && fs.existsSync(filename)) { if (code === 0 && fs.existsSync(filename)) {
const stats = fs.statSync(filename); const stats = fs.statSync(filename);
if (stats.size > 1024 * 1024) { if (stats.size > 1024 * 1024) {
resolve(true); appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size });
resolve({ success: true });
return; return;
} }
const tooSmall = `Datei zu klein (${stats.size} Bytes)`;
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
resolve({ success: false, error: tooSmall });
return;
} }
resolve(false); const genericError = lastErrorLine || `Streamlink Exit-Code ${code ?? -1}`;
appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError });
resolve({ success: false, error: genericError });
}); });
proc.on('error', (err) => { proc.on('error', (err) => {
clearInterval(progressInterval); clearInterval(progressInterval);
console.error('Process error:', err); console.error('Process error:', err);
currentProcess = null; currentProcess = null;
resolve(false); appendDebugLog('download-part-process-error', { itemId, error: String(err) });
resolve({ success: false, error: String(err) });
}); });
}); });
} }
@ -863,7 +899,7 @@ function downloadVODPart(
async function downloadVOD( async function downloadVOD(
item: QueueItem, item: QueueItem,
onProgress: (progress: DownloadProgress) => void onProgress: (progress: DownloadProgress) => void
): Promise<boolean> { ): Promise<DownloadResult> {
const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, ''); const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, '');
const date = new Date(item.date); const date = new Date(item.date);
const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
@ -907,7 +943,7 @@ async function downloadVOD(
const partFilename = makeClipFilename(partNum, startOffset); const partFilename = makeClipFilename(partNum, startOffset);
const success = await downloadVODPart( const result = await downloadVODPart(
item.url, item.url,
partFilename, partFilename,
formatDuration(startOffset), formatDuration(startOffset),
@ -918,11 +954,14 @@ async function downloadVOD(
numParts numParts
); );
if (!success) return false; if (!result.success) return result;
downloadedFiles.push(partFilename); downloadedFiles.push(partFilename);
} }
return downloadedFiles.length === numParts; return {
success: downloadedFiles.length === numParts,
error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Clip-Teile konnten heruntergeladen werden.'
};
} else { } else {
// Single clip file // Single clip file
const filename = makeClipFilename(clip.startPart, clip.startSec); const filename = makeClipFilename(clip.startPart, clip.startSec);
@ -959,7 +998,7 @@ async function downloadVOD(
const partFilename = path.join(folder, `${dateStr}_Part${(i + 1).toString().padStart(2, '0')}.mp4`); const partFilename = path.join(folder, `${dateStr}_Part${(i + 1).toString().padStart(2, '0')}.mp4`);
const success = await downloadVODPart( const result = await downloadVODPart(
item.url, item.url,
partFilename, partFilename,
formatDuration(startSec), formatDuration(startSec),
@ -970,20 +1009,24 @@ async function downloadVOD(
numParts numParts
); );
if (!success) { if (!result.success) {
return false; return result;
} }
downloadedFiles.push(partFilename); downloadedFiles.push(partFilename);
} }
return downloadedFiles.length === numParts; return {
success: downloadedFiles.length === numParts,
error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Teile konnten heruntergeladen werden.'
};
} }
} }
async function processQueue(): Promise<void> { async function processQueue(): Promise<void> {
if (isDownloading || downloadQueue.length === 0) return; if (isDownloading || downloadQueue.length === 0) return;
appendDebugLog('queue-start', { items: downloadQueue.length });
isDownloading = true; isDownloading = true;
mainWindow?.webContents.send('download-started'); mainWindow?.webContents.send('download-started');
mainWindow?.webContents.send('queue-updated', downloadQueue); mainWindow?.webContents.send('queue-updated', downloadQueue);
@ -992,17 +1035,27 @@ async function processQueue(): Promise<void> {
if (!isDownloading) break; if (!isDownloading) break;
if (item.status === 'completed') continue; if (item.status === 'completed') continue;
appendDebugLog('queue-item-start', { itemId: item.id, title: item.title, url: item.url });
currentDownloadCancelled = false; currentDownloadCancelled = false;
item.status = 'downloading'; item.status = 'downloading';
saveQueue(downloadQueue); saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue); mainWindow?.webContents.send('queue-updated', downloadQueue);
const success = await downloadVOD(item, (progress) => { item.last_error = '';
const result = await downloadVOD(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress); mainWindow?.webContents.send('download-progress', progress);
}); });
item.status = success ? 'completed' : 'error'; item.status = result.success ? 'completed' : 'error';
item.progress = success ? 100 : 0; item.progress = result.success ? 100 : 0;
item.last_error = result.success ? '' : (result.error || 'Unbekannter Fehler beim Download');
appendDebugLog('queue-item-finished', {
itemId: item.id,
status: item.status,
error: item.last_error
});
saveQueue(downloadQueue); saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue); mainWindow?.webContents.send('queue-updated', downloadQueue);
} }
@ -1011,6 +1064,7 @@ async function processQueue(): Promise<void> {
saveQueue(downloadQueue); saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue); mainWindow?.webContents.send('queue-updated', downloadQueue);
mainWindow?.webContents.send('download-finished'); mainWindow?.webContents.send('download-finished');
appendDebugLog('queue-finished', { items: downloadQueue.length });
} }
// ========================================== // ==========================================

View File

@ -42,6 +42,8 @@ interface QueueItem {
eta?: string; eta?: string;
downloadedBytes?: number; downloadedBytes?: number;
totalBytes?: number; totalBytes?: number;
progressStatus?: string;
last_error?: string;
customClip?: CustomClip; customClip?: CustomClip;
} }

View File

@ -19,6 +19,59 @@ async function clearCompleted(): Promise<void> {
renderQueue(); renderQueue();
} }
function getQueueStatusLabel(item: QueueItem): string {
if (item.status === 'completed') return 'Abgeschlossen';
if (item.status === 'error') return 'Fehlgeschlagen';
if (item.status === 'downloading') return 'Lauft';
return 'Wartet';
}
function getQueueProgressText(item: QueueItem): string {
if (item.status === 'completed') return '100%';
if (item.status === 'error') return 'Fehler';
if (item.status === 'pending') return 'Bereit';
if (item.progress > 0) return `${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`;
return item.progressStatus || 'Lade...';
}
function getQueueMetaText(item: QueueItem): string {
if (item.status === 'error' && item.last_error) {
return item.last_error;
}
const parts: string[] = [];
if (item.currentPart && item.totalParts) {
parts.push(`Teil ${item.currentPart}/${item.totalParts}`);
}
if (item.speed) {
parts.push(item.speed);
}
if (item.eta) {
parts.push(`ETA ${item.eta}`);
}
if (!parts.length && item.status === 'pending') {
parts.push('Bereit zum Download');
}
if (!parts.length && item.status === 'downloading') {
parts.push(item.progressStatus || 'Download gestartet');
}
if (!parts.length && item.status === 'completed') {
parts.push('Fertig');
}
if (!parts.length && item.status === 'error') {
parts.push('Download fehlgeschlagen');
}
return parts.join(' | ');
}
function renderQueue(): void { function renderQueue(): void {
if (!Array.isArray(queue)) { if (!Array.isArray(queue)) {
queue = []; queue = [];
@ -34,11 +87,30 @@ function renderQueue(): void {
list.innerHTML = queue.map((item: QueueItem) => { list.innerHTML = queue.map((item: QueueItem) => {
const safeTitle = escapeHtml(item.title || 'Untitled'); const safeTitle = escapeHtml(item.title || 'Untitled');
const safeStatusLabel = escapeHtml(getQueueStatusLabel(item));
const safeProgressText = escapeHtml(getQueueProgressText(item));
const safeMeta = escapeHtml(getQueueMetaText(item));
const isClip = item.customClip ? '* ' : ''; const isClip = item.customClip ? '* ' : '';
const hasDeterminateProgress = item.progress > 0 && item.progress <= 100;
const progressValue = item.status === 'completed'
? 100
: (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0);
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
return ` return `
<div class="queue-item"> <div class="queue-item">
<div class="status ${item.status}"></div> <div class="status ${item.status}"></div>
<div class="queue-main">
<div class="queue-title-row">
<div class="title" title="${safeTitle}">${isClip}${safeTitle}</div> <div class="title" title="${safeTitle}">${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div>
</div>
<div class="queue-meta">${safeMeta}</div>
<div class="queue-progress-wrap">
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div>
<div class="queue-progress-text">${safeProgressText}</div>
</div>
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span> <span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
</div> </div>
`; `;

View File

@ -31,7 +31,15 @@ async function init(): Promise<void> {
return; return;
} }
item.status = 'downloading';
item.progress = progress.progress; item.progress = progress.progress;
item.speed = progress.speed;
item.eta = progress.eta;
item.currentPart = progress.currentPart;
item.totalParts = progress.totalParts;
item.downloadedBytes = progress.downloadedBytes;
item.totalBytes = progress.totalBytes;
item.progressStatus = progress.status;
renderQueue(); renderQueue();
}); });

View File

@ -195,13 +195,13 @@ body {
} }
.queue-list { .queue-list {
max-height: 150px; max-height: 210px;
overflow-y: auto; overflow-y: auto;
} }
.queue-item { .queue-item {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 10px; gap: 10px;
padding: 8px; padding: 8px;
background: var(--bg-card); background: var(--bg-card);
@ -234,6 +234,63 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.queue-main {
flex: 1;
min-width: 0;
}
.queue-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.queue-status-label {
flex-shrink: 0;
font-size: 10px;
color: var(--text-secondary);
}
.queue-meta {
font-size: 10px;
color: var(--text-secondary);
margin-top: 2px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-progress-wrap {
height: 4px;
border-radius: 999px;
overflow: hidden;
background: rgba(255,255,255,0.12);
}
.queue-progress-bar {
height: 100%;
width: 0;
background: var(--accent);
transition: width 0.2s ease;
}
.queue-progress-bar.indeterminate {
width: 35% !important;
animation: queue-indeterminate 1.3s ease-in-out infinite;
}
.queue-progress-text {
margin-top: 3px;
font-size: 10px;
color: var(--text-secondary);
}
@keyframes queue-indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(280%); }
}
.queue-item .remove { .queue-item .remove {
cursor: pointer; cursor: pointer;
color: var(--error); color: var(--error);