feat: auto-resume queue toggle + already-downloaded VOD indicator

Two real UX wins.

1. Auto-resume queue on startup. New checkbox in Settings -> Download
   ("Queue beim Start automatisch fortsetzen"). When enabled and the
   persisted queue has pending items, processQueue() fires ~5 seconds
   after did-finish-load — long enough for the user to see the queue
   and pause if they did not actually want this. Default off so the
   existing behaviour (explicit Start click) is preserved on upgrade.
   The Settings auto-save fingerprint includes the new flag and
   syncSettingsFormFromConfig restores it. Tooltip explains the
   timing on hover.

2. Already-downloaded indicator on VOD cards. Config gains
   downloaded_vod_ids: string[] (bounded to 4096 latest entries).
   Every successful queue-item download appends its parsed VOD ID
   (or every component ID for merge groups). On the VOD grid each
   card whose vod.id is in the set gets a small green checkmark
   badge in the top-right plus a slightly dimmed thumbnail, with a
   localized "Already downloaded" / "Bereits heruntergeladen"
   tooltip. The lookup builds a Set once per render so it stays
   O(1) per card. The renderer refreshes its local config copy on
   every "newly completed" queue update so the badge appears live
   without waiting for a settings save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 15:16:21 +02:00
parent cb8e92732e
commit 3f04b42b02
10 changed files with 144 additions and 10 deletions

View File

@ -493,6 +493,10 @@
<input type="checkbox" id="persistQueueToggle" checked> <input type="checkbox" id="persistQueueToggle" checked>
<span id="persistQueueLabel">Queue zwischen App-Starts speichern</span> <span id="persistQueueLabel">Queue zwischen App-Starts speichern</span>
</label> </label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="autoResumeQueueToggle">
<span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span>
</label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label> <label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>

View File

