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

View File

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

View File

@ -493,10 +493,6 @@
<input type="checkbox" id="persistQueueToggle" checked>
<span id="persistQueueLabel">Queue zwischen App-Starts speichern</span>
</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 class="form-group">
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>

View File

@ -203,8 +203,6 @@ interface Config {
persist_queue_on_restart: boolean;
metadata_cache_minutes: number;
parallel_downloads: number;
auto_resume_queue_on_startup: boolean;
downloaded_vod_ids: string[];
}
interface RuntimeMetrics {
@ -316,9 +314,7 @@ const defaultConfig: Config = {
prevent_duplicate_downloads: true,
persist_queue_on_restart: true,
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES,
parallel_downloads: 1,
auto_resume_queue_on_startup: false,
downloaded_vod_ids: []
parallel_downloads: 1
};
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
@ -344,15 +340,6 @@ function normalizePerformanceMode(mode: unknown): PerformanceMode {
}
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 {
...input,
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),
prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false,
persist_queue_on_restart: input.persist_queue_on_restart !== false,
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes),
auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true,
downloaded_vod_ids: trimmedIds
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes)
};
}
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> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@ -3259,22 +3229,6 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
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) {
runtimeMetrics.downloadsCompleted += 1;
} else if (!wasPaused) {
@ -3424,22 +3378,6 @@ function createWindow(): void {
if (autoUpdateReadyToInstall && 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', () => {

View File

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

View File

@ -56,8 +56,6 @@ const UI_TEXT_DE = {
openDebugLogFile: 'Log-Datei oeffnen',
duplicatePreventionLabel: 'Duplikate in Queue verhindern',
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)',
filenameTemplatesTitle: 'Dateinamen-Templates',
vodTemplateLabel: 'VOD-Template',
@ -193,8 +191,7 @@ const UI_TEXT_DE = {
bulkAdding: 'Fuege hinzu...',
bulkClear: 'Loeschen',
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.',
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
alreadyDownloaded: 'Bereits heruntergeladen'
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).'
},
clips: {
dialogTitle: 'VOD zuschneiden',

View File

@ -56,8 +56,6 @@ const UI_TEXT_EN = {
openDebugLogFile: 'Open log file',
duplicatePreventionLabel: 'Prevent duplicate queue entries',
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)',
filenameTemplatesTitle: 'Filename Templates',
vodTemplateLabel: 'VOD Template',
@ -193,8 +191,7 @@ const UI_TEXT_EN = {
bulkAdding: 'Adding...',
bulkClear: 'Clear',
bulkAddedToQueue: 'Added {count} VODs to the queue.',
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
alreadyDownloaded: 'Already downloaded'
bulkAddSkipped: 'No VODs were added (already in queue or invalid).'
},
clips: {
dialogTitle: 'Trim VOD',

View File

@ -332,7 +332,6 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').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
};
}
@ -375,7 +374,6 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.smart_queue_scheduler !== false,
effective.prevent_duplicate_downloads !== false,
effective.persist_queue_on_restart !== false,
effective.auto_resume_queue_on_startup === true,
effective.metadata_cache_minutes ?? 10,
effective.filename_template_vod ?? '{title}.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>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads 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>('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';
@ -504,8 +501,7 @@ function initSettingsAutoSave(): void {
'performanceMode',
'smartSchedulerToggle',
'duplicatePreventionToggle',
'persistQueueToggle',
'autoResumeQueueToggle'
'persistQueueToggle'
] as const;
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 date = formatUiDate(vod.created_at);
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;');
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
const safeUrlAttr = escapeHtml(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 `
<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;">
${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>'">
<div class="vod-info">
<div class="vod-title">${safeDisplayTitle}</div>
@ -514,14 +509,6 @@ function renderVodGridFromCurrentState(): void {
grid.replaceChildren();
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 delayMs = document.hidden ? 16 : 0;
window.setTimeout(() => {
@ -539,7 +526,7 @@ function renderVodGridFromCurrentState(): void {
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) {
scheduleNextChunk(startIndex + chunk.length);

View File

@ -114,9 +114,6 @@ function applyLanguageToStaticUI(): void {
setTitle('smartSchedulerToggle', UI_TEXT.static.smartSchedulerHint);
setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
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('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
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')
showTab(loadPersistedActiveTab());
window.api.onQueueUpdated(async (q: QueueItem[]) => {
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();
}
}
window.api.onQueueUpdated((q: QueueItem[]) => {
queue = mergeQueueState(Array.isArray(q) ? q : []);
renderQueue();
updateStatusBarQueueSummary();
markQueueActivity();

View File

@ -592,29 +592,6 @@ body {
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 {
opacity: 0.4;
}