feat: auto-vod-download — per-streamer VOD toggle + background poller
Adds the second half of the live-archive flow. AUTO catches a stream as it happens; VOD catches the recently published archive. Both together close the gap a Twitch viewer-side archivist cares about. Streamer list grows a third per-streamer toggle (blue "VOD") next to AUTO and REC. When enabled, the main-process auto-VOD poller periodically scans that streamer's VOD list and queues anything that is (a) within the rolling age window, (b) not already in downloaded_vod_ids, and (c) not already in the active queue. The age window keeps freshly-enabled streamers from suddenly dumping their entire historical backlog into the queue — when a user flips VOD on, only VODs published in the last N hours (default 24, capped at 720) get auto-pulled. Polling cadence is in minutes, not seconds — VOD-listing scans are heavier than live-status checks and new VODs only appear after a stream ends, so minute-level lag is fine. Default 15 min, clamped [5, 360]. Independent timer from the auto-record poller because their cadences shouldn't be coupled. UI: - Streamer item: blue "VOD" pill next to AUTO/REC, identical interaction. - Settings card "Auto-VOD download": poll interval + max age fields. - Discord card: optional "Notify when a VOD gets auto-queued" checkbox. Wires through save-config so toggling triggers restartAutoVodPoller without a full app restart, and through shutdownCleanup so the timer is killed on quit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f1e5f4a9e
commit
1ab6f01e07
@ -686,6 +686,21 @@
|
|||||||
<input type="checkbox" id="discordNotifyVodCompleteToggle">
|
<input type="checkbox" id="discordNotifyVodCompleteToggle">
|
||||||
<span id="discordNotifyVodCompleteLabel">Bei abgeschlossenem VOD-Download benachrichtigen</span>
|
<span id="discordNotifyVodCompleteLabel">Bei abgeschlossenem VOD-Download benachrichtigen</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
|
||||||
|
<input type="checkbox" id="discordNotifyVodAutoQueuedToggle">
|
||||||
|
<span id="discordNotifyVodAutoQueuedLabel">Bei automatisch eingereihten VODs benachrichtigen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 id="autoVodCardTitle">Auto-VOD-Download</h3>
|
||||||
|
<p id="autoVodCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.</p>
|
||||||
|
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
|
||||||
|
<span id="autoVodPollMinutesLabel" style="font-size:12px; color:var(--text-secondary);">Poll-Intervall (Minuten)</span>
|
||||||
|
<input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" style="width:90px;">
|
||||||
|
<span id="autoVodMaxAgeHoursLabel" style="font-size:12px; color:var(--text-secondary); margin-left:12px;">Max. Alter (Stunden)</span>
|
||||||
|
<input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" style="width:90px;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
159
src/main.ts
159
src/main.ts
@ -221,11 +221,15 @@ interface Config {
|
|||||||
discord_notify_live_start: boolean;
|
discord_notify_live_start: boolean;
|
||||||
discord_notify_live_end: boolean;
|
discord_notify_live_end: boolean;
|
||||||
discord_notify_vod_complete: boolean;
|
discord_notify_vod_complete: boolean;
|
||||||
|
discord_notify_vod_auto_queued: boolean;
|
||||||
auto_cleanup_enabled: boolean;
|
auto_cleanup_enabled: boolean;
|
||||||
auto_cleanup_days: number;
|
auto_cleanup_days: number;
|
||||||
auto_cleanup_target: 'live_only' | 'all';
|
auto_cleanup_target: 'live_only' | 'all';
|
||||||
auto_cleanup_action: 'delete' | 'archive';
|
auto_cleanup_action: 'delete' | 'archive';
|
||||||
log_stream_events: boolean;
|
log_stream_events: boolean;
|
||||||
|
auto_vod_download_streamers: string[];
|
||||||
|
auto_vod_download_poll_minutes: number;
|
||||||
|
auto_vod_max_age_hours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeMetrics {
|
interface RuntimeMetrics {
|
||||||
@ -351,11 +355,15 @@ const defaultConfig: Config = {
|
|||||||
discord_notify_live_start: false,
|
discord_notify_live_start: false,
|
||||||
discord_notify_live_end: false,
|
discord_notify_live_end: false,
|
||||||
discord_notify_vod_complete: false,
|
discord_notify_vod_complete: false,
|
||||||
|
discord_notify_vod_auto_queued: false,
|
||||||
auto_cleanup_enabled: false,
|
auto_cleanup_enabled: false,
|
||||||
auto_cleanup_days: 30,
|
auto_cleanup_days: 30,
|
||||||
auto_cleanup_target: 'live_only',
|
auto_cleanup_target: 'live_only',
|
||||||
auto_cleanup_action: 'archive',
|
auto_cleanup_action: 'archive',
|
||||||
log_stream_events: true
|
log_stream_events: true,
|
||||||
|
auto_vod_download_streamers: [],
|
||||||
|
auto_vod_download_poll_minutes: 15,
|
||||||
|
auto_vod_max_age_hours: 24
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
||||||
@ -462,6 +470,7 @@ function normalizeConfigTemplates(input: Config): Config {
|
|||||||
discord_notify_live_start: input.discord_notify_live_start === true,
|
discord_notify_live_start: input.discord_notify_live_start === true,
|
||||||
discord_notify_live_end: input.discord_notify_live_end === true,
|
discord_notify_live_end: input.discord_notify_live_end === true,
|
||||||
discord_notify_vod_complete: input.discord_notify_vod_complete === true,
|
discord_notify_vod_complete: input.discord_notify_vod_complete === true,
|
||||||
|
discord_notify_vod_auto_queued: input.discord_notify_vod_auto_queued === true,
|
||||||
auto_cleanup_enabled: input.auto_cleanup_enabled === true,
|
auto_cleanup_enabled: input.auto_cleanup_enabled === true,
|
||||||
auto_cleanup_days: (() => {
|
auto_cleanup_days: (() => {
|
||||||
const n = Number(input.auto_cleanup_days);
|
const n = Number(input.auto_cleanup_days);
|
||||||
@ -470,7 +479,18 @@ function normalizeConfigTemplates(input: Config): Config {
|
|||||||
})(),
|
})(),
|
||||||
auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only',
|
auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only',
|
||||||
auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive',
|
auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive',
|
||||||
log_stream_events: input.log_stream_events !== false
|
log_stream_events: input.log_stream_events !== false,
|
||||||
|
auto_vod_download_streamers: normalizeAutoRecordList(input.auto_vod_download_streamers),
|
||||||
|
auto_vod_download_poll_minutes: (() => {
|
||||||
|
const n = Number(input.auto_vod_download_poll_minutes);
|
||||||
|
if (!Number.isFinite(n)) return 15;
|
||||||
|
return Math.max(5, Math.min(360, Math.floor(n)));
|
||||||
|
})(),
|
||||||
|
auto_vod_max_age_hours: (() => {
|
||||||
|
const n = Number(input.auto_vod_max_age_hours);
|
||||||
|
if (!Number.isFinite(n)) return 24;
|
||||||
|
return Math.max(1, Math.min(720, Math.floor(n)));
|
||||||
|
})()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3011,6 +3031,130 @@ async function runAutoRecordPoll(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// AUTO-VOD-DOWNLOAD POLLER
|
||||||
|
// ==========================================
|
||||||
|
// Periodically scans VOD listings of opted-in streamers and auto-queues
|
||||||
|
// any VOD that's (a) recent enough to be in scope, (b) not already
|
||||||
|
// downloaded, and (c) not already in the active queue. Cadence is
|
||||||
|
// minutes, not seconds — a VOD-listing scan is much heavier than a
|
||||||
|
// live-status check, and new VODs only appear after a stream ends, so
|
||||||
|
// minute-level lag is fine.
|
||||||
|
let autoVodPollTimer: NodeJS.Timeout | null = null;
|
||||||
|
let autoVodPollInFlight = false;
|
||||||
|
|
||||||
|
function stopAutoVodPoller(): void {
|
||||||
|
if (autoVodPollTimer) {
|
||||||
|
clearInterval(autoVodPollTimer);
|
||||||
|
autoVodPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartAutoVodPoller(): void {
|
||||||
|
stopAutoVodPoller();
|
||||||
|
const list = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
appendDebugLog('auto-vod-poller-idle', { reason: 'no streamers' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minutes = (() => {
|
||||||
|
const n = Number(config.auto_vod_download_poll_minutes);
|
||||||
|
if (!Number.isFinite(n)) return 15;
|
||||||
|
return Math.max(5, Math.min(360, Math.floor(n)));
|
||||||
|
})();
|
||||||
|
appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes });
|
||||||
|
autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000);
|
||||||
|
autoVodPollTimer.unref?.();
|
||||||
|
setTimeout(() => { void runAutoVodPoll(); }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAutoVodPoll(): Promise<void> {
|
||||||
|
if (autoVodPollInFlight) return;
|
||||||
|
autoVodPollInFlight = true;
|
||||||
|
try {
|
||||||
|
const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : [];
|
||||||
|
if (list.length === 0) return;
|
||||||
|
|
||||||
|
const maxAgeHours = (() => {
|
||||||
|
const n = Number(config.auto_vod_max_age_hours);
|
||||||
|
if (!Number.isFinite(n)) return 24;
|
||||||
|
return Math.max(1, Math.min(720, Math.floor(n)));
|
||||||
|
})();
|
||||||
|
const cutoffMs = Date.now() - maxAgeHours * 3600 * 1000;
|
||||||
|
|
||||||
|
const downloadedSet = new Set(Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : []);
|
||||||
|
const queuedUrls = new Set(downloadQueue.map((it) => it.url));
|
||||||
|
|
||||||
|
for (const streamer of list) {
|
||||||
|
if (!config.auto_vod_download_streamers.includes(streamer)) continue;
|
||||||
|
|
||||||
|
const userId = await getUserId(streamer);
|
||||||
|
if (!userId) {
|
||||||
|
appendDebugLog('auto-vod-skip-no-user', { streamer });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let vods: VOD[] = [];
|
||||||
|
try {
|
||||||
|
vods = await getVODs(userId, true);
|
||||||
|
} catch (e) {
|
||||||
|
appendDebugLog('auto-vod-list-failed', { streamer, error: String(e) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(vods) || vods.length === 0) continue;
|
||||||
|
|
||||||
|
for (const vod of vods) {
|
||||||
|
if (!vod || !vod.id || !vod.url) continue;
|
||||||
|
if (downloadedSet.has(vod.id)) continue;
|
||||||
|
if (queuedUrls.has(vod.url)) continue;
|
||||||
|
|
||||||
|
const createdMs = Date.parse(vod.created_at || '');
|
||||||
|
if (!Number.isFinite(createdMs) || createdMs < cutoffMs) continue;
|
||||||
|
|
||||||
|
const queueItem: QueueItem = {
|
||||||
|
id: generateQueueItemId(),
|
||||||
|
title: vod.title || `${streamer} VOD ${vod.id}`,
|
||||||
|
url: vod.url,
|
||||||
|
date: vod.created_at,
|
||||||
|
streamer,
|
||||||
|
duration_str: vod.duration || '',
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0
|
||||||
|
};
|
||||||
|
downloadQueue.push(queueItem);
|
||||||
|
queuedUrls.add(vod.url);
|
||||||
|
appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title });
|
||||||
|
|
||||||
|
if (config.discord_notify_vod_auto_queued) {
|
||||||
|
try {
|
||||||
|
await sendDiscordWebhook({
|
||||||
|
title: 'New VOD auto-queued',
|
||||||
|
description: `\`${streamer}\` published a new VOD — queued for download.`,
|
||||||
|
color: 'info',
|
||||||
|
fields: [
|
||||||
|
{ name: 'Title', value: queueItem.title, inline: false },
|
||||||
|
{ name: 'VOD ID', value: String(vod.id), inline: true },
|
||||||
|
{ name: 'URL', value: vod.url, inline: false }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch (_) { /* ignore webhook errors */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveQueue(downloadQueue);
|
||||||
|
emitQueueUpdated();
|
||||||
|
|
||||||
|
if (!isDownloading && downloadQueue.some((it) => it.status === 'pending')) {
|
||||||
|
void processQueue();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
appendDebugLog('auto-vod-poll-failed', String(e));
|
||||||
|
} finally {
|
||||||
|
autoVodPollInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// CHAT REPLAY DOWNLOAD
|
// CHAT REPLAY DOWNLOAD
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -5153,6 +5297,8 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
|||||||
const previousTheme = config.theme;
|
const previousTheme = config.theme;
|
||||||
const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []);
|
const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []);
|
||||||
const previousAutoRecordSeconds = config.auto_record_poll_seconds;
|
const previousAutoRecordSeconds = config.auto_record_poll_seconds;
|
||||||
|
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
|
||||||
|
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
|
||||||
|
|
||||||
config = normalizeConfigTemplates({ ...config, ...newConfig });
|
config = normalizeConfigTemplates({ ...config, ...newConfig });
|
||||||
|
|
||||||
@ -5195,6 +5341,13 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
|||||||
restartAutoRecordPoller();
|
restartAutoRecordPoller();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same dance for the auto-VOD poller — independent cadence from
|
||||||
|
// auto-record because VOD listings are heavier to fetch.
|
||||||
|
const newAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
|
||||||
|
if (newAutoVodList !== previousAutoVodList || config.auto_vod_download_poll_minutes !== previousAutoVodMinutes) {
|
||||||
|
restartAutoVodPoller();
|
||||||
|
}
|
||||||
|
|
||||||
// Restart cleanup timer when the toggle flips; harmless to call when
|
// Restart cleanup timer when the toggle flips; harmless to call when
|
||||||
// unchanged because restartAutoCleanupTimer just resets the interval.
|
// unchanged because restartAutoCleanupTimer just resets the interval.
|
||||||
restartAutoCleanupTimer();
|
restartAutoCleanupTimer();
|
||||||
@ -5958,6 +6111,7 @@ app.whenReady().then(() => {
|
|||||||
startMetadataCacheCleanup();
|
startMetadataCacheCleanup();
|
||||||
startDebugLogFlushTimer();
|
startDebugLogFlushTimer();
|
||||||
restartAutoRecordPoller();
|
restartAutoRecordPoller();
|
||||||
|
restartAutoVodPoller();
|
||||||
restartAutoCleanupTimer();
|
restartAutoCleanupTimer();
|
||||||
createWindow();
|
createWindow();
|
||||||
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
||||||
@ -5986,6 +6140,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
|
|||||||
cleanupMetadataCaches('shutdown');
|
cleanupMetadataCaches('shutdown');
|
||||||
stopAutoUpdatePolling();
|
stopAutoUpdatePolling();
|
||||||
stopAutoRecordPoller();
|
stopAutoRecordPoller();
|
||||||
|
stopAutoVodPoller();
|
||||||
stopAutoCleanupTimer();
|
stopAutoCleanupTimer();
|
||||||
|
|
||||||
// Kill all active children: queue downloads, standalone clip downloads,
|
// Kill all active children: queue downloads, standalone clip downloads,
|
||||||
|
|||||||
4
src/renderer-globals.d.ts
vendored
4
src/renderer-globals.d.ts
vendored
@ -29,11 +29,15 @@ interface AppConfig {
|
|||||||
discord_notify_live_start?: boolean;
|
discord_notify_live_start?: boolean;
|
||||||
discord_notify_live_end?: boolean;
|
discord_notify_live_end?: boolean;
|
||||||
discord_notify_vod_complete?: boolean;
|
discord_notify_vod_complete?: boolean;
|
||||||
|
discord_notify_vod_auto_queued?: boolean;
|
||||||
auto_cleanup_enabled?: boolean;
|
auto_cleanup_enabled?: boolean;
|
||||||
auto_cleanup_days?: number;
|
auto_cleanup_days?: number;
|
||||||
auto_cleanup_target?: 'live_only' | 'all';
|
auto_cleanup_target?: 'live_only' | 'all';
|
||||||
auto_cleanup_action?: 'delete' | 'archive';
|
auto_cleanup_action?: 'delete' | 'archive';
|
||||||
log_stream_events?: boolean;
|
log_stream_events?: boolean;
|
||||||
|
auto_vod_download_streamers?: string[];
|
||||||
|
auto_vod_download_poll_minutes?: number;
|
||||||
|
auto_vod_max_age_hours?: number;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -88,6 +88,11 @@ const UI_TEXT_DE = {
|
|||||||
discordWebhookUrlLabel: 'Webhook-URL',
|
discordWebhookUrlLabel: 'Webhook-URL',
|
||||||
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
|
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
|
||||||
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
|
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
|
||||||
|
discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen',
|
||||||
|
autoVodCardTitle: 'Auto-VOD-Download',
|
||||||
|
autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.',
|
||||||
|
autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
|
||||||
|
autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)',
|
||||||
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
|
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
|
||||||
backupCardTitle: 'Sicherung & Wartung',
|
backupCardTitle: 'Sicherung & Wartung',
|
||||||
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
|
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
|
||||||
@ -261,7 +266,10 @@ const UI_TEXT_DE = {
|
|||||||
liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden',
|
liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden',
|
||||||
autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf',
|
autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf',
|
||||||
autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...',
|
autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...',
|
||||||
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.'
|
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.',
|
||||||
|
autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen',
|
||||||
|
autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.',
|
||||||
|
autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.'
|
||||||
},
|
},
|
||||||
vods: {
|
vods: {
|
||||||
noneTitle: 'Keine VODs',
|
noneTitle: 'Keine VODs',
|
||||||
|
|||||||
@ -89,6 +89,11 @@ const UI_TEXT_EN = {
|
|||||||
discordNotifyLiveStartLabel: 'Notify on live recording start',
|
discordNotifyLiveStartLabel: 'Notify on live recording start',
|
||||||
discordNotifyLiveEndLabel: 'Notify on live recording end',
|
discordNotifyLiveEndLabel: 'Notify on live recording end',
|
||||||
discordNotifyVodCompleteLabel: 'Notify on completed VOD download',
|
discordNotifyVodCompleteLabel: 'Notify on completed VOD download',
|
||||||
|
discordNotifyVodAutoQueuedLabel: 'Notify when a VOD gets auto-queued',
|
||||||
|
autoVodCardTitle: 'Auto-VOD download',
|
||||||
|
autoVodCardIntro: 'Streamers with the VOD toggle on are scanned for new Twitch VODs at the interval set here. New VODs within the age window are added to the download queue automatically.',
|
||||||
|
autoVodPollMinutesLabel: 'Poll interval (minutes)',
|
||||||
|
autoVodMaxAgeHoursLabel: 'Max age (hours)',
|
||||||
backupCardTitle: 'Backup & Maintenance',
|
backupCardTitle: 'Backup & Maintenance',
|
||||||
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
|
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
|
||||||
exportConfig: 'Export config',
|
exportConfig: 'Export config',
|
||||||
@ -261,7 +266,10 @@ const UI_TEXT_EN = {
|
|||||||
liveRecordingFailed: 'Could not start live recording',
|
liveRecordingFailed: 'Could not start live recording',
|
||||||
autoRecordTitle: 'Auto-record: when this streamer goes live the app records automatically',
|
autoRecordTitle: 'Auto-record: when this streamer goes live the app records automatically',
|
||||||
autoRecordEnabled: 'Auto-record enabled for {streamer}. Polling for live state...',
|
autoRecordEnabled: 'Auto-record enabled for {streamer}. Polling for live state...',
|
||||||
autoRecordDisabled: 'Auto-record disabled for {streamer}.'
|
autoRecordDisabled: 'Auto-record disabled for {streamer}.',
|
||||||
|
autoVodTitle: 'Auto-download new VODs (recently published) for this streamer',
|
||||||
|
autoVodEnabled: 'Auto-VOD enabled for {streamer}. Will pick up new VODs.',
|
||||||
|
autoVodDisabled: 'Auto-VOD disabled for {streamer}.'
|
||||||
},
|
},
|
||||||
vods: {
|
vods: {
|
||||||
noneTitle: 'No VODs',
|
noneTitle: 'No VODs',
|
||||||
|
|||||||
@ -558,6 +558,9 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
|||||||
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
||||||
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
|
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
|
||||||
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
|
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
|
||||||
|
discord_notify_vod_auto_queued: byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked,
|
||||||
|
auto_vod_download_poll_minutes: parseInt(byId<HTMLInputElement>('autoVodPollMinutes').value, 10) || 15,
|
||||||
|
auto_vod_max_age_hours: parseInt(byId<HTMLInputElement>('autoVodMaxAgeHours').value, 10) || 24,
|
||||||
auto_cleanup_enabled: byId<HTMLInputElement>('autoCleanupEnabledToggle').checked,
|
auto_cleanup_enabled: byId<HTMLInputElement>('autoCleanupEnabledToggle').checked,
|
||||||
auto_cleanup_days: parseInt(byId<HTMLInputElement>('autoCleanupDays').value, 10) || 30,
|
auto_cleanup_days: parseInt(byId<HTMLInputElement>('autoCleanupDays').value, 10) || 30,
|
||||||
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
|
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
|
||||||
@ -615,6 +618,9 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
|||||||
effective.discord_notify_live_start === true,
|
effective.discord_notify_live_start === true,
|
||||||
effective.discord_notify_live_end === true,
|
effective.discord_notify_live_end === true,
|
||||||
effective.discord_notify_vod_complete === true,
|
effective.discord_notify_vod_complete === true,
|
||||||
|
effective.discord_notify_vod_auto_queued === true,
|
||||||
|
effective.auto_vod_download_poll_minutes ?? 15,
|
||||||
|
effective.auto_vod_max_age_hours ?? 24,
|
||||||
effective.auto_cleanup_enabled === true,
|
effective.auto_cleanup_enabled === true,
|
||||||
effective.auto_cleanup_days ?? 30,
|
effective.auto_cleanup_days ?? 30,
|
||||||
effective.auto_cleanup_target ?? 'live_only',
|
effective.auto_cleanup_target ?? 'live_only',
|
||||||
@ -647,6 +653,9 @@ function syncSettingsFormFromConfig(): void {
|
|||||||
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
|
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
|
||||||
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
|
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
|
||||||
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
|
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
|
||||||
|
byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked = (config.discord_notify_vod_auto_queued as boolean) === true;
|
||||||
|
byId<HTMLInputElement>('autoVodPollMinutes').value = String((config.auto_vod_download_poll_minutes as number) || 15);
|
||||||
|
byId<HTMLInputElement>('autoVodMaxAgeHours').value = String((config.auto_vod_max_age_hours as number) || 24);
|
||||||
byId<HTMLInputElement>('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true;
|
byId<HTMLInputElement>('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true;
|
||||||
byId<HTMLInputElement>('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
|
byId<HTMLInputElement>('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
|
||||||
byId<HTMLSelectElement>('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';
|
byId<HTMLSelectElement>('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';
|
||||||
|
|||||||
@ -421,6 +421,22 @@ function renderStreamers(): void {
|
|||||||
void toggleAutoRecord(streamer);
|
void toggleAutoRecord(streamer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// VOD-auto-download toggle — when enabled, the main-process auto-VOD
|
||||||
|
// poller scans this streamer's VOD list periodically and queues new
|
||||||
|
// VODs published in the rolling window automatically. Complements
|
||||||
|
// AUTO (live capture): VOD covers downtime + transcoded archive,
|
||||||
|
// AUTO covers a stream as it happens. Useful for both.
|
||||||
|
const vodList = (config.auto_vod_download_streamers as string[] | undefined) || [];
|
||||||
|
const isVodOn = vodList.includes(streamer);
|
||||||
|
const vodBtn = document.createElement('span');
|
||||||
|
vodBtn.className = 'streamer-vod' + (isVodOn ? ' active' : '');
|
||||||
|
vodBtn.textContent = 'VOD';
|
||||||
|
vodBtn.title = UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs';
|
||||||
|
vodBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void toggleAutoVodDownload(streamer);
|
||||||
|
});
|
||||||
|
|
||||||
// Live-record button — small red dot, only triggers a live capture
|
// Live-record button — small red dot, only triggers a live capture
|
||||||
// when the streamer is currently online (server checks via Helix).
|
// when the streamer is currently online (server checks via Helix).
|
||||||
const recBtn = document.createElement('span');
|
const recBtn = document.createElement('span');
|
||||||
@ -438,7 +454,7 @@ function renderStreamers(): void {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void removeStreamer(streamer);
|
void removeStreamer(streamer);
|
||||||
});
|
});
|
||||||
item.append(nameSpan, autoBtn, recBtn, removeSpan);
|
item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan);
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
// Skip click if drag was just released — drop fires after dragend
|
// Skip click if drag was just released — drop fires after dragend
|
||||||
@ -875,6 +891,25 @@ async function toggleAutoRecord(streamer: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleAutoVodDownload(streamer: string): Promise<void> {
|
||||||
|
const current = ((config.auto_vod_download_streamers as string[]) || []).slice();
|
||||||
|
const idx = current.indexOf(streamer);
|
||||||
|
if (idx >= 0) {
|
||||||
|
current.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
current.push(streamer);
|
||||||
|
}
|
||||||
|
config = await window.api.saveConfig({ auto_vod_download_streamers: current });
|
||||||
|
renderStreamers();
|
||||||
|
|
||||||
|
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||||
|
if (toast) {
|
||||||
|
const wasAdded = idx < 0;
|
||||||
|
const tmpl = wasAdded ? UI_TEXT.streamers.autoVodEnabled : UI_TEXT.streamers.autoVodDisabled;
|
||||||
|
toast(tmpl.replace('{streamer}', streamer), 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function triggerLiveRecording(streamer: string): Promise<void> {
|
async function triggerLiveRecording(streamer: string): Promise<void> {
|
||||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||||
const result = await window.api.startLiveRecording(streamer);
|
const result = await window.api.startLiveRecording(streamer);
|
||||||
|
|||||||
@ -197,6 +197,11 @@ function applyLanguageToStaticUI(): void {
|
|||||||
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
|
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
|
||||||
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
|
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
|
||||||
setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel);
|
setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel);
|
||||||
|
setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel);
|
||||||
|
setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle);
|
||||||
|
setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);
|
||||||
|
setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel);
|
||||||
|
setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel);
|
||||||
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
|
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
|
||||||
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
|
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
|
||||||
setText('btnExportConfig', UI_TEXT.static.exportConfig);
|
setText('btnExportConfig', UI_TEXT.static.exportConfig);
|
||||||
|
|||||||
@ -669,6 +669,31 @@ body {
|
|||||||
color: #00c853;
|
color: #00c853;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.streamer-vod {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-vod.active {
|
||||||
|
color: #2196f3;
|
||||||
|
border-color: rgba(33, 150, 243, 0.45);
|
||||||
|
background: rgba(33, 150, 243, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-vod:hover {
|
||||||
|
background: rgba(33, 150, 243, 0.18);
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-live-badge {
|
.queue-live-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #ff4444;
|
background: #ff4444;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user