@ -203,6 +203,8 @@ interface Config {
persist_queue_on_restart: boolean; persist_queue_on_restart: boolean;
metadata_cache_minutes: number; metadata_cache_minutes: number;
parallel_downloads: number; parallel_downloads: number;
auto_resume_queue_on_startup: boolean;
downloaded_vod_ids: string[];
} }
interface RuntimeMetrics { interface RuntimeMetrics {
@ -314,7 +316,9 @@ const defaultConfig: Config = {
prevent_duplicate_downloads: true, prevent_duplicate_downloads: true,
persist_queue_on_restart: true, persist_queue_on_restart: true,
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES, metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES,
parallel_downloads: 1 parallel_downloads: 1,
auto_resume_queue_on_startup: false,
downloaded_vod_ids: []
}; };
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string { function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
@ -340,6 +344,15 @@ function normalizePerformanceMode(mode: unknown): PerformanceMode {
} }
function normalizeConfigTemplates(input: Config): Config { function normalizeConfigTemplates(input: Config): Config {
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate
// an unbounded list across years of downloads. Latest entries kept.
const DOWNLOADED_IDS_MAX = 4096;
const rawIds = Array.isArray(input.downloaded_vod_ids) ? input.downloaded_vod_ids : [];
const cleanIds = rawIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX
? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX)
: cleanIds;
return { return {
...input, ...input,
filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD), filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD),
@ -349,10 +362,27 @@ function normalizeConfigTemplates(input: Config): Config {
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,
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes) metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes),
auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true,
downloaded_vod_ids: trimmedIds
}; };
} }
function recordDownloadedVodId(vodId: string): void {
if (!vodId) return;
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
if (config.downloaded_vod_ids.includes(vodId)) return;
config.downloaded_vod_ids.push(vodId);
// Cap to keep config size bounded — drop oldest first.
const DOWNLOADED_IDS_MAX = 4096;
if (config.downloaded_vod_ids.length > DOWNLOADED_IDS_MAX) {
config.downloaded_vod_ids = config.downloaded_vod_ids.slice(
config.downloaded_vod_ids.length - DOWNLOADED_IDS_MAX
);
}
saveConfig(config);
}
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
@ -3229,6 +3259,22 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
item.outputFiles = [...finalResult.outputFiles]; item.outputFiles = [...finalResult.outputFiles];
} }
if (finalResult.success) {
// Record the VOD ID so the renderer can mark this VOD as
// already-downloaded the next time the user browses the
// streamer's archive. Merge groups don't have a single VOD
// ID — record each component instead.
if (item.mergeGroup?.items?.length) {
for (const m of item.mergeGroup.items) {
const id = parseVodId(m.url);
if (id) recordDownloadedVodId(id);
}
} else {
const id = parseVodId(item.url);
if (id) recordDownloadedVodId(id);
}
}
if (finalResult.success) { if (finalResult.success) {
runtimeMetrics.downloadsCompleted += 1; runtimeMetrics.downloadsCompleted += 1;
} else if (!wasPaused) { } else if (!wasPaused) {
@ -3378,6 +3424,22 @@ function createWindow(): void {
if (autoUpdateReadyToInstall && downloadedUpdateVersion) { if (autoUpdateReadyToInstall && downloadedUpdateVersion) {
mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion)); mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion));
} }
// Auto-resume: if the user opted in AND the persisted queue has
// pending entries, kick off processing after a short delay so the
// UI has time to render and the user can still pause if they want.
if (config.auto_resume_queue_on_startup && !isDownloading) {
const hasPending = downloadQueue.some((it) => it.status === 'pending');
if (hasPending) {
appendDebugLog('auto-resume-queue-scheduled', { pending: downloadQueue.filter((it) => it.status === 'pending').length });
setTimeout(() => {
if (config.auto_resume_queue_on_startup && !isDownloading
&& downloadQueue.some((it) => it.status === 'pending')) {
void processQueue();
}
}, 5000);
}
}
}); });
mainWindow.on('closed', () => { mainWindow.on('closed', () => {

View File

@ -16,6 +16,8 @@ interface AppConfig {
persist_queue_on_restart?: boolean; persist_queue_on_restart?: boolean;
metadata_cache_minutes?: number; metadata_cache_minutes?: number;
parallel_downloads?: number; parallel_downloads?: number;
auto_resume_queue_on_startup?: boolean;
downloaded_vod_ids?: string[];
[key: string]: unknown; [key: string]: unknown;
} }

View File

@ -56,6 +56,8 @@ const UI_TEXT_DE = {
openDebugLogFile: 'Log-Datei oeffnen', openDebugLogFile: 'Log-Datei oeffnen',
duplicatePreventionLabel: 'Duplikate in Queue verhindern', duplicatePreventionLabel: 'Duplikate in Queue verhindern',
persistQueueLabel: 'Queue zwischen App-Starts speichern', persistQueueLabel: 'Queue zwischen App-Starts speichern',
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)', metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
filenameTemplatesTitle: 'Dateinamen-Templates', filenameTemplatesTitle: 'Dateinamen-Templates',
vodTemplateLabel: 'VOD-Template', vodTemplateLabel: 'VOD-Template',
@ -191,7 +193,8 @@ const UI_TEXT_DE = {
bulkAdding: 'Fuege hinzu...', bulkAdding: 'Fuege hinzu...',
bulkClear: 'Loeschen', bulkClear: 'Loeschen',
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.', bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.',
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).' bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
alreadyDownloaded: 'Bereits heruntergeladen'
}, },
clips: { clips: {
dialogTitle: 'VOD zuschneiden', dialogTitle: 'VOD zuschneiden',

View File

@ -56,6 +56,8 @@ const UI_TEXT_EN = {
openDebugLogFile: 'Open log file', openDebugLogFile: 'Open log file',
duplicatePreventionLabel: 'Prevent duplicate queue entries', duplicatePreventionLabel: 'Prevent duplicate queue entries',
persistQueueLabel: 'Keep queue between app restarts', persistQueueLabel: 'Keep queue between app restarts',
autoResumeQueueLabel: 'Auto-resume the queue on startup',
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)', metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
filenameTemplatesTitle: 'Filename Templates', filenameTemplatesTitle: 'Filename Templates',
vodTemplateLabel: 'VOD Template', vodTemplateLabel: 'VOD Template',
@ -191,7 +193,8 @@ const UI_TEXT_EN = {
bulkAdding: 'Adding...', bulkAdding: 'Adding...',
bulkClear: 'Clear', bulkClear: 'Clear',
bulkAddedToQueue: 'Added {count} VODs to the queue.', bulkAddedToQueue: 'Added {count} VODs to the queue.',
bulkAddSkipped: 'No VODs were added (already in queue or invalid).' bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
alreadyDownloaded: 'Already downloaded'
}, },
clips: { clips: {
dialogTitle: 'Trim VOD', dialogTitle: 'Trim VOD',

View File

@ -332,6 +332,7 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked, 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,
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10 metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
}; };
} }
@ -374,6 +375,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.smart_queue_scheduler !== false, 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.metadata_cache_minutes ?? 10, effective.metadata_cache_minutes ?? 10,
effective.filename_template_vod ?? '{title}.mp4', effective.filename_template_vod ?? '{title}.mp4',
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4', effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
@ -391,6 +393,7 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; 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>('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) || '{title}.mp4'; byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4'; byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
@ -501,7 +504,8 @@ function initSettingsAutoSave(): void {
'performanceMode', 'performanceMode',
'smartSchedulerToggle', 'smartSchedulerToggle',
'duplicatePreventionToggle', 'duplicatePreventionToggle',
'persistQueueToggle' 'persistQueueToggle',
'autoResumeQueueToggle'
] as const; ] as const;
const debouncedSaveIds = [ const debouncedSaveIds = [

View File

@ -170,17 +170,22 @@ function focusVodFilter(): void {
} }
} }
function buildVodCardHtml(vod: VOD, streamer: string): string { function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string>): string {
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180'); const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = formatUiDate(vod.created_at); const date = formatUiDate(vod.created_at);
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;'); const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;');
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled); const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
const safeUrlAttr = escapeHtml(vod.url); const safeUrlAttr = escapeHtml(vod.url);
const isChecked = selectedVodUrls.has(vod.url); const isChecked = selectedVodUrls.has(vod.url);
const isAlreadyDownloaded = downloadedIds ? downloadedIds.has(vod.id) : false;
const downloadedBadge = isAlreadyDownloaded
? `<div class="vod-downloaded-badge" title="${escapeHtml(UI_TEXT.vods.alreadyDownloaded)}">&#10003;</div>`
: '';
return ` return `
<div class="vod-card${isChecked ? ' selected' : ''}"> <div class="vod-card${isChecked ? ' selected' : ''}${isAlreadyDownloaded ? ' already-downloaded' : ''}">
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="Select for bulk action" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;"> <input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="Select for bulk action" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;">
${downloadedBadge}
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'"> <img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
<div class="vod-info"> <div class="vod-info">
<div class="vod-title">${safeDisplayTitle}</div> <div class="vod-title">${safeDisplayTitle}</div>
@ -509,6 +514,14 @@ function renderVodGridFromCurrentState(): void {
grid.replaceChildren(); grid.replaceChildren();
updateVodFilterCount(filtered.length, total); updateVodFilterCount(filtered.length, total);
// Build the downloaded-ids lookup once per render — Set.has is O(1) vs
// Array.includes which would be O(n*m) across all cards.
const downloadedIds = new Set(
Array.isArray(config.downloaded_vod_ids)
? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string')
: []
);
const scheduleNextChunk = (nextStartIndex: number): void => { const scheduleNextChunk = (nextStartIndex: number): void => {
const delayMs = document.hidden ? 16 : 0; const delayMs = document.hidden ? 16 : 0;
window.setTimeout(() => { window.setTimeout(() => {
@ -526,7 +539,7 @@ function renderVodGridFromCurrentState(): void {
return; return;
} }
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '')).join('')); grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '', downloadedIds)).join(''));
if (startIndex + chunk.length < filtered.length) { if (startIndex + chunk.length < filtered.length) {
scheduleNextChunk(startIndex + chunk.length); scheduleNextChunk(startIndex + chunk.length);

View File

@ -114,6 +114,9 @@ function applyLanguageToStaticUI(): void {
setTitle('smartSchedulerToggle', 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);
setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint);
setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint);
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel); setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle); setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel); setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);

