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">
|
||||
<span id="discordNotifyVodCompleteLabel">Bei abgeschlossenem VOD-Download benachrichtigen</span>
|
||||
</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>
|
||||
|
||||
|
||||
159
src/main.ts
159
src/main.ts
@ -221,11 +221,15 @@ interface Config {
|
||||
discord_notify_live_start: boolean;
|
||||
discord_notify_live_end: boolean;
|
||||
discord_notify_vod_complete: boolean;
|
||||
discord_notify_vod_auto_queued: boolean;
|
||||
auto_cleanup_enabled: boolean;
|
||||
auto_cleanup_days: number;
|
||||
auto_cleanup_target: 'live_only' | 'all';
|
||||
auto_cleanup_action: 'delete' | 'archive';
|
||||
log_stream_events: boolean;
|
||||
auto_vod_download_streamers: string[];
|
||||
auto_vod_download_poll_minutes: number;
|
||||
auto_vod_max_age_hours: number;
|
||||
}
|
||||
|
||||
interface RuntimeMetrics {
|
||||
@ -351,11 +355,15 @@ const defaultConfig: Config = {
|
||||
discord_notify_live_start: false,
|
||||
discord_notify_live_end: false,
|
||||
discord_notify_vod_complete: false,
|
||||
discord_notify_vod_auto_queued: false,
|
||||
auto_cleanup_enabled: false,
|
||||
auto_cleanup_days: 30,
|
||||
auto_cleanup_target: 'live_only',
|
||||
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;
|
||||
@ -462,6 +470,7 @@ function normalizeConfigTemplates(input: Config): Config {
|
||||
discord_notify_live_start: input.discord_notify_live_start === true,
|
||||
discord_notify_live_end: input.discord_notify_live_end === 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_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_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
|
||||
// ==========================================
|
||||
@ -5153,6 +5297,8 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
||||
const previousTheme = config.theme;
|
||||
const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []);
|
||||
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 });
|
||||
|
||||
@ -5195,6 +5341,13 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
||||
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
|
||||
// unchanged because restartAutoCleanupTimer just resets the interval.
|
||||
restartAutoCleanupTimer();
|
||||
@ -5958,6 +6111,7 @@ app.whenReady().then(() => {
|
||||
startMetadataCacheCleanup();
|
||||
startDebugLogFlushTimer();
|
||||
restartAutoRecordPoller();
|
||||
restartAutoVodPoller();
|
||||
restartAutoCleanupTimer();
|
||||
createWindow();
|
||||
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
||||
@ -5986,6 +6140,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
|
||||
cleanupMetadataCaches('shutdown');
|
||||
stopAutoUpdatePolling();
|
||||
stopAutoRecordPoller();
|
||||
stopAutoVodPoller();
|
||||
stopAutoCleanupTimer();
|
||||
|
||||
// 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_end?: boolean;
|
||||
discord_notify_vod_complete?: boolean;
|
||||
discord_notify_vod_auto_queued?: boolean;
|
||||
auto_cleanup_enabled?: boolean;
|
||||
auto_cleanup_days?: number;
|
||||
auto_cleanup_target?: 'live_only' | 'all';
|
||||
auto_cleanup_action?: 'delete' | 'archive';
|
||||
log_stream_events?: boolean;
|
||||
auto_vod_download_streamers?: string[];
|
||||
auto_vod_download_poll_minutes?: number;
|
||||
auto_vod_max_age_hours?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@ -88,6 +88,11 @@ const UI_TEXT_DE = {
|
||||
discordWebhookUrlLabel: 'Webhook-URL',
|
||||
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start 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',
|
||||
backupCardTitle: 'Sicherung & Wartung',
|
||||
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',
|
||||
autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf',
|
||||
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: {
|
||||
noneTitle: 'Keine VODs',
|
||||
|
||||
@ -89,6 +89,11 @@ const UI_TEXT_EN = {
|
||||
discordNotifyLiveStartLabel: 'Notify on live recording start',
|
||||
discordNotifyLiveEndLabel: 'Notify on live recording end',
|
||||
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',
|
||||
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
|
||||
exportConfig: 'Export config',
|
||||
@ -261,7 +266,10 @@ const UI_TEXT_EN = {
|
||||
liveRecordingFailed: 'Could not start live recording',
|
||||
autoRecordTitle: 'Auto-record: when this streamer goes live the app records automatically',
|
||||
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: {
|
||||
noneTitle: 'No VODs',
|
||||
|
||||
@ -558,6 +558,9 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
||||
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').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_days: parseInt(byId<HTMLInputElement>('autoCleanupDays').value, 10) || 30,
|
||||
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_end === 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_days ?? 30,
|
||||
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>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end 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>('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
|
||||
byId<HTMLSelectElement>('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';
|
||||
|
||||
@ -421,6 +421,22 @@ function renderStreamers(): void {
|
||||
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
|
||||
// when the streamer is currently online (server checks via Helix).
|
||||
const recBtn = document.createElement('span');
|
||||
@ -438,7 +454,7 @@ function renderStreamers(): void {
|
||||
e.stopPropagation();
|
||||
void removeStreamer(streamer);
|
||||
});
|
||||
item.append(nameSpan, autoBtn, recBtn, removeSpan);
|
||||
item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan);
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
// 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> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const result = await window.api.startLiveRecording(streamer);
|
||||
|
||||
@ -197,6 +197,11 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
|
||||
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
|
||||
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('backupCardIntro', UI_TEXT.static.backupCardIntro);
|
||||
setText('btnExportConfig', UI_TEXT.static.exportConfig);
|
||||
|
||||
@ -669,6 +669,31 @@ body {
|
||||
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 {
|
||||
display: inline-block;
|
||||
background: #ff4444;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user