// 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 += `
${escapeHtml(file.abs_path || '')}
`; html += `
` + `${t('media.container','Container')}: ${escapeHtml(file.container || t('common.na','n/a'))} ` + `${t('media.size','Size')}: ${formatSize(file.size_bytes || 0)} ` + `${t('media.duration','Duration')}: ${formatDuration(file.duration_ms || 0)}` + `
`; html += `
`; html += renderMetaBox(meta, file.name || '', file.kind || 'movie'); html += `` + `` + `` + `` + `` + `` + `` + `` + ``; for (const tr of tracks) { const flags = []; if (tr.default) flags.push('default'); if (tr.forced) flags.push('forced'); html += `` + `` + `` + `` + `` + `` + `` + `` + ``; } 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')}
${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 || '')}
`; 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 += `` + `` + `` + `` + `` + `` + ``; for (const f of files) { html += `` + `` + `` + `` + `` + `` + ``; } html += `
${t('grid.name','Name')}${t('grid.path','Path')}${t('grid.status','Status')}${t('grid.issues','Issues')}
${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))}
`; 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 += `
`; html += `
`; html += `
${t('meta.title','Title')}${escapeHtml(titleDisplay || fallbackTitle)}
`; html += `
${t('meta.original','Original')}${escapeHtml(titleOriginal)}
`; html += `
${t('meta.year','Year')}${escapeHtml(year)}
`; html += `
${t('meta.provider','Provider')}${escapeHtml(providerId ? (provider + ' / ' + providerId) : provider)}
`; html += `
${t('meta.source','Source')}${escapeHtml(source)}
`; html += `
`; html += `
`; html += ``; html += `
`; html += `
` + `` + `` + `` + `` + `
`; 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); }