View File

@ -63,8 +63,25 @@ async function init(): Promise<void> {
// Restore last active tab from previous session (default 'vods') // Restore last active tab from previous session (default 'vods')
showTab(loadPersistedActiveTab()); showTab(loadPersistedActiveTab());
window.api.onQueueUpdated((q: QueueItem[]) => { window.api.onQueueUpdated(async (q: QueueItem[]) => {
queue = mergeQueueState(Array.isArray(q) ? q : []); const previouslyCompleted = new Set(queue.filter((i) => i.status === 'completed').map((i) => i.id));
const next = Array.isArray(q) ? q : [];
const newlyCompletedItem = next.some((i) => i.status === 'completed' && !previouslyCompleted.has(i.id));
queue = mergeQueueState(next);
// When an item flips to 'completed' the main process appends its
// VOD ID to config.downloaded_vod_ids. Refresh our local config
// copy so the "already downloaded" badge on the VOD grid updates
// live without waiting for a settings save.
if (newlyCompletedItem) {
try {
config = await window.api.getConfig();
} catch { /* network blip — next sync will refresh */ }
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
renderVodGridFromCurrentState();
}
}
renderQueue(); renderQueue();
updateStatusBarQueueSummary(); updateStatusBarQueueSummary();
markQueueActivity(); markQueueActivity();

View File

@ -592,6 +592,29 @@ body {
box-shadow: 0 0 0 2px #9146FF, 0 8px 25px rgba(145, 70, 255, 0.25); box-shadow: 0 0 0 2px #9146FF, 0 8px 25px rgba(145, 70, 255, 0.25);
} }
.vod-downloaded-badge {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 200, 83, 0.92);
color: white;
border-radius: 50%;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
z-index: 2;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.vod-card.already-downloaded .vod-thumbnail {
opacity: 0.6;
}
.streamer-item.dragging { .streamer-item.dragging {
opacity: 0.4; opacity: 0.4;
} }