Two separate places (Settings filename templates + clip-cutter modal custom template) had their own lint state. Each set the colour by JS as `lintNode.style.color = "#8bc34a"` (green for OK) or `"#ff8a80"` (red for unknown placeholder). Same intent, different implementations, different shades than the rest of the app (--success #00c853 + --error #ff4444). Extracted to a shared .template-lint class with .ok / .warn modifiers driven by the canonical CSS vars. The renderers now swap classNames instead of inline colours. Also picked up the stale `color: #888` on filenameTemplateHint and replaced with the existing .form-note utility class (which uses var(--text-secondary)). The old .clip-template-lint rule stays as a no-op alias for safety, but its hard-coded #8bc34a is removed — colour now comes from .template-lint.ok / .warn. Three hard-coded hex literals retired, two state branches consolidated, semantics now track the global palette. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
973 lines
40 KiB
TypeScript
973 lines
40 KiB
TypeScript
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();
|
|
}
|
|
|
|
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', '');
|
|
if (activeTab === 'vods' && currentStreamer) {
|
|
byId('pageTitle').textContent = currentStreamer;
|
|
} else {
|
|
byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
|
|
}
|
|
|
|
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.textContent = h;
|
|
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.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;
|