scMedia/public/assets/js/settings.js
2026-01-16 22:53:04 +01:00

3511 lines
120 KiB
JavaScript

/* public/assets/settings.js */
const state = {
settings: null,
profiles: [],
dirty: false,
editProfileId: null,
draggingId: null,
mediaRules: null,
rulesList: [],
ruleSort: {
field: 'name',
dir: 'asc',
},
tasksList: [],
toolsList: [],
toolEditType: null,
ruleEditId: null,
ruleEditType: null,
rootsList: [],
rootEditId: null,
taskEditId: null,
logsView: {
from: '',
to: '',
},
auditItems: [],
auditSelected: new Map(),
auditItemsCache: new Map(),
logSelected: new Map(),
logItemsCache: new Map(),
snapshots: [],
ui: {
table_mode: 'pagination',
table_page_size: 50,
},
tables: {},
settingsLoadPromise: null,
};
const AUDIT_EVENTS = [
'register',
'login_failed',
'login_mfa_required',
'login',
'mfa_failed',
'refresh',
'logout',
'mfa_setup',
'mfa_enabled',
'mfa_disabled',
'password_reset_requested',
'password_reset',
'roles_changed',
'user_disabled',
'user_enabled',
'mfa_reset',
'forced_logout',
'password_change',
'password_change_failed',
'email_change',
];
const i18n = {
lang: 'ru',
dict: {},
};
function t(key, fallback) {
const v = i18n.dict && Object.prototype.hasOwnProperty.call(i18n.dict, key) ? i18n.dict[key] : null;
return (typeof v === 'string' && v.length) ? v : (fallback ?? key);
}
function setStatus(text) {
const el = UI.qs('#status');
if (el) el.textContent = text;
}
function setDirty(d) {
state.dirty = d;
const btn = UI.qs('#btnSave');
if (btn) btn.disabled = !d;
const statusEl = UI.qs('#status');
if (d) {
setStatus(t('common.unsaved_changes', 'Unsaved changes'));
if (statusEl) statusEl.classList.add('dirty');
localStorage.setItem('scmedia_settings_dirty', '1');
}
if (!d) {
if (statusEl) statusEl.classList.remove('dirty');
localStorage.removeItem('scmedia_settings_dirty');
}
}
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;
return {
items: items.slice(start, start + size),
total,
page: p,
per_page: size,
};
}
function sortItems(items, sortKey, dir, valueMap) {
if (!sortKey || !valueMap?.[sortKey]) return items;
const getter = valueMap[sortKey];
const asc = dir !== 'desc';
const out = [...items];
out.sort((a, b) => {
const av = getter(a);
const bv = getter(b);
if (av === bv) return 0;
if (asc) return av < bv ? -1 : 1;
return av > bv ? -1 : 1;
});
return out;
}
const RULE_TYPES = {
name_map: { label: 'Name mapping', i18n: 'rules.type.name_map' },
delete_track: { label: 'Delete tracks', i18n: 'rules.type.delete_track' },
priorities: { label: 'Priorities', i18n: 'rules.type.priorities' },
lang_fix: { label: 'Language fix', i18n: 'rules.type.lang_fix' },
source_filter: { label: 'Source filter', i18n: 'rules.type.source_filter' },
};
function ruleTypeLabel(type) {
const info = RULE_TYPES[type];
if (!info) return type || '';
return t(info.i18n, info.label);
}
function buildSourceOptions(selected) {
const out = [];
const sourcesCfg = state.pluginConfig?.sources || {};
if (sourcesCfg.transmission) {
out.push({ value: 'transmission', label: t('settings.plugins.transmission_label', 'Transmission') });
}
if (selected && !out.some(o => o.value === selected)) {
out.push({ value: selected, label: selected });
}
if (out.length === 0) {
out.push({ value: 'transmission', label: t('settings.plugins.transmission_label', 'Transmission') });
}
return out;
}
function renderSourceOptions(selected) {
return buildSourceOptions(selected)
.map(o => `<option value="${escapeHtml(o.value)}">${escapeHtml(o.label)}</option>`)
.join('');
}
function applyI18n() {
const themeLabel = UI.qs('#themeState');
if (themeLabel) {
const mode = document.documentElement.getAttribute('data-theme') || 'dark';
themeLabel.textContent = (mode === 'light')
? t('theme.light', 'Light')
: t('theme.dark', 'Dark');
}
}
async function api(url, method = 'GET', body = null) {
const opts = { method };
if (body !== null) {
opts.body = body;
}
const res = await window.Api.request(url, opts);
return res.data;
}
function commaToList(s) {
const t = (s || '').split(',').map(x => x.trim()).filter(Boolean);
return Array.from(new Set(t));
}
function listToComma(a) {
if (!Array.isArray(a)) return '';
return a.join(',');
}
function formatBytes(n) {
const v = Number(n || 0);
if (!isFinite(v) || v <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let idx = 0;
let cur = v;
while (cur >= 1024 && idx < units.length - 1) {
cur /= 1024;
idx += 1;
}
return `${cur.toFixed(cur >= 10 ? 0 : 1)} ${units[idx]}`;
}
function initTabs() {
UI.qsa('nav.tabs .tab').forEach(btn => {
btn.addEventListener('click', () => {
console.log('[debug] tab click', btn.dataset.tab);
UI.qsa('nav.tabs .tab').forEach(x => x.classList.remove('active'));
UI.qsa('.tabpane').forEach(x => x.classList.remove('active'));
btn.classList.add('active');
const pane = UI.qs(`#pane-${btn.dataset.tab}`);
if (pane) pane.classList.add('active');
if (btn.dataset.tab === 'debug') {
console.log('[debug] tab debug: loadDebugStats');
loadDebugStats().catch(() => {});
}
});
});
}
function enforceAdminAccess() {
const auth = window.Auth;
if (!auth) return;
const access = auth.getAccessToken?.();
const refresh = auth.getRefreshToken?.();
if (!access && refresh && auth.refreshTokens) {
auth.refreshTokens().then((ok) => {
if (!ok || (auth.isAdmin && !auth.isAdmin())) {
window.location.href = '/';
}
});
return;
}
if (auth.isAdmin && !auth.isAdmin()) {
window.location.href = '/';
}
}
function bindDirtyInputs() {
// NOTE: language switch should NOT mark settings dirty
UI.qsa('input,select').forEach(el => {
if (el.dataset.local === 'true') return;
if (el.closest('#ruleModal')) return;
el.addEventListener('change', () => setDirty(true));
el.addEventListener('input', () => setDirty(true));
});
}
function setVersion() {
const el = UI.qs('[data-version] .version');
if (el && typeof APP_VERSION === 'string') {
el.textContent = `v${APP_VERSION}`;
}
}
function updateAboutVersions(meta) {
const uiVersion = (typeof APP_VERSION === 'string' && APP_VERSION) ? APP_VERSION : '';
const backendVersion = meta?.backend_version || '';
const dbVersion = meta?.db_version || '';
const pluginsTable = UI.qs('#aboutPluginsTable tbody');
if (pluginsTable) {
const plugins = [
{ name: 'UI', type: 'Core', author: 'SAFE-CAP', version: uiVersion || '-', update: '-' },
{ name: 'Backend', type: 'Core', author: 'SAFE-CAP', version: backendVersion || '-', update: '-' },
{ name: 'Database', type: 'Core', author: 'SAFE-CAP', version: dbVersion || '-', update: '-' },
{ name: 'OMDb', type: 'Metadata', author: 'OMDb API', version: '0.1.0', update: '-' },
{ name: 'TVDB', type: 'Metadata', author: 'TheTVDB', version: '0.1.0', update: '-' },
{ name: 'Kodi', type: 'Export', author: 'Kodi', version: '0.1.0', update: '-' },
{ name: 'Jellyfin', type: 'Export', author: 'Jellyfin', version: '0.1.0', update: '-' },
{ name: 'Transmission', type: 'Source', author: 'Transmission', version: '0.1.0', update: '-' },
];
pluginsTable.innerHTML = '';
for (const p of plugins) {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = p.name;
const tdType = document.createElement('td');
tdType.textContent = p.type;
const tdAuthor = document.createElement('td');
tdAuthor.textContent = p.author;
const tdVersion = document.createElement('td');
tdVersion.textContent = p.version;
const tdUpdate = document.createElement('td');
tdUpdate.textContent = p.update;
tr.append(tdName, tdType, tdAuthor, tdVersion, tdUpdate);
pluginsTable.appendChild(tr);
}
}
}
/* ---------------------------
Profiles
--------------------------- */
function renderProfileRow(tbody, p) {
const tpl = UI.qs('#tplProfileRow');
const frag = tpl.content.cloneNode(true);
const tr = frag.querySelector('tr');
tr.dataset.id = String(p.id);
tr.draggable = true;
const enabled = tr.querySelector('[data-field="enabled"]');
enabled.checked = !!p.enabled;
enabled.dataset.action = 'toggle';
enabled.dataset.id = String(p.id);
tr.querySelector('[data-field="profile_type"]').textContent = typeLabel(p.profile_type || 'scan');
tr.querySelector('[data-field="name"]').textContent = p.name || '';
tr.querySelector('[data-field="root_path"]').textContent = p.root_path || '';
tr.querySelector('[data-field="max_depth"]').textContent = String(p.max_depth ?? '');
tr.querySelector('[data-field="exclude"]').textContent = (p.exclude_patterns || []).join(', ');
tr.querySelector('[data-field="ext"]').textContent =
(p.include_ext_mode === 'custom')
? ((p.include_ext || []).join(','))
: t('settings.scan_profiles.ext_default', 'default');
tr.querySelector('[data-field="last_scan"]').textContent =
p.last_scan_at ? String(p.last_scan_at) : t('common.never', 'never');
tr.querySelector('[data-field="last_result"]').textContent =
p.last_result || t('common.never', 'never');
const btns = tr.querySelectorAll('button[data-action]');
btns.forEach(b => {
b.dataset.id = String(p.id);
b.addEventListener('click', onProfileAction);
});
enabled.addEventListener('change', onProfileToggle);
tbody.appendChild(frag);
}
function renderProfiles() {
const table = state.tables.profiles;
if (table) {
table.reload().then(applyI18n);
return;
}
const tbody = UI.qs('#profilesTable tbody');
tbody.innerHTML = '';
for (const p of state.profiles) {
renderProfileRow(tbody, p);
}
applyI18n();
}
function typeLabel(v) {
if (v === 'analyze') return t('settings.scan_profiles.type_analyze', 'Analyze');
return t('settings.scan_profiles.type_scan', 'Scan');
}
async function onProfileToggle(e) {
const id = Number(e.target.dataset.id);
const p = state.profiles.find(x => x.id === id);
if (!p) return;
p.enabled = e.target.checked ? 1 : 0;
await api(`/api/scan-profiles/${id}`, 'PUT', {
enabled: !!p.enabled,
name: p.name,
root_path: p.root_path,
max_depth: p.max_depth,
profile_type: p.profile_type || 'scan',
exclude_patterns: p.exclude_patterns || [],
include_ext_mode: p.include_ext_mode || 'default',
include_ext: p.include_ext || null,
});
setStatus(t('common.saved', 'Saved'));
}
async function onProfileAction(e) {
const action = e.target.dataset.action;
const id = Number(e.target.dataset.id);
if (action === 'edit') {
openProfileModal(id);
return;
}
if (action === 'drag') {
return;
}
if (action === 'del') {
if (!confirm(t('settings.scan_profiles.confirm_delete', 'Delete profile?'))) return;
await api(`/api/scan-profiles/${id}`, 'DELETE');
state.profiles = state.profiles.filter(x => x.id !== id);
renderProfiles();
return;
}
}
function openProfileModal(id = null) {
state.editProfileId = id;
const modal = UI.qs('#modal');
modal.style.display = 'flex';
const isNew = id === null;
UI.qs('#modalTitle').textContent = isNew
? t('settings.scan_profiles.modal_add', 'Add profile')
: t('settings.scan_profiles.modal_edit', 'Edit profile');
const p = isNew ? {
enabled: 1,
name: '',
root_path: '',
max_depth: 3,
exclude_patterns: [],
include_ext_mode: 'default',
include_ext: null,
profile_type: 'scan',
} : state.profiles.find(x => x.id === id);
UI.qs('#pEnabled').value = p.enabled ? '1' : '0';
UI.qs('#pName').value = p.name || '';
UI.qs('#pRoot').value = p.root_path || '';
UI.qs('#pDepth').value = String(p.max_depth || 3);
UI.qs('#pType').value = p.profile_type || 'scan';
UI.qs('#pExcludes').value = listToComma(p.exclude_patterns || []);
UI.qs('#pExtMode').value = p.include_ext_mode || 'default';
UI.qs('#pExtCustom').value = listToComma(p.include_ext || []);
refreshExtCustomVisibility();
}
function closeProfileModal() {
UI.qs('#modal').style.display = 'none';
state.editProfileId = null;
}
function refreshExtCustomVisibility() {
const mode = UI.qs('#pExtMode').value;
UI.qs('#pExtCustomWrap').style.display = (mode === 'custom') ? 'block' : 'none';
}
async function saveProfileModal() {
const isNew = state.editProfileId === null;
const payload = {
enabled: UI.qs('#pEnabled').value === '1',
name: UI.qs('#pName').value.trim(),
root_path: UI.qs('#pRoot').value.trim(),
max_depth: Number(UI.qs('#pDepth').value || 3),
profile_type: UI.qs('#pType').value || 'scan',
exclude_patterns: commaToList(UI.qs('#pExcludes').value),
include_ext_mode: UI.qs('#pExtMode').value,
include_ext: (UI.qs('#pExtMode').value === 'custom') ? commaToList(UI.qs('#pExtCustom').value) : null,
};
if (isNew) {
const data = await api('/api/scan-profiles', 'POST', payload);
payload.id = data.id;
state.profiles.push(payload);
} else {
const id = state.editProfileId;
await api(`/api/scan-profiles/${id}`, 'PUT', payload);
const idx = state.profiles.findIndex(x => x.id === id);
state.profiles[idx] = { ...state.profiles[idx], ...payload };
}
closeProfileModal();
await loadProfiles();
}
function initProfileReorder() {
const tbody = UI.qs('#profilesTable tbody');
if (!tbody) return;
tbody.addEventListener('dragstart', (e) => {
const handle = e.target.closest('.drag-handle');
const tr = e.target.closest('tr');
if (!handle || !tr) {
e.preventDefault();
return;
}
state.draggingId = tr.dataset.id || null;
e.dataTransfer.effectAllowed = 'move';
});
tbody.addEventListener('dragover', (e) => {
e.preventDefault();
const tr = e.target.closest('tr');
if (!tr || !state.draggingId) return;
const dragging = tbody.querySelector(`tr[data-id="${state.draggingId}"]`);
if (!dragging || dragging === tr) return;
const rect = tr.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
if (after) {
tr.after(dragging);
} else {
tr.before(dragging);
}
});
tbody.addEventListener('drop', async (e) => {
e.preventDefault();
if (!state.draggingId) return;
state.draggingId = null;
await persistProfileOrder();
});
}
async function persistProfileOrder() {
const rows = Array.from(UI.qs('#profilesTable tbody').querySelectorAll('tr'));
const ids = rows.map(r => Number(r.dataset.id)).filter(Boolean);
if (ids.length === 0) return;
await api('/api/scan-profiles/reorder', 'POST', { ids });
state.profiles.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
setStatus(t('common.saved', 'Saved'));
}
/* ---------------------------
Settings UI mapping
--------------------------- */
function applySettingsToUI(all, userUi = null) {
const data = all.data || all; // tolerate nesting
state.settings = data;
// Debug visibility
const meta = data.meta || {};
if (meta.debug_tools_enabled) {
UI.qs('#tabDebug').style.display = 'block';
}
if (!meta.allow_db_reset) {
UI.qs('#btnResetDb').disabled = true;
const restoreBtn = UI.qs('#btnDbRestore');
if (restoreBtn) restoreBtn.disabled = true;
}
updateAboutVersions(meta);
const envEl = UI.qs('#serverEnv');
if (envEl) envEl.value = meta.env || '';
const appIdEl = UI.qs('#serverAppId');
if (appIdEl) appIdEl.value = meta.app_id || '';
const debugEl = UI.qs('#serverDebug');
if (debugEl) debugEl.value = meta.debug_tools_enabled ? 'true' : 'false';
const backendEl = UI.qs('#serverBackendVersion');
if (backendEl) backendEl.value = meta.backend_version || '';
const dbVerEl = UI.qs('#serverDbVersion');
if (dbVerEl) dbVerEl.value = meta.db_version || '';
// Scanner defaults
const sd = data.scanner_defaults || {};
UI.qs('#videoExt').value = listToComma(sd.video_ext || []);
UI.qs('#maxDepthDefault').value = String(sd.max_depth_default ?? 3);
UI.qs('#maxFilesPerItem').value = String(sd.max_files_per_item ?? 3000);
UI.qs('#maxItemsPerScan').value = String(sd.max_items_per_scan ?? 0);
// Paths (roots list)
const paths = data.paths || {};
state.rootsList = buildRootsList(paths);
renderRootsList();
// Layout (rules moved to Rules tab)
const layout = data.layout || {};
const cp = layout.collision_policy || 'stop';
const collisionEl = UI.qs('#collisionPolicy');
if (collisionEl) collisionEl.value = cp;
// Tools list
const tools = data.tools || {};
state.toolsList = buildToolsList(tools);
renderToolsList();
// Logs
const logs = data.logs || {};
UI.qs('#logsRetention').value = String(logs.retention_days ?? 7);
UI.qs('#logsLevel').value = logs.level || 'info';
initLogsViewDates();
// Rules list (new UI)
const rulesList = Array.isArray(data.rules) ? data.rules : [];
state.rulesList = rulesList.map(r => normalizeRule(r));
renderRulesList();
// Tasks list
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
state.tasksList = tasks.map(t => normalizeTask(t));
renderTasksList();
// Plugins
state.pluginConfig = {
metadata: data.metadata || {},
exports: data.exports || {},
sources: data.sources || {},
};
normalizePluginConfig();
renderPluginsList();
// UI settings (per-user, stored in account prefs)
const ui = userUi || {};
const tableMode = ui.table_mode || 'pagination';
const tableSize = ui.table_page_size || 50;
state.ui = { table_mode: tableMode, table_page_size: tableSize };
const serverUi = data.ui || {};
UI.qs('#uiSseTickSeconds').value = String(serverUi.sse_tick_seconds ?? 10);
// Background policies
const bg = data.background || {};
const bgMode = UI.qs('#bgMode');
if (bgMode) bgMode.value = bg.mode || 'light';
const bgParallel = UI.qs('#bgMaxParallel');
if (bgParallel) bgParallel.value = String(bg.max_parallel_jobs ?? 1);
const bgNetwork = UI.qs('#bgMaxNetwork');
if (bgNetwork) bgNetwork.value = String(bg.max_network_jobs ?? 1);
const bgIo = UI.qs('#bgMaxIo');
if (bgIo) bgIo.value = String(bg.max_io_jobs ?? 1);
const bgSleep = UI.qs('#bgBatchSleep');
if (bgSleep) bgSleep.value = String(bg.batch_sleep_ms ?? 500);
const bgWatchdog = UI.qs('#bgWatchdog');
if (bgWatchdog) bgWatchdog.value = String(bg.watchdog_minutes ?? 10);
const bgSseTtl = UI.qs('#bgSseTtl');
if (bgSseTtl) bgSseTtl.value = String(bg.sse_session_ttl_seconds ?? 20);
const safety = data.safety || {};
const safetyDepth = UI.qs('#safetyMaxDepth');
if (safetyDepth) safetyDepth.value = String(safety.max_depth ?? 10);
const safetyFiles = UI.qs('#safetyMaxFiles');
if (safetyFiles) safetyFiles.value = String(safety.max_files_per_item ?? 200000);
const safetyItems = UI.qs('#safetyMaxItems');
if (safetyItems) safetyItems.value = String(safety.max_items_per_scan ?? 1000000);
setDirty(false);
}
function collectSettingsFromUI() {
const meta = state.settings?.meta || {};
const rev = meta.settings_revision ?? 1;
const scanner_defaults = {
video_ext: commaToList(UI.qs('#videoExt').value),
max_depth_default: Number(UI.qs('#maxDepthDefault').value || 3),
max_files_per_item: Number(UI.qs('#maxFilesPerItem').value || 3000),
max_items_per_scan: Number(UI.qs('#maxItemsPerScan').value || 0),
};
const paths = buildPathsPayload();
const layout = {
collision_policy: UI.qs('#collisionPolicy')?.value || 'stop',
};
const tools = {
mkvmerge_path: toolPathByType('mkvmerge'),
mkvpropedit_path: toolPathByType('mkvpropedit'),
ffmpeg_path: toolPathByType('ffmpeg'),
};
const logs = {
retention_days: Number(UI.qs('#logsRetention').value || 7),
level: UI.qs('#logsLevel').value || 'info',
};
const background = {
mode: UI.qs('#bgMode')?.value || 'light',
max_parallel_jobs: Number(UI.qs('#bgMaxParallel')?.value || 1),
max_network_jobs: Number(UI.qs('#bgMaxNetwork')?.value || 1),
max_io_jobs: Number(UI.qs('#bgMaxIo')?.value || 1),
batch_sleep_ms: Number(UI.qs('#bgBatchSleep')?.value || 500),
watchdog_minutes: Number(UI.qs('#bgWatchdog')?.value || 10),
sse_session_ttl_seconds: Number(UI.qs('#bgSseTtl')?.value || 20),
paused: !!state.settings?.background?.paused,
};
const ui = {
sse_tick_seconds: Number(UI.qs('#uiSseTickSeconds')?.value || 10),
};
const safety = {
max_depth: Number(UI.qs('#safetyMaxDepth')?.value || 10),
max_files_per_item: Number(UI.qs('#safetyMaxFiles')?.value || 200000),
max_items_per_scan: Number(UI.qs('#safetyMaxItems')?.value || 1000000),
};
const rules = state.rulesList || [];
const tasks = state.tasksList || [];
const metadata = state.pluginConfig?.metadata || {};
const exportsCfg = state.pluginConfig?.exports || {};
const sources = state.pluginConfig?.sources || {};
return {
if_revision: rev,
scanner_defaults,
paths,
tools,
logs,
layout,
ui,
safety,
rules,
sources,
metadata,
exports: exportsCfg,
background,
tasks,
};
}
function refreshStrategyVisibility() {
// No-op (layout rules moved to Rules tab)
}
/* ---------------------------
Data loading
--------------------------- */
async function loadSettings() {
setStatus(t('common.loading_settings', 'Loading settings…'));
const data = await api('/api/settings', 'GET');
console.log('[debug] loadSettings', data);
const meta = data.data?.meta || data.meta || {};
state.debugToolsEnabled = !!meta.debug_tools_enabled;
const prefs = window.UserPrefs ? await window.UserPrefs.load().catch(() => null) : null;
// Prefer per-user language, fall back to server default
const lang = (prefs?.language || window.APP_LANG || data.data?.general?.language || data.general?.language || 'ru');
// Apply settings to UI, then set status
applySettingsToUI(data, prefs);
if (meta.debug_tools_enabled) {
loadDebugStats().catch(() => {});
}
setStatus(t('common.loaded', 'Loaded'));
}
async function loadDiagnostics() {
const data = await api('/api/settings/diagnostics', 'GET');
const diag = data.data || data || {};
const phpEl = UI.qs('#serverPhpVersion');
if (phpEl) phpEl.value = diag.php_version || '';
const diskEl = UI.qs('#serverDisk');
if (diskEl) {
const free = formatBytes(diag.disk?.free_bytes);
const total = formatBytes(diag.disk?.total_bytes);
diskEl.value = (free && total) ? `${free} / ${total}` : '';
}
const binsEl = UI.qs('#serverBinaries');
if (binsEl) {
const bins = diag.binaries || {};
const parts = Object.keys(bins).map(k => `${k}:${bins[k] ? 'ok' : 'missing'}`);
binsEl.value = parts.join(' ');
}
const jobsEl = UI.qs('#serverJobs');
if (jobsEl) {
const jobs = diag.jobs || {};
jobsEl.value = `running=${jobs.running || 0} queued=${jobs.queued || 0} error=${jobs.errors || 0}`;
}
}
async function loadSnapshots() {
const data = await api('/api/settings/snapshots', 'GET');
const items = data.data?.items || data.items || [];
state.snapshots = items;
renderSnapshots();
}
function renderSnapshots() {
const tbody = UI.qs('#snapshotsTable tbody');
if (!tbody) return;
tbody.innerHTML = '';
(state.snapshots || []).forEach((s) => {
const tr = document.createElement('tr');
tr.dataset.id = String(s.id || '');
const tdId = document.createElement('td');
tdId.textContent = String(s.id || '');
const tdLabel = document.createElement('td');
tdLabel.textContent = s.label || '';
const tdDate = document.createElement('td');
tdDate.textContent = s.created_at || '';
const tdActions = document.createElement('td');
const btnRestore = document.createElement('button');
btnRestore.className = 'btn';
btnRestore.textContent = t('settings.server.snapshot_restore', 'Restore');
btnRestore.addEventListener('click', () => restoreSnapshot(Number(s.id || 0)));
const btnDelete = document.createElement('button');
btnDelete.className = 'btn';
btnDelete.textContent = t('common.delete', 'Delete');
btnDelete.addEventListener('click', () => deleteSnapshot(Number(s.id || 0)));
tdActions.appendChild(btnRestore);
tdActions.appendChild(btnDelete);
tr.appendChild(tdId);
tr.appendChild(tdLabel);
tr.appendChild(tdDate);
tr.appendChild(tdActions);
tbody.appendChild(tr);
});
}
async function createSnapshot() {
const hint = UI.qs('#snapshotsHint');
if (hint) hint.textContent = t('common.saving', 'Saving…');
try {
const label = UI.qs('#snapshotLabel')?.value || '';
await api('/api/settings/snapshots', 'POST', { label });
if (UI.qs('#snapshotLabel')) UI.qs('#snapshotLabel').value = '';
await loadSnapshots();
if (hint) hint.textContent = t('common.saved', 'Saved');
} catch (e) {
if (hint) hint.textContent = `${t('common.error', 'Error')}: ${e.message}`;
}
}
async function restoreSnapshot(id) {
if (!id) return;
const ok = confirm(t('settings.server.snapshot_restore_confirm', 'Restore this snapshot? Current settings will be replaced.'));
if (!ok) return;
const hint = UI.qs('#snapshotsHint');
if (hint) hint.textContent = t('common.running', 'Running…');
try {
await api('/api/settings/snapshots/restore', 'POST', { id });
await loadSettings();
if (hint) hint.textContent = t('common.saved', 'Saved');
} catch (e) {
if (hint) hint.textContent = `${t('common.error', 'Error')}: ${e.message}`;
}
}
async function deleteSnapshot(id) {
if (!id) return;
const ok = confirm(t('settings.server.snapshot_delete_confirm', 'Delete this snapshot?'));
if (!ok) return;
const hint = UI.qs('#snapshotsHint');
if (hint) hint.textContent = t('common.running', 'Running…');
try {
await api(`/api/settings/snapshots/${id}`, 'DELETE');
await loadSnapshots();
if (hint) hint.textContent = t('common.saved', 'Saved');
} catch (e) {
if (hint) hint.textContent = `${t('common.error', 'Error')}: ${e.message}`;
}
}
/* ---------------------------
Rules UI (new)
--------------------------- */
function normalizeRule(r) {
const id = r?.id || `r_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
return {
id,
type: r?.type || 'name_map',
name: r?.name || '',
enabled: (r?.enabled !== false),
config: r?.config || {},
};
}
function normalizeTask(t) {
const id = t?.id || `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
return {
id,
name: t?.name || '',
enabled: t?.enabled !== false,
sources: Array.isArray(t?.sources) ? t.sources : [],
actions: Array.isArray(t?.actions) ? t.actions : [],
};
}
function renderRuleRow(tbody, r) {
const tr = document.createElement('tr');
tr.dataset.id = r.id;
const tdName = document.createElement('td');
tdName.textContent = r.name || t('rules.unnamed', 'Untitled');
const tdType = document.createElement('td');
tdType.textContent = ruleTypeLabel(r.type);
const tdSummary = document.createElement('td');
tdSummary.textContent = ruleSummary(r);
const tdStatus = document.createElement('td');
tdStatus.textContent = r.enabled ? t('rules.status.on', 'On') : t('rules.status.off', 'Off');
const tdActions = document.createElement('td');
const btnEdit = document.createElement('button');
btnEdit.className = 'btn';
btnEdit.textContent = t('common.edit', 'Edit');
btnEdit.addEventListener('click', () => openRuleModal(r.type, r.id));
const btnToggle = document.createElement('button');
btnToggle.className = 'btn';
btnToggle.textContent = r.enabled ? t('rules.disable', 'Disable') : t('rules.enable', 'Enable');
btnToggle.addEventListener('click', () => {
r.enabled = !r.enabled;
renderRulesList();
setDirty(true);
});
const btnDel = document.createElement('button');
btnDel.className = 'btn';
btnDel.textContent = t('common.delete', 'Delete');
btnDel.addEventListener('click', () => {
if (!confirm(t('rules.confirm_delete', 'Delete rule?'))) return;
state.rulesList = state.rulesList.filter(x => x.id !== r.id);
renderRulesList();
setDirty(true);
});
tdActions.appendChild(btnEdit);
tdActions.appendChild(btnToggle);
tdActions.appendChild(btnDel);
tr.appendChild(tdName);
tr.appendChild(tdType);
tr.appendChild(tdSummary);
tr.appendChild(tdStatus);
tr.appendChild(tdActions);
tbody.appendChild(tr);
}
function renderRulesList() {
const field = state.ruleSort.field || 'name';
const dir = state.ruleSort.dir || 'asc';
const sortField = UI.qs('#rulesSortField');
if (sortField) sortField.value = field;
const sortBtn = UI.qs('#rulesSortDir');
if (sortBtn) sortBtn.textContent = (dir === 'asc') ? '↑' : '↓';
const table = state.tables.rules;
if (table) {
table.setSort(field, dir);
table.reload();
return;
}
const tbody = UI.qs('#rulesTable tbody');
if (!tbody) return;
tbody.innerHTML = '';
const rows = sortItems(state.rulesList, field, dir, {
name: r => (r.name || '').toLowerCase(),
type: r => (r.type || '').toLowerCase(),
});
rows.forEach(r => renderRuleRow(tbody, r));
}
function renderTaskRow(tbody, task) {
const tr = document.createElement('tr');
tr.dataset.id = task.id;
const tdName = document.createElement('td');
tdName.textContent = task.name || t('rules.unnamed', 'Untitled');
const tdSources = document.createElement('td');
tdSources.textContent = (task.sources || []).map(taskSourceLabel).join(', ');
const tdActions = document.createElement('td');
tdActions.textContent = (task.actions || []).map(taskActionLabel).join(', ');
const tdStatus = document.createElement('td');
tdStatus.textContent = task.enabled ? t('settings.tasks.status.on', 'On') : t('settings.tasks.status.off', 'Off');
const tdButtons = document.createElement('td');
const btnEdit = document.createElement('button');
btnEdit.className = 'btn';
btnEdit.textContent = t('common.edit', 'Edit');
btnEdit.addEventListener('click', () => openTaskModal(task.id));
const btnToggle = document.createElement('button');
btnToggle.className = 'btn';
btnToggle.textContent = task.enabled ? t('rules.disable', 'Disable') : t('rules.enable', 'Enable');
btnToggle.addEventListener('click', () => {
task.enabled = !task.enabled;
renderTasksList();
setDirty(true);
});
const btnDel = document.createElement('button');
btnDel.className = 'btn';
btnDel.textContent = t('common.delete', 'Delete');
btnDel.addEventListener('click', () => {
if (!confirm(t('settings.tasks.confirm_delete', 'Delete task?'))) return;
state.tasksList = state.tasksList.filter(x => x.id !== task.id);
renderTasksList();
setDirty(true);
});
tdButtons.appendChild(btnEdit);
tdButtons.appendChild(btnToggle);
tdButtons.appendChild(btnDel);
tr.appendChild(tdName);
tr.appendChild(tdSources);
tr.appendChild(tdActions);
tr.appendChild(tdStatus);
tr.appendChild(tdButtons);
tbody.appendChild(tr);
}
function renderTasksList() {
const table = state.tables.tasks;
if (table) {
table.reload();
return;
}
const tbody = UI.qs('#tasksTable tbody');
if (!tbody) return;
tbody.innerHTML = '';
state.tasksList.forEach(task => renderTaskRow(tbody, task));
}
function taskSourceLabel(src) {
if (src === 'library') return t('settings.tasks.source.library', 'Library');
if (src === 'transmission') return t('settings.tasks.source.transmission', 'Transmission');
if (src === 'staging') return t('settings.tasks.source.staging', 'Staging');
return src || '';
}
function taskActionLabel(action) {
if (action === 'analyze') return t('settings.tasks.action.analyze', 'Analyze');
if (action === 'identify') return t('settings.tasks.action.identify', 'Identify');
if (action === 'normalize') return t('settings.tasks.action.normalize', 'Normalize');
if (action === 'rename') return t('settings.tasks.action.rename', 'Rename');
if (action === 'export') return t('settings.tasks.action.export', 'Export');
return action || '';
}
function sourceStatusOptions(source) {
if (source === 'transmission') {
return [
{ value: 'completed', label: t('sources.status.completed', 'completed') },
{ value: 'downloading', label: t('sources.status.downloading', 'downloading') },
{ value: 'seeding', label: t('sources.status.seeding', 'seeding') },
{ value: 'stopped', label: t('sources.status.stopped', 'stopped') },
{ value: 'unknown', label: t('sources.status.unknown', 'unknown') },
];
}
return [];
}
function renderStatusOptions(source) {
const options = sourceStatusOptions(source);
if (options.length === 0) {
return `<option value="">${t('rules.statuses.none', 'No statuses')}</option>`;
}
return options.map(opt => `<option value="${escapeHtml(opt.value)}">${escapeHtml(opt.label)}</option>`).join('');
}
function conditionFieldOptions() {
return [
{ value: 'status', label: t('rules.cond.status', 'Status') },
{ value: 'label', label: t('rules.cond.label', 'Label') },
{ value: 'name_regex', label: t('rules.cond.name_regex', 'Name regex') },
{ value: 'path_regex', label: t('rules.cond.path_regex', 'Path regex') },
{ value: 'min_size', label: t('rules.cond.min_size', 'Min size') },
];
}
function conditionOperatorOptions(field) {
if (field === 'status') {
return [
{ value: 'any', label: t('rules.op.any', 'any') },
{ value: 'in', label: '=' },
{ value: 'not_in', label: '!=' },
];
}
if (field === 'min_size') {
return [
{ value: '>=', label: '>=' },
{ value: '>', label: '>' },
{ value: '<=', label: '<=' },
{ value: '<', label: '<' },
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
];
}
if (field === 'name_regex' || field === 'path_regex') {
return [
{ value: 'regex', label: 'regex' },
{ value: 'not_regex', label: '!regex' },
];
}
if (field === 'label') {
return [
{ value: 'contains', label: t('rules.op.contains', 'contains') },
{ value: 'not_contains', label: t('rules.op.not_contains', 'not contains') },
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
];
}
return [
{ value: '=', label: '=' },
];
}
function createConditionValueControl(field, source, op, value) {
if (field === 'status') {
const sel = document.createElement('select');
sel.multiple = true;
sel.dataset.field = 'cond_value';
sel.innerHTML = renderStatusOptions(source);
const values = Array.isArray(value) ? value : [];
Array.from(sel.options).forEach(opt => {
if (values.includes(opt.value)) opt.selected = true;
});
if (op === 'any') {
sel.disabled = true;
}
return sel;
}
if (field === 'min_size') {
const input = document.createElement('input');
input.type = 'number';
input.dataset.field = 'cond_value';
input.value = value ?? '';
return input;
}
const input = document.createElement('input');
input.type = 'text';
input.dataset.field = 'cond_value';
input.value = value ?? '';
return input;
}
function formatConditionValue(field, source, value, op) {
if (field === 'status') {
if (op === 'any') return t('rules.op.any', 'any');
const values = Array.isArray(value) ? value : [];
const labels = sourceStatusOptions(source).reduce((acc, s) => {
acc[s.value] = s.label;
return acc;
}, {});
const parts = values.map(v => labels[v] || v).filter(Boolean);
const orLabel = t('rules.logic.or', 'OR');
return parts.length ? parts.join(` ${orLabel} `) : t('rules.statuses.none', 'No statuses');
}
if (field === 'min_size') {
return String(value ?? '');
}
return String(value ?? '');
}
function formatConditionField(field) {
const opt = conditionFieldOptions().find(o => o.value === field);
return opt ? opt.label : field;
}
function formatConditionOp(field, op) {
if (field === 'status' && op === 'any') return '=';
if (op) return op;
const opts = conditionOperatorOptions(field);
return opts.length ? opts[0].value : '=';
}
function ruleSummary(r) {
const cfg = r.config || {};
if (r.type === 'name_map') {
return `${cfg.mode || 'exact'}: ${cfg.pattern || '*'} -> ${cfg.canonical || '*'}`;
}
if (r.type === 'delete_track') {
return `type=${cfg.track_type || '*'} lang=${cfg.lang || '*'} audio=${cfg.audio_type || '*'} contains=${cfg.name_contains || '*'}`;
}
if (r.type === 'priorities') {
const langs = Array.isArray(cfg.languages) ? cfg.languages.join(',') : '';
const aud = Array.isArray(cfg.audio_types) ? cfg.audio_types.join(',') : '';
return `langs=${langs || '*'} audio=${aud || '*'}`;
}
if (r.type === 'lang_fix') {
return `${cfg.from_lang || '*'} -> ${cfg.to_lang || '*'} (${cfg.mode || 'exact'})`;
}
if (r.type === 'source_filter') {
const conds = Array.isArray(cfg.conditions) ? cfg.conditions : [];
const condText = conds
.filter(c => c?.enabled !== false)
.map(c => `${c.field || '?'}${c.op || '='}${Array.isArray(c.value) ? c.value.join(',') : (c.value ?? '')}`)
.join(' ');
return `src=${cfg.source || '*'} ${condText || '*'}`;
}
return '';
}
function initRulesUi() {
const addBtn = UI.qs('#btnAddRule');
const menu = UI.qs('#ruleTypeMenu');
const sortField = UI.qs('#rulesSortField');
const sortDir = UI.qs('#rulesSortDir');
const modalClose = UI.qs('#ruleModalClose');
const modalCancel = UI.qs('#ruleModalCancel');
const modalSave = UI.qs('#ruleModalSave');
if (addBtn && menu) {
addBtn.addEventListener('click', (e) => {
e.preventDefault();
addBtn.parentElement?.classList.toggle('open');
});
menu.querySelectorAll('button[data-rule-type]').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.ruleType || 'name_map';
addBtn.parentElement?.classList.remove('open');
openRuleModal(type, null);
});
});
document.addEventListener('click', (e) => {
if (!addBtn.parentElement?.contains(e.target)) {
addBtn.parentElement?.classList.remove('open');
}
});
}
if (sortField) {
sortField.addEventListener('change', () => {
state.ruleSort.field = sortField.value || 'name';
renderRulesList();
});
}
if (sortDir) {
sortDir.addEventListener('click', () => {
state.ruleSort.dir = (state.ruleSort.dir === 'asc') ? 'desc' : 'asc';
renderRulesList();
});
}
if (modalClose) modalClose.addEventListener('click', closeRuleModal);
if (modalCancel) modalCancel.addEventListener('click', closeRuleModal);
if (modalSave) {
modalSave.addEventListener('click', () => {
if (state.taskEditId !== null) return;
saveRuleModal();
});
}
}
function initTaskModalRouting() {
const modalSave = UI.qs('#ruleModalSave');
const modalCancel = UI.qs('#ruleModalCancel');
const modalClose = UI.qs('#ruleModalClose');
if (modalSave) {
modalSave.addEventListener('click', () => {
const body = UI.qs('#ruleModalBody');
if (!body) return;
if (state.taskEditId !== null) {
saveTaskModal(body);
return;
}
});
}
if (modalCancel) modalCancel.addEventListener('click', closeRuleModal);
if (modalClose) modalClose.addEventListener('click', closeRuleModal);
}
function openRuleModal(type, id = null) {
const modal = UI.qs('#ruleModal');
const body = UI.qs('#ruleModalBody');
const title = UI.qs('#ruleModalTitle');
if (!modal || !body || !title) return;
const rule = id ? state.rulesList.find(r => r.id === id) : null;
const data = rule ? { ...rule } : normalizeRule({ type });
data.type = type || data.type;
state.ruleEditId = data.id;
state.ruleEditType = data.type;
state.toolEditType = null;
state.taskEditId = null;
title.textContent = `${t('rules.modal_title', 'Rule')}: ${ruleTypeLabel(data.type)}`;
body.innerHTML = renderRuleForm(data);
applyRuleFormValues(body, data);
if (data.type === 'source_filter') {
const sourceSel = body.querySelector('[data-field="source"]');
if (sourceSel) {
sourceSel.addEventListener('change', () => {
renderConditionsTable(body, sourceSel.value, readConditionsTable(body));
});
}
}
modal.style.display = 'flex';
}
function closeRuleModal() {
const modal = UI.qs('#ruleModal');
if (modal) modal.style.display = 'none';
state.ruleEditId = null;
state.ruleEditType = null;
state.toolEditType = null;
state.rootEditId = null;
state.pluginEditType = null;
state.taskEditId = null;
}
function renderRuleForm(rule) {
const cfg = rule.config || {};
let fields = '';
if (rule.type === 'name_map') {
fields += `
<label>
<div class="lbl">${t('rules.field.pattern','Pattern')}</div>
<input data-field="pattern" type="text" value="${escapeHtml(cfg.pattern || '')}">
</label>
<label>
<div class="lbl">${t('rules.field.canonical','Canonical')}</div>
<input data-field="canonical" type="text" value="${escapeHtml(cfg.canonical || '')}">
</label>
<label>
<div class="lbl">${t('rules.field.mode','Mode')}</div>
<select data-field="mode">
<option value="exact">${t('rules.mode.exact','exact')}</option>
<option value="regex">${t('rules.mode.regex','regex')}</option>
</select>
</label>
`;
} else if (rule.type === 'delete_track') {
fields += `
<label>
<div class="lbl">${t('rules.field.track_type','Track type')}</div>
<select data-field="track_type">
<option value="">${t('rules.any','Any')}</option>
<option value="audio">audio</option>
<option value="subtitle">subtitle</option>
<option value="video">video</option>
</select>
</label>
<label>
<div class="lbl">${t('rules.field.lang','Language')}</div>
<input data-field="lang" type="text" value="${escapeHtml(cfg.lang || '')}">
</label>
<label>
<div class="lbl">${t('rules.field.audio_type','Audio type')}</div>
<input data-field="audio_type" type="text" value="${escapeHtml(cfg.audio_type || '')}">
</label>
<label>
<div class="lbl">${t('rules.field.name_contains','Name contains')}</div>
<input data-field="name_contains" type="text" value="${escapeHtml(cfg.name_contains || '')}">
</label>
<label>
<div class="lbl">${t('rules.field.except_default','Except default')}</div>
<select data-field="except_default">
<option value="0">${t('common.no','No')}</option>
<option value="1">${t('common.yes','Yes')}</option>
</select>
</label>
<label>
<div class="lbl">${t('rules.field.except_forced','Except forced')}</div>
<select data-field="except_forced">
<option value="0">${t('common.no','No')}</option>
<option value="1">${t('common.yes','Yes')}</option>
</select>
</label>
`;
} else if (rule.type === 'priorities') {
fields += `
<label>
<div class="lbl">${t('rules.field.languages','Languages (comma)')}</div>
<input data-field="languages" type="text" value="${escapeHtml((cfg.languages || []).join(','))}">
</label>
<label>
<div class="lbl">${t('rules.field.audio_types','Audio types (comma)')}</div>
<input data-field="audio_types" type="text" value="${escapeHtml((cfg.audio_types || []).join(','))}">
</label>
`;
} else if (rule.type === 'lang_fix') {
fields += `
<label>
<div class="lbl">${t('rules.field.from_lang','From language')}</div>
<input data-field="from_lang" type="text" value="${escapeHtml(cfg.from_lang || '')}">
</label>
<label>
<div class="lbl">${t('rules.field.to_lang','To language')}</div>
<input data-field="to_lang" type="text" value="${escapeHtml(cfg.to_lang || '')}">
</label>
<label>
<div class="lbl">${t('rules.field.mode','Mode')}</div>
<select data-field="mode">
<option value="exact">${t('rules.mode.exact','exact')}</option>
<option value="regex">${t('rules.mode.regex','regex')}</option>
</select>
</label>
`;
} else if (rule.type === 'source_filter') {
const selectedSource = cfg.source || 'transmission';
const conditions = Array.isArray(cfg.conditions) ? cfg.conditions : [];
fields += `
<label>
<div class="lbl">${t('rules.field.source','Source')}</div>
<select data-field="source">
${renderSourceOptions(selectedSource)}
</select>
</label>
<label class="full">
<div class="lbl">${t('rules.field.conditions','Conditions')}</div>
<div class="conditions">
<div class="conditions-builder" data-field="conditions_builder">
<select data-field="cond_builder_field"></select>
<select data-field="cond_builder_op"></select>
<div data-field="cond_builder_value"></div>
<button type="button" class="btn" data-action="add_condition">${t('rules.cond.add','Add condition')}</button>
</div>
<table class="table" data-field="conditions_table">
<thead>
<tr>
<th>${t('rules.cond.field','Field')}</th>
<th>${t('rules.cond.op','Op')}</th>
<th>${t('rules.cond.value','Value')}</th>
<th>${t('rules.cond.enabled','Active')}</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</label>
`;
}
return `
<div class="grid">
<label>
<div class="lbl">${t('rules.field.name','Name')}</div>
<input data-field="name" type="text" value="${escapeHtml(rule.name || '')}">
</label>
<label>
<div class="lbl">${t('rules.field.enabled','Enabled')}</div>
<select data-field="enabled">
<option value="1">${t('common.yes','Yes')}</option>
<option value="0">${t('common.no','No')}</option>
</select>
</label>
${fields}
</div>
`;
}
function applyRuleFormValues(body, rule) {
const cfg = rule.config || {};
const setVal = (field, value) => {
const el = body.querySelector(`[data-field="${field}"]`);
if (el) el.value = value ?? '';
};
setVal('name', rule.name || '');
setVal('enabled', rule.enabled ? '1' : '0');
if (rule.type === 'name_map') {
setVal('pattern', cfg.pattern || '');
setVal('canonical', cfg.canonical || '');
setVal('mode', cfg.mode || 'exact');
} else if (rule.type === 'delete_track') {
setVal('track_type', cfg.track_type || '');
setVal('lang', cfg.lang || '');
setVal('audio_type', cfg.audio_type || '');
setVal('name_contains', cfg.name_contains || '');
setVal('except_default', cfg.except_default ? '1' : '0');
setVal('except_forced', cfg.except_forced ? '1' : '0');
} else if (rule.type === 'priorities') {
setVal('languages', (cfg.languages || []).join(','));
setVal('audio_types', (cfg.audio_types || []).join(','));
} else if (rule.type === 'lang_fix') {
setVal('from_lang', cfg.from_lang || '');
setVal('to_lang', cfg.to_lang || '');
setVal('mode', cfg.mode || 'exact');
} else if (rule.type === 'source_filter') {
setVal('source', cfg.source || 'transmission');
const conditions = Array.isArray(cfg.conditions) ? cfg.conditions : buildLegacyConditions(cfg);
renderConditionsTable(body, cfg.source || 'transmission', conditions);
}
}
function buildLegacyConditions(cfg) {
const conditions = [];
const statuses = Array.isArray(cfg.statuses) ? cfg.statuses : (cfg.status ? [cfg.status] : []);
if (statuses.length > 0) {
conditions.push({ field: 'status', op: 'in', value: statuses });
}
if (cfg.label) {
conditions.push({ field: 'label', op: 'contains', value: cfg.label });
}
if (cfg.name_regex) {
conditions.push({ field: 'name_regex', op: 'regex', value: cfg.name_regex });
}
if (cfg.path_regex) {
conditions.push({ field: 'path_regex', op: 'regex', value: cfg.path_regex });
}
if (cfg.min_size) {
conditions.push({ field: 'min_size', op: '>=', value: Number(cfg.min_size) });
}
return conditions;
}
function renderConditionsTable(body, source, conditions) {
const table = body.querySelector('[data-field="conditions_table"]');
if (!table) return;
const tbody = table.querySelector('tbody');
if (!tbody) return;
tbody.innerHTML = '';
(conditions || []).forEach(c => renderConditionRow(tbody, source, c));
initConditionBuilder(body, source, () => {
const cond = readConditionBuilder(body);
if (!cond) return;
renderConditionRow(tbody, source, cond);
});
}
function renderConditionRow(tbody, source, cond) {
const tr = document.createElement('tr');
const fieldCell = document.createElement('td');
const opCell = document.createElement('td');
const valueCell = document.createElement('td');
const enabledCell = document.createElement('td');
const delCell = document.createElement('td');
const field = cond.field || 'status';
const op = cond.op || '';
const value = cond.value ?? '';
tr.dataset.field = field;
tr.dataset.op = op;
tr.dataset.value = JSON.stringify(value);
tr.dataset.enabled = (cond.enabled === false) ? '0' : '1';
fieldCell.textContent = formatConditionField(field);
opCell.textContent = formatConditionOp(field, op);
valueCell.textContent = formatConditionValue(field, source, value, op);
const enabledBtn = document.createElement('button');
enabledBtn.type = 'button';
enabledBtn.className = 'btn';
const setEnabledLabel = (on) => {
enabledBtn.textContent = on ? t('common.yes','Yes') : t('common.no','No');
};
setEnabledLabel(cond.enabled !== false);
enabledBtn.addEventListener('click', () => {
const on = tr.dataset.enabled !== '1';
tr.dataset.enabled = on ? '1' : '0';
setEnabledLabel(on);
});
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn';
removeBtn.textContent = 'X';
removeBtn.addEventListener('click', () => tr.remove());
enabledCell.appendChild(enabledBtn);
delCell.appendChild(removeBtn);
tr.append(fieldCell, opCell, valueCell, enabledCell, delCell);
tbody.appendChild(tr);
}
function readConditionsTable(body) {
const rows = Array.from(body.querySelectorAll('[data-field="conditions_table"] tbody tr'));
return rows.map(row => {
const field = row.dataset.field || '';
const op = row.dataset.op || '';
let value = row.dataset.value || '';
try {
value = JSON.parse(value);
} catch {
// keep raw
}
const enabled = row.dataset.enabled !== '0';
return { field, op, value, enabled };
}).filter(c => c.field);
}
function initConditionBuilder(body, source, onAdd) {
const builder = body.querySelector('[data-field="conditions_builder"]');
if (!builder) return;
const fieldSel = builder.querySelector('[data-field="cond_builder_field"]');
const opSel = builder.querySelector('[data-field="cond_builder_op"]');
const valueWrap = builder.querySelector('[data-field="cond_builder_value"]');
const addBtn = builder.querySelector('[data-action="add_condition"]');
if (!fieldSel || !opSel || !valueWrap || !addBtn) return;
fieldSel.innerHTML = '';
conditionFieldOptions().forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
fieldSel.appendChild(o);
});
const refreshBuilder = (field, op, value) => {
opSel.innerHTML = '';
conditionOperatorOptions(field).forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
if (opt.value === op) o.selected = true;
opSel.appendChild(o);
});
if (!opSel.value && opSel.options.length > 0) opSel.value = opSel.options[0].value;
valueWrap.innerHTML = '';
valueWrap.appendChild(createConditionValueControl(field, source, opSel.value, value));
};
refreshBuilder(fieldSel.value || 'status', opSel.value || '', '');
fieldSel.onchange = () => refreshBuilder(fieldSel.value, '', '');
opSel.onchange = () => {
const currentEl = valueWrap.querySelector('[data-field="cond_value"]');
let val = '';
if (currentEl && currentEl.multiple) {
val = Array.from(currentEl.selectedOptions).map(o => o.value).filter(Boolean);
} else if (currentEl) {
val = currentEl.value ?? '';
}
refreshBuilder(fieldSel.value, opSel.value, val);
};
addBtn.onclick = () => onAdd();
}
function readConditionBuilder(body) {
const builder = body.querySelector('[data-field="conditions_builder"]');
if (!builder) return null;
const field = builder.querySelector('[data-field="cond_builder_field"]')?.value || '';
const op = builder.querySelector('[data-field="cond_builder_op"]')?.value || '';
const valueEl = builder.querySelector('[data-field="cond_value"]');
let value = '';
if (valueEl && valueEl.multiple) {
value = Array.from(valueEl.selectedOptions).map(o => o.value).filter(Boolean);
} else if (valueEl) {
value = valueEl.value ?? '';
}
if (field === 'min_size') {
value = Number(value || 0);
}
if (!field) return null;
return { field, op, value, enabled: true };
}
function saveRuleModal() {
const modal = UI.qs('#ruleModal');
const body = UI.qs('#ruleModalBody');
if (!modal || !body) return;
if (state.pluginEditType) {
savePluginModal(body);
setDirty(true);
closeRuleModal();
return;
}
if (state.rootEditId !== null) {
const type = body.querySelector('[data-field="root_type"]')?.value || 'movie';
const path = body.querySelector('[data-field="root_path"]')?.value?.trim() || '';
const enabled = body.querySelector('[data-field="root_enabled"]')?.value === '1';
const current = state.rootsList.find(r => r.id === state.rootEditId);
if (current) {
current.type = type;
current.path = path;
current.enabled = enabled;
} else {
state.rootsList.push({
id: state.rootEditId,
type,
path,
enabled,
});
}
renderRootsList();
setDirty(true);
closeRuleModal();
return;
}
if (state.toolEditType) {
const path = body.querySelector('[data-field="tool_path"]')?.value?.trim() || '';
updateToolPath(state.toolEditType, path);
setDirty(true);
closeRuleModal();
return;
}
const id = state.ruleEditId;
const type = state.ruleEditType;
const current = state.rulesList.find(r => r.id === id) || normalizeRule({ type });
const getVal = (field) => body.querySelector(`[data-field="${field}"]`)?.value ?? '';
const name = getVal('name').trim();
const enabled = getVal('enabled') === '1';
const config = {};
if (type === 'name_map') {
config.pattern = getVal('pattern').trim();
config.canonical = getVal('canonical').trim();
config.mode = getVal('mode') || 'exact';
} else if (type === 'delete_track') {
config.track_type = getVal('track_type').trim();
config.lang = getVal('lang').trim();
config.audio_type = getVal('audio_type').trim();
config.name_contains = getVal('name_contains').trim();
config.except_default = getVal('except_default') === '1';
config.except_forced = getVal('except_forced') === '1';
} else if (type === 'priorities') {
config.languages = commaToList(getVal('languages'));
config.audio_types = commaToList(getVal('audio_types'));
} else if (type === 'lang_fix') {
config.from_lang = getVal('from_lang').trim();
config.to_lang = getVal('to_lang').trim();
config.mode = getVal('mode') || 'exact';
} else if (type === 'source_filter') {
config.source = getVal('source').trim();
config.conditions = readConditionsTable(body);
}
const updated = {
id: current.id,
type,
name,
enabled,
config,
};
const idx = state.rulesList.findIndex(r => r.id === current.id);
if (idx >= 0) {
state.rulesList[idx] = updated;
} else {
state.rulesList.push(updated);
}
renderRulesList();
setDirty(true);
closeRuleModal();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => (
c === '&' ? '&amp;' :
c === '<' ? '&lt;' :
c === '>' ? '&gt;' :
c === '"' ? '&quot;' : '&#39;'
));
}
function cellText(value) {
const td = document.createElement('td');
td.textContent = value ?? '';
return td;
}
async function loadProfiles() {
const data = await api('/api/scan-profiles', 'GET');
state.profiles = data;
renderProfiles();
}
function initTableControllers() {
if (!window.TableController) return;
const mode = state.ui.table_mode || 'pagination';
const pageSize = state.ui.table_page_size || 50;
const getPrefs = (table) => {
const id = table?.dataset?.tableId || table?.id || '';
const prefs = window.UserPrefs?.getTable?.(id) || {};
return { id, prefs };
};
const profilesTable = UI.qs('#profilesTable');
if (profilesTable) {
const { id, prefs } = getPrefs(profilesTable);
state.tables.profiles = new TableController({
table: profilesTable,
mode,
pageSize,
sort: prefs.sort || '',
dir: prefs.dir || 'asc',
onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }),
fetchPage: ({ page, per_page, sort, dir }) => {
const sorted = sortItems(state.profiles, sort, dir, {
name: p => (p.name || '').toLowerCase(),
});
return Promise.resolve(paginateItems(sorted, page, per_page));
},
renderRow: renderProfileRow,
});
}
const rootsTable = UI.qs('#rootsTable');
if (rootsTable) {
const { id, prefs } = getPrefs(rootsTable);
state.tables.roots = new TableController({
table: rootsTable,
mode,
pageSize,
sort: prefs.sort || '',
dir: prefs.dir || 'asc',
onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }),
fetchPage: ({ page, per_page, sort, dir }) => {
const sorted = sortItems(state.rootsList, sort, dir, {
path: r => (r.path || '').toLowerCase(),
});
return Promise.resolve(paginateItems(sorted, page, per_page));
},
renderRow: renderRootRow,
});
}
const toolsTable = UI.qs('#toolsTable');
if (toolsTable) {
const { id, prefs } = getPrefs(toolsTable);
state.tables.tools = new TableController({
table: toolsTable,
mode,
pageSize,
sort: prefs.sort || '',
dir: prefs.dir || 'asc',
onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }),
fetchPage: ({ page, per_page, sort, dir }) => {
const sorted = sortItems(state.toolsList, sort, dir, {
name: t => (t.label || '').toLowerCase(),
});
return Promise.resolve(paginateItems(sorted, page, per_page));
},
renderRow: renderToolRow,
});
}
const rulesTable = UI.qs('#rulesTable');
if (rulesTable) {
const { id, prefs } = getPrefs(rulesTable);
state.tables.rules = new TableController({
table: rulesTable,
mode,
pageSize,
sort: prefs.sort || '',
dir: prefs.dir || 'asc',
onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }),
fetchPage: ({ page, per_page, sort, dir }) => {
const sortKey = sort || state.ruleSort.field;
const sortDir = dir || state.ruleSort.dir;
const sorted = sortItems(state.rulesList, sortKey, sortDir, {
name: r => (r.name || '').toLowerCase(),
type: r => (r.type || '').toLowerCase(),
});
return Promise.resolve(paginateItems(sorted, page, per_page));
},
renderRow: renderRuleRow,
});
}
const pluginsTable = UI.qs('#pluginsTable');
if (pluginsTable) {
const { id, prefs } = getPrefs(pluginsTable);
state.tables.plugins = new TableController({
table: pluginsTable,
mode,
pageSize,
sort: prefs.sort || '',
dir: prefs.dir || 'asc',
onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }),
fetchPage: ({ page, per_page, sort, dir }) => {
const list = buildPluginsList();
const sorted = sortItems(list, sort, dir, {
name: p => (p.label || '').toLowerCase(),
});
return Promise.resolve(paginateItems(sorted, page, per_page));
},
renderRow: renderPluginRow,
});
}
const tasksTable = UI.qs('#tasksTable');
if (tasksTable) {
const { id, prefs } = getPrefs(tasksTable);
state.tables.tasks = new TableController({
table: tasksTable,
mode,
pageSize,
sort: prefs.sort || '',
dir: prefs.dir || 'asc',
onSortChange: (sort, dir) => window.UserPrefs?.setTable?.(id, { sort, dir }),
fetchPage: ({ page, per_page, sort, dir }) => {
const sorted = sortItems(state.tasksList, sort, dir, {
name: t => (t.name || '').toLowerCase(),
});
return Promise.resolve(paginateItems(sorted, page, per_page));
},
renderRow: renderTaskRow,
});
}
}
/* ---------------------------
Actions
--------------------------- */
async function doSave() {
const prevRetention = Number(state.settings?.logs?.retention_days ?? 7);
const payload = collectSettingsFromUI();
const newRetention = Number(payload.logs?.retention_days ?? 7);
if (prevRetention > 0 && newRetention > 0 && newRetention < prevRetention) {
if (!confirm(t('settings.logs.retention_warn', 'Older logs will be deleted. Continue?'))) {
return;
}
}
setStatus(t('common.saving', 'Saving…'));
await api('/api/settings', 'POST', payload);
setDirty(false);
await loadSettings();
setStatus(t('common.saved', 'Saved'));
}
async function generatePreview() {
const box = UI.qs('#previewBox');
if (!box) return;
box.textContent = t('common.generating_preview', 'Generating preview…');
try {
const movies = await api('/api/layout/preview', 'POST', { kind: 'movies', mode: 'samples', limit: 10 });
const series = await api('/api/layout/preview', 'POST', { kind: 'series', mode: 'samples', limit: 10 });
let txt = '';
txt += `${t('settings.preview.movies','MOVIES')}\n`;
for (const ex of movies.examples || []) {
txt += `- ${ex.input.title} (${ex.input.year ?? t('common.na','n/a')}) -> ${ex.output_abs}\n`;
}
txt += `\n${t('settings.preview.series','SERIES')}\n`;
for (const ex of series.examples || []) {
txt += `- ${ex.input.title} (${ex.input.year ?? t('common.na','n/a')}) -> ${ex.output_abs}\n`;
}
box.textContent = txt;
} catch (e) {
box.textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
async function loadDebugStats() {
const filesEl = UI.qs('#dbgContentFiles');
if (!filesEl) return;
console.log('[debug] loadDebugStats: start');
try {
const data = await api('/api/debug/stats', 'GET');
console.log('[debug] loadDebugStats: response', data);
const content = data?.content || {};
const db = data?.db || {};
const info = data?.db_info || {};
UI.qs('#dbgContentFiles').textContent = String(content.files ?? 0);
UI.qs('#dbgContentMeta').textContent = String(content.meta ?? 0);
UI.qs('#dbgContentItems').textContent = String(content.items ?? 0);
UI.qs('#dbgDbTables').textContent = String(db.tables ?? 0);
UI.qs('#dbgDbSize').textContent = UI.formatBytes(db.size_bytes ?? 0);
UI.qs('#dbgDbName').textContent = String(info.db_name || '-');
UI.qs('#dbgDbUser').textContent = String(info.current_user || info.user_name || '-');
console.log('[debug] loadDebugStats: db_info', info);
loadDebugTables();
} catch (e) {
console.log('[debug] loadDebugStats: error', e);
UI.qs('#dbgContentFiles').textContent = '-';
UI.qs('#dbgContentMeta').textContent = '-';
UI.qs('#dbgContentItems').textContent = '-';
UI.qs('#dbgDbTables').textContent = '-';
UI.qs('#dbgDbSize').textContent = '-';
UI.qs('#dbgDbName').textContent = '-';
UI.qs('#dbgDbUser').textContent = '-';
const body = UI.qs('#dbgDbTablesList tbody');
if (body) body.innerHTML = '';
const preview = UI.qs('#dbgDbTablePreview');
if (preview) preview.textContent = '';
}
}
async function loadDebugTables() {
const table = UI.qs('#dbgDbTablesList');
if (!table) return;
const body = table.querySelector('tbody');
const status = UI.qs('#dbgDbTablesStatus');
if (body) body.innerHTML = '';
if (status) status.textContent = t('common.loading', 'Loading…');
console.log('[debug] loadDebugTables: start');
try {
const data = await api('/api/debug/db-tables', 'GET');
const items = Array.isArray(data?.items) ? data.items : [];
if (status) status.textContent = `items: ${items.length}`;
console.log('[debug] loadDebugTables: items', items.length, items.slice(0, 3));
if (body) {
if (items.length === 0) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 2;
cell.textContent = t('common.empty', 'Empty');
row.appendChild(cell);
body.appendChild(row);
} else {
items.forEach((item) => {
const row = document.createElement('tr');
row.className = 'db-table-row';
row.dataset.tableName = item.name || '';
const name = document.createElement('td');
name.textContent = item.name || '';
const count = document.createElement('td');
count.textContent = String(item.rows ?? 0);
row.appendChild(name);
row.appendChild(count);
body.appendChild(row);
});
}
}
} catch (e) {
if (body) {
body.innerHTML = `<tr><td colspan="2">${escapeHtml(e.message)}</td></tr>`;
}
if (status) status.textContent = `error: ${e.message}`;
}
}
async function loadDebugTablePreview(tableName) {
const preview = UI.qs('#dbgDbTablePreview');
if (!preview) return;
if (!tableName) {
preview.textContent = '';
return;
}
preview.textContent = t('common.loading', 'Loading…');
try {
const data = await api('/api/debug/db-table?name=' + encodeURIComponent(tableName) + '&limit=50', 'GET');
const rows = Array.isArray(data?.rows) ? data.rows : [];
preview.textContent = rows.length === 0
? t('common.empty', 'Empty')
: JSON.stringify(rows, null, 2);
} catch (e) {
preview.textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
async function debugClearContent() {
UI.qs('#dbgContentOut').textContent = t('common.running', 'Running…');
try {
await api('/api/debug/clear-content', 'POST', { confirm: UI.qs('#dbgContentConfirm').value.trim() });
UI.qs('#dbgContentOut').textContent = 'OK';
window.location.href = '/';
} catch (e) {
UI.qs('#dbgContentOut').textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
async function debugResetDb() {
UI.qs('#dbgResetOut').textContent = t('common.running', 'Running…');
try {
const data = await api('/api/debug/reset-db', 'POST', { confirm: UI.qs('#dbgResetConfirm').value.trim() });
const log = data.log || [];
UI.qs('#dbgResetOut').textContent = log.join('\n');
window.location.href = '/';
} catch (e) {
UI.qs('#dbgResetOut').textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
async function debugDump() {
const out = UI.qs('#dbgDumpOut');
out.textContent = t('common.running', 'Running…');
try {
const res = await fetch('/api/debug/db-dump');
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const a = document.createElement('a');
a.href = url;
a.download = `scmedia_dump_${ts}.sql`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
out.textContent = 'OK';
} catch (e) {
out.textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
async function debugRestore() {
const out = UI.qs('#dbgDumpOut');
const file = UI.qs('#dbgRestoreFile')?.files?.[0];
if (!file) {
out.textContent = `${t('common.error','Error')}: ${t('settings.debug.dump.restore', 'Restore dump')}`;
return;
}
const ok = confirm(t('settings.debug.dump.restore_confirm', 'Restore database from dump? This will overwrite current data.'));
if (!ok) return;
out.textContent = t('common.running', 'Running…');
try {
const sql = await file.text();
await api('/api/debug/db-restore', 'POST', { confirm: 'RESTORE DATABASE', sql });
out.textContent = 'OK';
window.location.href = '/';
} catch (e) {
out.textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
function init() {
if (window.Auth?.requireAuth) {
window.Auth.requireAuth();
}
enforceAdminAccess();
window.UI?.initHeader?.();
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 !== (window.APP_LANG || 'en')) {
localStorage.setItem('scmedia_lang', prefs.language);
}
}).catch(() => {});
}
i18n.dict = window.I18N || {};
i18n.lang = window.APP_LANG || 'en';
initTabs();
setVersion();
initProfileReorder();
const logsType = UI.qs('#logsType');
if (logsType) {
logsType.addEventListener('change', updateLogsTypeUI);
updateLogsTypeUI();
}
updateAuditEventOptions([]);
const logsEvent = UI.qs('#logsFilterEvent');
if (logsEvent) {
logsEvent.value = loadLogsEventFilter();
logsEvent.addEventListener('change', async () => {
saveLogsEventFilter(logsEvent.value || 'all');
if (!state.tables.audit) return;
const hint = UI.qs('#logsHint');
if (hint) hint.textContent = t('common.loading', 'Loading…');
try {
const from = UI.qs('#logsDateFrom').value || '';
const to = UI.qs('#logsDateTo').value || '';
state.auditItemsCache = new Map();
clearAuditSelections();
const filters = [];
if (from && to) {
filters.push({ key: 'created_at', op: 'between', value: [from, to] });
}
const eventValue = logsEvent.value || 'all';
if (eventValue !== 'all') {
filters.push({ key: 'action', op: 'eq', value: eventValue });
}
state.tables.audit.setFilters(filters);
await state.tables.audit.load(1, false);
const total = Number(state.tables.audit.total || 0);
if (hint) hint.textContent = `${t('common.loaded', 'Loaded')} (${total})`;
} catch (e) {
if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`;
}
});
}
UI.qs('#btnSave')?.addEventListener('click', doSave);
UI.qs('#btnExport')?.addEventListener('click', () => window.open('/api/settings', '_blank'));
UI.qs('#btnAddProfile')?.addEventListener('click', () => openProfileModal(null));
UI.qs('#modalClose')?.addEventListener('click', closeProfileModal);
UI.qs('#modalSave')?.addEventListener('click', saveProfileModal);
UI.qs('#pExtMode')?.addEventListener('change', refreshExtCustomVisibility);
UI.qs('#btnPreview')?.addEventListener('click', generatePreview);
UI.qs('#btnAddRoot')?.addEventListener('click', () => openRootModal(null));
UI.qs('#btnClearContent')?.addEventListener('click', debugClearContent);
UI.qs('#btnResetDb')?.addEventListener('click', debugResetDb);
UI.qs('#btnDbDump')?.addEventListener('click', debugDump);
UI.qs('#btnDbRestore')?.addEventListener('click', debugRestore);
const dbTable = UI.qs('#dbgDbTablesList');
if (dbTable) {
dbTable.addEventListener('dblclick', (e) => {
const row = e.target.closest('tr[data-table-name]');
if (!row) return;
loadDebugTablePreview(row.dataset.tableName || '');
});
}
UI.qs('#btnDetectTools')?.addEventListener('click', detectTools);
UI.qs('#btnCreateSnapshot')?.addEventListener('click', createSnapshot);
UI.qs('#btnLoadLogs')?.addEventListener('click', loadLogs);
UI.qs('#btnCleanupLogs')?.addEventListener('click', cleanupLogs);
UI.qs('#btnResetLogsDate')?.addEventListener('click', resetLogsDates);
UI.qs('#btnLogsCopy')?.addEventListener('click', () => copySelectedLogs().catch(() => {}));
UI.qs('#btnLogsSelectAll')?.addEventListener('click', () => selectAllLogsOnPage());
initLogsTabs();
initToolsUi();
initPluginsUi();
initTasksUi();
initTaskModalRouting();
initRulesUi();
bindDirtyInputs();
UI.initThemeToggle();
UI.bindThemePreference?.(() => applyI18n());
initUnsavedGuard();
state.settingsLoadPromise = loadSettings();
Promise.all([state.settingsLoadPromise, loadProfiles(), loadDiagnostics(), loadSnapshots()])
.then(() => {
initTableControllers();
initLogsTable();
renderProfiles();
renderRootsList();
renderToolsList();
renderRulesList();
renderPluginsList();
renderTasksList();
})
.catch(e => setStatus(`${t('common.error','Error')}: ${e.message}`));
}
init();
function initUnsavedGuard() {
window.addEventListener('beforeunload', (e) => {
if (!state.dirty) return;
e.preventDefault();
e.returnValue = '';
});
document.querySelectorAll('a[href]').forEach(a => {
a.addEventListener('click', (e) => {
if (!state.dirty) return;
const ok = confirm(t('settings.unsaved_confirm', 'Unsaved changes. Leave settings?'));
if (!ok) {
e.preventDefault();
}
});
});
}
async function loadLogs() {
const type = UI.qs('#logsType')?.value || 'system';
if (type === 'audit') {
await loadAuditLogs();
return;
}
const from = UI.qs('#logsDateFrom').value;
const to = UI.qs('#logsDateTo').value;
const level = UI.qs('#logsFilterLevel').value || 'all';
const hint = UI.qs('#logsHint');
if (!from || !to) {
if (hint) hint.textContent = t('settings.logs.date_required', 'Select a date range');
return;
}
if (hint) hint.textContent = t('common.loading', 'Loading…');
try {
initLogsTable();
if (!state.tables.logs) return;
state.logItemsCache = new Map();
clearLogSelections();
const filters = [];
if (from && to) {
filters.push({ key: 'ts', op: 'between', value: [from, to] });
}
if (level && level !== 'all') {
filters.push({ key: 'level', op: 'eq', value: level });
}
state.tables.logs.setFilters(filters);
await state.tables.logs.load(1, false);
const total = Number(state.tables.logs.total || 0);
if (hint) hint.textContent = `${t('common.loaded', 'Loaded')} (${total})`;
saveLogsViewDates(from, to);
} catch (e) {
if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
async function loadAuditLogs() {
const hint = UI.qs('#logsHint');
if (hint) hint.textContent = t('common.loading', 'Loading…');
try {
initAuditTable();
if (!state.tables.audit) return;
state.auditItemsCache = new Map();
clearAuditSelections();
const from = UI.qs('#logsDateFrom').value || '';
const to = UI.qs('#logsDateTo').value || '';
const eventFilter = UI.qs('#logsFilterEvent').value || 'all';
const filters = [];
if (from && to) {
filters.push({ key: 'created_at', op: 'between', value: [from, to] });
}
if (eventFilter && eventFilter !== 'all') {
filters.push({ key: 'action', op: 'eq', value: eventFilter });
}
state.tables.audit.setFilters(filters);
await state.tables.audit.load(1, false);
const total = Number(state.tables.audit.total || 0);
if (hint) hint.textContent = `${t('common.loaded', 'Loaded')} (${total})`;
} catch (e) {
if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
function updateAuditEventOptions(items) {
const sel = UI.qs('#logsFilterEvent');
if (!sel) return;
const current = sel.value || 'all';
const saved = loadLogsEventFilter();
const fromItems = Array.from(new Set(items.map(x => x.action).filter(Boolean)));
const combined = [...AUDIT_EVENTS, ...fromItems.filter(x => !AUDIT_EVENTS.includes(x))];
const options = ['all', ...combined];
sel.innerHTML = options.map(v => {
if (v === 'all') {
return `<option value="all">${t('filters.all', 'All')}</option>`;
}
return `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`;
}).join('');
const preferred = current !== 'all' ? current : saved;
sel.value = options.includes(preferred) ? preferred : 'all';
}
function renderAuditLogs() {}
function normalizeAuditDate(value) {
if (!value) return '';
const s = String(value);
if (/^\d{2}\.\d{2}\.\d{4}/.test(s)) {
const parts = s.slice(0, 10).split('.');
return `${parts[2]}-${parts[1]}-${parts[0]}`;
}
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
return s.slice(0, 10);
}
const d = new Date(s);
if (Number.isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 10);
}
function formatLogContext(value) {
if (!value || value === 'null') {
return '';
}
try {
return ' ' + JSON.stringify(JSON.parse(value));
} catch (e) {
return ' ' + value;
}
}
function logRowId(row) {
if (row?.id !== undefined && row?.id !== null) return String(row.id);
return `${row?.ts || ''}|${row?.level || ''}|${row?.message || ''}`;
}
function updateLogSelectionCount() {
const el = UI.qs('#logsSelectionCount');
if (!el) return;
const count = state.logSelected.size;
el.textContent = count ? `${t('settings.logs.selected', 'Selected')}: ${count}` : '';
}
function clearLogSelections() {
state.logSelected = new Map();
updateLogSelectionCount();
}
function updateAuditSelectionCount() {
const el = UI.qs('#logsSelectionCount');
if (!el) return;
const count = state.auditSelected.size;
el.textContent = count ? `${t('settings.logs.selected', 'Selected')}: ${count}` : '';
}
function clearAuditSelections() {
state.auditSelected = new Map();
updateAuditSelectionCount();
}
function renderLogRow(body, row) {
const tr = document.createElement('tr');
const id = logRowId(row);
state.logItemsCache.set(id, row);
const selTd = document.createElement('td');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.dataset.logSelect = '1';
checkbox.dataset.id = id;
checkbox.checked = state.logSelected.has(id);
selTd.appendChild(checkbox);
tr.appendChild(selTd);
const ctx = (formatLogContext(row.context_json) || '').trim();
tr.appendChild(cellText(row.ts || ''));
tr.appendChild(cellText(row.level || ''));
tr.appendChild(cellText(row.message || ''));
tr.appendChild(cellText(ctx));
body.appendChild(tr);
}
function renderAuditRow(body, row) {
const tr = document.createElement('tr');
const id = logRowId(row);
state.auditItemsCache.set(id, row);
const selTd = document.createElement('td');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.dataset.auditSelect = '1';
checkbox.dataset.id = id;
checkbox.checked = state.auditSelected.has(id);
selTd.appendChild(checkbox);
tr.appendChild(selTd);
tr.appendChild(cellText(row.created_at || ''));
tr.appendChild(cellText(row.actor_email || row.actor_user_id || ''));
tr.appendChild(cellText(row.action || ''));
tr.appendChild(cellText(`${row.target_type || ''} ${row.target_id || ''}`.trim()));
tr.appendChild(cellText(row.meta_json || ''));
body.appendChild(tr);
}
function initLogsTable() {
const table = UI.qs('#logsTable');
if (!table || !window.TableController) return;
const mode = state.ui.table_mode || 'pagination';
const pageSize = state.ui.table_page_size || 50;
const id = table?.dataset?.tableId || table?.id || '';
const prefs = window.UserPrefs?.getTable?.(id) || {};
state.tables.logs = new TableController({
table,
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 }) => {
const data = await api('/api/logs', 'POST', {
page,
per_page,
sort,
dir,
filters,
});
return {
items: data.items || [],
total: data.total || 0,
page: data.page || page,
per_page: data.per_page || per_page,
};
},
renderRow: renderLogRow,
});
state.tables.logs.total = 0;
state.tables.logs.page = 1;
state.tables.logs.updateFooter();
const body = table.querySelector('tbody');
if (body && !body.dataset.bindLogs) {
body.dataset.bindLogs = '1';
body.addEventListener('change', (e) => {
const input = e.target;
if (!input || input.dataset.logSelect !== '1') return;
const id = input.dataset.id || '';
if (!id) return;
if (input.checked) {
const row = state.logItemsCache.get(id);
if (row) state.logSelected.set(id, row);
} else {
state.logSelected.delete(id);
}
updateLogSelectionCount();
});
}
}
function initAuditTable() {
const table = UI.qs('#adminAuditTable');
if (!table || !window.TableController) return;
const mode = state.ui.table_mode || 'pagination';
const pageSize = state.ui.table_page_size || 50;
const id = table?.dataset?.tableId || table?.id || '';
const prefs = window.UserPrefs?.getTable?.(id) || {};
state.tables.audit = new TableController({
table,
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 }) => {
const data = await api('/api/admin/audit', 'POST', {
page,
per_page,
sort,
dir,
filters,
});
const items = data.items || [];
updateAuditEventOptions(items);
return {
items,
total: data.total || 0,
page: data.page || page,
per_page: data.per_page || per_page,
};
},
renderRow: renderAuditRow,
});
state.tables.audit.total = 0;
state.tables.audit.page = 1;
state.tables.audit.updateFooter();
const body = table.querySelector('tbody');
if (body && !body.dataset.bindAudit) {
body.dataset.bindAudit = '1';
body.addEventListener('change', (e) => {
const input = e.target;
if (!input || input.dataset.auditSelect !== '1') return;
const id = input.dataset.id || '';
if (!id) return;
if (input.checked) {
const row = state.auditItemsCache.get(id);
if (row) state.auditSelected.set(id, row);
} else {
state.auditSelected.delete(id);
}
updateAuditSelectionCount();
});
}
}
async function copySelectedLogs() {
const type = UI.qs('#logsType')?.value || 'system';
const items = type === 'audit'
? Array.from(state.auditSelected.values())
: Array.from(state.logSelected.values());
if (!items.length) return;
const lines = items.map((r) => {
if (type === 'audit') {
return `${r.created_at || ''} | ${r.actor_email || r.actor_user_id || ''} | ${r.action || ''} | ${r.target_type || ''} ${r.target_id || ''} | ${r.meta_json || ''}`;
}
const ctx = formatLogContext(r.context_json);
return `${r.ts || ''} [${r.level || ''}] ${r.message || ''}${ctx}`;
});
const text = lines.join('\n');
try {
await navigator.clipboard.writeText(text);
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
function selectAllLogsOnPage() {
const type = UI.qs('#logsType')?.value || 'system';
const body = type === 'audit' ? UI.qs('#adminAudit') : UI.qs('#logsTableBody');
if (!body) return;
const inputs = Array.from(body.querySelectorAll(type === 'audit' ? 'input[data-audit-select="1"]' : 'input[data-log-select="1"]'));
if (!inputs.length) return;
const allSelected = inputs.every(input => input.checked);
inputs.forEach((input) => {
if (input.checked === !allSelected) return;
input.checked = !allSelected;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
}
async function cleanupLogs() {
const hint = UI.qs('#logsSettingsHint');
if (!confirm(t('settings.logs.delete_all_warn', 'Delete all logs?'))) {
return;
}
const keepDays = 0;
if (hint) hint.textContent = t('common.running', 'Running…');
try {
const data = await api('/api/logs/cleanup', 'POST', { keep_days: keepDays });
if (hint) hint.textContent = t('settings.logs.cleaned', 'Cleaned') + ` (${data.deleted || 0})`;
if (state.tables.logs?.tbody) {
state.tables.logs.tbody.innerHTML = '';
}
if (state.tables.logs) {
state.tables.logs.total = 0;
state.tables.logs.page = 1;
state.tables.logs.updateFooter();
}
clearLogSelections();
} catch (e) {
if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
function initLogsTabs() {
const tabs = document.querySelectorAll('.logs-tabs .tab');
const panes = document.querySelectorAll('.logs-pane');
tabs.forEach(btn => {
btn.addEventListener('click', () => {
tabs.forEach(x => x.classList.remove('active'));
panes.forEach(x => x.classList.remove('active'));
btn.classList.add('active');
const target = btn.dataset.logsTab || 'view';
UI.qs(`#logs-pane-${target}`).classList.add('active');
});
});
}
function initLogsViewDates() {
const saved = loadLogsViewDates();
if (saved) {
UI.qs('#logsDateFrom').value = saved.from;
UI.qs('#logsDateTo').value = saved.to;
return;
}
resetLogsDates();
}
function resetLogsDates() {
const today = new Date();
const yyyy = String(today.getFullYear());
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const d = `${yyyy}-${mm}-${dd}`;
UI.qs('#logsDateFrom').value = d;
UI.qs('#logsDateTo').value = d;
saveLogsViewDates(d, d);
if (state.tables.logs?.tbody) {
state.tables.logs.tbody.innerHTML = '';
}
if (state.tables.logs) {
state.tables.logs.total = 0;
state.tables.logs.page = 1;
state.tables.logs.updateFooter();
}
clearLogSelections();
clearAuditSelections();
const hint = UI.qs('#logsHint');
if (hint) hint.textContent = '';
}
function updateLogsTypeUI() {
const sel = UI.qs('#logsType');
const type = sel ? sel.value : 'system';
const levelControls = UI.qs('#logsControlsLevel');
const eventControls = UI.qs('#logsControlsEvent');
const systemActions = UI.qs('#logsControlsSystemActions');
const output = UI.qs('#logsOutputWrap');
const auditWrap = UI.qs('#logsAuditWrap');
const auditBody = UI.qs('#adminAudit');
const hint = UI.qs('#logsHint');
const isAudit = type === 'audit';
if (levelControls) levelControls.classList.toggle('is-hidden', isAudit);
if (eventControls) eventControls.classList.toggle('is-hidden', !isAudit);
if (systemActions) systemActions.classList.toggle('is-hidden', false);
if (output) output.classList.toggle('is-hidden', isAudit);
if (auditWrap) auditWrap.classList.toggle('is-hidden', !isAudit);
if (isAudit) {
state.auditItems = [];
clearLogSelections();
if (auditBody) auditBody.innerHTML = '';
if (hint) hint.textContent = '';
updateAuditSelectionCount();
} else {
updateLogSelectionCount();
}
}
function saveLogsViewDates(from, to) {
state.logsView.from = from;
state.logsView.to = to;
localStorage.setItem('scmedia_logs_from', from);
localStorage.setItem('scmedia_logs_to', to);
}
function loadLogsViewDates() {
const from = localStorage.getItem('scmedia_logs_from') || '';
const to = localStorage.getItem('scmedia_logs_to') || '';
if (!from || !to) return null;
return { from, to };
}
function saveLogsEventFilter(value) {
localStorage.setItem('scmedia_logs_event', value || 'all');
}
function loadLogsEventFilter() {
return localStorage.getItem('scmedia_logs_event') || 'all';
}
async function detectTools() {
if (state.settingsLoadPromise) {
await state.settingsLoadPromise;
}
const hint = UI.qs('#toolsHint');
if (hint) hint.textContent = t('common.running', 'Running…');
try {
const data = await api('/api/tools/detect-binaries', 'GET');
state.toolsList = buildToolsList(data || {});
renderToolsList();
setDirty(true);
if (hint) hint.textContent = t('settings.tools.detected', 'Detected');
} catch (e) {
if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`;
}
}
function buildToolsList(tools) {
const list = [];
if (tools.mkvmerge_path) list.push({ type: 'mkvmerge', label: 'mkvmerge', path: tools.mkvmerge_path });
if (tools.mkvpropedit_path) list.push({ type: 'mkvpropedit', label: 'mkvpropedit', path: tools.mkvpropedit_path });
if (tools.ffmpeg_path) list.push({ type: 'ffmpeg', label: 'ffmpeg', path: tools.ffmpeg_path });
return list;
}
function toolPathByType(type) {
const t = state.toolsList.find(x => x.type === type);
return t ? t.path : '';
}
function updateToolPath(type, path) {
const existing = state.toolsList.find(x => x.type === type);
if (path === '' && existing) {
state.toolsList = state.toolsList.filter(x => x.type !== type);
} else if (existing) {
existing.path = path;
} else if (path !== '') {
state.toolsList.push({ type, label: type, path });
}
renderToolsList();
}
function renderToolRow(tbody, tl) {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = t(`settings.tools.${tl.type}`, tl.label);
const tdPath = document.createElement('td');
tdPath.textContent = tl.path || '';
const tdActions = document.createElement('td');
const btnEdit = document.createElement('button');
btnEdit.className = 'btn';
btnEdit.textContent = t('common.edit', 'Edit');
btnEdit.addEventListener('click', () => openToolModal(tl.type));
const btnRemove = document.createElement('button');
btnRemove.className = 'btn';
btnRemove.textContent = t('common.delete', 'Delete');
btnRemove.addEventListener('click', () => {
updateToolPath(tl.type, '');
setDirty(true);
});
tdActions.appendChild(btnEdit);
tdActions.appendChild(btnRemove);
tr.appendChild(tdName);
tr.appendChild(tdPath);
tr.appendChild(tdActions);
tbody.appendChild(tr);
}
function renderToolsList() {
const table = state.tables.tools;
if (table) {
table.reload();
return;
}
const tbody = UI.qs('#toolsTable tbody');
if (!tbody) return;
tbody.innerHTML = '';
state.toolsList.forEach(tl => renderToolRow(tbody, tl));
}
function initToolsUi() {
const btnAdd = UI.qs('#btnAddTool');
const menu = UI.qs('#toolTypeMenu');
if (btnAdd && menu) {
btnAdd.addEventListener('click', (e) => {
e.preventDefault();
btnAdd.parentElement?.classList.toggle('open');
});
menu.querySelectorAll('button[data-tool-type]').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.toolType || 'mkvmerge';
btnAdd.parentElement?.classList.remove('open');
openToolModal(type);
});
});
document.addEventListener('click', (e) => {
if (!btnAdd.parentElement?.contains(e.target)) {
btnAdd.parentElement?.classList.remove('open');
}
});
}
}
function initTasksUi() {
const btnAdd = UI.qs('#btnAddTask');
if (!btnAdd) return;
btnAdd.addEventListener('click', () => openTaskModal(null));
}
function openTaskModal(id = null) {
const modal = UI.qs('#ruleModal');
const body = UI.qs('#ruleModalBody');
const title = UI.qs('#ruleModalTitle');
if (!modal || !body || !title) return;
state.ruleEditId = null;
state.ruleEditType = null;
state.toolEditType = null;
state.taskEditId = (id !== null && id !== undefined) ? id : 'new';
const task = id ? state.tasksList.find(t => t.id === id) : null;
const current = task || normalizeTask({});
title.textContent = t('settings.tasks.modal_title', 'Task');
const checked = (list, value) => list.includes(value) ? 'checked' : '';
body.innerHTML = `
<div class="grid">
<label>
<div class="lbl" data-i18n="settings.tasks.field_name">${t('settings.tasks.field_name', 'Name')}</div>
<input data-field="task_name" type="text" value="${escapeHtml(current.name || '')}">
</label>
<label>
<div class="lbl" data-i18n="settings.tasks.field_enabled">${t('settings.tasks.field_enabled', 'Enabled')}</div>
<select data-field="task_enabled">
<option value="1">${t('rules.status.on', 'On')}</option>
<option value="0">${t('rules.status.off', 'Off')}</option>
</select>
</label>
<div class="full">
<div class="lbl" data-i18n="settings.tasks.field_sources">${t('settings.tasks.field_sources', 'Sources')}</div>
<div class="checks">
<label class="row"><input type="checkbox" data-field="src_library" ${checked(current.sources, 'library')}> ${t('settings.tasks.source.library','Library')}</label>
<label class="row"><input type="checkbox" data-field="src_transmission" ${checked(current.sources, 'transmission')}> ${t('settings.tasks.source.transmission','Transmission')}</label>
<label class="row"><input type="checkbox" data-field="src_staging" ${checked(current.sources, 'staging')}> ${t('settings.tasks.source.staging','Staging')}</label>
</div>
</div>
<div class="full">
<div class="lbl" data-i18n="settings.tasks.field_actions">${t('settings.tasks.field_actions', 'Actions')}</div>
<div class="checks">
<label class="row"><input type="checkbox" data-field="act_analyze" ${checked(current.actions, 'analyze')}> ${t('settings.tasks.action.analyze','Analyze')}</label>
<label class="row"><input type="checkbox" data-field="act_identify" ${checked(current.actions, 'identify')}> ${t('settings.tasks.action.identify','Identify')}</label>
<label class="row"><input type="checkbox" data-field="act_normalize" ${checked(current.actions, 'normalize')}> ${t('settings.tasks.action.normalize','Normalize')}</label>
<label class="row"><input type="checkbox" data-field="act_rename" ${checked(current.actions, 'rename')}> ${t('settings.tasks.action.rename','Rename')}</label>
<label class="row"><input type="checkbox" data-field="act_export" ${checked(current.actions, 'export')}> ${t('settings.tasks.action.export','Export')}</label>
</div>
</div>
</div>
`;
const enabledSel = body.querySelector('[data-field="task_enabled"]');
if (enabledSel) enabledSel.value = current.enabled ? '1' : '0';
modal.style.display = 'flex';
}
function saveTaskModal(body) {
const name = body.querySelector('[data-field="task_name"]')?.value?.trim() || '';
const enabled = body.querySelector('[data-field="task_enabled"]')?.value === '1';
const sources = [];
if (body.querySelector('[data-field="src_library"]')?.checked) sources.push('library');
if (body.querySelector('[data-field="src_transmission"]')?.checked) sources.push('transmission');
if (body.querySelector('[data-field="src_staging"]')?.checked) sources.push('staging');
const actions = [];
if (body.querySelector('[data-field="act_analyze"]')?.checked) actions.push('analyze');
if (body.querySelector('[data-field="act_identify"]')?.checked) actions.push('identify');
if (body.querySelector('[data-field="act_normalize"]')?.checked) actions.push('normalize');
if (body.querySelector('[data-field="act_rename"]')?.checked) actions.push('rename');
if (body.querySelector('[data-field="act_export"]')?.checked) actions.push('export');
const updated = {
id: state.taskEditId || normalizeTask({}).id,
name,
enabled,
sources,
actions,
};
const idx = state.tasksList.findIndex(t => t.id === updated.id);
if (idx >= 0) {
state.tasksList[idx] = updated;
} else {
state.tasksList.push(updated);
}
renderTasksList();
setDirty(true);
closeRuleModal();
}
function openToolModal(type) {
const modal = UI.qs('#ruleModal');
const body = UI.qs('#ruleModalBody');
const title = UI.qs('#ruleModalTitle');
if (!modal || !body || !title) return;
state.ruleEditId = null;
state.ruleEditType = null;
state.toolEditType = type;
title.textContent = t('settings.tools.modal_title', 'Tool');
const current = toolPathByType(type);
body.innerHTML = `
<div class="grid">
<label>
<div class="lbl">${t('settings.tools.th_name','Tool')}</div>
<input data-field="tool_type" type="text" value="${escapeHtml(type)}" disabled>
</label>
<label>
<div class="lbl">${t('settings.tools.th_path','Path')}</div>
<input data-field="tool_path" type="text" value="${escapeHtml(current)}" placeholder="/usr/bin/${escapeHtml(type)}">
</label>
</div>
`;
modal.style.display = 'flex';
}
function normalizePluginConfig() {
if (!state.pluginConfig) state.pluginConfig = { metadata: {}, exports: {}, sources: {} };
const meta = state.pluginConfig.metadata || {};
const exportsCfg = state.pluginConfig.exports || {};
const sourcesCfg = state.pluginConfig.sources || {};
if (typeof meta.enabled !== 'boolean') meta.enabled = false;
if (!Array.isArray(meta.languages)) meta.languages = ['de','ru','en'];
if (!Array.isArray(meta.provider_priority)) meta.provider_priority = ['tvdb','omdb'];
if (!meta.providers) meta.providers = {};
if (!meta.providers.omdb) meta.providers.omdb = { enabled: false, api_key: '', base_url: 'https://www.omdbapi.com/' };
if (!meta.providers.tvdb) meta.providers.tvdb = { enabled: false, api_key: '', pin: '' };
if (!exportsCfg.kodi) exportsCfg.kodi = { enabled: false };
if (!exportsCfg.jellyfin) exportsCfg.jellyfin = { enabled: false };
if (!sourcesCfg.transmission) {
sourcesCfg.transmission = { enabled: false, last_test_ok: false, last_test_at: null, protocol: 'http', host: '', port: 9091, path: '/transmission/rpc', username: '', password: '', display_fields: [] };
}
if (typeof sourcesCfg.transmission.last_test_ok !== 'boolean') {
sourcesCfg.transmission.last_test_ok = false;
}
if (!Array.isArray(sourcesCfg.transmission.display_fields)) {
sourcesCfg.transmission.display_fields = [];
}
state.pluginConfig.metadata = meta;
state.pluginConfig.exports = exportsCfg;
state.pluginConfig.sources = sourcesCfg;
}
function renderPluginRow(tbody, p) {
const tr = document.createElement('tr');
tr.className = `plugin-row${p.enabled ? '' : ' is-off'}`;
const tdName = document.createElement('td');
tdName.textContent = p.label;
const tdType = document.createElement('td');
tdType.textContent = p.kindLabel;
const tdStatus = document.createElement('td');
tdStatus.textContent = p.enabled === null ? t('rules.status.on', 'On') : (p.enabled ? t('rules.status.on', 'On') : t('rules.status.off', 'Off'));
const tdActions = document.createElement('td');
const btnEdit = document.createElement('button');
btnEdit.className = 'btn';
btnEdit.textContent = t('common.edit', 'Edit');
btnEdit.addEventListener('click', () => openPluginModal(p.type));
tdActions.appendChild(btnEdit);
if (p.enabled !== null) {
const btnToggle = document.createElement('button');
btnToggle.className = 'btn';
btnToggle.textContent = p.enabled ? t('rules.disable', 'Disable') : t('rules.enable', 'Enable');
btnToggle.addEventListener('click', () => {
setPluginEnabled(p.type, !p.enabled);
renderPluginsList();
setDirty(true);
});
tdActions.appendChild(btnToggle);
}
tr.appendChild(tdName);
tr.appendChild(tdType);
tr.appendChild(tdStatus);
tr.appendChild(tdActions);
tbody.appendChild(tr);
}
function renderPluginsList() {
const table = state.tables.plugins;
if (table) {
table.reload();
return;
}
const tbody = UI.qs('#pluginsTable tbody');
if (!tbody) return;
tbody.innerHTML = '';
const items = buildPluginsList();
items.forEach(p => renderPluginRow(tbody, p));
}
function buildPluginsList() {
const meta = state.pluginConfig?.metadata || {};
const providers = meta.providers || {};
const exportsCfg = state.pluginConfig?.exports || {};
const sourcesCfg = state.pluginConfig?.sources || {};
return [
{ type: 'meta_settings', label: t('settings.plugins.meta_settings', 'Metadata settings'), kindLabel: t('settings.plugins.kind.meta', 'Metadata'), enabled: !!meta.enabled },
{ type: 'omdb', label: t('settings.plugins.omdb_label', 'IMDb (OMDb)'), kindLabel: t('settings.plugins.kind.meta', 'Metadata'), enabled: !!providers.omdb?.enabled && !!meta.enabled },
{ type: 'tvdb', label: t('settings.plugins.tvdb_label', 'TVDB'), kindLabel: t('settings.plugins.kind.meta', 'Metadata'), enabled: !!providers.tvdb?.enabled && !!meta.enabled },
{ type: 'kodi', label: t('settings.plugins.kodi_label', 'Kodi'), kindLabel: t('settings.plugins.kind.export', 'Export'), enabled: !!exportsCfg.kodi?.enabled },
{ type: 'jellyfin', label: t('settings.plugins.jellyfin_label', 'Jellyfin'), kindLabel: t('settings.plugins.kind.export', 'Export'), enabled: !!exportsCfg.jellyfin?.enabled },
{ type: 'transmission', label: t('settings.plugins.transmission_label', 'Transmission'), kindLabel: t('settings.plugins.kind.source', 'Source'), enabled: !!sourcesCfg.transmission?.enabled },
];
}
function ensureTransmissionRule() {
const existing = state.rulesList.find(r => r.type === 'source_filter' && r.config?.source === 'transmission');
if (existing) {
if (!existing.enabled) existing.enabled = true;
return;
}
const rule = normalizeRule({
type: 'source_filter',
name: 'Transmission (all)',
enabled: true,
config: {
source: 'transmission',
status: '',
label: '',
min_size: 0,
name_regex: '',
path_regex: '',
},
});
state.rulesList.push(rule);
}
function setPluginEnabled(type, enabled) {
normalizePluginConfig();
if ((type === 'omdb' || type === 'tvdb') && !state.pluginConfig.metadata.enabled && enabled) {
const hint = UI.qs('#pluginsHint');
if (hint) hint.textContent = t('settings.plugins.requires_meta', 'Enable metadata settings first');
return;
}
if (type === 'transmission' && enabled && !state.pluginConfig.sources.transmission.last_test_ok) {
const hint = UI.qs('#pluginsHint');
if (hint) hint.textContent = t('settings.plugins.requires_test', 'Test connection first');
return;
}
if (type === 'omdb') state.pluginConfig.metadata.providers.omdb.enabled = enabled;
if (type === 'tvdb') state.pluginConfig.metadata.providers.tvdb.enabled = enabled;
if (type === 'meta_settings') state.pluginConfig.metadata.enabled = enabled;
if (type === 'kodi') state.pluginConfig.exports.kodi.enabled = enabled;
if (type === 'jellyfin') state.pluginConfig.exports.jellyfin.enabled = enabled;
if (type === 'transmission') {
state.pluginConfig.sources.transmission.enabled = enabled;
if (enabled) {
ensureTransmissionRule();
renderRulesList();
setDirty(true);
}
}
}
function initPluginsUi() {
const btnAdd = UI.qs('#btnAddPlugin');
const fileInput = UI.qs('#pluginFileInput');
if (btnAdd && fileInput) {
btnAdd.addEventListener('click', (e) => {
e.preventDefault();
fileInput.click();
});
fileInput.addEventListener('change', () => {
// Placeholder only: no install yet
fileInput.value = '';
const hint = UI.qs('#pluginsHint');
if (hint) hint.textContent = t('settings.plugins.install_placeholder', 'Installer coming soon');
});
}
}
function openPluginModal(type) {
const modal = UI.qs('#ruleModal');
const body = UI.qs('#ruleModalBody');
const title = UI.qs('#ruleModalTitle');
if (!modal || !body || !title) return;
state.ruleEditId = null;
state.ruleEditType = null;
state.toolEditType = null;
state.rootEditId = null;
state.pluginEditType = type;
title.textContent = t('settings.plugins.modal_title', 'Plugin');
body.innerHTML = renderPluginForm(type);
applyPluginFormValues(body, type);
modal.style.display = 'flex';
}
function renderPluginForm(type) {
const meta = state.pluginConfig?.metadata || {};
const providers = meta.providers || {};
const exportsCfg = state.pluginConfig?.exports || {};
const sourcesCfg = state.pluginConfig?.sources || {};
const tr = sourcesCfg.transmission || {};
if (type === 'meta_settings') {
return `
<div class="grid">
<label>
<div class="lbl">${t('settings.plugins.enable','Enabled')}</div>
<select data-field="enabled"><option value="1">${t('common.yes','Yes')}</option><option value="0">${t('common.no','No')}</option></select>
</label>
<label>
<div class="lbl">${t('settings.plugins.languages','Metadata languages (comma)')}</div>
<input data-field="meta_languages" type="text" value="${escapeHtml((meta.languages || []).join(','))}">
</label>
<label>
<div class="lbl">${t('settings.plugins.provider_priority','Provider priority (comma)')}</div>
<input data-field="meta_priority" type="text" value="${escapeHtml((meta.provider_priority || []).join(','))}">
</label>
</div>
`;
}
if (type === 'omdb') {
return `
<div class="grid">
<label>
<div class="lbl">${t('settings.plugins.enable','Enabled')}</div>
<select data-field="enabled"><option value="1">${t('common.yes','Yes')}</option><option value="0">${t('common.no','No')}</option></select>
</label>
<label>
<div class="lbl">${t('settings.plugins.omdb_key','OMDb API key')}</div>
<input data-field="api_key" type="text" value="${escapeHtml(providers.omdb?.api_key || '')}">
</label>
</div>
`;
}
if (type === 'tvdb') {
return `
<div class="grid">
<label>
<div class="lbl">${t('settings.plugins.enable','Enabled')}</div>
<select data-field="enabled"><option value="1">${t('common.yes','Yes')}</option><option value="0">${t('common.no','No')}</option></select>
</label>
<label>
<div class="lbl">${t('settings.plugins.tvdb_key','TVDB API key')}</div>
<input data-field="api_key" type="text" value="${escapeHtml(providers.tvdb?.api_key || '')}">
</label>
<label>
<div class="lbl">${t('settings.plugins.tvdb_pin','TVDB PIN (optional)')}</div>
<input data-field="pin" type="text" value="${escapeHtml(providers.tvdb?.pin || '')}">
</label>
</div>
`;
}
if (type === 'kodi' || type === 'jellyfin') {
const enabled = (type === 'kodi') ? exportsCfg.kodi?.enabled : exportsCfg.jellyfin?.enabled;
return `
<div class="grid">
<label>
<div class="lbl">${t('settings.plugins.enable','Enabled')}</div>
<select data-field="enabled"><option value="1">${t('common.yes','Yes')}</option><option value="0">${t('common.no','No')}</option></select>
</label>
<div class="hint">${type === 'kodi' ? t('settings.plugins.kodi_hint','Writes movie.nfo / tvshow.nfo near files') : t('settings.plugins.jellyfin_hint','Writes movie.nfo / tvshow.nfo near files')}</div>
</div>
`;
}
if (type === 'transmission') {
return `
<div class="grid">
<label>
<div class="lbl">${t('settings.plugins.enable','Enabled')}</div>
<select data-field="enabled"><option value="1">${t('common.yes','Yes')}</option><option value="0">${t('common.no','No')}</option></select>
</label>
<label>
<div class="lbl">${t('settings.plugins.transmission_protocol','Protocol')}</div>
<select data-field="protocol">
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
<label>
<div class="lbl">${t('settings.plugins.transmission_host','Host')}</div>
<input data-field="host" type="text" value="${escapeHtml(tr.host || '')}">
</label>
<label>
<div class="lbl">${t('settings.plugins.transmission_port','Port')}</div>
<input data-field="port" type="number" value="${escapeHtml(String(tr.port ?? 9091))}">
</label>
<label>
<div class="lbl">${t('settings.plugins.transmission_path','RPC path')}</div>
<input data-field="path" type="text" value="${escapeHtml(tr.path || '/transmission/rpc')}">
</label>
<label>
<div class="lbl">${t('settings.plugins.transmission_user','Username')}</div>
<input data-field="username" type="text" value="${escapeHtml(tr.username || '')}">
</label>
<label>
<div class="lbl">${t('settings.plugins.transmission_pass','Password')}</div>
<input data-field="password" type="password" value="${escapeHtml(tr.password || '')}">
</label>
<label>
<div class="lbl">${t('settings.plugins.transmission_display_fields','Display fields (comma)')}</div>
<input data-field="display_fields" type="text" value="${escapeHtml((tr.display_fields || []).join(','))}">
</label>
</div>
<div class="row">
<button class="btn" type="button" id="btnPluginTest">${t('settings.plugins.transmission_test','Test connection')}</button>
<div class="hint" id="pluginTestHint"></div>
</div>
`;
}
return '';
}
function applyPluginFormValues(body, type) {
normalizePluginConfig();
const meta = state.pluginConfig.metadata;
const providers = meta.providers || {};
const exportsCfg = state.pluginConfig.exports;
const sourcesCfg = state.pluginConfig.sources;
const setVal = (field, value) => {
const el = body.querySelector(`[data-field="${field}"]`);
if (el) el.value = value ?? '';
};
if (type === 'meta_settings') {
setVal('enabled', meta.enabled ? '1' : '0');
setVal('meta_languages', (meta.languages || []).join(','));
setVal('meta_priority', (meta.provider_priority || []).join(','));
} else if (type === 'omdb') {
setVal('enabled', providers.omdb?.enabled ? '1' : '0');
setVal('api_key', providers.omdb?.api_key || '');
} else if (type === 'tvdb') {
setVal('enabled', providers.tvdb?.enabled ? '1' : '0');
setVal('api_key', providers.tvdb?.api_key || '');
setVal('pin', providers.tvdb?.pin || '');
} else if (type === 'kodi') {
setVal('enabled', exportsCfg.kodi?.enabled ? '1' : '0');
} else if (type === 'jellyfin') {
setVal('enabled', exportsCfg.jellyfin?.enabled ? '1' : '0');
} else if (type === 'transmission') {
const tr = sourcesCfg.transmission || {};
setVal('enabled', tr.enabled ? '1' : '0');
setVal('protocol', tr.protocol || 'http');
setVal('host', tr.host || '');
setVal('port', String(tr.port ?? 9091));
setVal('path', tr.path || '/transmission/rpc');
setVal('username', tr.username || '');
setVal('password', tr.password || '');
setVal('display_fields', (tr.display_fields || []).join(','));
const btn = body.querySelector('#btnPluginTest');
if (btn) {
btn.addEventListener('click', async () => {
const hint = body.querySelector('#pluginTestHint');
if (hint) hint.textContent = t('common.testing', 'Testing…');
clearFieldErrors(body, ['host','port','path']);
const payload = { transmission: readTransmissionFields(body) };
const missing = [];
if (!payload.transmission.host) missing.push('host');
if (!payload.transmission.port) missing.push('port');
if (!payload.transmission.path) missing.push('path');
if (missing.length > 0) {
markFieldErrors(body, missing);
if (hint) hint.textContent = t('settings.plugins.transmission_missing', 'Fill required fields');
return;
}
try {
await api('/api/sources/test', 'POST', payload);
state.pluginConfig.sources.transmission.last_test_ok = true;
state.pluginConfig.sources.transmission.last_test_at = new Date().toISOString();
renderPluginsList();
setDirty(true);
if (hint) hint.textContent = t('settings.plugins.transmission_ok', 'OK');
} catch (e) {
state.pluginConfig.sources.transmission.last_test_ok = false;
state.pluginConfig.sources.transmission.last_test_at = new Date().toISOString();
renderPluginsList();
setDirty(true);
const msg = normalizeTransmissionErrorMessage(e.message);
if (hint) hint.textContent = `${t('common.error','Error')}: ${msg}`;
}
});
}
}
}
function normalizeTransmissionErrorMessage(message) {
if (!message) return t('settings.plugins.transmission_rpc_failed', 'RPC failed. Check address or auth.');
if (message === 'unauthorized') {
return t('settings.plugins.transmission_unauthorized', 'Unauthorized. Check RPC username/password.');
}
if (message === 'forbidden') {
return t('settings.plugins.transmission_forbidden', 'Access denied. Check whitelist or credentials.');
}
if (message === 'rpc failed') {
return t('settings.plugins.transmission_rpc_failed', 'RPC failed. Check address or auth.');
}
return message;
}
function isTaskModalOpen() {
return state.taskEditId !== null;
}
function savePluginModal(body) {
normalizePluginConfig();
const type = state.pluginEditType;
if (!type) return;
const meta = state.pluginConfig.metadata;
const providers = meta.providers || {};
const exportsCfg = state.pluginConfig.exports;
const sourcesCfg = state.pluginConfig.sources;
const getVal = (field) => body.querySelector(`[data-field="${field}"]`)?.value ?? '';
if (type === 'meta_settings') {
meta.enabled = getVal('enabled') === '1';
meta.languages = commaToList(getVal('meta_languages'));
meta.provider_priority = commaToList(getVal('meta_priority'));
} else if (type === 'omdb') {
providers.omdb = providers.omdb || {};
providers.omdb.enabled = getVal('enabled') === '1';
providers.omdb.api_key = getVal('api_key').trim();
providers.omdb.base_url = providers.omdb.base_url || 'https://www.omdbapi.com/';
} else if (type === 'tvdb') {
providers.tvdb = providers.tvdb || {};
providers.tvdb.enabled = getVal('enabled') === '1';
providers.tvdb.api_key = getVal('api_key').trim();
providers.tvdb.pin = getVal('pin').trim();
} else if (type === 'kodi') {
exportsCfg.kodi.enabled = getVal('enabled') === '1';
} else if (type === 'jellyfin') {
exportsCfg.jellyfin.enabled = getVal('enabled') === '1';
} else if (type === 'transmission') {
sourcesCfg.transmission = readTransmissionFields(body);
if (sourcesCfg.transmission.enabled && !sourcesCfg.transmission.last_test_ok) {
sourcesCfg.transmission.enabled = false;
const hint = UI.qs('#pluginsHint');
if (hint) hint.textContent = t('settings.plugins.requires_test', 'Test connection first');
}
}
state.pluginConfig.metadata = meta;
state.pluginConfig.exports = exportsCfg;
state.pluginConfig.sources = sourcesCfg;
renderPluginsList();
}
function readTransmissionFields(body) {
return {
enabled: body.querySelector('[data-field="enabled"]')?.value === '1',
last_test_ok: state.pluginConfig?.sources?.transmission?.last_test_ok ?? false,
last_test_at: state.pluginConfig?.sources?.transmission?.last_test_at ?? null,
protocol: body.querySelector('[data-field="protocol"]')?.value || 'http',
host: body.querySelector('[data-field="host"]')?.value?.trim() || '',
port: Number(body.querySelector('[data-field="port"]')?.value || 9091),
path: body.querySelector('[data-field="path"]')?.value?.trim() || '/transmission/rpc',
username: body.querySelector('[data-field="username"]')?.value?.trim() || '',
password: body.querySelector('[data-field="password"]')?.value ?? '',
display_fields: commaToList(body.querySelector('[data-field="display_fields"]')?.value ?? ''),
};
}
function markFieldErrors(body, fields) {
fields.forEach(f => {
const el = body.querySelector(`[data-field="${f}"]`);
if (el) el.classList.add('input-error');
});
}
function clearFieldErrors(body, fields) {
fields.forEach(f => {
const el = body.querySelector(`[data-field="${f}"]`);
if (el) el.classList.remove('input-error');
});
}
function buildRootsList(paths) {
const roots = Array.isArray(paths.roots) ? paths.roots : [];
if (roots.length > 0) {
return roots.map(r => normalizeRoot(r));
}
const out = [];
if (paths.movies_root) out.push(normalizeRoot({ type: 'movie', path: paths.movies_root, enabled: true }));
if (paths.series_root) out.push(normalizeRoot({ type: 'series', path: paths.series_root, enabled: true }));
if (paths.staging_root) out.push(normalizeRoot({ type: 'staging', path: paths.staging_root, enabled: true }));
return out;
}
function normalizeRoot(r) {
return {
id: r?.id || `root_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type: r?.type || 'movie',
path: r?.path || '',
enabled: r?.enabled !== false,
};
}
function renderRootRow(tbody, r) {
const tr = document.createElement('tr');
const tdType = document.createElement('td');
tdType.textContent = rootTypeLabel(r.type);
const tdPath = document.createElement('td');
tdPath.textContent = r.path || '';
const tdStatus = document.createElement('td');
tdStatus.textContent = r.enabled ? t('rules.status.on', 'On') : t('rules.status.off', 'Off');
const tdActions = document.createElement('td');
const btnEdit = document.createElement('button');
btnEdit.className = 'btn';
btnEdit.textContent = t('common.edit', 'Edit');
btnEdit.addEventListener('click', () => openRootModal(r.id));
const btnTest = document.createElement('button');
btnTest.className = 'btn';
btnTest.textContent = t('common.test', 'Test');
btnTest.addEventListener('click', () => testRootPath(r));
const btnDel = document.createElement('button');
btnDel.className = 'btn';
btnDel.textContent = t('common.delete', 'Delete');
btnDel.addEventListener('click', () => {
if (!confirm(t('settings.library_layout.confirm_delete', 'Delete root?'))) return;
state.rootsList = state.rootsList.filter(x => x.id !== r.id);
renderRootsList();
setDirty(true);
});
tdActions.appendChild(btnEdit);
tdActions.appendChild(btnTest);
tdActions.appendChild(btnDel);
tr.appendChild(tdType);
tr.appendChild(tdPath);
tr.appendChild(tdStatus);
tr.appendChild(tdActions);
tbody.appendChild(tr);
}
function renderRootsList() {
const table = state.tables.roots;
if (table) {
table.reload();
return;
}
const tbody = UI.qs('#rootsTable tbody');
if (!tbody) return;
tbody.innerHTML = '';
state.rootsList.forEach(r => renderRootRow(tbody, r));
}
function rootTypeLabel(type) {
if (type === 'movie') return t('root.type.movie', 'Movie');
if (type === 'series') return t('root.type.series', 'Series');
if (type === 'staging') return t('root.type.staging', 'Staging');
return type || '';
}
function openRootModal(id = null) {
const modal = UI.qs('#ruleModal');
const body = UI.qs('#ruleModalBody');
const title = UI.qs('#ruleModalTitle');
if (!modal || !body || !title) return;
state.ruleEditId = null;
state.ruleEditType = null;
state.toolEditType = null;
const root = id ? state.rootsList.find(r => r.id === id) : null;
const data = root ? { ...root } : normalizeRoot({});
state.rootEditId = data.id;
title.textContent = t('settings.library_layout.modal_title', 'Root');
body.innerHTML = `
<div class="grid">
<label>
<div class="lbl">${t('settings.library_layout.th_type','Type')}</div>
<select data-field="root_type">
<option value="movie">${t('root.type.movie','Movie')}</option>
<option value="series">${t('root.type.series','Series')}</option>
<option value="staging">${t('root.type.staging','Staging')}</option>
</select>
</label>
<label>
<div class="lbl">${t('settings.library_layout.th_path','Path')}</div>
<input data-field="root_path" type="text" value="${escapeHtml(data.path || '')}">
</label>
<label>
<div class="lbl">${t('settings.library_layout.th_status','Status')}</div>
<select data-field="root_enabled">
<option value="1">${t('common.yes','Yes')}</option>
<option value="0">${t('common.no','No')}</option>
</select>
</label>
</div>
`;
const typeSel = body.querySelector('[data-field="root_type"]');
const enabledSel = body.querySelector('[data-field="root_enabled"]');
if (typeSel) typeSel.value = data.type;
if (enabledSel) enabledSel.value = data.enabled ? '1' : '0';
modal.style.display = 'flex';
}
function buildPathsPayload() {
const roots = state.rootsList || [];
const movie = roots.find(r => r.type === 'movie' && r.enabled);
const series = roots.find(r => r.type === 'series' && r.enabled);
const staging = roots.find(r => r.type === 'staging' && r.enabled);
return {
movies_root: movie?.path || '',
series_root: series?.path || '',
staging_root: staging?.path || '',
roots,
};
}
async function testRootPath(root) {
const hint = UI.qs('#rootsHint');
if (hint) hint.textContent = t('common.testing', 'Testing…');
try {
const data = await api('/api/tools/test-path', 'POST', { path: root.path, checks: ['exists','read','write','rename'] });
const r = data.results || {};
const txt = `${t('common.exists','exists')}=${r.exists} ${t('common.read','read')}=${r.read} ${t('common.write','write')}=${r.write} ${t('common.rename','rename')}=${r.rename} ${(data.notes || []).join('; ')}`;
if (hint) hint.textContent = txt;
} catch (e) {
if (hint) hint.textContent = `${t('common.error','Error')}: ${e.message}`;
}
}