Compare commits

..

No commits in common. "56d4e0904f34e833170c77a57fd45d57bba2fbb1" and "cb8e92732e66ee13832d92c369e6ddd08fd92050" have entirely different histories.

12 changed files with 13 additions and 147 deletions

4
package-lock.json generated
View File

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

@ -493,10 +493,6 @@
<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,8 +203,6 @@ 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 {
@ -316,9 +314,7 @@ 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 {
@ -344,15 +340,6 @@ 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),
@ -362,27 +349,10 @@ 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);
} }
@ -3259,22 +3229,6 @@ 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) {
@ -3424,22 +3378,6 @@ 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,8 +16,6 @@ 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,8 +56,6 @@ 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',
@ -193,8 +191,7 @@ 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,8 +56,6 @@ 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',
@ -193,8 +191,7 @@ 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,7 +332,6 @@ 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
}; };
} }
@ -375,7 +374,6 @@ 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',
@ -393,7 +391,6 @@ 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';
@ -504,8 +501,7 @@ function initSettingsAutoSave(): void {
'performanceMode', 'performanceMode',
'smartSchedulerToggle', 'smartSchedulerToggle',
'duplicatePreventionToggle', 'duplicatePreventionToggle',
'persistQueueToggle', 'persistQueueToggle'
'autoResumeQueueToggle'
] as const; ] as const;
const debouncedSaveIds = [ const debouncedSaveIds = [

View File

@ -170,22 +170,17 @@ function focusVodFilter(): void {
} }
} }
function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string>): string { function buildVodCardHtml(vod: VOD, streamer: 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' : ''}${isAlreadyDownloaded ? ' already-downloaded' : ''}"> <div class="vod-card${isChecked ? ' selected' : ''}">
<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>
@ -514,14 +509,6 @@ 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(() => {
@ -539,7 +526,7 @@ function renderVodGridFromCurrentState(): void {
return; return;
} }
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '', downloadedIds)).join('')); grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '')).join(''));
if (startIndex + chunk.length < filtered.length) { if (startIndex + chunk.length < filtered.length) {
scheduleNextChunk(startIndex + chunk.length); scheduleNextChunk(startIndex + chunk.length);

View File

@ -114,9 +114,6 @@ 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,25 +63,8 @@ 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(async (q: QueueItem[]) => { window.api.onQueueUpdated((q: QueueItem[]) => {
const previouslyCompleted = new Set(queue.filter((i) => i.status === 'completed').map((i) => i.id)); queue = mergeQueueState(Array.isArray(q) ? q : []);
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,29 +592,6 @@ 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;
} }