1744 lines
57 KiB
JavaScript
1744 lines
57 KiB
JavaScript
// 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 += `<div class="detail-meta">`;
|
|
html += `<div>${escapeHtml(file.abs_path || '')}</div>`;
|
|
html += `<div class="small">` +
|
|
`${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)}` +
|
|
`</div>`;
|
|
html += `</div>`;
|
|
html += renderMetaBox(meta, file.name || '', file.kind || 'movie');
|
|
|
|
html += `<table class="detail-table"><thead><tr>` +
|
|
`<th>${t('media.track.type','Type')}</th>` +
|
|
`<th>${t('media.track.lang','Lang')}</th>` +
|
|
`<th>${t('media.track.name','Name')}</th>` +
|
|
`<th>${t('media.track.codec','Codec')}</th>` +
|
|
`<th>${t('media.track.channels','Channels')}</th>` +
|
|
`<th>${t('media.track.flags','Flags')}</th>` +
|
|
`<th>${t('media.track.audio_type','Audio type')}</th>` +
|
|
`</tr></thead><tbody>`;
|
|
|
|
for (const tr of tracks) {
|
|
const flags = [];
|
|
if (tr.default) flags.push('default');
|
|
if (tr.forced) flags.push('forced');
|
|
html += `<tr>` +
|
|
`<td>${escapeHtml(tr.type || '')}</td>` +
|
|
`<td>${escapeHtml(tr.lang || '')}</td>` +
|
|
`<td>${escapeHtml(tr.name_norm || tr.name || '')}</td>` +
|
|
`<td>${escapeHtml(tr.codec || '')}</td>` +
|
|
`<td>${escapeHtml(tr.channels || '')}</td>` +
|
|
`<td>${escapeHtml(flags.join(','))}</td>` +
|
|
`<td>${escapeHtml(tr.audio_type || '')}</td>` +
|
|
`</tr>`;
|
|
}
|
|
|
|
html += `</tbody></table>`;
|
|
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 += `<table class="detail-table"><thead><tr>` +
|
|
`<th></th>` +
|
|
`<th>${t('grid.name','Name')}</th>` +
|
|
`<th>${t('grid.path','Path')}</th>` +
|
|
`<th>${t('grid.status','Status')}</th>` +
|
|
`<th>${t('grid.issues','Issues')}</th>` +
|
|
`</tr></thead><tbody>`;
|
|
for (const f of files) {
|
|
html += `<tr data-path="${escapeHtml(f.abs_path || '')}">` +
|
|
`<td><button class="expand-btn" data-action="file">+</button></td>` +
|
|
`<td>${escapeHtml(f.name || '')}</td>` +
|
|
`<td>${escapeHtml(f.rel_path || f.abs_path || '')}</td>` +
|
|
`<td>${escapeHtml(f.needs_attention ? t('status.needs','Needs') : t('status.ok','OK'))}</td>` +
|
|
`<td>${escapeHtml(issueBadges(f.issues))}</td>` +
|
|
`</tr>`;
|
|
}
|
|
html += `</tbody></table>`;
|
|
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 += `<div class="meta-box" data-subject-kind="${escapeHtml(subjectKind)}" data-subject-key="${escapeHtml(subjectKey)}" data-type="${escapeHtml(type || 'movie')}">`;
|
|
html += `<div class="meta-current">`;
|
|
html += `<div class="meta-line"><span class="meta-label">${t('meta.title','Title')}</span><span class="meta-value" data-role="title">${escapeHtml(titleDisplay || fallbackTitle)}</span></div>`;
|
|
html += `<div class="meta-line"><span class="meta-label">${t('meta.original','Original')}</span><span class="meta-value" data-role="original">${escapeHtml(titleOriginal)}</span></div>`;
|
|
html += `<div class="meta-line"><span class="meta-label">${t('meta.year','Year')}</span><span class="meta-value" data-role="year">${escapeHtml(year)}</span></div>`;
|
|
html += `<div class="meta-line"><span class="meta-label">${t('meta.provider','Provider')}</span><span class="meta-value" data-role="provider">${escapeHtml(providerId ? (provider + ' / ' + providerId) : provider)}</span></div>`;
|
|
html += `<div class="meta-line"><span class="meta-label">${t('meta.source','Source')}</span><span class="meta-value" data-role="source">${escapeHtml(source)}</span></div>`;
|
|
html += `</div>`;
|
|
|
|
html += `<div class="meta-actions">`;
|
|
html += `<div class="meta-search">` +
|
|
`<input type="text" data-role="query" placeholder="${escapeHtml(t('meta.search_placeholder','Search title'))}" value="${escapeHtml(titleDisplay || fallbackTitle)}">` +
|
|
`<button class="btn" data-action="meta-search">${t('meta.search','Search')}</button>` +
|
|
`</div>`;
|
|
html += `<div class="meta-results" data-role="results"></div>`;
|
|
|
|
html += `<div class="meta-manual">` +
|
|
`<input type="text" data-role="manual-title" placeholder="${escapeHtml(t('meta.manual_title','Manual title'))}" value="${escapeHtml(manualTitle)}">` +
|
|
`<input type="number" data-role="manual-year" placeholder="${escapeHtml(t('meta.manual_year','Year'))}" value="${escapeHtml(manualYear)}">` +
|
|
`<button class="btn" data-action="meta-save">${t('meta.save','Save')}</button>` +
|
|
`<button class="btn" data-action="meta-clear">${t('meta.clear','Clear')}</button>` +
|
|
`</div>`;
|
|
html += `</div>`;
|
|
html += `</div>`;
|
|
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 = `
|
|
<div>${t('sources.source','Source')}: ${core.source || fallback.source || ''}</div>
|
|
<div>${t('sources.status','Status')}: ${core.status || fallback.status || ''}</div>
|
|
<div>${t('sources.size','Size')}: ${UI.formatBytes(core.size_bytes || 0)}</div>
|
|
<div>${t('sources.progress','Progress')}: ${pct}%</div>
|
|
`;
|
|
}
|
|
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 = `<div class="lbl">${f.label || f.key || ''}</div><div>${display}</div>`;
|
|
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 = `<div class="lbl">${t('sources.files', 'Files')}</div>`;
|
|
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 = `
|
|
<div class="lbl">${t('sources.preview.title','Preview')}</div>
|
|
<div class="preview-grid">
|
|
<div class="preview-col">
|
|
<div class="preview-title">${t('sources.preview.current','Current')}</div>
|
|
<div>${t('sources.preview.name','Name')}: ${preview.current?.name || ''}</div>
|
|
<div>${t('sources.preview.kind','Type')}: ${preview.current?.kind || ''}</div>
|
|
<div>${t('sources.preview.structure','Structure')}: ${preview.current?.structure || ''}</div>
|
|
</div>
|
|
<div class="preview-col">
|
|
<div class="preview-title">${t('sources.preview.planned','Planned')}</div>
|
|
<div>${t('sources.preview.name','Name')}: ${preview.planned?.name || ''}</div>
|
|
<div>${t('sources.preview.kind','Type')}: ${preview.planned?.kind || ''}</div>
|
|
<div>${t('sources.preview.structure','Structure')}: ${preview.planned?.structure || ''}</div>
|
|
${preview.planned?.note ? `<div class="hint">${preview.planned?.note}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `<span class="file-folder">${child.name}</span>`;
|
|
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 = `<span class="file-item">${f.name}</span><span class="file-meta">${pctText}</span>`;
|
|
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);
|
|
}
|