Compare commits

..

No commits in common. "ddaf4807f4b3129a2e44fe5d08b95c401bad40aa" and "2f1e5f4a9ea9c73e7cf49750d44571a6c3adec1c" have entirely different histories.

11 changed files with 8 additions and 272 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "4.6.10",
"version": "4.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.6.10",
"version": "4.6.9",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

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

View File

@ -686,21 +686,6 @@
<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>

View File

@ -221,15 +221,11 @@ 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 {
@ -355,15 +351,11 @@ 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,
auto_vod_download_streamers: [],
auto_vod_download_poll_minutes: 15,
auto_vod_max_age_hours: 24
log_stream_events: true
};
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
@ -470,7 +462,6 @@ 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);
@ -479,18 +470,7 @@ 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,
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)));
})()
log_stream_events: input.log_stream_events !== false
};
}
@ -3031,130 +3011,6 @@ 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
// ==========================================
@ -5297,8 +5153,6 @@ 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 });
@ -5341,13 +5195,6 @@ 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();
@ -6111,7 +5958,6 @@ app.whenReady().then(() => {
startMetadataCacheCleanup();
startDebugLogFlushTimer();
restartAutoRecordPoller();
restartAutoVodPoller();
restartAutoCleanupTimer();
createWindow();
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
@ -6140,7 +5986,6 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
cleanupMetadataCaches('shutdown');
stopAutoUpdatePolling();
stopAutoRecordPoller();
stopAutoVodPoller();
stopAutoCleanupTimer();
// Kill all active children: queue downloads, standalone clip downloads,

View File

@ -29,15 +29,11 @@ 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;
}

View File

@ -88,11 +88,6 @@ 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.',
@ -266,10 +261,7 @@ 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.',
autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen',
autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.',
autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.'
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.'
},
vods: {
noneTitle: 'Keine VODs',

View File

@ -89,11 +89,6 @@ 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',
@ -266,10 +261,7 @@ 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}.',
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}.'
autoRecordDisabled: 'Auto-record disabled for {streamer}.'
},
vods: {
noneTitle: 'No VODs',

View File

@ -558,9 +558,6 @@ 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',
@ -618,9 +615,6 @@ 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',
@ -653,9 +647,6 @@ 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';

View File

@ -421,22 +421,6 @@ 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');
@ -454,7 +438,7 @@ function renderStreamers(): void {
e.stopPropagation();
void removeStreamer(streamer);
});
item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan);
item.append(nameSpan, autoBtn, recBtn, removeSpan);
item.addEventListener('click', () => {
// Skip click if drag was just released — drop fires after dragend
@ -891,25 +875,6 @@ 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);

View File

@ -197,11 +197,6 @@ 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);

View File

@ -669,31 +669,6 @@ 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;