fix(queue): honor visible order and keep transfer metrics on one line
Windows CI / verify (push) Canceled after 0s

This commit is contained in:
Sucukdeluxe
2026-09-07 03:04:12 +02:00
parent 3ec56c45e4
commit 6d721d88be
17 changed files with 124 additions and 65 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## Unreleased
- Start pending downloads in the visible queue order, including manually reordered entries. Retire the automatic short-job prioritization, including for existing configurations.
- Keep transfer size, percentage, speed and remaining time on one compact line below 1 GB as well. Preserve wrapping for error messages and full values in tooltips.
## 1.0.22 - 2026-09-06 ## 1.0.22 - 2026-09-06
- Show small round profile pictures beside streamer names in the sidebar, with an initial fallback when an image is unavailable. - Show small round profile pictures beside streamer names in the sidebar, with an initial fallback when an image is unavailable.
+11
View File
@@ -8,6 +8,8 @@ Am 6. September 2026 nach einem Festplatten-Reset aus den vorhandenen Remote-Rep
Aktueller veröffentlichter Stand: **1.0.22**, Tag `v1.0.22`, veröffentlicht am 6. September 2026 auf GitHub und Forgejo. Der Tag zeigt auf `a526bd3541fd1d909dae0060ba92d64347668099`; nachfolgende Memory-Commits gehören nicht zum Installer. Aktueller veröffentlichter Stand: **1.0.22**, Tag `v1.0.22`, veröffentlicht am 6. September 2026 auf GitHub und Forgejo. Der Tag zeigt auf `a526bd3541fd1d909dae0060ba92d64347668099`; nachfolgende Memory-Commits gehören nicht zum Installer.
Unveröffentlichte Änderungen vom 7. September 2026: Queue startet wartende Downloads strikt in der sichtbaren Reihenfolge; kompakte, einzeilige Fortschrittsanzeige auch unter 1 GB.
## Git und maßgeblicher Stand ## Git und maßgeblicher Stand
- Arbeitsbranch: `public-v1`, Tracking: `origin/public-v1`. - Arbeitsbranch: `public-v1`, Tracking: `origin/public-v1`.
@@ -21,6 +23,8 @@ Aktueller veröffentlichter Stand: **1.0.22**, Tag `v1.0.22`, veröffentlicht am
## Letzte Änderungen ## Letzte Änderungen
- Release 1.0.22 abgeschlossen: runde Profilbilder in der Streamerliste; auf GitHub und Forgejo mit identischen Assets sowie englischem/deutschem Changelog veröffentlicht und geprüft. - Release 1.0.22 abgeschlossen: runde Profilbilder in der Streamerliste; auf GitHub und Forgejo mit identischen Assets sowie englischem/deutschem Changelog veröffentlicht und geprüft.
- 7. September 2026: Automatische Bevorzugung kurzer Downloads/Clips entfernt, damit die Queue von oben nach unten startet und manuelles Umsortieren respektiert. Die frühere Scheduler-Einstellung ist aus der Oberfläche entfernt; vorhandene Konfigurationen werden auf deaktiviert normalisiert, das alte Feld bleibt kompatibel lesbar. Parallele Downloads werden weiterhin in sichtbarer Reihenfolge gestartet.
- 7. September 2026: Transferzeile zeigt die Datenmenge ohne redundantes „heruntergeladen“, daneben Prozent, Tempo und Restzeit in 11 px ohne Zeilenumbruch. Vollständige Angaben bleiben im Tooltip, Fehlermeldungen weiterhin mehrzeilig in 12 px. 500 MB, 1003 MB und 1,35 GB in beiden Sprachen und Themes ohne abgeschnittene Werte gemessen.
- Linke Streamerliste zeigt runde Profilbilder in 24 × 24 px direkt vor dem Namen. Verwendet den bestehenden Profil-Cache und dessen Vorladen/Hintergrundaktualisierung ohne zusätzliche Profilanfragen. Bilder werden in der vorhandenen Zeile nachgeladen und erhalten den Tastaturfokus; fehlende oder defekte Bilder zeigen den Anfangsbuchstaben. Bildklick wählt den Streamer, lange Namen werden einzeilig gekürzt und bleiben im Tooltip lesbar. Build, 37 gezielte Tests und vollständige Workspace-UI-Prüfung inklusive Laden, Fallback, Auswahl und Hell/Dunkel erfolgreich; Lint ohne Fehler bei 15 bestehenden Warnungen. Screenshots unter `tmp_streamer-avatar-artifacts/`. Mit 1.0.22 veröffentlicht, auch im Hot-Dev aktiv. - Linke Streamerliste zeigt runde Profilbilder in 24 × 24 px direkt vor dem Namen. Verwendet den bestehenden Profil-Cache und dessen Vorladen/Hintergrundaktualisierung ohne zusätzliche Profilanfragen. Bilder werden in der vorhandenen Zeile nachgeladen und erhalten den Tastaturfokus; fehlende oder defekte Bilder zeigen den Anfangsbuchstaben. Bildklick wählt den Streamer, lange Namen werden einzeilig gekürzt und bleiben im Tooltip lesbar. Build, 37 gezielte Tests und vollständige Workspace-UI-Prüfung inklusive Laden, Fallback, Auswahl und Hell/Dunkel erfolgreich; Lint ohne Fehler bei 15 bestehenden Warnungen. Screenshots unter `tmp_streamer-avatar-artifacts/`. Mit 1.0.22 veröffentlicht, auch im Hot-Dev aktiv.
- Release 1.0.21 abgeschlossen: alle Queue-Nachbesserungen seit 1.0.20 enthalten; auf GitHub und Forgejo mit identischen Assets sowie englischem/deutschem Changelog veröffentlicht und geprüft. - Release 1.0.21 abgeschlossen: alle Queue-Nachbesserungen seit 1.0.20 enthalten; auf GitHub und Forgejo mit identischen Assets sowie englischem/deutschem Changelog veröffentlicht und geprüft.
- Streamer in Queue-Details verwendet jetzt den aufgelösten Twitch-Anzeigenamen mit originaler Groß-/Kleinschreibung, etwa `xRohat` statt `xrohat`. Gespeicherter Login bleibt unverändert; noch unbekannte Anzeigenamen fallen auf den Login zurück. Nachgeladene Profilnamen aktualisieren vorhandene Queue-Cards sofort und werden bei der Render-Erkennung berücksichtigt. Build, 43 gezielte Tests, Queue-UI-Test einschließlich nachgeladener Groß-/Kleinschreibung und Lint erfolgreich (15 bestehende Warnungen). Mit 1.0.21 veröffentlicht. - Streamer in Queue-Details verwendet jetzt den aufgelösten Twitch-Anzeigenamen mit originaler Groß-/Kleinschreibung, etwa `xRohat` statt `xrohat`. Gespeicherter Login bleibt unverändert; noch unbekannte Anzeigenamen fallen auf den Login zurück. Nachgeladene Profilnamen aktualisieren vorhandene Queue-Cards sofort und werden bei der Render-Erkennung berücksichtigt. Build, 43 gezielte Tests, Queue-UI-Test einschließlich nachgeladener Groß-/Kleinschreibung und Lint erfolgreich (15 bestehende Warnungen). Mit 1.0.21 veröffentlicht.
@@ -145,3 +149,10 @@ Abschließende Verifikation von Release 1.0.22 am 6. September 2026:
- Alle Assets von beiden Plattformen heruntergeladen und bytegenau per SHA-256/SHA-512 gegen den lokalen Build geprüft. Nach Veröffentlichung öffentliche Asset-URLs, Latest-Release und Update-Metadaten beider Plattformen einschließlich GitHubs Update-Kanal erfolgreich verifiziert. - Alle Assets von beiden Plattformen heruntergeladen und bytegenau per SHA-256/SHA-512 gegen den lokalen Build geprüft. Nach Veröffentlichung öffentliche Asset-URLs, Latest-Release und Update-Metadaten beider Plattformen einschließlich GitHubs Update-Kanal erfolgreich verifiziert.
- Installer: 110.441.936 Bytes, SHA-256 `bbfc1ab5ac2e7e7d0d5b5e86c7c757ab2c3e03e854a4e0a830156283af2131ba`. Blockmap: 117.025 Bytes. Update-Metadaten: 368 Bytes mit passender Version, Dateigröße und SHA-512-Prüfsumme. - Installer: 110.441.936 Bytes, SHA-256 `bbfc1ab5ac2e7e7d0d5b5e86c7c757ab2c3e03e854a4e0a830156283af2131ba`. Blockmap: 117.025 Bytes. Update-Metadaten: 368 Bytes mit passender Version, Dateigröße und SHA-512-Prüfsumme.
- Keine produktiven Datenbankänderungen und keine vollständige Live-Update-Installation einer alten Version auf diesem Rechner. Hot-Dev bleibt aktiv. Ignorierte Prüfdateien liegen in `tmp_release_1.0.22/`, Testprotokolle im Windows-Temp-Verzeichnis. - Keine produktiven Datenbankänderungen und keine vollständige Live-Update-Installation einer alten Version auf diesem Rechner. Hot-Dev bleibt aktiv. Ignorierte Prüfdateien liegen in `tmp_release_1.0.22/`, Testprotokolle im Windows-Temp-Verzeichnis.
7. September 2026, Queue-Reihenfolge und Transferzeile (noch nicht veröffentlicht):
- Build und alle 635 Unit-Tests in 83 Testdateien erfolgreich. Neuer Produktionstest prüft die sichtbare Startreihenfolge trotz früher aktivierter Priorisierung, manuelles Umsortieren und das Überspringen nicht wartender Einträge.
- Queue-UI-Prüfung in Deutsch/Englisch und Hell/Dunkel einschließlich einzeiliger Mengen-/Tempo-/Restzeitanzeige erfolgreich; Screenshots visuell geprüft. Der Animationstest liest Transitions nun direkt nach dem Umschalten aus, damit langsame Frames die kurze Animation nicht vor der Messung beenden.
- Vollständiger Workspace-UI-Test und isolierter Einstellungen-Autosave-Test erfolgreich, keine gemeldeten Laufzeitfehler. Die Checkbox-Layoutprüfung verwendet nach Entfernen der Scheduler-Option den vorhandenen Duplikatschutz.
- Lint ohne Fehler bei 15 bestehenden Warnungen; Security-/Public-Manifest-Prüfung erfolgreich. Keine Live-Downloads oder produktiven Datenbankänderungen für die Tests. Release-Version bleibt 1.0.22; Änderungen auf public-v1 für die nächste Veröffentlichung vorbereitet.
+23 -2
View File
@@ -79,6 +79,29 @@ async function main() {
assert(card.overflow <= 1, `Card overflow: ${JSON.stringify(card)}`); assert(card.overflow <= 1, `Card overflow: ${JSON.stringify(card)}`);
} }
await win.locator('.queue-section').screenshot({ path: path.join(artifacts, `queue-${theme}-${language}.png`) }); await win.locator('.queue-section').screenshot({ path: path.join(artifacts, `queue-${theme}-${language}.png`) });
for (const size of ['500.0 MB', '1003.0 MB', '1.35 GB']) {
const transfer = await win.evaluate(({ size, language }) => {
const item = queue.find((candidate) => candidate.id === 'running');
item.progress = 32.2;
item.progressStatus = `${size} ${language === 'de' ? 'heruntergeladen' : 'downloaded'}`;
item.speed = '23.1 MB/s';
item.eta = '12m 34s';
updateQueueItemProgress({ id: item.id });
const row = document.querySelector('[data-id="running"] .queue-progress-info');
const status = row.querySelector('.queue-progress-status');
const metrics = row.querySelector('.queue-progress-metrics');
return {
height: row.getBoundingClientRect().height,
sameLine: status.getBoundingClientRect().top === metrics.getBoundingClientRect().top,
statusVisible: status.scrollWidth <= status.clientWidth,
metricsVisible: metrics.scrollWidth <= metrics.clientWidth,
status: status.textContent,
tooltip: status.title,
};
}, { size, language });
assert(transfer.height <= 18 && transfer.sameLine && transfer.statusVisible && transfer.metricsVisible && transfer.status === size && transfer.tooltip.includes(size), `Transfer line (${theme}/${language}): ${JSON.stringify(transfer)}`);
}
await win.locator('[data-id="running"]').screenshot({ path: path.join(artifacts, `transfer-${theme}-${language}.png`) });
const expansionLayout = await win.locator('#queueList').evaluate(async (list) => { const expansionLayout = await win.locator('#queueList').evaluate(async (list) => {
const items = [...list.querySelectorAll('.queue-item')]; const items = [...list.querySelectorAll('.queue-item')];
const originalStyles = [list, ...items].map((element) => element.getAttribute('style')); const originalStyles = [list, ...items].map((element) => element.getAttribute('style'));
@@ -139,8 +162,6 @@ async function main() {
const details = item.querySelector('.queue-details'); const details = item.querySelector('.queue-details');
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const sample = async () => { const sample = async () => {
await frame();
await frame();
const animations = details.getAnimations(); const animations = details.getAnimations();
const slide = animations.find((animation) => animation.transitionProperty === 'grid-template-rows'); const slide = animations.find((animation) => animation.transitionProperty === 'grid-template-rows');
if (!slide) throw new Error('Queue details have no height transition'); if (!slide) throw new Error('Queue details have no height transition');
+2 -2
View File
@@ -979,14 +979,14 @@ async function run() {
window.setSettingsPane('downloads'); window.setSettingsPane('downloads');
window.changeLanguage('de'); window.changeLanguage('de');
updateStatus(UI_TEXT.status.noLogin, false, 'public'); updateStatus(UI_TEXT.status.noLogin, false, 'public');
document.getElementById('smartSchedulerToggle').checked = true; document.getElementById('duplicatePreventionToggle').checked = true;
}); });
await win.waitForTimeout(240); await win.waitForTimeout(240);
const downloadSettingsWide = await win.evaluate(() => { const downloadSettingsWide = await win.evaluate(() => {
const tab = document.getElementById('settingsTab'); const tab = document.getElementById('settingsTab');
const card = tab?.querySelector('.settings-card[data-settings-pane="downloads"]'); const card = tab?.querySelector('.settings-card[data-settings-pane="downloads"]');
const layout = card?.querySelector('.download-settings-layout'); const layout = card?.querySelector('.download-settings-layout');
const checkbox = card?.querySelector('#smartSchedulerToggle'); const checkbox = card?.querySelector('#duplicatePreventionToggle');
const label = checkbox?.closest('.toggle-row')?.querySelector('span'); const label = checkbox?.closest('.toggle-row')?.querySelector('span');
const dot = document.getElementById('statusDot'); const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText'); const text = document.getElementById('statusText');
-1
View File
@@ -900,7 +900,6 @@
<section class="download-settings-section"> <section class="download-settings-section">
<h4 id="queueAutomationTitle">Queue und Automatisierung</h4> <h4 id="queueAutomationTitle">Queue und Automatisierung</h4>
<div class="download-settings-toggles"> <div class="download-settings-toggles">
<label class="toggle-row"><input type="checkbox" id="smartSchedulerToggle" checked><span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span></label>
<label class="toggle-row"><input type="checkbox" id="duplicatePreventionToggle" checked><span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span></label> <label class="toggle-row"><input type="checkbox" id="duplicatePreventionToggle" checked><span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span></label>
<label class="toggle-row"><input type="checkbox" id="persistQueueToggle" checked><span id="persistQueueLabel">Queue zwischen App-Starts speichern</span></label> <label class="toggle-row"><input type="checkbox" id="persistQueueToggle" checked><span id="persistQueueLabel">Queue zwischen App-Starts speichern</span></label>
<label class="toggle-row"><input type="checkbox" id="autoResumeQueueToggle"><span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span></label> <label class="toggle-row"><input type="checkbox" id="autoResumeQueueToggle"><span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span></label>
+6 -38
View File
@@ -447,7 +447,7 @@ const defaultConfig: Config = {
filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD, filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD,
filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS, filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS,
filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP, filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP,
smart_queue_scheduler: true, smart_queue_scheduler: false,
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,
@@ -521,7 +521,7 @@ function normalizeConfigTemplates(input: Config): Config {
filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS), filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP), filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP),
sidebar_split_view: input.sidebar_split_view !== false, sidebar_split_view: input.sidebar_split_view !== false,
smart_queue_scheduler: input.smart_queue_scheduler !== false, smart_queue_scheduler: false,
performance_mode: normalizePerformanceMode(input.performance_mode), performance_mode: normalizePerformanceMode(input.performance_mode),
prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false, prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false,
persist_queue_on_restart: input.persist_queue_on_restart !== false, persist_queue_on_restart: input.persist_queue_on_restart !== false,
@@ -1786,7 +1786,7 @@ function getRuntimeMetricsSnapshot(): RuntimeMetricsSnapshot {
}, },
config: { config: {
performanceMode: normalizePerformanceMode(config.performance_mode), performanceMode: normalizePerformanceMode(config.performance_mode),
smartScheduler: config.smart_queue_scheduler !== false, smartScheduler: false,
metadataCacheMinutes: normalizeMetadataCacheMinutes(config.metadata_cache_minutes), metadataCacheMinutes: normalizeMetadataCacheMinutes(config.metadata_cache_minutes),
duplicatePrevention: config.prevent_duplicate_downloads !== false duplicatePrevention: config.prevent_duplicate_downloads !== false
} }
@@ -1810,39 +1810,8 @@ function hasActiveDuplicate(candidate: Pick<QueueItem, 'url' | 'streamer' | 'dat
}); });
} }
function getQueuePriorityScore(item: QueueItem): number {
const now = Date.now();
const createdMs = getQueueCreatedAtMs(item, now);
const waitSeconds = Math.max(0, Math.floor((now - createdMs) / 1000));
const durationSeconds = Math.max(0, parseDuration(item.duration_str || '0s'));
const clipBoost = item.customClip ? 1500 : 0;
const shortJobBoost = Math.max(0, 7200 - Math.min(7200, durationSeconds)) / 5;
const ageBoost = Math.min(waitSeconds, 1800) / 2;
return clipBoost + shortJobBoost + ageBoost;
}
function pickNextPendingQueueItem(): QueueItem | null { function pickNextPendingQueueItem(): QueueItem | null {
const pendingItems = downloadQueue.filter((item) => item.status === 'pending'); return downloadQueue.find((item) => item.status === 'pending') || null;
if (!pendingItems.length) return null;
if (!config.smart_queue_scheduler) {
return pendingItems[0];
}
let best = pendingItems[0];
let bestScore = getQueuePriorityScore(best);
for (let i = 1; i < pendingItems.length; i += 1) {
const candidate = pendingItems[i];
const score = getQueuePriorityScore(candidate);
if (score > bestScore) {
best = candidate;
bestScore = score;
}
}
return best;
} }
function parseClockDurationSeconds(duration: string | null): number | null { function parseClockDurationSeconds(duration: string | null): number | null {
@@ -6824,8 +6793,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
appendDebugLog('queue-item-start', { appendDebugLog('queue-item-start', {
itemId: item.id, itemId: item.id,
title: item.title, title: item.title,
url: item.url, url: item.url
smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0
}); });
runtimeMetrics.downloadsStarted += 1; runtimeMetrics.downloadsStarted += 1;
@@ -7107,7 +7075,7 @@ async function processQueue(manualOverride = false): Promise<void> {
appendDebugLog('queue-start', { appendDebugLog('queue-start', {
items: downloadQueue.length, items: downloadQueue.length,
smartScheduler: config.smart_queue_scheduler, scheduling: 'queue-order',
performanceMode: config.performance_mode, performanceMode: config.performance_mode,
parallelDownloads: config.parallel_downloads || 1 parallelDownloads: config.parallel_downloads || 1
}); });
@@ -1,8 +1,36 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
describe('queue addition IPC contract', () => { describe('queue addition IPC contract', () => {
it('starts pending downloads in visible order even with a legacy smart scheduler setting', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
const start = source.indexOf('function pickNextPendingQueueItem');
const end = source.indexOf('function parseClockDurationSeconds', start);
const context = {
config: { smart_queue_scheduler: true },
downloadQueue: [
{ id: 'finished', status: 'completed' },
{ id: 'long-first', status: 'pending', duration_str: '10h', createdAt: '2026-09-07T10:00:00Z' },
{ id: 'short-second', status: 'pending', duration_str: '1m', customClip: { durationSec: 60 }, createdAt: '2026-09-07T10:01:00Z' },
],
};
const code = transpileModule(`${source.slice(start, end)}\nglobalThis.pickNext = pickNextPendingQueueItem;`, {
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 },
}).outputText;
const runtime = runInNewContext(`${code}\nglobalThis`, context);
expect(runtime.pickNext().id).toBe('long-first');
context.downloadQueue[1].status = 'downloading';
expect(runtime.pickNext().id).toBe('short-second');
context.downloadQueue[1].status = 'pending';
context.downloadQueue.reverse();
expect(runtime.pickNext().id).toBe('short-second');
context.downloadQueue.forEach((item) => { item.status = 'paused'; });
expect(runtime.pickNext()).toBeNull();
});
it('keeps the legacy queue result and exposes the atomic accepted result separately', () => { it('keeps the legacy queue result and exposes the atomic accepted result separately', () => {
const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
-2
View File
@@ -88,8 +88,6 @@ const UI_TEXT_DE = {
performanceModeStability: 'Max Stabilität', performanceModeStability: 'Max Stabilität',
performanceModeBalanced: 'Ausgewogen', performanceModeBalanced: 'Ausgewogen',
performanceModeSpeed: 'Max Geschwindigkeit', performanceModeSpeed: 'Max Geschwindigkeit',
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
smartSchedulerHint: 'Bevorzugt kürzere VODs und ältere Queue-Einträge zuerst, damit der Durchsatz gleichmäßig bleibt. Deaktivieren = strikte Einfügereihenfolge.',
sidebarSplitViewLabel: 'Streamer und Queue gleichzeitig anzeigen', sidebarSplitViewLabel: 'Streamer und Queue gleichzeitig anzeigen',
sidebarSplitViewHint: 'Teilt die linke Seitenleiste gleichmäßig auf. Deaktiviert erscheinen wieder die Umschaltflächen.', sidebarSplitViewHint: 'Teilt die linke Seitenleiste gleichmäßig auf. Deaktiviert erscheinen wieder die Umschaltflächen.',
streamerInvalid: 'Twitch-Username ungültig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).', streamerInvalid: 'Twitch-Username ungültig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).',
-2
View File
@@ -88,8 +88,6 @@ const UI_TEXT_EN = {
performanceModeStability: 'Max Stability', performanceModeStability: 'Max Stability',
performanceModeBalanced: 'Balanced', performanceModeBalanced: 'Balanced',
performanceModeSpeed: 'Max Speed', performanceModeSpeed: 'Max Speed',
smartSchedulerLabel: 'Enable smart queue scheduler',
smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.',
sidebarSplitViewLabel: 'Show streamers and queue together', sidebarSplitViewLabel: 'Show streamers and queue together',
sidebarSplitViewHint: 'Splits the left sidebar evenly. Disable it to restore the Streamer and Queue switches.', sidebarSplitViewHint: 'Splits the left sidebar evenly. Disable it to restore the Streamer and Queue switches.',
streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).', streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).',
@@ -413,6 +413,17 @@ describe('renderer queue production paths', () => {
expect(metrics({ status: 'completed', progress: 100, speed: '4 MB/s', eta: '2m' })).toBe('100%'); expect(metrics({ status: 'completed', progress: 100, speed: '4 MB/s', eta: '2m' })).toBe('100%');
}); });
it('keeps transfer sizes compact while preserving non-transfer status messages', () => {
const runtime = evaluate(fragment('function getQueueProgressStatusText', 'function getQueueProgressMetricsText'), 'getQueueProgressStatusText', { UI_TEXT: { queue: queueText } });
for (const size of ['500.0 MB', '1003.0 MB', '1.35 GB']) {
for (const suffix of ['downloaded', 'heruntergeladen']) {
expect(runtime.exposed.getQueueProgressStatusText({ status: 'downloading', progressStatus: `${size} ${suffix}` })).toBe(size);
}
}
expect(runtime.exposed.getQueueProgressStatusText({ status: 'downloading', progressStatus: 'Preparing download tools' })).toBe('Preparing download tools');
expect(runtime.exposed.getQueueProgressStatusText({ status: 'error', last_error: 'Connection failed. Please retry.' })).toBe('Connection failed. Please retry.');
});
it('keeps monotonic progress while treating explicit empty telemetry as an authoritative reset', () => { it('keeps monotonic progress while treating explicit empty telemetry as an authoritative reset', () => {
const mergePath = rendererSource.slice( const mergePath = rendererSource.slice(
rendererSource.indexOf('function mergeQueueState'), rendererSource.indexOf('function mergeQueueState'),
+14 -5
View File
@@ -458,7 +458,10 @@ function getQueueProgressStatusText(item: QueueItem): string {
if (item.status === 'paused') return UI_TEXT.queue.statusPaused; if (item.status === 'paused') return UI_TEXT.queue.statusPaused;
if (item.status === 'completed') return UI_TEXT.queue.done; if (item.status === 'completed') return UI_TEXT.queue.done;
if (item.status === 'error') return UI_TEXT.queue.failed; if (item.status === 'error') return UI_TEXT.queue.failed;
if (item.status === 'downloading' && item.progressStatus) return item.progressStatus; if (item.status === 'downloading' && item.progressStatus) {
const downloadedSize = item.progressStatus.match(/^([\d.,]+\s(?:B|KB|MB|GB|TB)) (?:downloaded|heruntergeladen)$/);
return downloadedSize ? downloadedSize[1] : item.progressStatus;
}
if (item.currentPart && item.totalParts) { if (item.currentPart && item.totalParts) {
return `${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`; return `${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`;
} }
@@ -565,8 +568,14 @@ function updateQueueItemProgress(progress: DownloadProgress): void {
bar.className = 'queue-progress-bar'; bar.className = 'queue-progress-bar';
if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct))); if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
} }
if (status) status.textContent = getQueueProgressStatusText(item); if (status) {
if (metrics) metrics.textContent = getQueueProgressMetricsText(item); status.textContent = getQueueProgressStatusText(item);
status.title = item.progressStatus || status.textContent;
}
if (metrics) {
metrics.textContent = getQueueProgressMetricsText(item);
metrics.title = metrics.textContent;
}
syncQueueRecordingHealth(el, item); syncQueueRecordingHealth(el, item);
} }
@@ -727,8 +736,8 @@ function renderQueue(): void {
<span class="queue-status-badge" title="${safeProgressStatus}"><span class="status ${item.status}" aria-hidden="true"></span><span class="queue-status-label">${safeStatusLabel}</span></span> <span class="queue-status-badge" title="${safeProgressStatus}"><span class="status ${item.status}" aria-hidden="true"></span><span class="queue-status-label">${safeStatusLabel}</span></span>
</div> </div>
<div class="queue-progress-info${item.status === 'pending' || item.status === 'completed' ? ' is-hidden' : ''}"> <div class="queue-progress-info${item.status === 'pending' || item.status === 'completed' ? ' is-hidden' : ''}">
<span class="queue-progress-status${progressStatusClass}${item.status === 'paused' ? ' is-hidden' : ''}">${safeProgressStatus}</span> <span class="queue-progress-status${progressStatusClass}${item.status === 'paused' ? ' is-hidden' : ''}" title="${escapeHtml(item.progressStatus || getQueueProgressStatusText(item))}">${safeProgressStatus}</span>
<span class="queue-progress-metrics">${safeProgressMetrics}</span> <span class="queue-progress-metrics" title="${safeProgressMetrics}">${safeProgressMetrics}</span>
</div> </div>
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="${detailsId}"${expandedQueueIds.has(item.id) ? '' : ' inert'}> <div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="${detailsId}"${expandedQueueIds.has(item.id) ? '' : ' inert'}>
<div class="queue-details-clip"><div class="queue-details-content"> <div class="queue-details-clip"><div class="queue-details-content">
+1 -1
View File
@@ -11,7 +11,7 @@ type Input = {
const inputIds = [ const inputIds = [
'clientId', 'clientSecret', 'sidebarSplitViewToggle', 'downloadMode', 'partMinutes', 'parallelDownloads', 'clientId', 'clientSecret', 'sidebarSplitViewToggle', 'downloadMode', 'partMinutes', 'parallelDownloads',
'performanceMode', 'smartSchedulerToggle', 'duplicatePreventionToggle', 'persistQueueToggle', 'performanceMode', 'duplicatePreventionToggle', 'persistQueueToggle',
'autoResumeQueueToggle', 'notifyEachCompletionToggle', 'streamlinkDisableAdsToggle', 'downloadChatReplayToggle', 'autoResumeQueueToggle', 'notifyEachCompletionToggle', 'streamlinkDisableAdsToggle', 'downloadChatReplayToggle',
'captureLiveChatToggle', 'logStreamEventsToggle', 'autoResumeLiveRecordingToggle', 'autoMergeResumedPartsToggle', 'captureLiveChatToggle', 'logStreamEventsToggle', 'autoResumeLiveRecordingToggle', 'autoMergeResumedPartsToggle',
'deletePartsAfterMergeToggle', 'discordWebhookUrl', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle', 'deletePartsAfterMergeToggle', 'discordWebhookUrl', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle',
-4
View File
@@ -900,7 +900,6 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
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, 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,
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked, prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked, persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked,
auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked, auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked,
@@ -965,7 +964,6 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.part_minutes ?? 120, effective.part_minutes ?? 120,
effective.parallel_downloads ?? 1, effective.parallel_downloads ?? 1,
effective.performance_mode ?? 'balanced', effective.performance_mode ?? 'balanced',
effective.smart_queue_scheduler !== false,
effective.prevent_duplicate_downloads !== false, effective.prevent_duplicate_downloads !== false,
effective.persist_queue_on_restart !== false, effective.persist_queue_on_restart !== false,
effective.auto_resume_queue_on_startup === true, effective.auto_resume_queue_on_startup === true,
@@ -1007,7 +1005,6 @@ function syncSettingsFormFromConfig(syncSecrets = true): void {
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>('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>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false; byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false;
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true; byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
@@ -1151,7 +1148,6 @@ function initSettingsAutoSave(): void {
'sidebarSplitViewToggle', 'sidebarSplitViewToggle',
'parallelDownloads', 'parallelDownloads',
'performanceMode', 'performanceMode',
'smartSchedulerToggle',
'duplicatePreventionToggle', 'duplicatePreventionToggle',
'persistQueueToggle', 'persistQueueToggle',
'autoResumeQueueToggle', 'autoResumeQueueToggle',
-3
View File
@@ -269,9 +269,6 @@ function applyLanguageToStaticUI(): void {
setText('performanceModeStability', UI_TEXT.static.performanceModeStability); setText('performanceModeStability', UI_TEXT.static.performanceModeStability);
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced); setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);
setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed); setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed);
setText('smartSchedulerLabel', UI_TEXT.static.smartSchedulerLabel);
setTitle('smartSchedulerLabel', UI_TEXT.static.smartSchedulerHint);
setTitle('smartSchedulerToggle', UI_TEXT.static.smartSchedulerHint);
setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel); setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel); setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel);
setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel); setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel);
-1
View File
@@ -35,7 +35,6 @@ async function init(): Promise<void> {
byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full'; byId<HTMLSelectElement>('downloadMode').value = config.download_mode ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120); byId<HTMLInputElement>('partMinutes').value = String(config.part_minutes ?? 120);
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>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE; byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE;
+1 -1
View File
@@ -20,7 +20,7 @@ describe('production style modules', () => {
.replace(/\r\n/g, '\n')); .replace(/\r\n/g, '\n'));
const digest = createHash('sha256').update(content).digest('hex'); const digest = createHash('sha256').update(content).digest('hex');
expect(digest).toBe('ece1a9e9f064bc42b0797cd15fcdcdc6367530a67f7c8f6b72c9e10253d73f9a'); expect(digest).toBe('674178288cf7695d7981ebc6f9360a4b7043c9eb66a44b363c830dd8b413114c');
}); });
test('derives the Windows hot-development executable version from package metadata', () => { test('derives the Windows hot-development executable version from package metadata', () => {
+22 -3
View File
@@ -280,10 +280,10 @@
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 4px;
margin-top: 3px; margin-top: 3px;
flex-wrap: wrap; flex-wrap: nowrap;
font-size: 12px; font-size: 11px;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 18px; line-height: 18px;
} }
@@ -291,7 +291,24 @@
.queue-progress-status, .queue-progress-status,
.queue-progress-metrics { .queue-progress-metrics {
min-width: 0; min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-progress-status {
flex: 1;
}
.queue-item:has(.status.error) .queue-progress-info {
flex-wrap: wrap;
}
.queue-item:has(.status.error) .queue-progress-status {
flex-basis: 100%;
white-space: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
font-size: 12px;
} }
.queue-progress-status.is-starting { .queue-progress-status.is-starting {
@@ -304,6 +321,8 @@
} }
.queue-progress-metrics { .queue-progress-metrics {
flex-shrink: 0;
max-width: 100%;
margin-left: auto; margin-left: auto;
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;