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`
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",
"version": "3.7.7",
"version": "3.7.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "3.7.7",
"version": "3.7.8",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

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

View File

@ -335,7 +335,7 @@
<div class="settings-card">
<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>
</div>
</div>
@ -346,7 +346,7 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v3.7.7</span>
<span id="versionText">v3.7.8</span>
</div>
</main>
</div>

View File

@ -8,13 +8,14 @@ import { autoUpdater } from 'electron-updater';
// ==========================================
// CONFIG & CONSTANTS
// ==========================================
const APP_VERSION = '3.7.7';
const APP_VERSION = '3.7.8';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.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');
// Timeouts
@ -74,9 +75,15 @@ interface QueueItem {
eta?: string;
downloadedBytes?: number;
totalBytes?: number;
last_error?: string;
customClip?: CustomClip;
}
interface DownloadResult {
success: boolean;
error?: string;
}
interface DownloadProgress {
id: string;
progress: number;
@ -225,6 +232,18 @@ function getFFprobePath(): string {
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
// ==========================================
@ -756,10 +775,11 @@ function downloadVODPart(
itemId: string,
partNum: number,
totalParts: number
): Promise<boolean> {
): Promise<DownloadResult> {
return new Promise((resolve) => {
const streamlinkPath = getStreamlinkPath();
const args = [url, 'best', '-o', filename, '--force'];
let lastErrorLine = '';
if (startTime) {
args.push('--hls-start-offset', startTime);
@ -769,6 +789,7 @@ function downloadVODPart(
}
console.log('Starting download:', streamlinkPath, args);
appendDebugLog('download-part-start', { itemId, streamlinkPath, filename, args });
const proc = spawn(streamlinkPath, args, { windowsHide: true });
currentProcess = proc;
@ -828,7 +849,12 @@ function downloadVODPart(
});
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) => {
@ -836,26 +862,36 @@ function downloadVODPart(
currentProcess = null;
if (currentDownloadCancelled) {
resolve(false);
appendDebugLog('download-part-cancelled', { itemId, filename });
resolve({ success: false, error: 'Download wurde abgebrochen.' });
return;
}
if (code === 0 && fs.existsSync(filename)) {
const stats = fs.statSync(filename);
if (stats.size > 1024 * 1024) {
resolve(true);
appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size });
resolve({ success: true });
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) => {
clearInterval(progressInterval);
console.error('Process error:', err);
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(
item: QueueItem,
onProgress: (progress: DownloadProgress) => void
): Promise<boolean> {
): Promise<DownloadResult> {
const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, '');
const date = new Date(item.date);
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 success = await downloadVODPart(
const result = await downloadVODPart(
item.url,
partFilename,
formatDuration(startOffset),
@ -918,11 +954,14 @@ async function downloadVOD(
numParts
);
if (!success) return false;
if (!result.success) return result;
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 {
// Single clip file
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 success = await downloadVODPart(
const result = await downloadVODPart(
item.url,
partFilename,
formatDuration(startSec),
@ -970,20 +1009,24 @@ async function downloadVOD(
numParts
);
if (!success) {
return false;
if (!result.success) {
return result;
}
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> {
if (isDownloading || downloadQueue.length === 0) return;
appendDebugLog('queue-start', { items: downloadQueue.length });
isDownloading = true;
mainWindow?.webContents.send('download-started');
mainWindow?.webContents.send('queue-updated', downloadQueue);
@ -992,17 +1035,27 @@ async function processQueue(): Promise<void> {
if (!isDownloading) break;
if (item.status === 'completed') continue;
appendDebugLog('queue-item-start', { itemId: item.id, title: item.title, url: item.url });
currentDownloadCancelled = false;
item.status = 'downloading';
saveQueue(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);
});
item.status = success ? 'completed' : 'error';
item.progress = success ? 100 : 0;
item.status = result.success ? 'completed' : 'error';
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);
mainWindow?.webContents.send('queue-updated', downloadQueue);
}
@ -1011,6 +1064,7 @@ async function processQueue(): Promise<void> {
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
mainWindow?.webContents.send('download-finished');
appendDebugLog('queue-finished', { items: downloadQueue.length });
}
// ==========================================

View File

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

View File

@ -19,6 +19,59 @@ async function clearCompleted(): Promise<void> {
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 {
if (!Array.isArray(queue)) {
queue = [];
@ -34,11 +87,30 @@ function renderQueue(): void {
list.innerHTML = queue.map((item: QueueItem) => {
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 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 `
<div class="queue-item">
<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="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>
</div>
`;

View File

@ -31,7 +31,15 @@ async function init(): Promise<void> {
return;
}
item.status = 'downloading';
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();
});

View File

@ -195,13 +195,13 @@ body {
}
.queue-list {
max-height: 150px;
max-height: 210px;
overflow-y: auto;
}
.queue-item {
display: flex;
align-items: center;
align-items: flex-start;
gap: 10px;
padding: 8px;
background: var(--bg-card);
@ -234,6 +234,63 @@ body {
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 {
cursor: pointer;
color: var(--error);