// public/assets/app.js
/* English comments: minimal JS without external libs */
const state = {
mediaItems: [],
mediaTab: 'movies',
i18n: {},
lang: 'en',
ui: {
table_mode: 'pagination',
table_page_size: 50,
},
background: {
mode: 'light',
max_parallel_jobs: 1,
max_network_jobs: 1,
max_io_jobs: 1,
batch_sleep_ms: 500,
watchdog_minutes: 10,
paused: false,
},
tables: {},
activeJobId: null,
jobPollTimer: null,
queuePollTimer: null,
watchdogTimer: null,
sourcesPollTimer: null,
sourcesCache: null,
sourceDetail: null,
sourceDetailItem: null,
sourceDetailData: null,
sourceDetailLastFetch: 0,
eventsSource: null,
tasks: [],
lastTasksFetch: 0,
queueActiveList: [],
queueRecentList: [],
debugToolsEnabled: false,
sse: {
connected: false,
lastEventAt: 0,
lastEventType: '',
reconnects: 0,
retryDelayMs: 5000,
leaseKey: 'scmedia_sse_lease',
tabId: '',
},
};
const LS_ACTIVE_JOB = 'scmedia_active_job_id';
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function paginateItems(items, page, perPage) {
const total = items.length;
const size = Math.max(1, Number(perPage || 50));
const p = Math.max(1, Number(page || 1));
const start = (p - 1) * size;
const slice = items.slice(start, start + size);
return { items: slice, total, page: p, per_page: size };
}
function api(path, opts = {}) {
return window.Api.request(path, opts);
}
/* ---------------------------
i18n
--------------------------- */
function t(key, fallback = null) {
const v = state.i18n?.[key];
if (typeof v === 'string') return v;
if (fallback !== null) return fallback;
return key;
}
function updateThemeLabel() {
const label = UI.qs('themeState');
if (label) {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
label.textContent = (current === 'light')
? t('theme.light', 'Light')
: t('theme.dark', 'Dark');
}
}
function initSourceDialog() {
const closeBtn = UI.qs('btnSourceClose');
const dlg = UI.qs('dlg-source');
const approveBtn = UI.qs('btnSourceApprove');
if (closeBtn && dlg) {
closeBtn.addEventListener('click', () => dlg.close());
}
if (approveBtn) {
approveBtn.addEventListener('click', () => {
if (!state.sourceDetail) return;
api('/api/sources/approve', {
method: 'POST',
body: JSON.stringify({ source: state.sourceDetail.source, id: state.sourceDetail.id }),
}).catch(() => {});
});
}
if (dlg) {
dlg.addEventListener('close', () => {
state.sourceDetail = null;
state.sourceDetailItem = null;
state.sourceDetailData = null;
});
}
}
function setVersion() {
const el = document.querySelector('[data-version] .version');
if (el && typeof APP_VERSION === 'string') {
el.textContent = `v${APP_VERSION}`;
}
}
/* ---------------------------
Filters + Grid
--------------------------- */
async function loadUiSettings() {
try {
const res = await api('/api/settings');
const data = res.data || res;
state.debugToolsEnabled = !!(data.meta?.debug_tools_enabled);
const prefs = window.UserPrefs ? await window.UserPrefs.load().catch(() => null) : null;
const ui = prefs || {};
state.ui.table_mode = ui.table_mode || 'pagination';
state.ui.table_page_size = ui.table_page_size || 50;
const bg = data.background || {};
state.background = {
mode: bg.mode || 'light',
max_parallel_jobs: Number(bg.max_parallel_jobs || 1),
max_network_jobs: Number(bg.max_network_jobs || 1),
max_io_jobs: Number(bg.max_io_jobs || 1),
batch_sleep_ms: Number(bg.batch_sleep_ms || 500),
watchdog_minutes: Number(bg.watchdog_minutes || 10),
paused: !!bg.paused,
};
} catch (e) {
state.ui.table_mode = 'pagination';
state.ui.table_page_size = 50;
}
}
function setSseIndicator(stateClass) {
if (window.UI?.updateSseIndicator) {
window.UI.updateSseIndicator(stateClass.replace('sse-', ''));
return;
}
const items = document.querySelectorAll('[data-sse-indicator]');
items.forEach((el) => {
el.classList.remove('sse-ok', 'sse-idle', 'sse-offline');
el.classList.add(stateClass);
});
}
function pulseSseIndicator() {
if (window.UI?.blinkSseIndicator) {
window.UI.blinkSseIndicator();
return;
}
const items = document.querySelectorAll('[data-sse-indicator]');
items.forEach((el) => {
el.classList.add('sse-blink');
setTimeout(() => el.classList.remove('sse-blink'), 250);
});
}
function updateSseStats() {
const wrap = UI.qs('queueSseStats');
if (!wrap || !state.debugToolsEnabled) return;
wrap.classList.remove('is-hidden');
const el = wrap.querySelector('.queue-stats');
if (!el) return;
const sse = window.UI?.getSseStats?.() || null;
const lastAt = state.sse.lastEventAt ? new Date(state.sse.lastEventAt).toLocaleTimeString() : '—';
const reconnects = sse ? sse.reconnects : Number(state.sse.reconnects || 0);
state.sse.reconnects = reconnects;
el.textContent = `last: ${lastAt} | type: ${state.sse.lastEventType || '—'} | reconnects: ${reconnects}`;
}
function noteSseEvent(type) {
state.sse.lastEventAt = Date.now();
state.sse.lastEventType = type;
localStorage.setItem('scmedia_sse_last', String(state.sse.lastEventAt));
pulseSseIndicator();
updateSseStats();
}
function setSseConnected(connected) {
state.sse.connected = connected;
localStorage.setItem('scmedia_sse_connected', connected ? '1' : '0');
if (connected && state.sse.lastEventAt === 0) {
state.sse.lastEventAt = Date.now();
localStorage.setItem('scmedia_sse_last', String(state.sse.lastEventAt));
}
const now = Date.now();
if (!connected) {
setSseIndicator('sse-offline');
return;
}
const recent = state.sse.lastEventAt > 0 && (now - state.sse.lastEventAt) <= 15000;
setSseIndicator(recent ? 'sse-ok' : 'sse-idle');
}
function initTables() {
if (!window.TableController) return;
const mode = state.ui.table_mode || 'pagination';
const pageSize = state.ui.table_page_size || 50;
const grid = UI.qs('grid');
const sourcesGrid = UI.qs('sources-grid');
const getPrefs = (table) => {
const id = table?.dataset?.tableId || table?.id || '';
const prefs = window.UserPrefs?.getTable?.(id) || {};
return { id, prefs };
};
if (grid) {
const { id, prefs } = getPrefs(grid);
state.tables.media = new TableController({
table: grid,
mode,
pageSize,
sort: prefs.sort || '',
dir: prefs.dir || 'asc',
onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }),
fetchPage: async ({ page, per_page, sort, dir, filters, params }) => {
const res = await api('/api/media/list', {
method: 'POST',
body: {
page,
per_page,
sort,
dir,
filters,
params: {
tab: params?.tab || state.mediaTab || 'movies',
},
},
});
const data = res.data || {};
return {
items: data.items || [],
total: data.total || 0,
page: data.page || page,
per_page: data.per_page || per_page,
};
},
renderRow: renderMediaRow,
});
}
if (sourcesGrid) {
const { id, prefs } = getPrefs(sourcesGrid);
state.tables.sources = new TableController({
table: sourcesGrid,
mode,
pageSize,
sort: prefs.sort || '',
dir: prefs.dir || 'asc',
onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }),
fetchPage: async ({ page, per_page, sort, dir, filters }) => {
if (Array.isArray(state.sourcesCache)) {
const filtered = applySourceFilters(state.sourcesCache, filters);
const sorted = sortSources(filtered, sort, dir);
return paginateItems(sorted, page, per_page);
}
const res = await api('/api/sources/list', {
method: 'POST',
body: {
page,
per_page,
sort,
dir,
filters,
},
});
const data = res.data || {};
return {
items: data.items || [],
total: data.total || 0,
page: data.page || page,
per_page: data.per_page || per_page,
};
},
renderRow: renderSourceRow,
});
}
}
function sortSources(items, sort, dir) {
if (!sort) return [...items];
const desc = dir === 'desc';
const list = [...items];
const get = (it) => {
if (sort === 'name') return (it.name || '').toLowerCase();
if (sort === 'size') return Number(it.size_bytes || 0);
if (sort === 'status') return (it.status || '').toLowerCase();
if (sort === 'source') return (it.source || '').toLowerCase();
if (sort === 'progress') return Number(it.percent_done || 0);
return (it[sort] ?? '').toString().toLowerCase();
};
list.sort((a, b) => {
const av = get(a);
const bv = get(b);
if (av === bv) return 0;
if (desc) return av < bv ? 1 : -1;
return av > bv ? 1 : -1;
});
return list;
}
function matchFilterValue(current, op, value) {
const opKey = (op || 'eq').toLowerCase();
if (opKey === 'empty') {
if (Array.isArray(current)) return current.length === 0;
return current === null || current === '';
}
if (opKey === 'like') {
const needle = String(value || '').toLowerCase();
const hay = String(current ?? '').toLowerCase();
return needle === '' ? true : hay.includes(needle);
}
if (opKey === 'between' && Array.isArray(value) && value.length >= 2) {
const left = value[0];
const right = value[1];
const cur = Number(current);
const l = Number(left);
const r = Number(right);
if (!Number.isNaN(cur) && !Number.isNaN(l) && !Number.isNaN(r)) {
return cur >= l && cur <= r;
}
return String(current ?? '') >= String(left ?? '') && String(current ?? '') <= String(right ?? '');
}
if (opKey === 'gt' || opKey === 'lt') {
const cur = Number(current);
const val = Number(value);
if (!Number.isNaN(cur) && !Number.isNaN(val)) {
return opKey === 'gt' ? cur > val : cur < val;
}
return opKey === 'gt'
? String(current ?? '') > String(value ?? '')
: String(current ?? '') < String(value ?? '');
}
return String(current ?? '').toLowerCase() === String(value ?? '').toLowerCase();
}
function applySourceFilters(items, filters) {
if (!Array.isArray(filters) || filters.length === 0) return items;
const map = {
source: it => it.source,
type: it => it.content_type,
name: it => it.name,
size: it => it.size_bytes,
status: it => it.status,
progress: it => Number(it.percent_done || 0) * 100,
};
return items.filter((it) => {
return filters.every((f) => {
if (!f || !f.key) return true;
const getter = map[f.key];
const current = getter ? getter(it) : it[f.key];
return matchFilterValue(current, f.op, f.value);
});
});
}
function renderMediaRow(body, it) {
if (state.mediaTab === 'series') {
renderSeriesRow(body, it);
} else {
renderFileRow(body, it);
}
}
function renderSourceRow(body, it) {
const tr = document.createElement('tr');
tr.appendChild(cellText(it.source ?? '', '', 'source'));
tr.appendChild(cellText(sourceTypeLabel(it.content_type), '', 'type'));
tr.appendChild(cellText(it.name ?? '', '', 'name'));
tr.appendChild(cellText(it.size_bytes ? formatSize(it.size_bytes) : '', 'align-right', 'size'));
tr.appendChild(cellText(it.status ?? '', 'align-center', 'status'));
const percent = Number(it.percent_done ?? 0);
const pctLabel = Number.isFinite(percent) ? Math.round(percent * 100) : 0;
tr.appendChild(cellText(`${pctLabel}%`, 'align-right', 'progress'));
tr.addEventListener('click', () => openSourceDetail(it));
body.appendChild(tr);
}
function sourceTypeLabel(type) {
if (type === 'folder') return t('sources.type.folder', 'Folder');
if (type === 'file') return t('sources.type.file', 'File');
return '';
}
function cellText(txt, className = '', key = '') {
const td = document.createElement('td');
td.textContent = txt ?? '';
if (className) td.className = className;
if (key) td.dataset.key = key;
return td;
}
function createYearCell(year) {
const td = document.createElement('td');
td.className = 'col-year';
td.textContent = (year !== null && year !== undefined && year !== '') ? String(year) : '';
return td;
}
function createTitleCell(display, original, fallback) {
const td = document.createElement('td');
td.className = 'col-title';
const span = document.createElement('span');
const displayText = display || fallback || '';
const originalText = original || displayText;
span.textContent = displayText;
span.dataset.display = displayText;
span.dataset.original = originalText;
attachTitleToggle(span);
td.appendChild(span);
return td;
}
function attachTitleToggle(el) {
const showOriginal = () => {
if (el.dataset.original) el.textContent = el.dataset.original;
};
const showDisplay = () => {
if (el.dataset.display) el.textContent = el.dataset.display;
};
el.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
showOriginal();
});
el.addEventListener('mouseup', showDisplay);
el.addEventListener('mouseleave', showDisplay);
el.addEventListener('touchstart', showOriginal, { passive: true });
el.addEventListener('touchend', showDisplay);
}
function typeLabel(v) {
if (v === 'auto') return t('types.auto', 'Auto');
if (v === 'movie') return t('types.movie', 'Movie');
if (v === 'series') return t('types.series', 'Series');
return v;
}
function cellType(it) {
const td = document.createElement('td');
td.textContent = typeLabel(it.kind ?? 'auto');
return td;
}
function issueBadges(issues) {
if (!Array.isArray(issues) || issues.length === 0) return '';
return issues.map(i => {
if (i === 'rename') return '📝';
if (i === 'delete') return '🗑';
if (i === 'unknown_type') return '❓';
return '⚠';
}).join(' ');
}
function renderFileRow(body, it) {
const tr = document.createElement('tr');
tr.dataset.path = it.abs_path || '';
const tdExpand = document.createElement('td');
const btn = document.createElement('button');
btn.className = 'expand-btn';
btn.textContent = '+';
btn.addEventListener('click', () => toggleFileRow(tr, it, btn));
tdExpand.appendChild(btn);
tr.appendChild(tdExpand);
tr.appendChild(cellType(it));
const displayTitle = it.title_display || it.name || '';
const originalTitle = it.title_original || displayTitle;
tr.appendChild(createTitleCell(displayTitle, originalTitle, it.name ?? ''));
tr.appendChild(createYearCell(it.year ?? ''));
tr.appendChild(cellText(it.needs_attention ? t('status.needs', 'Needs') : t('status.ok', 'OK')));
tr.appendChild(cellText(issueBadges(it.issues)));
body.appendChild(tr);
}
function renderSeriesRow(body, it) {
const tr = document.createElement('tr');
tr.dataset.series = it.series_key || '';
const tdExpand = document.createElement('td');
const btn = document.createElement('button');
btn.className = 'expand-btn';
btn.textContent = '+';
btn.addEventListener('click', () => toggleSeriesRow(tr, it, btn));
tdExpand.appendChild(btn);
tr.appendChild(tdExpand);
tr.appendChild(cellText(t('types.series', 'Series')));
const displayTitle = it.title_display || it.series_key || '';
const originalTitle = it.title_original || displayTitle;
tr.appendChild(createTitleCell(displayTitle, originalTitle, it.series_key ?? ''));
tr.appendChild(createYearCell(it.year ?? ''));
const status = (it.needs_attention > 0) ? t('status.needs', 'Needs') : t('status.ok', 'OK');
tr.appendChild(cellText(status));
tr.appendChild(cellText(issueBadges(it.issues)));
body.appendChild(tr);
}
async function loadMediaList() {
const mediaTable = state.tables.media;
const sourcesTable = state.tables.sources;
if (state.mediaTab === 'sources') {
if (sourcesTable) {
sourcesTable.setParams({});
await sourcesTable.load(1, false);
}
return;
}
if (mediaTable) {
mediaTable.setParams({ tab: state.mediaTab });
await mediaTable.load(1, false);
}
}
async function loadSources() {
const sourcesTable = state.tables.sources;
if (sourcesTable) {
await sourcesTable.load(1, false);
}
}
async function toggleFileRow(tr, it, btn) {
const next = tr.nextElementSibling;
if (next && next.classList.contains('detail-row')) {
next.remove();
if (btn) btn.textContent = '+';
return;
}
if (btn) btn.textContent = '-';
const detail = document.createElement('tr');
detail.className = 'detail-row';
const td = document.createElement('td');
td.colSpan = 6;
td.textContent = t('common.loading', 'Loading…');
detail.appendChild(td);
tr.after(detail);
const res = await api('/api/media/file?path=' + encodeURIComponent(it.abs_path || ''));
if (!res.ok || !res.data) {
td.textContent = t('common.error', 'Error');
return;
}
td.innerHTML = renderDetail(res.data);
bindMetadataHandlers(td);
}
async function toggleSeriesRow(tr, it, btn) {
const next = tr.nextElementSibling;
if (next && next.classList.contains('detail-row')) {
next.remove();
if (btn) btn.textContent = '+';
return;
}
if (btn) btn.textContent = '-';
const detail = document.createElement('tr');
detail.className = 'detail-row';
const td = document.createElement('td');
td.colSpan = 6;
td.textContent = t('common.loading', 'Loading…');
detail.appendChild(td);
tr.after(detail);
const res = await api('/api/media/series?key=' + encodeURIComponent(it.series_key || ''));
if (!res.ok || !res.data) {
td.textContent = t('common.error', 'Error');
return;
}
td.innerHTML = renderSeriesDetail(res.data || {});
bindSeriesDetailHandlers(td);
bindMetadataHandlers(td);
}
function renderDetail(data) {
const file = data.file || {};
const tracks = data.tracks || [];
const meta = data.meta || {};
let html = '';
html += `
`;
html += renderMetaBox(meta, file.name || '', file.kind || 'movie');
html += `` +
`| ${t('media.track.type','Type')} | ` +
`${t('media.track.lang','Lang')} | ` +
`${t('media.track.name','Name')} | ` +
`${t('media.track.codec','Codec')} | ` +
`${t('media.track.channels','Channels')} | ` +
`${t('media.track.flags','Flags')} | ` +
`${t('media.track.audio_type','Audio type')} | ` +
`
`;
for (const tr of tracks) {
const flags = [];
if (tr.default) flags.push('default');
if (tr.forced) flags.push('forced');
html += `` +
`| ${escapeHtml(tr.type || '')} | ` +
`${escapeHtml(tr.lang || '')} | ` +
`${escapeHtml(tr.name_norm || tr.name || '')} | ` +
`${escapeHtml(tr.codec || '')} | ` +
`${escapeHtml(tr.channels || '')} | ` +
`${escapeHtml(flags.join(','))} | ` +
`${escapeHtml(tr.audio_type || '')} | ` +
`
`;
}
html += `
`;
return html;
}
function renderSeriesDetail(payload) {
const files = Array.isArray(payload.files) ? payload.files : [];
const meta = payload.meta || {};
let html = renderMetaBox(meta, meta.title_display || '', 'series');
html += `` +
` | ` +
`${t('grid.name','Name')} | ` +
`${t('grid.path','Path')} | ` +
`${t('grid.status','Status')} | ` +
`${t('grid.issues','Issues')} | ` +
`
`;
for (const f of files) {
html += `` +
` | ` +
`${escapeHtml(f.name || '')} | ` +
`${escapeHtml(f.rel_path || f.abs_path || '')} | ` +
`${escapeHtml(f.needs_attention ? t('status.needs','Needs') : t('status.ok','OK'))} | ` +
`${escapeHtml(issueBadges(f.issues))} | ` +
`
`;
}
html += `
`;
return html;
}
function renderMetaBox(meta, fallbackTitle, type) {
const subjectKind = meta.subject_kind || type || 'movie';
const subjectKey = meta.subject_key || '';
const titleDisplay = meta.title_display || '';
const titleOriginal = meta.title_original || '';
const year = (meta.year !== null && meta.year !== undefined) ? String(meta.year) : '';
const provider = meta.provider || '';
const providerId = meta.provider_id || '';
const source = meta.source || 'auto';
const manualTitle = meta.manual_title || '';
const manualYear = (meta.manual_year !== null && meta.manual_year !== undefined) ? String(meta.manual_year) : '';
let html = '';
html += ``;
return html;
}
function bindSeriesDetailHandlers(root) {
root.querySelectorAll('button[data-action="file"]').forEach(btn => {
btn.addEventListener('click', async () => {
const tr = btn.closest('tr');
if (!tr) return;
const path = tr.dataset.path || '';
const next = tr.nextElementSibling;
if (next && next.classList.contains('detail-row')) {
next.remove();
btn.textContent = '+';
return;
}
btn.textContent = '-';
const detail = document.createElement('tr');
detail.className = 'detail-row';
const td = document.createElement('td');
td.colSpan = 5;
td.textContent = t('common.loading', 'Loading…');
detail.appendChild(td);
tr.after(detail);
const res = await api('/api/media/file?path=' + encodeURIComponent(path));
if (!res.ok || !res.data) {
td.textContent = t('common.error', 'Error');
return;
}
td.innerHTML = renderDetail(res.data);
bindMetadataHandlers(td);
});
});
}
function bindMetadataHandlers(root) {
root.querySelectorAll('.meta-box').forEach(box => {
const subjectKind = box.dataset.subjectKind || 'movie';
const subjectKey = box.dataset.subjectKey || '';
const type = box.dataset.type || 'movie';
const resultsEl = box.querySelector('[data-role="results"]');
const queryInput = box.querySelector('[data-role="query"]');
const manualTitle = box.querySelector('[data-role="manual-title"]');
const manualYear = box.querySelector('[data-role="manual-year"]');
const setDisplay = (data) => {
if (!data) return;
const title = data.title_display || '';
const original = data.title_original || '';
const year = (data.year !== null && data.year !== undefined) ? String(data.year) : '';
const provider = data.meta?.provider || data.provider || '';
const providerId = data.meta?.provider_id || data.provider_id || '';
const source = data.meta?.source || data.source || 'auto';
const titleEl = box.querySelector('[data-role="title"]');
const origEl = box.querySelector('[data-role="original"]');
const yearEl = box.querySelector('[data-role="year"]');
const provEl = box.querySelector('[data-role="provider"]');
const srcEl = box.querySelector('[data-role="source"]');
if (titleEl) titleEl.textContent = title;
if (origEl) origEl.textContent = original;
if (yearEl) yearEl.textContent = year;
if (provEl) provEl.textContent = providerId ? `${provider} / ${providerId}` : provider;
if (srcEl) srcEl.textContent = source;
if (manualTitle) manualTitle.value = data.meta?.manual_title || '';
if (manualYear) manualYear.value = data.meta?.manual_year ?? '';
updateRowMeta(subjectKind, subjectKey, data);
};
const onSearch = async () => {
const query = queryInput?.value?.trim() || '';
if (!query || subjectKey === '') return;
if (resultsEl) resultsEl.textContent = t('common.loading', 'Loading…');
const res = await api('/api/metadata/search', {
method: 'POST',
body: JSON.stringify({ query, type }),
});
if (!res.ok) {
if (resultsEl) resultsEl.textContent = t('common.error', 'Error');
return;
}
renderMetaResults(resultsEl, res.data || [], async (selection) => {
const save = await api('/api/metadata/select', {
method: 'POST',
body: JSON.stringify({
subject_kind: subjectKind,
subject_key: subjectKey,
selection,
}),
});
if (save.ok) {
setDisplay(save.data || {});
}
});
};
const onSaveManual = async () => {
const title = manualTitle?.value?.trim() || '';
const year = manualYear?.value?.trim() || '';
if (subjectKey === '') return;
const save = await api('/api/metadata/manual', {
method: 'POST',
body: JSON.stringify({
subject_kind: subjectKind,
subject_key: subjectKey,
title,
year: year === '' ? null : Number(year),
}),
});
if (save.ok) {
setDisplay(save.data || {});
}
};
const onClearManual = async () => {
if (subjectKey === '') return;
const save = await api('/api/metadata/manual/clear', {
method: 'POST',
body: JSON.stringify({
subject_kind: subjectKind,
subject_key: subjectKey,
}),
});
if (save.ok) {
setDisplay(save.data || {});
}
};
const btnSearch = box.querySelector('[data-action="meta-search"]');
const btnSave = box.querySelector('[data-action="meta-save"]');
const btnClear = box.querySelector('[data-action="meta-clear"]');
if (btnSearch) btnSearch.addEventListener('click', onSearch);
if (btnSave) btnSave.addEventListener('click', onSaveManual);
if (btnClear) btnClear.addEventListener('click', onClearManual);
});
}
function renderMetaResults(root, results, onSelect) {
if (!root) return;
root.innerHTML = '';
if (!Array.isArray(results) || results.length === 0) {
root.textContent = t('meta.no_results', 'No results');
return;
}
results.forEach(r => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'meta-result';
const img = document.createElement('img');
img.alt = '';
img.loading = 'lazy';
img.src = r.poster || '';
img.className = 'meta-poster';
if (!r.poster) img.style.display = 'none';
const body = document.createElement('div');
body.className = 'meta-result-body';
const title = document.createElement('div');
title.className = 'meta-result-title';
const titleMap = r.title_map || {};
const bestTitle = titleMap[state.lang] || r.original_title || titleMap[Object.keys(titleMap)[0]] || '';
title.textContent = bestTitle;
const meta = document.createElement('div');
meta.className = 'meta-result-meta';
const year = (r.year !== null && r.year !== undefined) ? String(r.year) : '';
const providerLabel = r.provider_name || r.provider || '';
meta.textContent = `${providerLabel}${r.provider_id ? ' / ' + r.provider_id : ''}${year ? ' • ' + year : ''}`;
body.appendChild(title);
body.appendChild(meta);
btn.appendChild(img);
btn.appendChild(body);
btn.addEventListener('click', () => onSelect(r));
root.appendChild(btn);
});
}
function updateRowMeta(kind, key, data) {
const rows = Array.from(document.querySelectorAll('#grid tbody tr'));
const row = rows.find(r => (kind === 'series')
? r.dataset.series === key
: r.dataset.path === key);
if (!row) return;
const titleCell = row.querySelector('.col-title span');
const yearCell = row.querySelector('.col-year');
const title = data.title_display || '';
const original = data.title_original || title;
const year = (data.year !== null && data.year !== undefined) ? String(data.year) : '';
if (titleCell) {
titleCell.textContent = title;
titleCell.dataset.display = title;
titleCell.dataset.original = original;
}
if (yearCell) yearCell.textContent = year;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => (
c === '&' ? '&' :
c === '<' ? '<' :
c === '>' ? '>' :
c === '"' ? '"' : '''
));
}
function formatSize(bytes) {
const b = Number(bytes || 0);
if (!b) return '0 B';
const units = ['B','KB','MB','GB','TB'];
let i = 0;
let v = b;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(1)} ${units[i]}`;
}
function formatDuration(ms) {
const m = Number(ms || 0);
if (!m) return t('common.na','n/a');
const total = Math.floor(m / 1000);
const h = Math.floor(total / 3600);
const min = Math.floor((total % 3600) / 60);
const s = total % 60;
return `${h}:${String(min).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function formatSpeed(bytesPerSec) {
return `${UI.formatBytes(bytesPerSec)}/s`;
}
function formatDate(ts) {
const n = Number(ts || 0);
if (!Number.isFinite(n) || n <= 0) return t('common.na', 'n/a');
return new Date(n * 1000).toLocaleString();
}
function formatSeconds(sec) {
const n = Number(sec || 0);
if (!Number.isFinite(n) || n <= 0) return t('common.na', 'n/a');
const h = Math.floor(n / 3600);
const m = Math.floor((n % 3600) / 60);
const s = n % 60;
return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
function openSourceDetail(item) {
const dlg = UI.qs('dlg-source');
if (!dlg || !item?.id || !item?.source) return;
state.sourceDetail = { id: item.id, source: item.source };
state.sourceDetailItem = item;
const title = UI.qs('sourceTitle');
const meta = UI.qs('sourceMeta');
const fieldsWrap = UI.qs('sourceFields');
const raw = UI.qs('sourceRaw');
if (title) title.textContent = t('sources.detail.title', 'Source detail');
if (meta) meta.textContent = t('common.loading', 'Loading…');
if (fieldsWrap) fieldsWrap.innerHTML = '';
if (raw) raw.textContent = '';
fetchSourceDetail(item.source, item.id);
if (typeof dlg.showModal === 'function') dlg.showModal();
}
function refreshOpenSourceDetail() {
if (!state.sourceDetail) return;
const now = Date.now();
if (now - state.sourceDetailLastFetch < 1500) return;
fetchSourceDetail(state.sourceDetail.source, state.sourceDetail.id);
}
function fetchSourceDetail(source, id) {
state.sourceDetailLastFetch = Date.now();
const title = UI.qs('sourceTitle');
const meta = UI.qs('sourceMeta');
const fieldsWrap = UI.qs('sourceFields');
const raw = UI.qs('sourceRaw');
const fallback = state.sourceDetailItem || {};
api('/api/sources/detail?source=' + encodeURIComponent(source) + '&id=' + encodeURIComponent(id))
.then(res => {
if (!res || res.ok !== true) {
const msg = res?.error?.message || res?.error || 'Request failed';
throw new Error(msg);
}
const data = res.data || {};
state.sourceDetailData = data;
const core = data.core || {};
if (title) title.textContent = `${core.name || fallback.name || ''}`;
if (meta) {
const pct = Math.round((core.percent_done || 0) * 100);
meta.innerHTML = `
${t('sources.source','Source')}: ${core.source || fallback.source || ''}
${t('sources.status','Status')}: ${core.status || fallback.status || ''}
${t('sources.size','Size')}: ${UI.formatBytes(core.size_bytes || 0)}
${t('sources.progress','Progress')}: ${pct}%
`;
}
if (fieldsWrap) {
const fields = Array.isArray(data.fields) ? data.fields : [];
fieldsWrap.innerHTML = '';
fields.forEach(f => {
const val = f.value;
let display = '';
if (f.type === 'bytes') display = UI.formatBytes(val);
else if (f.type === 'speed') display = formatSpeed(val);
else if (f.type === 'date') display = formatDate(val);
else if (f.type === 'seconds') display = formatSeconds(val);
else if (f.type === 'list') display = Array.isArray(val) ? val.join(', ') : '';
else display = (val ?? '').toString();
const card = document.createElement('div');
card.className = 'source-field';
card.innerHTML = `${f.label || f.key || ''}
${display}
`;
fieldsWrap.appendChild(card);
});
const files = Array.isArray(data.files) ? data.files : [];
if (files.length > 0) {
const tree = buildFileTree(files);
const block = document.createElement('div');
block.className = 'source-files';
block.innerHTML = `${t('sources.files', 'Files')}
`;
block.appendChild(renderFileTree(tree));
fieldsWrap.appendChild(block);
}
const preview = data.preview || null;
if (preview) {
const block = document.createElement('div');
block.className = 'source-preview';
block.innerHTML = `
${t('sources.preview.title','Preview')}
${t('sources.preview.current','Current')}
${t('sources.preview.name','Name')}: ${preview.current?.name || ''}
${t('sources.preview.kind','Type')}: ${preview.current?.kind || ''}
${t('sources.preview.structure','Structure')}: ${preview.current?.structure || ''}
${t('sources.preview.planned','Planned')}
${t('sources.preview.name','Name')}: ${preview.planned?.name || ''}
${t('sources.preview.kind','Type')}: ${preview.planned?.kind || ''}
${t('sources.preview.structure','Structure')}: ${preview.planned?.structure || ''}
${preview.planned?.note ? `
${preview.planned?.note}
` : ''}
`;
fieldsWrap.appendChild(block);
}
}
if (raw) {
raw.textContent = JSON.stringify(data.raw || {}, null, 2);
}
})
.catch(e => {
if (meta) meta.textContent = `${t('common.error','Error')}: ${e.message}`;
});
}
function buildFileTree(files) {
const root = { name: '', children: new Map(), files: [] };
files.forEach(f => {
const path = (f.path || '').split('/').filter(Boolean);
let node = root;
for (let i = 0; i < path.length; i++) {
const part = path[i];
if (i === path.length - 1) {
node.files.push({ name: part, meta: f });
} else {
if (!node.children.has(part)) {
node.children.set(part, { name: part, children: new Map(), files: [] });
}
node = node.children.get(part);
}
}
});
return root;
}
function renderFileTree(node) {
const ul = document.createElement('ul');
ul.className = 'file-tree';
node.children.forEach(child => {
const li = document.createElement('li');
li.innerHTML = `${child.name}`;
li.appendChild(renderFileTree(child));
ul.appendChild(li);
});
node.files.forEach(f => {
const li = document.createElement('li');
const pct = Number.isFinite(f.meta?.percent_done) ? f.meta.percent_done : 0;
const pctText = pct ? ` (${pct}%)` : '';
li.innerHTML = `${f.name}${pctText}`;
ul.appendChild(li);
});
return ul;
}
/* ---------------------------
Jobs: global indicator + persistence
--------------------------- */
function setActiveJob(jobId) {
state.activeJobId = jobId ? String(jobId) : null;
if (state.activeJobId) {
localStorage.setItem(LS_ACTIVE_JOB, state.activeJobId);
} else {
localStorage.removeItem(LS_ACTIVE_JOB);
}
updateJobIndicatorVisible(!!state.activeJobId);
}
function updateJobIndicatorVisible(show) {
const el = UI.qs('job-indicator');
if (!el) return;
if (show) el.classList.remove('hidden');
else el.classList.add('hidden');
}
function updateJobIndicator(job) {
const p = UI.qs('job-indicator-progress');
if (!p) return;
const total = Number(job.progress_total || 0);
const cur = Number(job.progress || 0);
const pct = total > 0 ? Math.floor((cur / total) * 100) : 0;
p.max = 100;
p.value = pct;
}
function updateQueueSummary(data) {
const summaryEl = UI.qs('queueSummary');
if (!summaryEl) return;
const activeEl = summaryEl.querySelector('[data-queue-active]');
const errorsEl = summaryEl.querySelector('[data-queue-errors]');
const dividerEl = summaryEl.querySelector('[data-queue-divider]');
if (activeEl || errorsEl) {
const activeLabel = activeEl?.dataset.queueActiveLabel || t('queue.active', 'Active');
const errorsLabel = errorsEl?.dataset.queueErrorsLabel || t('queue.errors', 'Errors');
const activeCount = Number(data.running || 0);
const errorsCount = Number(data.errors || 0);
if (activeEl) activeEl.textContent = `${activeLabel}: ${activeCount}`;
if (errorsEl) {
errorsEl.textContent = `${errorsLabel}: ${errorsCount}`;
errorsEl.classList.toggle('is-hidden', errorsCount <= 0);
}
if (dividerEl) dividerEl.classList.toggle('is-hidden', errorsCount <= 0);
return;
}
const textEl = summaryEl.querySelector('[data-queue-text]');
const template = t('queue.summary', 'Active: {active} | Errors: {errors}');
const text = template
.replace('{active}', String(data.running || 0))
.replace('{errors}', String(data.errors || 0));
if (textEl) {
textEl.textContent = text;
} else {
summaryEl.textContent = text;
}
}
function isQueueMenuOpen() {
const menu = UI.qs('queueMenu');
return !!menu && !menu.classList.contains('is-hidden');
}
function toggleQueueMenu() {
const menu = UI.qs('queueMenu');
if (!menu) return;
menu.classList.toggle('is-hidden');
if (!menu.classList.contains('is-hidden')) {
loadQueueStatus();
loadQueueRecent();
}
}
async function cancelJobById(id) {
if (!id) return;
if (!confirm(t('job.cancel_confirm', 'Cancel job?'))) return;
try {
await api('/api/jobs/cancel', {
method: 'POST',
body: JSON.stringify({ id }),
});
} catch (e) {
alert(`${t('common.error','Error')}: ${e.message}`);
}
}
async function loadQueueRecent() {
try {
const res = await api('/api/jobs/recent');
if (!res.ok || !res.data) return;
state.queueRecentList = res.data.items || [];
if (isQueueMenuOpen()) renderQueueMenu();
} catch (e) {
// ignore
}
}
function renderQueueMenu() {
const activeWrap = UI.qs('queueMenuActive');
const finishedWrap = UI.qs('queueMenuFinished');
if (!activeWrap || !finishedWrap) return;
activeWrap.innerHTML = '';
finishedWrap.innerHTML = '';
if (state.queueActiveList.length === 0) {
const empty = document.createElement('div');
empty.className = 'queue-item-status';
empty.textContent = t('queue.none_active', 'No active tasks');
activeWrap.appendChild(empty);
} else {
state.queueActiveList.forEach(j => {
const row = document.createElement('div');
row.className = 'queue-item';
const title = document.createElement('div');
const pct = j.progress_total > 0 ? Math.floor((j.progress / j.progress_total) * 100) : 0;
title.textContent = `${j.title} (${pct}%)`;
const actions = document.createElement('div');
actions.className = 'queue-item-actions';
const btn = document.createElement('button');
btn.className = 'btn';
btn.textContent = t('queue.cancel', 'Cancel');
btn.addEventListener('click', () => cancelJobById(j.id));
actions.appendChild(btn);
row.appendChild(title);
row.appendChild(actions);
activeWrap.appendChild(row);
});
}
if (state.queueRecentList.length === 0) {
const empty = document.createElement('div');
empty.className = 'queue-item-status';
empty.textContent = t('queue.none_finished', 'No finished tasks');
finishedWrap.appendChild(empty);
} else {
state.queueRecentList.forEach(j => {
const row = document.createElement('div');
row.className = 'queue-item';
const title = document.createElement('div');
title.textContent = j.title || '';
const status = document.createElement('div');
status.className = 'queue-item-status';
status.textContent = j.status || '';
row.appendChild(title);
row.appendChild(status);
finishedWrap.appendChild(row);
});
}
updateSseStats();
}
async function loadQueueStatus() {
try {
const res = await api('/api/jobs/status');
if (!res.ok || !res.data) return;
const data = res.data;
state.background.paused = !!data.paused;
state.queueActiveList = Array.isArray(data.active_list) ? data.active_list : [];
updateQueueSummary(data);
if (data.active) updateJobIndicator(data.active);
if (isQueueMenuOpen()) renderQueueMenu();
} catch (e) {
// ignore
}
}
function initQueuePanel() {
const summary = UI.qs('queueSummary');
if (summary) {
summary.addEventListener('click', toggleQueueMenu);
}
document.addEventListener('click', (e) => {
const menu = UI.qs('queueMenu');
if (!menu) return;
if (summary && (summary.contains(e.target) || menu.contains(e.target))) return;
menu.classList.add('is-hidden');
});
updateQueueSummary({ running: 0, errors: 0 });
loadQueueStatus();
}
function initWatchdog() {
const minutes = Number(state.background.watchdog_minutes || 0);
if (minutes <= 0) return;
const intervalMs = Math.max(1, Math.floor(minutes / 2)) * 60 * 1000;
if (state.watchdogTimer) clearInterval(state.watchdogTimer);
state.watchdogTimer = setInterval(() => {
api('/api/jobs/watchdog', {
method: 'POST',
body: JSON.stringify({ minutes }),
}).catch(() => {});
}, intervalMs);
}
function initSourcesPolling() {
if (state.sourcesPollTimer) clearInterval(state.sourcesPollTimer);
}
function claimSseLease() {
if (!state.sse.tabId) {
const existing = sessionStorage.getItem('scmedia_sse_tab_id');
state.sse.tabId = existing || Math.random().toString(36).slice(2);
sessionStorage.setItem('scmedia_sse_tab_id', state.sse.tabId);
}
const now = Date.now();
const ttlMs = 15000;
const raw = localStorage.getItem(state.sse.leaseKey) || '';
let lease = null;
try {
lease = raw ? JSON.parse(raw) : null;
} catch (e) {
lease = null;
}
if (lease && lease.ts && (now - lease.ts) < ttlMs && lease.id !== state.sse.tabId) {
return false;
}
localStorage.setItem(state.sse.leaseKey, JSON.stringify({ ts: now, id: state.sse.tabId }));
return true;
}
function refreshSseLease() {
localStorage.setItem(state.sse.leaseKey, JSON.stringify({ ts: Date.now(), id: state.sse.tabId }));
}
function releaseSseLease() {
const raw = localStorage.getItem(state.sse.leaseKey) || '';
try {
const lease = raw ? JSON.parse(raw) : null;
if (lease && lease.id && lease.id !== state.sse.tabId) {
return;
}
} catch (e) {
// ignore
}
localStorage.removeItem(state.sse.leaseKey);
}
function initEventsPipe() {
if (!window.EventSource) return false;
if (!claimSseLease()) {
return false;
}
try {
if (state.eventsSource) {
state.eventsSource.close();
state.eventsSource = null;
}
const open = async () => {
if (!window.Auth?.getAccessToken?.() && window.Auth?.refreshTokens) {
await window.Auth.refreshTokens();
}
const res = await window.Http.apiJson('/api/auth/sse-key', { method: 'POST' });
const key = res?.data?.key || '';
const ttl = Number(res?.data?.expires_in || 60);
if (!res?.ok || !key) {
initSourcesPolling();
return;
}
document.cookie = `sse_key=${encodeURIComponent(key)}; path=/; max-age=${Math.max(10, ttl)}`;
await new Promise((r) => setTimeout(r, 50));
const es = new EventSource('/api/events');
state.eventsSource = es;
es.addEventListener('open', () => {
setSseConnected(true);
state.sse.retryDelayMs = 5000;
refreshSseLease();
if (state.queuePollTimer) {
clearInterval(state.queuePollTimer);
state.queuePollTimer = null;
}
if (state.sourcesPollTimer) {
clearInterval(state.sourcesPollTimer);
state.sourcesPollTimer = null;
}
loadQueueStatus();
});
es.addEventListener('jobs', (e) => {
try {
const data = JSON.parse(e.data);
noteSseEvent('jobs');
refreshSseLease();
state.queueActiveList = Array.isArray(data.active_list) ? data.active_list : [];
updateQueueSummary(data);
if (data.active) updateJobIndicator(data.active);
if (isQueueMenuOpen()) renderQueueMenu();
} catch (err) {
// ignore
}
});
es.addEventListener('sources', (e) => {
try {
const payload = JSON.parse(e.data);
noteSseEvent('sources');
refreshSseLease();
const items = Array.isArray(payload.items) ? payload.items : [];
const removed = Array.isArray(payload.removed) ? payload.removed : [];
const isSnapshot = !!payload.snapshot;
if (!Array.isArray(state.sourcesCache) || isSnapshot) {
state.sourcesCache = items;
} else {
const map = new Map();
state.sourcesCache.forEach(it => {
const key = `${it.source || ''}:${it.id || ''}`;
if (key !== ':') map.set(key, it);
});
items.forEach(it => {
const key = `${it.source || ''}:${it.id || ''}`;
if (key !== ':') map.set(key, it);
});
removed.forEach(key => map.delete(key));
state.sourcesCache = Array.from(map.values());
}
if (state.mediaTab === 'sources') {
const sourcesTable = state.tables.sources;
if (sourcesTable) sourcesTable.load(sourcesTable.page || 1, false);
}
refreshOpenSourceDetail();
} catch (err) {
// ignore
}
});
es.addEventListener('tick', () => {
noteSseEvent('tick');
refreshSseLease();
});
es.addEventListener('error', () => {
if (state.eventsSource) state.eventsSource.close();
state.eventsSource = null;
state.sse.reconnects += 1;
setSseConnected(false);
const delay = state.sse.retryDelayMs;
state.sse.retryDelayMs = Math.min(state.sse.retryDelayMs * 2, 60000);
releaseSseLease();
setTimeout(() => initEventsPipe(), delay);
});
};
open().catch(() => {
initSourcesPolling();
releaseSseLease();
});
return true;
} catch (e) {
state.eventsSource = null;
releaseSseLease();
return false;
}
}
async function restoreJobFromStorage() {
const id = localStorage.getItem(LS_ACTIVE_JOB);
if (!id) return;
setActiveJob(id);
// start polling silently
if (state.jobPollTimer) clearInterval(state.jobPollTimer);
state.jobPollTimer = setInterval(async () => {
try {
const res = await api('/api/jobs/get?id=' + encodeURIComponent(id));
if (!res.ok || !res.job) return;
updateJobIndicator(res.job);
if (res.job.status === 'done' || res.job.status === 'error' || res.job.status === 'canceled') {
clearInterval(state.jobPollTimer);
state.jobPollTimer = null;
setActiveJob(null);
}
} catch (e) {
// ignore
}
}, 1200);
}
/* ---------------------------
Scan
--------------------------- */
function openJobDialog() {
const dlg = UI.qs('dlg-job');
if (dlg && typeof dlg.showModal === 'function') dlg.showModal();
}
async function cancelActiveJob() {
if (!state.activeJobId) return;
if (!confirm(t('job.cancel_confirm', 'Cancel job?'))) return;
try {
await api('/api/jobs/cancel', {
method: 'POST',
body: JSON.stringify({ id: state.activeJobId }),
});
} catch (e) {
alert(`${t('common.error','Error')}: ${e.message}`);
}
}
async function runScan() {
// Prefer job-based scan. If backend returns job_id, we track progress.
const res = await api('/api/items/scan', { method: 'POST', body: '{}' });
if (!res.ok) {
alert(t('errors.scan_failed', 'Scan failed') + ': ' + (res.error || ''));
return;
}
if (res.job_id) {
openJobDialog();
await watchJob(res.job_id, { autoClose: true });
await loadMediaList();
return;
}
// Fallback (legacy): scan completes immediately
alert(t('messages.scan_finished', 'Scan finished'));
await loadMediaList();
}
async function watchJob(jobId, opts = {}) {
const statusEl = UI.qs('job-status');
const prog = UI.qs('job-progress');
const logEl = UI.qs('job-log');
setActiveJob(jobId);
for (;;) {
const res = await api('/api/jobs/get?id=' + encodeURIComponent(jobId));
if (!res.ok) {
if (statusEl) statusEl.textContent = t('errors.job_fetch', 'Error fetching job');
setActiveJob(null);
return;
}
const job = res.job;
updateJobIndicator(job);
const total = Number(job.progress_total || 0);
const cur = Number(job.progress || 0);
const pct = total > 0 ? Math.floor((cur / total) * 100) : 0;
if (statusEl) {
statusEl.textContent =
`${t('job.status', 'Status')}: ${job.status} | ${cur}/${total}`;
}
if (prog) {
prog.max = 100;
prog.value = pct;
}
if (logEl) {
logEl.textContent = job.log_text || '';
}
if (job.status === 'done' || job.status === 'error' || job.status === 'canceled') {
setActiveJob(null);
if (opts.autoClose) {
// do not force-close dialog; user can read log
}
return;
}
await sleep(1000);
}
}
async function runDryRun() {
const dlg = UI.qs('dlg-dry-run');
const out = UI.qs('dry-run-log');
if (out) out.textContent = t('common.loading', 'Loading…');
if (dlg && typeof dlg.showModal === 'function') dlg.showModal();
const q = new URLSearchParams();
q.set('tab', state.mediaTab || 'movies');
const res = await api('/api/media/dry-run?' + q.toString());
if (!res.ok || !res.data) {
if (out) out.textContent = t('common.error', 'Error');
return;
}
const data = res.data;
let text = '';
text += `${t('media.dry_run.summary','Summary')}\n`;
text += `${t('media.dry_run.files','Files')}: ${data.files}\n`;
text += `${t('media.dry_run.rename','Rename')}: ${data.rename}\n`;
text += `${t('media.dry_run.delete','Delete')}: ${data.delete}\n`;
text += `${t('media.dry_run.unknown','Unknown type')}: ${data.unknown_type}\n`;
text += `${t('media.dry_run.convert','Convert')}: ${data.convert}\n\n`;
text += `${t('media.dry_run.details','Details')}\n`;
for (const row of data.rows || []) {
text += `- ${row.name} | ${row.actions.join(', ')}\n`;
}
if (out) out.textContent = text;
}
async function runApply() {
const res = await api('/api/media/apply', {
method: 'POST',
body: JSON.stringify({ tab: state.mediaTab || 'movies' }),
});
if (!res.ok) {
alert(t('errors.apply_failed', 'Apply failed') + ': ' + (res.error || ''));
return;
}
if (res.job_id) {
openJobDialog();
await watchJob(res.job_id, { autoClose: true });
await loadMediaList();
}
}
/* ---------------------------
Init
--------------------------- */
function init() {
if (window.Auth?.requireAuth) {
window.Auth.requireAuth();
}
window.UI?.initHeader?.();
state.i18n = window.I18N || {};
state.lang = window.APP_LANG || 'en';
setVersion();
if (window.UserPrefs?.load) {
window.UserPrefs.load().then((prefs) => {
if (prefs?.theme && window.UI?.setTheme) {
window.UI.setTheme(prefs.theme);
}
if (prefs?.language && prefs.language !== state.lang) {
localStorage.setItem('scmedia_lang', prefs.language);
}
}).catch(() => {});
}
UI.initThemeToggle();
updateThemeLabel();
UI.bindThemePreference?.(() => updateThemeLabel());
initSourceDialog();
if (UI.qs('btn-scan')) UI.qs('btn-scan').addEventListener('click', runScan);
if (UI.qs('btnJobCancel')) UI.qs('btnJobCancel').addEventListener('click', cancelActiveJob);
restoreJobFromStorage();
initMediaTabs();
updateTabVisibility();
loadUiSettings()
.then(() => {
initTables();
if (window.Sse?.on) {
window.Sse.on('jobs', (data) => {
if (!data) return;
noteSseEvent('jobs');
state.queueActiveList = Array.isArray(data.active_list) ? data.active_list : [];
updateQueueSummary(data);
if (data.active) updateJobIndicator(data.active);
if (isQueueMenuOpen()) renderQueueMenu();
});
window.Sse.on('sources', (payload) => {
if (!payload) return;
noteSseEvent('sources');
const items = Array.isArray(payload.items) ? payload.items : [];
const removed = Array.isArray(payload.removed) ? payload.removed : [];
const isSnapshot = !!payload.snapshot;
if (!Array.isArray(state.sourcesCache) || isSnapshot) {
state.sourcesCache = items;
} else {
const map = new Map();
state.sourcesCache.forEach(it => {
const key = `${it.source || ''}:${it.id || ''}`;
if (key !== ':') map.set(key, it);
});
items.forEach(it => {
const key = `${it.source || ''}:${it.id || ''}`;
if (key !== ':') map.set(key, it);
});
removed.forEach(key => map.delete(key));
state.sourcesCache = Array.from(map.values());
}
if (state.mediaTab === 'sources') {
const sourcesTable = state.tables.sources;
if (sourcesTable) sourcesTable.load(sourcesTable.page || 1, false);
}
refreshOpenSourceDetail();
});
window.Sse.on('tick', () => {
noteSseEvent('tick');
});
}
window.Sse?.start?.();
initWatchdog();
loadMediaList();
})
.catch(() => {
initTables();
window.Sse?.start?.();
initWatchdog();
loadMediaList();
});
}
init();
window.addEventListener('pagehide', () => {
window.Sse?.stop?.();
});
function initMediaTabs() {
const tabs = document.querySelectorAll('#media-tabs .tab');
tabs.forEach(btn => {
btn.addEventListener('click', () => {
tabs.forEach(x => x.classList.remove('active'));
btn.classList.add('active');
state.mediaTab = btn.dataset.tab || 'movies';
updateTabVisibility();
loadMediaList();
});
});
}
function updateTabVisibility() {
const grid = UI.qs('grid');
const sourcesGrid = UI.qs('sources-grid');
const showSources = state.mediaTab === 'sources';
const gridWrap = grid?.closest('.table-wrap');
const sourcesWrap = sourcesGrid?.closest('.table-wrap');
if (gridWrap) gridWrap.classList.toggle('is-hidden', showSources);
if (sourcesWrap) sourcesWrap.classList.toggle('is-hidden', !showSources);
}