Compare commits
2 Commits
cb8e92732e
...
56d4e0904f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d4e0904f | ||
|
|
3f04b42b02 |
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.5.21",
|
||||
"version": "4.5.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.5.21",
|
||||
"version": "4.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.5.21",
|
||||
"version": "4.5.22",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
|
||||
@ -493,6 +493,10 @@
|
||||
<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>
|
||||
|
||||
66
src/main.ts
66
src/main.ts
@ -203,6 +203,8 @@ 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 {
|
||||
@ -314,7 +316,9 @@ const defaultConfig: Config = {
|
||||
prevent_duplicate_downloads: true,
|
||||
persist_queue_on_restart: true,
|
||||
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 {
|
||||
@ -340,6 +344,15 @@ 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),
|
||||
@ -349,10 +362,27 @@ 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)
|
||||
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> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@ -3229,6 +3259,22 @@ 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) {
|
||||
@ -3378,6 +3424,22 @@ 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', () => {
|
||||
|
||||
2
src/renderer-globals.d.ts
vendored
2
src/renderer-globals.d.ts
vendored
@ -16,6 +16,8 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@ -56,6 +56,8 @@ 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',
|
||||
@ -191,7 +193,8 @@ const UI_TEXT_DE = {
|
||||
bulkAdding: 'Fuege hinzu...',
|
||||
bulkClear: 'Loeschen',
|
||||
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: {
|
||||
dialogTitle: 'VOD zuschneiden',
|
||||
|
||||
@ -56,6 +56,8 @@ 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',
|
||||
@ -191,7 +193,8 @@ 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).'
|
||||
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
|
||||
alreadyDownloaded: 'Already downloaded'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Trim VOD',
|
||||
|
||||
@ -332,6 +332,7 @@ 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
|
||||
};
|
||||
}
|
||||
@ -374,6 +375,7 @@ 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',
|
||||
@ -391,6 +393,7 @@ 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';
|
||||
@ -501,7 +504,8 @@ function initSettingsAutoSave(): void {
|
||||
'performanceMode',
|
||||
'smartSchedulerToggle',
|
||||
'duplicatePreventionToggle',
|
||||
'persistQueueToggle'
|
||||
'persistQueueToggle',
|
||||
'autoResumeQueueToggle'
|
||||
] as const;
|
||||
|
||||
const debouncedSaveIds = [
|
||||
|
||||
@ -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 date = formatUiDate(vod.created_at);
|
||||
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '"');
|
||||
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)}">✓</div>`
|
||||
: '';
|
||||
|
||||
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;">
|
||||
${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>
|
||||
@ -509,6 +514,14 @@ 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(() => {
|
||||
@ -526,7 +539,7 @@ function renderVodGridFromCurrentState(): void {
|
||||
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) {
|
||||
scheduleNextChunk(startIndex + chunk.length);
|
||||
|
||||
@ -114,6 +114,9 @@ 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);
|
||||
|
||||
@ -63,8 +63,25 @@ async function init(): Promise<void> {
|
||||
// Restore last active tab from previous session (default 'vods')
|
||||
showTab(loadPersistedActiveTab());
|
||||
|
||||
window.api.onQueueUpdated((q: QueueItem[]) => {
|
||||
queue = mergeQueueState(Array.isArray(q) ? q : []);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
renderQueue();
|
||||
updateStatusBarQueueSummary();
|
||||
markQueueActivity();
|
||||
|
||||
@ -592,6 +592,29 @@ 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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user