Launch v4.0.0 with reliability center and advanced queue controls

Ship a major update that adds in-app preflight diagnostics with health badge, live debug log tooling, retry management, pause/resume downloads, queue reordering support, locale polish (flags + clearer retry wording), and extensive backend hardening for day-to-day stability.
This commit is contained in:
xRangerDE 2026-02-14 06:07:36 +01:00
parent 551690d09c
commit 8f44211115
14 changed files with 171 additions and 37 deletions

View File

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

View File

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

View File

@ -125,12 +125,13 @@
<div class="queue-section">
<div class="queue-header">
<span class="queue-title" id="queueTitleText">Warteschlange</span>
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
<span class="queue-count" id="queueCount">0</span>
</div>
<div class="queue-list" id="queueList"></div>
<div class="queue-actions">
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()">Fehler neu</button>
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
</div>
</div>
@ -300,8 +301,8 @@
<div class="form-group">
<label id="languageLabel">Sprache</label>
<select id="languageSelect" onchange="changeLanguage(this.value)">
<option value="de" id="languageDeText">Deutsch</option>
<option value="en" id="languageEnText">Englisch</option>
<option value="de" id="languageDeText">🇩🇪 Deutsch</option>
<option value="en" id="languageEnText">🇺🇸 English</option>
</select>
</div>
</div>
@ -344,7 +345,7 @@
<div class="settings-card">
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.9.0</p>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.0.0</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
@ -376,7 +377,7 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v3.9.0</span>
<span id="versionText">v4.0.0</span>
</div>
</main>
</div>

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ==========================================
// CONFIG & CONSTANTS
// ==========================================
const APP_VERSION = '3.9.0';
const APP_VERSION = '4.0.0';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths
@ -71,7 +71,7 @@ interface QueueItem {
date: string;
streamer: string;
duration_str: string;
status: 'pending' | 'downloading' | 'completed' | 'error';
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
progress: number;
currentPart?: number;
totalParts?: number;
@ -190,6 +190,7 @@ let downloadQueue: QueueItem[] = loadQueue();
let isDownloading = false;
let currentProcess: ChildProcess | null = null;
let currentDownloadCancelled = false;
let pauseRequested = false;
let downloadStartTime = 0;
let downloadedBytes = 0;
const userIdLoginCache = new Map<string, string>();
@ -1440,12 +1441,13 @@ async function processQueue(): Promise<void> {
appendDebugLog('queue-start', { items: downloadQueue.length });
isDownloading = true;
pauseRequested = false;
mainWindow?.webContents.send('download-started');
mainWindow?.webContents.send('queue-updated', downloadQueue);
for (const item of downloadQueue) {
if (!isDownloading) break;
if (item.status === 'completed') continue;
if (!isDownloading || pauseRequested) break;
if (item.status === 'completed' || item.status === 'error' || item.status === 'paused') continue;
appendDebugLog('queue-item-start', { itemId: item.id, title: item.title, url: item.url });
@ -1472,8 +1474,8 @@ async function processQueue(): Promise<void> {
finalResult = result;
if (!isDownloading || currentDownloadCancelled) {
finalResult = { success: false, error: 'Download wurde abgebrochen.' };
if (!isDownloading || currentDownloadCancelled || pauseRequested) {
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
break;
}
@ -1494,9 +1496,10 @@ async function processQueue(): Promise<void> {
}
}
item.status = finalResult.success ? 'completed' : 'error';
item.progress = finalResult.success ? 100 : 0;
item.last_error = finalResult.success ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
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');
appendDebugLog('queue-item-finished', {
itemId: item.id,
status: item.status,
@ -1507,6 +1510,7 @@ async function processQueue(): Promise<void> {
}
isDownloading = false;
pauseRequested = false;
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
mainWindow?.webContents.send('download-finished');
@ -1659,6 +1663,20 @@ ipcMain.handle('clear-completed', () => {
return downloadQueue;
});
ipcMain.handle('reorder-queue', (_, orderIds: string[]) => {
const order = new Map(orderIds.map((id, idx) => [id, idx]));
const withOrder = [...downloadQueue].sort((a, b) => {
const ai = order.has(a.id) ? (order.get(a.id) as number) : Number.MAX_SAFE_INTEGER;
const bi = order.has(b.id) ? (order.get(b.id) as number) : Number.MAX_SAFE_INTEGER;
return ai - bi;
});
downloadQueue = withOrder;
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
return downloadQueue;
});
ipcMain.handle('retry-failed-downloads', () => {
downloadQueue = downloadQueue.map((item) => {
if (item.status !== 'error') return item;
@ -1682,18 +1700,35 @@ ipcMain.handle('retry-failed-downloads', () => {
});
ipcMain.handle('start-download', async () => {
const hasPendingItems = downloadQueue.some(item => item.status !== 'completed');
downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item);
const hasPendingItems = downloadQueue.some(item => item.status === 'pending');
if (!hasPendingItems) {
mainWindow?.webContents.send('queue-updated', downloadQueue);
return false;
}
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
processQueue();
return true;
});
ipcMain.handle('pause-download', () => {
if (!isDownloading) return false;
pauseRequested = true;
currentDownloadCancelled = true;
if (currentProcess) {
currentProcess.kill();
}
return true;
});
ipcMain.handle('cancel-download', () => {
isDownloading = false;
pauseRequested = false;
currentDownloadCancelled = true;
if (currentProcess) {
currentProcess.kill();

View File

@ -15,7 +15,7 @@ interface QueueItem {
date: string;
streamer: string;
duration_str: string;
status: 'pending' | 'downloading' | 'completed' | 'error';
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
progress: number;
currentPart?: number;
totalParts?: number;
@ -60,11 +60,13 @@ contextBridge.exposeInMainWorld('api', {
getQueue: () => ipcRenderer.invoke('get-queue'),
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item),
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
clearCompleted: () => ipcRenderer.invoke('clear-completed'),
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
// Download
startDownload: () => ipcRenderer.invoke('start-download'),
pauseDownload: () => ipcRenderer.invoke('pause-download'),
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
isDownloading: () => ipcRenderer.invoke('is-downloading'),
downloadClip: (url: string) => ipcRenderer.invoke('download-clip', url),

View File

@ -35,7 +35,7 @@ interface QueueItem {
date: string;
streamer: string;
duration_str: string;
status: 'pending' | 'downloading' | 'completed' | 'error';
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
progress: number;
currentPart?: number;
totalParts?: number;
@ -112,9 +112,11 @@ interface ApiBridge {
getQueue(): Promise<QueueItem[]>;
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
removeFromQueue(id: string): Promise<QueueItem[]>;
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
clearCompleted(): Promise<QueueItem[]>;
retryFailedDownloads(): Promise<QueueItem[]>;
startDownload(): Promise<boolean>;
pauseDownload(): Promise<boolean>;
cancelDownload(): Promise<boolean>;
isDownloading(): Promise<boolean>;
downloadClip(url: string): Promise<{ success: boolean; error?: string }>;

View File

@ -7,7 +7,12 @@ const UI_TEXT_DE = {
navMerge: 'Videos zusammenfugen',
navSettings: 'Einstellungen',
queueTitle: 'Warteschlange',
retryFailed: 'Fehler neu',
retryFailed: 'Wiederholen',
retryFailedHint: 'Nur fehlgeschlagene Downloads erneut starten',
healthUnknown: 'System: Unbekannt',
healthGood: 'System: Stabil',
healthWarn: 'System: Warnung',
healthBad: 'System: Problem',
clearQueue: 'Leeren',
refresh: 'Aktualisieren',
streamerPlaceholder: 'Streamer hinzufugen...',
@ -22,8 +27,8 @@ const UI_TEXT_DE = {
designTitle: 'Design',
themeLabel: 'Theme',
languageLabel: 'Sprache',
languageDe: 'DE - Deutsch',
languageEn: 'EN - Englisch',
languageDe: '🇩🇪 Deutsch',
languageEn: '🇺🇸 Englisch',
apiTitle: 'Twitch API',
clientIdLabel: 'Client ID',
clientSecretLabel: 'Client Secret',
@ -41,6 +46,14 @@ const UI_TEXT_DE = {
preflightRun: 'Check ausfuhren',
preflightFix: 'Auto-Fix Tools',
preflightEmpty: 'Noch kein Check ausgefuhrt.',
preflightChecking: 'Prufe...',
preflightFixing: 'Fixe...',
preflightReady: 'Alles bereit.',
preflightInternet: 'Internet',
preflightStreamlink: 'Streamlink',
preflightFfmpeg: 'FFmpeg',
preflightFfprobe: 'FFprobe',
preflightPath: 'Download-Pfad',
debugLogTitle: 'Live Debug-Log',
refreshLog: 'Aktualisieren',
autoRefresh: 'Auto-Refresh',
@ -62,10 +75,12 @@ const UI_TEXT_DE = {
queue: {
empty: 'Keine Downloads in der Warteschlange',
start: 'Start',
stop: 'Stoppen',
stop: 'Pausieren',
resume: 'Fortsetzen',
statusDone: 'Abgeschlossen',
statusFailed: 'Fehlgeschlagen',
statusRunning: 'Laeuft',
statusPaused: 'Pausiert',
statusWaiting: 'Wartet',
progressError: 'Fehler',
progressReady: 'Bereit',

View File

@ -7,7 +7,12 @@ const UI_TEXT_EN = {
navMerge: 'Merge Videos',
navSettings: 'Settings',
queueTitle: 'Queue',
retryFailed: 'Retry failed',
retryFailed: 'Retry',
retryFailedHint: 'Retry failed downloads only',
healthUnknown: 'System: Unknown',
healthGood: 'System: Stable',
healthWarn: 'System: Warning',
healthBad: 'System: Problem',
clearQueue: 'Clear',
refresh: 'Refresh',
streamerPlaceholder: 'Add streamer...',
@ -22,8 +27,8 @@ const UI_TEXT_EN = {
designTitle: 'Design',
themeLabel: 'Theme',
languageLabel: 'Language',
languageDe: 'DE - German',
languageEn: 'EN - English',
languageDe: '🇩🇪 German',
languageEn: '🇺🇸 English',
apiTitle: 'Twitch API',
clientIdLabel: 'Client ID',
clientSecretLabel: 'Client Secret',
@ -41,6 +46,14 @@ const UI_TEXT_EN = {
preflightRun: 'Run check',
preflightFix: 'Auto-fix tools',
preflightEmpty: 'No checks run yet.',
preflightChecking: 'Checking...',
preflightFixing: 'Fixing...',
preflightReady: 'Everything is ready.',
preflightInternet: 'Internet',
preflightStreamlink: 'Streamlink',
preflightFfmpeg: 'FFmpeg',
preflightFfprobe: 'FFprobe',
preflightPath: 'Download path',
debugLogTitle: 'Live Debug Log',
refreshLog: 'Refresh',
autoRefresh: 'Auto refresh',
@ -62,10 +75,12 @@ const UI_TEXT_EN = {
queue: {
empty: 'No downloads in queue',
start: 'Start',
stop: 'Stop',
stop: 'Pause',
resume: 'Resume',
statusDone: 'Completed',
statusFailed: 'Failed',
statusRunning: 'Running',
statusPaused: 'Paused',
statusWaiting: 'Waiting',
progressError: 'Error',
progressReady: 'Ready',

View File

@ -27,6 +27,7 @@ async function retryFailedDownloads(): Promise<void> {
function getQueueStatusLabel(item: QueueItem): string {
if (item.status === 'completed') return UI_TEXT.queue.statusDone;
if (item.status === 'error') return UI_TEXT.queue.statusFailed;
if (item.status === 'paused') return UI_TEXT.queue.statusPaused;
if (item.status === 'downloading') return UI_TEXT.queue.statusRunning;
return UI_TEXT.queue.statusWaiting;
}
@ -34,6 +35,7 @@ function getQueueStatusLabel(item: QueueItem): string {
function getQueueProgressText(item: QueueItem): string {
if (item.status === 'completed') return '100%';
if (item.status === 'error') return UI_TEXT.queue.progressError;
if (item.status === 'paused') return UI_TEXT.queue.progressReady;
if (item.status === 'pending') return UI_TEXT.queue.progressReady;
if (item.progress > 0) return `${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`;
return item.progressStatus || UI_TEXT.queue.progressLoading;
@ -62,6 +64,10 @@ function getQueueMetaText(item: QueueItem): string {
parts.push(UI_TEXT.queue.readyToDownload);
}
if (!parts.length && item.status === 'paused') {
parts.push(UI_TEXT.queue.statusPaused);
}
if (!parts.length && item.status === 'downloading') {
parts.push(item.progressStatus || UI_TEXT.queue.started);
}
@ -84,6 +90,9 @@ function renderQueue(): void {
const list = byId('queueList');
byId('queueCount').textContent = String(queue.length);
const retryBtn = byId<HTMLButtonElement>('btnRetryFailed');
const hasFailed = queue.some((item) => item.status === 'error');
retryBtn.disabled = !hasFailed;
if (queue.length === 0) {
list.innerHTML = `<div style="color: var(--text-secondary); font-size: 12px; text-align: center; padding: 15px;">${UI_TEXT.queue.empty}</div>`;
@ -124,7 +133,7 @@ function renderQueue(): void {
async function toggleDownload(): Promise<void> {
if (downloading) {
await window.api.cancelDownload();
await window.api.pauseDownload();
return;
}

View File

@ -42,24 +42,42 @@ function changeLanguage(lang: string): void {
function renderPreflightResult(result: PreflightResult): void {
const entries = [
['Internet', result.checks.internet],
['Streamlink', result.checks.streamlink],
['FFmpeg', result.checks.ffmpeg],
['FFprobe', result.checks.ffprobe],
['Download-Pfad', result.checks.downloadPathWritable]
[UI_TEXT.static.preflightInternet, result.checks.internet],
[UI_TEXT.static.preflightStreamlink, result.checks.streamlink],
[UI_TEXT.static.preflightFfmpeg, result.checks.ffmpeg],
[UI_TEXT.static.preflightFfprobe, result.checks.ffprobe],
[UI_TEXT.static.preflightPath, result.checks.downloadPathWritable]
];
const lines = entries.map(([name, ok]) => `${ok ? 'OK' : 'FAIL'} ${name}`).join('\n');
const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : '\n\nAlles bereit.';
const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : `\n\n${UI_TEXT.static.preflightReady}`;
byId('preflightResult').textContent = `${lines}${extra}`;
const badge = byId('healthBadge');
badge.classList.remove('good', 'warn', 'bad', 'unknown');
if (result.ok) {
badge.classList.add('good');
badge.textContent = UI_TEXT.static.healthGood;
return;
}
const failCount = Object.values(result.checks).filter((ok) => !ok).length;
if (failCount <= 2) {
badge.classList.add('warn');
badge.textContent = UI_TEXT.static.healthWarn;
} else {
badge.classList.add('bad');
badge.textContent = UI_TEXT.static.healthBad;
}
}
async function runPreflight(autoFix = false): Promise<void> {
const btn = byId<HTMLButtonElement>(autoFix ? 'btnPreflightFix' : 'btnPreflightRun');
const old = btn.textContent || '';
btn.disabled = true;
btn.textContent = autoFix ? 'Fixe...' : 'Prufe...';
btn.textContent = autoFix ? UI_TEXT.static.preflightFixing : UI_TEXT.static.preflightChecking;
try {
const result = await window.api.runPreflight(autoFix);

View File

@ -39,3 +39,4 @@ let clipTotalSeconds = 0;
let updateReady = false;
let debugLogAutoRefreshTimer: number | null = null;
let draggedQueueItemId: string | null = null;

View File

@ -31,6 +31,11 @@ function setPlaceholder(id: string, value: string): void {
if (node) node.placeholder = value;
}
function setTitle(id: string, value: string): void {
const node = document.getElementById(id);
if (node) node.setAttribute('title', value);
}
function setLanguage(lang: string): LanguageCode {
currentLanguage = lang === 'en' ? 'en' : 'de';
UI_TEXT = UI_TEXTS[currentLanguage];
@ -46,7 +51,9 @@ function applyLanguageToStaticUI(): void {
setText('navMergeText', UI_TEXT.static.navMerge);
setText('navSettingsText', UI_TEXT.static.navSettings);
setText('queueTitleText', UI_TEXT.static.queueTitle);
setText('healthBadge', UI_TEXT.static.healthUnknown);
setText('btnRetryFailed', UI_TEXT.static.retryFailed);
setTitle('btnRetryFailed', UI_TEXT.static.retryFailedHint);
setText('btnClear', UI_TEXT.static.clearQueue);
setText('refreshText', UI_TEXT.static.refresh);
setText('clipsHeading', UI_TEXT.static.clipsHeading);

View File

@ -117,7 +117,8 @@ function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
function updateDownloadButtonState(): void {
const btn = byId('btnStart');
btn.textContent = downloading ? UI_TEXT.queue.stop : UI_TEXT.queue.start;
const hasPaused = queue.some((item) => item.status === 'paused');
btn.textContent = downloading ? UI_TEXT.queue.stop : (hasPaused ? UI_TEXT.queue.resume : UI_TEXT.queue.start);
btn.classList.toggle('downloading', downloading);
}

View File

@ -179,6 +179,7 @@ body {
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
gap: 8px;
}
.queue-title {
@ -199,6 +200,33 @@ body {
overflow-y: auto;
}
.health-badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid transparent;
white-space: nowrap;
}
.health-badge.good {
background: rgba(0, 200, 83, 0.2);
border-color: rgba(0, 200, 83, 0.45);
color: #93efb9;
}
.health-badge.warn {
background: rgba(255, 171, 0, 0.2);
border-color: rgba(255, 171, 0, 0.45);
color: #ffd98e;
}
.health-badge.bad,
.health-badge.unknown {
background: rgba(255, 68, 68, 0.2);
border-color: rgba(255, 68, 68, 0.45);
color: #ffaaaa;
}
.queue-item {
display: flex;
align-items: flex-start;