release: veröffentliche Twitch VOD Manager 1.0.1
Startet die öffentliche Versionslinie mit einer bereinigten Ein-Commit-Historie, stellt den Updater auf GitHub Releases um, entfernt interne Release-Ziele und beschränkt den gepackten Anwendungssatz auf notwendige Laufzeitdateien. Enthält aktualisierte produktive Abhängigkeiten ohne bekannte npm-Audit-Funde sowie die geprüfte öffentliche Quell-Positivliste.
This commit is contained in:
@@ -0,0 +1,984 @@
|
||||
let lastRuntimeMetricsOutput = '';
|
||||
let lastDebugLogOutput = '';
|
||||
let settingsAutoSaveBound = false;
|
||||
let settingsAutoSaveInFlight = false;
|
||||
let pendingSettingsAutoSave = false;
|
||||
let settingsAutoSaveTimer: number | null = null;
|
||||
let pendingCredentialsReconnect = false;
|
||||
let lastPersistedSettingsFingerprint = '';
|
||||
|
||||
function canRunSettingsAutoRefresh(): boolean {
|
||||
if (document.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return document.querySelector('.tab-content.active')?.id === 'settingsTab';
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
|
||||
if (!hasCredentials) {
|
||||
isConnected = false;
|
||||
updateStatus(UI_TEXT.status.noLogin, false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(UI_TEXT.status.connecting, false);
|
||||
const success = await window.api.login();
|
||||
isConnected = success;
|
||||
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success);
|
||||
}
|
||||
|
||||
function formatBytesForMetrics(bytes: number): string {
|
||||
const value = Math.max(0, Number(bytes) || 0);
|
||||
if (value < 1024) return `${value.toFixed(0)} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function validateFilenameTemplates(showAlert = false): boolean {
|
||||
const templates = [
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value.trim(),
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value.trim(),
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim()
|
||||
];
|
||||
|
||||
const unknown = templates.flatMap((template) => collectUnknownTemplatePlaceholders(template));
|
||||
const uniqueUnknown = Array.from(new Set(unknown));
|
||||
const lintNode = byId('filenameTemplateLint');
|
||||
|
||||
if (!uniqueUnknown.length) {
|
||||
lintNode.className = 'template-lint ok';
|
||||
lintNode.textContent = UI_TEXT.static.templateLintOk;
|
||||
return true;
|
||||
}
|
||||
|
||||
lintNode.className = 'template-lint warn';
|
||||
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
|
||||
|
||||
if (showAlert) {
|
||||
alert(`${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyTemplatePreset(preset: string): void {
|
||||
const presets: Record<string, { vod: string; parts: string; clip: string }> = {
|
||||
default: {
|
||||
vod: '{title}.mp4',
|
||||
parts: '{date}_Part{part_padded}.mp4',
|
||||
clip: '{date}_{part}.mp4'
|
||||
},
|
||||
archive: {
|
||||
vod: '{channel}_{date_custom="yyyy-MM-dd"}_{title}.mp4',
|
||||
parts: '{channel}_{date_custom="yyyy-MM-dd"}_Part{part_padded}.mp4',
|
||||
clip: '{channel}_{date_custom="yyyy-MM-dd"}_{trim_start}_{part}.mp4'
|
||||
},
|
||||
clipper: {
|
||||
vod: '{date_custom="yyyy-MM-dd"}_{title}.mp4',
|
||||
parts: '{date_custom="yyyy-MM-dd"}_{part_padded}_{trim_start}.mp4',
|
||||
clip: '{title}_{trim_start_custom="HH-mm-ss"}_{part}.mp4'
|
||||
}
|
||||
};
|
||||
|
||||
const selected = presets[preset] || presets.default;
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value = selected.vod;
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
|
||||
validateFilenameTemplates();
|
||||
// Programmatic .value = ... does not trigger the 'input' event the
|
||||
// template inputs listen on for debounced save, so the preset click
|
||||
// would otherwise look applied but never persist until the user
|
||||
// types into one of the inputs. Schedule the save explicitly.
|
||||
scheduleSettingsAutoSave();
|
||||
}
|
||||
|
||||
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
|
||||
const output = byId('runtimeMetricsOutput');
|
||||
if (showLoading) {
|
||||
output.textContent = UI_TEXT.static.runtimeMetricsLoading;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await window.api.getRuntimeMetrics();
|
||||
const lines = [
|
||||
`${UI_TEXT.static.runtimeMetricQueue}: ${metrics.queue.total} total (${metrics.queue.pending} pending, ${metrics.queue.downloading} downloading, ${metrics.queue.error} failed)`,
|
||||
`${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`,
|
||||
`${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`,
|
||||
`${UI_TEXT.static.runtimeMetricIntegrity}: ${metrics.integrityFailures}`,
|
||||
`${UI_TEXT.static.runtimeMetricCache}: hits=${metrics.cacheHits}, misses=${metrics.cacheMisses}, vod=${metrics.caches.vodList}, users=${metrics.caches.loginToUserId}, clips=${metrics.caches.clipInfo}`,
|
||||
`${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`,
|
||||
`${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`,
|
||||
`${UI_TEXT.static.runtimeMetricActive}: ${metrics.activeItemTitle || '-'} (${metrics.activeItemId || '-'})`,
|
||||
`${UI_TEXT.static.runtimeMetricLastError}: ${metrics.lastErrorClass || '-'}, retryDelay=${metrics.lastRetryDelaySeconds}s`,
|
||||
`${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}`
|
||||
];
|
||||
|
||||
const nextOutput = lines.join('\n');
|
||||
if (nextOutput !== lastRuntimeMetricsOutput) {
|
||||
output.textContent = nextOutput;
|
||||
lastRuntimeMetricsOutput = nextOutput;
|
||||
}
|
||||
} catch {
|
||||
if (lastRuntimeMetricsOutput !== UI_TEXT.static.runtimeMetricsError) {
|
||||
output.textContent = UI_TEXT.static.runtimeMetricsError;
|
||||
lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRuntimeMetrics(): Promise<void> {
|
||||
const result = await window.api.exportRuntimeMetrics();
|
||||
|
||||
const toast = (window as unknown as { showAppToast?: (message: string, type?: 'info' | 'warn') => void }).showAppToast;
|
||||
const notify = (message: string, type: 'info' | 'warn' = 'info') => {
|
||||
if (typeof toast === 'function') {
|
||||
toast(message, type);
|
||||
} else if (type === 'warn') {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
notify(UI_TEXT.static.runtimeMetricsExportDone, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.cancelled) {
|
||||
notify(UI_TEXT.static.runtimeMetricsExportCancelled, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
notify(`${UI_TEXT.static.runtimeMetricsExportFailed}${result.error ? `\n${result.error}` : ''}`, 'warn');
|
||||
}
|
||||
|
||||
function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
|
||||
if (runtimeMetricsAutoRefreshTimer) {
|
||||
clearInterval(runtimeMetricsAutoRefreshTimer);
|
||||
runtimeMetricsAutoRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
runtimeMetricsAutoRefreshTimer = window.setInterval(() => {
|
||||
if (!canRunSettingsAutoRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshRuntimeMetrics(false);
|
||||
void refreshAutomationStatusLine();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(text: string, connected: boolean): void {
|
||||
byId('statusText').textContent = text;
|
||||
const dot = byId('statusDot');
|
||||
dot.classList.remove('connected', 'error');
|
||||
dot.classList.add(connected ? 'connected' : 'error');
|
||||
}
|
||||
|
||||
function changeLanguage(lang: string): void {
|
||||
const normalized = setLanguage(lang);
|
||||
byId<HTMLSelectElement>('languageSelect').value = normalized;
|
||||
updateLanguagePicker(normalized);
|
||||
config.language = normalized;
|
||||
void window.api.saveConfig({ language: normalized });
|
||||
|
||||
const currentStatus = byId('statusText').textContent?.trim() || '';
|
||||
updateStatus(localizeCurrentStatusText(currentStatus), isConnected);
|
||||
|
||||
renderQueue();
|
||||
renderStreamers();
|
||||
// Re-render the VOD grid so the dynamically built button labels
|
||||
// (trim / queue) and the filter empty-state pick up the new locale.
|
||||
renderVodGridFromCurrentState();
|
||||
refreshVodSortSelectLabels();
|
||||
|
||||
const activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab';
|
||||
const activeTab = activeTabId.replace('Tab', '');
|
||||
const titleText = (activeTab === 'vods' && currentStreamer)
|
||||
? currentStreamer
|
||||
: ((UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName);
|
||||
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
|
||||
if (typeof setTitle === 'function') setTitle(titleText);
|
||||
else byId('pageTitle').textContent = titleText;
|
||||
|
||||
void refreshRuntimeMetrics();
|
||||
void refreshAutomationStatusLine();
|
||||
validateFilenameTemplates();
|
||||
}
|
||||
|
||||
function updateLanguagePicker(lang: string): void {
|
||||
const de = byId<HTMLButtonElement>('langOptionDe');
|
||||
const en = byId<HTMLButtonElement>('langOptionEn');
|
||||
|
||||
const isDe = lang === 'de';
|
||||
de.classList.toggle('active', isDe);
|
||||
en.classList.toggle('active', !isDe);
|
||||
de.setAttribute('aria-pressed', String(isDe));
|
||||
en.setAttribute('aria-pressed', String(!isDe));
|
||||
}
|
||||
|
||||
function selectLanguageOption(lang: string): void {
|
||||
changeLanguage(lang);
|
||||
}
|
||||
|
||||
function renderPreflightResult(result: PreflightResult): void {
|
||||
const entries = [
|
||||
[UI_TEXT.static.preflightInternet, result.checks.internet],
|
||||
[UI_TEXT.static.preflightStreamlink, result.checks.streamlink],
|
||||
[UI_TEXT.static.preflightFfmpeg, result.checks.ffmpeg],
|
||||
[UI_TEXT.static.preflightFfprobe, result.checks.ffprobe],
|
||||
[UI_TEXT.static.preflightPath, result.checks.downloadPathWritable]
|
||||
];
|
||||
|
||||
const lines = entries.map(([name, ok]) => `${ok ? 'OK' : 'FAIL'} ${name}`).join('\n');
|
||||
const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : `\n\n${UI_TEXT.static.preflightReady}`;
|
||||
|
||||
byId('preflightResult').textContent = `${lines}${extra}`;
|
||||
|
||||
const badge = byId('healthBadge');
|
||||
badge.classList.remove('good', 'warn', 'bad', 'unknown');
|
||||
|
||||
if (result.ok) {
|
||||
badge.classList.add('good');
|
||||
badge.textContent = UI_TEXT.static.healthGood;
|
||||
return;
|
||||
}
|
||||
|
||||
const failCount = Object.values(result.checks).filter((ok) => !ok).length;
|
||||
if (failCount <= 2) {
|
||||
badge.classList.add('warn');
|
||||
badge.textContent = UI_TEXT.static.healthWarn;
|
||||
} else {
|
||||
badge.classList.add('bad');
|
||||
badge.textContent = UI_TEXT.static.healthBad;
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreflight(autoFix = false): Promise<void> {
|
||||
const btn = byId<HTMLButtonElement>(autoFix ? 'btnPreflightFix' : 'btnPreflightRun');
|
||||
const old = btn.textContent || '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = autoFix ? UI_TEXT.static.preflightFixing : UI_TEXT.static.preflightChecking;
|
||||
|
||||
try {
|
||||
const result = await window.api.runPreflight(autoFix);
|
||||
renderPreflightResult(result);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCleanupDryRun(): Promise<void> {
|
||||
await runCleanupOnce(true);
|
||||
}
|
||||
|
||||
async function runCleanupNow(): Promise<void> {
|
||||
await runCleanupOnce(false);
|
||||
}
|
||||
|
||||
async function runCleanupOnce(dryRun: boolean): Promise<void> {
|
||||
const reportEl = byId('cleanupReport');
|
||||
const dryBtn = byId<HTMLButtonElement>('btnCleanupDryRun');
|
||||
const runBtn = byId<HTMLButtonElement>('btnCleanupRunNow');
|
||||
dryBtn.disabled = true;
|
||||
runBtn.disabled = true;
|
||||
reportEl.textContent = UI_TEXT.static.storageScanning;
|
||||
|
||||
try {
|
||||
const report = await window.api.runStorageCleanup({ dryRun });
|
||||
if (report.candidates === 0) {
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportEmpty.replace('{days}', String(report.cutoffDays));
|
||||
} else if (dryRun) {
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportPreview
|
||||
.replace('{count}', String(report.candidates))
|
||||
.replace('{size}', formatBytesForMetrics(report.bytesFreed));
|
||||
} else {
|
||||
const failedSuffix = report.failed > 0
|
||||
? UI_TEXT.static.cleanupReportFailedSuffix.replace('{failed}', String(report.failed))
|
||||
: '';
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportDone
|
||||
.replace('{count}', String(report.processed))
|
||||
.replace('{size}', formatBytesForMetrics(report.bytesFreed))
|
||||
.replace('{failed}', failedSuffix);
|
||||
// Refresh the storage list since files moved/disappeared.
|
||||
void refreshStorageStats();
|
||||
}
|
||||
} catch (e) {
|
||||
reportEl.textContent = String(e);
|
||||
} finally {
|
||||
dryBtn.disabled = false;
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStorageStats(): Promise<void> {
|
||||
const summary = byId('storageSummary');
|
||||
const list = byId('storageList');
|
||||
const btn = byId<HTMLButtonElement>('btnRefreshStorage');
|
||||
const old = btn.textContent || '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = UI_TEXT.static.storageScanning;
|
||||
summary.textContent = UI_TEXT.static.storageScanning;
|
||||
list.replaceChildren();
|
||||
|
||||
try {
|
||||
const stats = await window.api.getStorageStats();
|
||||
renderStorageStats(stats);
|
||||
} catch {
|
||||
summary.textContent = UI_TEXT.static.storageEmpty;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old || UI_TEXT.static.storageRefresh;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStorageStats(stats: StorageStatsResult): void {
|
||||
const summary = byId('storageSummary');
|
||||
const list = byId('storageList');
|
||||
|
||||
if (!stats.rootExists) {
|
||||
summary.textContent = UI_TEXT.static.storageEmpty;
|
||||
list.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
summary.textContent = UI_TEXT.static.storageSummary
|
||||
.replace('{files}', String(stats.totalFiles))
|
||||
.replace('{size}', formatBytesForMetrics(stats.totalBytes))
|
||||
.replace('{free}', stats.freeBytes !== null ? formatBytesForMetrics(stats.freeBytes) : '-');
|
||||
|
||||
list.replaceChildren();
|
||||
if (stats.streamers.length === 0 && stats.extras.length === 0) return;
|
||||
|
||||
const buildTable = (rows: StreamerStorageEntry[]): HTMLTableElement => {
|
||||
const table = document.createElement('table');
|
||||
table.className = 'storage-stats-table';
|
||||
|
||||
const thead = document.createElement('thead');
|
||||
const headRow = document.createElement('tr');
|
||||
const headers = [
|
||||
UI_TEXT.static.storageColumnFolder,
|
||||
UI_TEXT.static.storageColumnFiles,
|
||||
UI_TEXT.static.storageColumnTotal,
|
||||
UI_TEXT.static.storageColumnLive,
|
||||
UI_TEXT.static.storageColumnChat,
|
||||
''
|
||||
];
|
||||
for (const h of headers) {
|
||||
const th = document.createElement('th');
|
||||
th.scope = 'col';
|
||||
if (h) {
|
||||
th.textContent = h;
|
||||
} else {
|
||||
th.setAttribute('aria-label', UI_TEXT.static.storageColumnActionsAria);
|
||||
}
|
||||
headRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement('tbody');
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const cells: Array<string | HTMLElement> = [
|
||||
row.name,
|
||||
String(row.fileCount),
|
||||
formatBytesForMetrics(row.totalBytes),
|
||||
row.liveBytes > 0 ? formatBytesForMetrics(row.liveBytes) : '-',
|
||||
row.chatBytes > 0 ? formatBytesForMetrics(row.chatBytes) : '-'
|
||||
];
|
||||
for (const c of cells) {
|
||||
const td = document.createElement('td');
|
||||
if (typeof c === 'string') td.textContent = c;
|
||||
else td.appendChild(c);
|
||||
tr.appendChild(td);
|
||||
}
|
||||
const openCell = document.createElement('td');
|
||||
const openBtn = document.createElement('button');
|
||||
openBtn.type = 'button';
|
||||
openBtn.textContent = UI_TEXT.static.storageOpen;
|
||||
openBtn.className = 'btn-pill';
|
||||
openBtn.addEventListener('click', () => {
|
||||
void window.api.openFolder(row.folderPath);
|
||||
});
|
||||
openCell.appendChild(openBtn);
|
||||
tr.appendChild(openCell);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
if (stats.streamers.length > 0) {
|
||||
list.appendChild(buildTable(stats.streamers));
|
||||
}
|
||||
if (stats.extras.length > 0) {
|
||||
const heading = document.createElement('div');
|
||||
heading.textContent = UI_TEXT.static.storageOtherFolders;
|
||||
heading.className = 'storage-stats-section';
|
||||
list.appendChild(heading);
|
||||
list.appendChild(buildTable(stats.extras));
|
||||
}
|
||||
}
|
||||
|
||||
async function exportConfigToFile(): Promise<void> {
|
||||
const result = await window.api.exportConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
if (toast) toast(UI_TEXT.static.configExported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configExportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function importConfigFromFile(): Promise<void> {
|
||||
const result = await window.api.importConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Reload local config copy + refresh forms / streamer list / VOD grid
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof setLanguage === 'function' && typeof config.language === 'string') {
|
||||
setLanguage(config.language);
|
||||
}
|
||||
if (typeof renderStreamers === 'function') renderStreamers();
|
||||
if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore — next refresh will catch up */ }
|
||||
if (toast) toast(UI_TEXT.static.configImported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configImportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDownloadedIds(): Promise<void> {
|
||||
if (!confirm(UI_TEXT.static.resetDownloadedConfirm)) return;
|
||||
const result = await window.api.resetDownloadedVodIds();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Refresh local config so the badges disappear immediately
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (toast) {
|
||||
toast(UI_TEXT.static.resetDownloadedDone.replace('{count}', String(result.removedCount)), 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openDebugLogFile(): Promise<void> {
|
||||
const ok = await window.api.openDebugLogFile();
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast('Debug log file not yet present.', 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDebugLog(): Promise<void> {
|
||||
const text = await window.api.getDebugLog(250);
|
||||
const panel = byId('debugLogOutput');
|
||||
const keepAtBottom = (panel.scrollHeight - panel.scrollTop - panel.clientHeight) < 20;
|
||||
|
||||
if (text !== lastDebugLogOutput) {
|
||||
panel.textContent = text;
|
||||
lastDebugLogOutput = text;
|
||||
}
|
||||
|
||||
if (keepAtBottom) {
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDebugAutoRefresh(enabled: boolean): void {
|
||||
if (debugLogAutoRefreshTimer) {
|
||||
clearInterval(debugLogAutoRefreshTimer);
|
||||
debugLogAutoRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
debugLogAutoRefreshTimer = window.setInterval(() => {
|
||||
if (!canRunSettingsAutoRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshDebugLog();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function collectCredentialsPayload(): Partial<AppConfig> {
|
||||
return {
|
||||
client_id: byId<HTMLInputElement>('clientId').value.trim(),
|
||||
client_secret: byId<HTMLInputElement>('clientSecret').value.trim()
|
||||
};
|
||||
}
|
||||
|
||||
function syncPartMinutesFieldState(): void {
|
||||
const downloadMode = byId<HTMLSelectElement>('downloadMode').value;
|
||||
const partMinutes = byId<HTMLInputElement>('partMinutes');
|
||||
const label = byId<HTMLElement>('partMinutesLabel');
|
||||
const isSplitMode = downloadMode === 'parts';
|
||||
|
||||
partMinutes.disabled = !isSplitMode;
|
||||
partMinutes.setAttribute('aria-disabled', String(!isSplitMode));
|
||||
label.classList.toggle('input-disabled', !isSplitMode);
|
||||
}
|
||||
|
||||
function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||
return {
|
||||
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
|
||||
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
|
||||
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
|
||||
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
|
||||
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,
|
||||
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
|
||||
streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked,
|
||||
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
|
||||
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
|
||||
log_stream_events: byId<HTMLInputElement>('logStreamEventsToggle').checked,
|
||||
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
|
||||
auto_merge_resumed_parts: byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked,
|
||||
delete_parts_after_merge: byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked,
|
||||
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
|
||||
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',
|
||||
auto_cleanup_action: byId<HTMLSelectElement>('autoCleanupAction').value === 'delete' ? 'delete' : 'archive',
|
||||
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
||||
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
|
||||
};
|
||||
}
|
||||
|
||||
function collectFilenameTemplatePayload(showAlert = false): Partial<AppConfig> | null {
|
||||
if (!validateFilenameTemplates(showAlert)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
filename_template_vod: byId<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4',
|
||||
filename_template_parts: byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4',
|
||||
filename_template_clip: byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4'
|
||||
};
|
||||
}
|
||||
|
||||
function collectAutoSavePayload(): Partial<AppConfig> {
|
||||
const payload: Partial<AppConfig> = {
|
||||
...collectCredentialsPayload(),
|
||||
...collectDownloadSettingsPayload()
|
||||
};
|
||||
|
||||
const templatePayload = collectFilenameTemplatePayload(false);
|
||||
if (templatePayload) {
|
||||
Object.assign(payload, templatePayload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
||||
const effective = { ...config, ...payload };
|
||||
return JSON.stringify([
|
||||
effective.client_id ?? '',
|
||||
effective.client_secret ?? '',
|
||||
effective.download_mode ?? 'full',
|
||||
effective.part_minutes ?? 120,
|
||||
effective.parallel_downloads ?? 1,
|
||||
effective.performance_mode ?? 'balanced',
|
||||
effective.smart_queue_scheduler !== false,
|
||||
effective.prevent_duplicate_downloads !== false,
|
||||
effective.persist_queue_on_restart !== false,
|
||||
effective.auto_resume_queue_on_startup === true,
|
||||
effective.notify_on_each_completion === true,
|
||||
effective.streamlink_disable_ads !== false,
|
||||
effective.download_chat_replay === true,
|
||||
effective.capture_live_chat === true,
|
||||
effective.log_stream_events !== false,
|
||||
effective.auto_resume_live_recording !== false,
|
||||
effective.auto_merge_resumed_parts === true,
|
||||
effective.delete_parts_after_merge === true,
|
||||
effective.discord_webhook_url ?? '',
|
||||
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',
|
||||
effective.auto_cleanup_action ?? 'archive',
|
||||
effective.streamlink_quality ?? 'best',
|
||||
effective.metadata_cache_minutes ?? 10,
|
||||
effective.filename_template_vod ?? '{title}.mp4',
|
||||
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
|
||||
effective.filename_template_clip ?? '{date}_{part}.mp4'
|
||||
]);
|
||||
}
|
||||
|
||||
function syncSettingsFormFromConfig(): void {
|
||||
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
||||
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
|
||||
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
|
||||
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
|
||||
byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
|
||||
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
|
||||
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>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
||||
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
|
||||
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
|
||||
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
|
||||
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true;
|
||||
byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true;
|
||||
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
|
||||
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';
|
||||
byId<HTMLSelectElement>('autoCleanupAction').value = (config.auto_cleanup_action as string) === 'delete' ? 'delete' : 'archive';
|
||||
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
|
||||
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';
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
|
||||
syncPartMinutesFieldState();
|
||||
validateFilenameTemplates();
|
||||
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
|
||||
}
|
||||
|
||||
async function persistSettings(options: {
|
||||
includeCredentials?: boolean;
|
||||
includeTemplates?: boolean;
|
||||
reconnectAfterSave?: boolean;
|
||||
showTemplateAlert?: boolean;
|
||||
} = {}): Promise<boolean> {
|
||||
const payload: Partial<AppConfig> = {
|
||||
...collectDownloadSettingsPayload()
|
||||
};
|
||||
|
||||
if (options.includeCredentials) {
|
||||
Object.assign(payload, collectCredentialsPayload());
|
||||
}
|
||||
|
||||
if (options.includeTemplates !== false) {
|
||||
const templatePayload = collectFilenameTemplatePayload(options.showTemplateAlert);
|
||||
if (!templatePayload) {
|
||||
return false;
|
||||
}
|
||||
Object.assign(payload, templatePayload);
|
||||
}
|
||||
|
||||
config = await window.api.saveConfig(payload);
|
||||
syncSettingsFormFromConfig();
|
||||
pendingCredentialsReconnect = false;
|
||||
|
||||
if (options.reconnectAfterSave) {
|
||||
await connect();
|
||||
}
|
||||
|
||||
if (canRunSettingsAutoRefresh()) {
|
||||
await refreshRuntimeMetrics(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function flushSettingsAutoSave(reconnectAfterSave = false): Promise<void> {
|
||||
if (settingsAutoSaveTimer) {
|
||||
clearTimeout(settingsAutoSaveTimer);
|
||||
settingsAutoSaveTimer = null;
|
||||
}
|
||||
|
||||
const payload = collectAutoSavePayload();
|
||||
const fingerprint = getSettingsFingerprint(payload);
|
||||
|
||||
if (fingerprint === lastPersistedSettingsFingerprint) {
|
||||
if (reconnectAfterSave && pendingCredentialsReconnect) {
|
||||
pendingCredentialsReconnect = false;
|
||||
await connect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsAutoSaveInFlight) {
|
||||
pendingSettingsAutoSave = true;
|
||||
return;
|
||||
}
|
||||
|
||||
settingsAutoSaveInFlight = true;
|
||||
try {
|
||||
config = await window.api.saveConfig(payload);
|
||||
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
|
||||
if (reconnectAfterSave && pendingCredentialsReconnect) {
|
||||
pendingCredentialsReconnect = false;
|
||||
await connect();
|
||||
}
|
||||
} finally {
|
||||
settingsAutoSaveInFlight = false;
|
||||
if (pendingSettingsAutoSave) {
|
||||
pendingSettingsAutoSave = false;
|
||||
void flushSettingsAutoSave(pendingCredentialsReconnect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSettingsAutoSave(delayMs = 450): void {
|
||||
if (settingsAutoSaveTimer) {
|
||||
clearTimeout(settingsAutoSaveTimer);
|
||||
}
|
||||
|
||||
settingsAutoSaveTimer = window.setTimeout(() => {
|
||||
settingsAutoSaveTimer = null;
|
||||
void flushSettingsAutoSave(false);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function initSettingsAutoSave(): void {
|
||||
if (settingsAutoSaveBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
settingsAutoSaveBound = true;
|
||||
syncSettingsFormFromConfig();
|
||||
|
||||
const immediateSaveIds = [
|
||||
'downloadMode',
|
||||
'parallelDownloads',
|
||||
'performanceMode',
|
||||
'smartSchedulerToggle',
|
||||
'duplicatePreventionToggle',
|
||||
'persistQueueToggle',
|
||||
'autoResumeQueueToggle',
|
||||
'notifyEachCompletionToggle',
|
||||
'streamlinkDisableAdsToggle',
|
||||
'downloadChatReplayToggle',
|
||||
'captureLiveChatToggle',
|
||||
'logStreamEventsToggle',
|
||||
'discordNotifyLiveStartToggle',
|
||||
'discordNotifyLiveEndToggle',
|
||||
'discordNotifyVodCompleteToggle',
|
||||
'autoCleanupEnabledToggle',
|
||||
'autoCleanupTarget',
|
||||
'autoCleanupAction',
|
||||
'streamlinkQuality'
|
||||
] as const;
|
||||
|
||||
const debouncedSaveIds = [
|
||||
'partMinutes',
|
||||
'metadataCacheMinutes',
|
||||
'vodFilenameTemplate',
|
||||
'partsFilenameTemplate',
|
||||
'defaultClipFilenameTemplate',
|
||||
'discordWebhookUrl',
|
||||
'autoCleanupDays'
|
||||
] as const;
|
||||
|
||||
const credentialIds = [
|
||||
'clientId',
|
||||
'clientSecret'
|
||||
] as const;
|
||||
|
||||
const triggerImmediateSave = () => {
|
||||
void flushSettingsAutoSave(false);
|
||||
};
|
||||
|
||||
byId<HTMLSelectElement>('downloadMode').addEventListener('change', syncPartMinutesFieldState);
|
||||
|
||||
for (const id of immediateSaveIds) {
|
||||
const element = byId<HTMLInputElement | HTMLSelectElement>(id);
|
||||
element.addEventListener('change', triggerImmediateSave);
|
||||
element.addEventListener('blur', triggerImmediateSave);
|
||||
}
|
||||
|
||||
for (const id of debouncedSaveIds) {
|
||||
const element = byId<HTMLInputElement>(id);
|
||||
element.addEventListener('input', () => {
|
||||
scheduleSettingsAutoSave();
|
||||
});
|
||||
element.addEventListener('blur', () => {
|
||||
void flushSettingsAutoSave(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const id of credentialIds) {
|
||||
const element = byId<HTMLInputElement>(id);
|
||||
element.addEventListener('input', () => {
|
||||
pendingCredentialsReconnect = true;
|
||||
scheduleSettingsAutoSave();
|
||||
});
|
||||
element.addEventListener('blur', () => {
|
||||
pendingCredentialsReconnect = true;
|
||||
void flushSettingsAutoSave(true);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
if (settingsAutoSaveTimer || pendingCredentialsReconnect) {
|
||||
void flushSettingsAutoSave(pendingCredentialsReconnect);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && (settingsAutoSaveTimer || pendingCredentialsReconnect)) {
|
||||
void flushSettingsAutoSave(pendingCredentialsReconnect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSettings(): Promise<void> {
|
||||
const saved = await persistSettings({
|
||||
includeCredentials: true,
|
||||
includeTemplates: true,
|
||||
reconnectAfterSave: true,
|
||||
showTemplateAlert: true
|
||||
});
|
||||
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFolder(): Promise<void> {
|
||||
const folder = await window.api.selectFolder();
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
byId<HTMLInputElement>('downloadPath').value = folder;
|
||||
config = await window.api.saveConfig({ download_path: folder });
|
||||
|
||||
// Warn-only validation — the user explicitly chose this folder, so don't
|
||||
// refuse to save (they might be picking a path on a USB stick that's
|
||||
// currently disconnected). Just surface the writability problem early
|
||||
// instead of letting the next download fail with a cryptic error.
|
||||
try {
|
||||
const writable = await window.api.checkFolderWritable(folder);
|
||||
if (!writable) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn');
|
||||
}
|
||||
} catch { /* ignore — preflight will catch it later */ }
|
||||
}
|
||||
|
||||
function openFolder(): void {
|
||||
const folder = config.download_path;
|
||||
if (!folder || typeof folder !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
void window.api.openFolder(folder);
|
||||
}
|
||||
|
||||
function changeTheme(theme: string): void {
|
||||
document.body.className = `theme-${theme}`;
|
||||
config.theme = theme;
|
||||
void window.api.saveConfig({ theme });
|
||||
}
|
||||
|
||||
function formatRelativeTime(ms: number, future: boolean): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) {
|
||||
return future ? UI_TEXT.streamers.autoVodScanEmpty || '' : '-';
|
||||
}
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
async function refreshAutomationStatusLine(): Promise<void> {
|
||||
const lineEl = document.getElementById('autoVodStatusLine');
|
||||
if (!lineEl) return;
|
||||
try {
|
||||
const status = await window.api.getAutomationStatus();
|
||||
const now = Date.now();
|
||||
const parts: string[] = [];
|
||||
|
||||
if (status.autoVod.watching > 0) {
|
||||
const lastAgo = status.autoVod.lastRunAt > 0 ? formatRelativeTime(now - status.autoVod.lastRunAt, false) : '-';
|
||||
const nextIn = status.autoVod.nextRunAt > now ? formatRelativeTime(status.autoVod.nextRunAt - now, true) : '-';
|
||||
parts.push(`VOD: ${status.autoVod.watching} watched · last ${lastAgo} ago · next in ${nextIn} · last run +${status.autoVod.lastQueuedCount}`);
|
||||
}
|
||||
if (status.autoRecord.watching > 0) {
|
||||
const lastAgo = status.autoRecord.lastRunAt > 0 ? formatRelativeTime(now - status.autoRecord.lastRunAt, false) : '-';
|
||||
const nextIn = status.autoRecord.nextRunAt > now ? formatRelativeTime(status.autoRecord.nextRunAt - now, true) : '-';
|
||||
parts.push(`REC: ${status.autoRecord.watching} watched · last ${lastAgo} ago · next in ${nextIn}`);
|
||||
}
|
||||
if (parts.length === 0) parts.push('No streamers watched.');
|
||||
lineEl.textContent = parts.join(' · ');
|
||||
} catch (_) {
|
||||
lineEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualAutoVodScan(): Promise<void> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const btn = document.getElementById('btnAutoVodScanNow') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const result = await window.api.triggerAutoVodScan();
|
||||
if (toast) {
|
||||
const tmpl = result.queuedCount > 0
|
||||
? UI_TEXT.streamers.autoVodScanQueued
|
||||
: UI_TEXT.streamers.autoVodScanEmpty;
|
||||
toast((tmpl || '').replace('{count}', String(result.queuedCount)), 'info');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
void refreshAutomationStatusLine();
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualAutoRecordScan(): Promise<void> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const btn = document.getElementById('btnAutoRecordScanNow') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const result = await window.api.triggerAutoRecordScan();
|
||||
if (toast) {
|
||||
const tmpl = result.triggered > 0
|
||||
? UI_TEXT.streamers.autoRecordScanTriggered
|
||||
: UI_TEXT.streamers.autoRecordScanEmpty;
|
||||
toast((tmpl || '').replace('{count}', String(result.triggered)), 'info');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
void refreshAutomationStatusLine();
|
||||
}
|
||||
}
|
||||
|
||||
(window as unknown as { triggerManualAutoVodScan: typeof triggerManualAutoVodScan }).triggerManualAutoVodScan = triggerManualAutoVodScan;
|
||||
(window as unknown as { triggerManualAutoRecordScan: typeof triggerManualAutoRecordScan }).triggerManualAutoRecordScan = triggerManualAutoRecordScan;
|
||||
Reference in New Issue
Block a user