/* public/assets/settings.js */ const state = { settings: null, dirty: false, mediaRules: null, rulesList: [], templatesList: [], tasksList: [], toolsList: [], toolEditType: null, ruleEditId: null, ruleEditType: null, rootsList: [], rootEditId: null, taskEditId: null, templateEditId: 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; } 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 => ``) .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); } } } /* --------------------------- 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 || ''; // 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(); // Templates list const templatesList = Array.isArray(data.templates) ? data.templates : []; state.templatesList = templatesList.map(tpl => normalizeTemplate(tpl)); renderTemplatesList(); // 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 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 templates = state.templatesList || []; const tasks = state.tasksList || []; const metadata = state.pluginConfig?.metadata || {}; const exportsCfg = state.pluginConfig?.exports || {}; const sources = state.pluginConfig?.sources || {}; return { if_revision: rev, paths, tools, logs, layout, ui, safety, rules, templates, 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)}`; const cfg = r?.config || {}; const conditions = Array.isArray(r?.conditions) ? r.conditions : (Array.isArray(cfg.conditions) ? cfg.conditions : []); const tags = Array.isArray(r?.tags) ? r.tags : (Array.isArray(cfg.tags) ? cfg.tags : []); return { id, name: r?.name || '', enabled: (r?.enabled !== false), logic: r?.logic || cfg.logic || 'and', conditions, tags, }; } 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 : [], rule_ids: Array.isArray(t?.rule_ids) ? t.rule_ids : [], template_id: t?.template_id || '', }; } function normalizeTemplate(tpl) { const id = tpl?.id || `tpl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; return { id, name: tpl?.name || '', action: tpl?.action || '', enabled: tpl?.enabled !== false, }; } 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 tdTags = document.createElement('td'); tdTags.textContent = (r.tags || []).join(', '); 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.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(tdTags); tr.appendChild(tdSummary); tr.appendChild(tdStatus); tr.appendChild(tdActions); tbody.appendChild(tr); } function renderRulesList() { const table = state.tables.rules; if (table) { table.reload(); return; } const tbody = UI.qs('#rulesTable tbody'); if (!tbody) return; tbody.innerHTML = ''; state.rulesList.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 tdRules = document.createElement('td'); tdRules.textContent = (task.rule_ids || []).map(ruleNameById).filter(Boolean).join(', '); const tdTemplate = document.createElement('td'); tdTemplate.textContent = templateNameById(task.template_id); 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(tdRules); tr.appendChild(tdTemplate); 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 === 'movie') return t('settings.tasks.source.movie', 'Movies'); if (src === 'series') return t('settings.tasks.source.series', 'Series'); if (src === 'staging') return t('settings.tasks.source.staging', 'Staging'); if (src === 'transmission') return t('settings.tasks.source.transmission', 'Transmission'); return src || ''; } function ruleNameById(id) { if (!id) return ''; const rule = state.rulesList.find(r => r.id === id); return rule?.name || ''; } function templateNameById(id) { if (!id) return ''; const tpl = state.templatesList.find(tpl => tpl.id === id); return tpl?.name || ''; } 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 ``; } return options.map(opt => ``).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 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 (modalClose) modalClose.addEventListener('click', closeRuleModal); if (modalCancel) modalCancel.addEventListener('click', closeRuleModal); if (modalSave) { modalSave.addEventListener('click', () => { if (state.taskEditId !== null || state.templateEditId !== 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 (state.templateEditId !== null) { saveTemplateModal(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; state.templateEditId = null; } function renderRuleForm(rule) { return `
`; } function applyRuleFormValues(body, rule) { 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'); setVal('logic', rule.logic || 'and'); setVal('tags', (rule.tags || []).join(',')); renderRuleConditions(body, Array.isArray(rule.conditions) ? rule.conditions : []); } function ruleFieldOptions() { const roots = state.rootsList || []; const options = []; if (roots.some(r => r.type === 'movie')) options.push({ value: 'movie', label: t('settings.tasks.source.movie', 'Movies') }); if (roots.some(r => r.type === 'series')) options.push({ value: 'series', label: t('settings.tasks.source.series', 'Series') }); if (roots.some(r => r.type === 'staging')) options.push({ value: 'staging', label: t('settings.tasks.source.staging', 'Staging') }); if (state.pluginConfig?.sources?.transmission) options.push({ value: 'transmission', label: t('settings.tasks.source.transmission', 'Transmission') }); return { source: { label: t('rules.cond.source','Source'), type: 'select', options }, status: { label: t('rules.cond.status','Status'), type: 'select', options: sourceStatusOptions('transmission') }, name_regex: { label: t('rules.cond.name_regex','Name regex'), type: 'regex' }, path_regex: { label: t('rules.cond.path_regex','Path regex'), type: 'regex' }, size_mb: { label: t('rules.cond.size','Size (MB)'), type: 'number' }, container: { label: t('rules.cond.container','Container'), type: 'select', options: [ { value: 'mkv', label: 'mkv' }, { value: 'mp4', label: 'mp4' }, { value: 'avi', label: 'avi' }, ]}, lang: { label: t('rules.cond.lang','Language'), type: 'text' }, }; } function ruleOperatorOptions(field) { const meta = ruleFieldOptions()[field]; const type = meta?.type || 'text'; if (type === 'number') { return [ { value: 'eq', label: '=' }, { value: 'gt', label: '>' }, { value: 'lt', label: '<' }, { value: 'between', label: t('rules.op.between','between') }, ]; } if (type === 'regex') { return [{ value: 'regex', label: t('rules.op.regex','regex') }]; } return [ { value: 'eq', label: '=' }, { value: 'contains', label: t('rules.op.contains','contains') }, { value: 'in', label: t('rules.op.in','in') }, { value: 'not_in', label: t('rules.op.not_in','not in') }, ]; } function renderRuleConditions(body, conditions) { const wrap = body.querySelector('[data-field="rule_conditions"] .conditions-list'); const addBtn = body.querySelector('[data-action="add_rule_condition"]'); if (!wrap || !addBtn) return; wrap.innerHTML = ''; (conditions || []).forEach(cond => wrap.appendChild(createRuleConditionRow(cond))); addBtn.addEventListener('click', () => { wrap.appendChild(createRuleConditionRow({})); }); } function createRuleConditionRow(cond) { const meta = ruleFieldOptions(); const row = document.createElement('div'); row.className = 'conditions-builder'; const fieldSel = document.createElement('select'); Object.keys(meta).forEach(key => { const opt = document.createElement('option'); opt.value = key; opt.textContent = meta[key].label; fieldSel.appendChild(opt); }); fieldSel.value = cond.field || 'source'; const opSel = document.createElement('select'); const valueWrap = document.createElement('div'); valueWrap.className = 'cond-value'; const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'btn'; removeBtn.textContent = 'X'; removeBtn.addEventListener('click', () => row.remove()); const updateOps = () => { opSel.innerHTML = ''; ruleOperatorOptions(fieldSel.value).forEach(opt => { const o = document.createElement('option'); o.value = opt.value; o.textContent = opt.label; opSel.appendChild(o); }); }; const updateValue = () => { valueWrap.innerHTML = ''; const field = fieldSel.value; const op = opSel.value; const fieldMeta = meta[field]; const type = fieldMeta?.type || 'text'; const value = cond.value; if (type === 'select') { const select = document.createElement('select'); if (op === 'in' || op === 'not_in') select.multiple = true; (fieldMeta.options || []).forEach(opt => { const o = document.createElement('option'); o.value = opt.value; o.textContent = opt.label; const list = Array.isArray(value) ? value : [value]; if (list.includes(opt.value)) o.selected = true; select.appendChild(o); }); valueWrap.appendChild(select); return; } if (type === 'number' && op === 'between') { const min = document.createElement('input'); min.type = 'number'; min.placeholder = 'min'; min.value = Array.isArray(value) ? (value[0] ?? '') : ''; min.dataset.bound = 'min'; const max = document.createElement('input'); max.type = 'number'; max.placeholder = 'max'; max.value = Array.isArray(value) ? (value[1] ?? '') : ''; max.dataset.bound = 'max'; valueWrap.appendChild(min); valueWrap.appendChild(max); return; } const input = document.createElement('input'); input.type = (type === 'number') ? 'number' : 'text'; input.value = (value ?? ''); if (op === 'in' || op === 'not_in') { input.placeholder = 'a,b,c'; } valueWrap.appendChild(input); }; updateOps(); opSel.value = cond.op || opSel.value || 'eq'; updateValue(); fieldSel.addEventListener('change', () => { updateOps(); opSel.value = opSel.value || 'eq'; updateValue(); }); opSel.addEventListener('change', updateValue); row.appendChild(fieldSel); row.appendChild(opSel); row.appendChild(valueWrap); row.appendChild(removeBtn); return row; } function readRuleConditions(body) { const rows = Array.from(body.querySelectorAll('[data-field="rule_conditions"] .conditions-list .conditions-builder')); const meta = ruleFieldOptions(); return rows.map(row => { const selects = row.querySelectorAll('select'); const field = selects[0]?.value || ''; const op = selects[1]?.value || 'eq'; const fieldMeta = meta[field]; const type = fieldMeta?.type || 'text'; let value = ''; const valueWrap = row.querySelector('.cond-value'); if (!valueWrap) return null; if (type === 'select') { const select = valueWrap.querySelector('select'); if (select && select.multiple) { value = Array.from(select.selectedOptions).map(o => o.value); } else { value = select?.value || ''; } } else if (type === 'number' && op === 'between') { const min = valueWrap.querySelector('[data-bound="min"]')?.value || ''; const max = valueWrap.querySelector('[data-bound="max"]')?.value || ''; value = [Number(min || 0), Number(max || 0)]; } else { const input = valueWrap.querySelector('input'); value = input?.value ?? ''; if (op === 'in' || op === 'not_in') { value = String(value).split(',').map(v => v.trim()).filter(Boolean); } else if (type === 'number') { value = Number(value || 0); } } return { field, op, value }; }).filter(Boolean); } 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 === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''' )); } function cellText(value) { const td = document.createElement('td'); td.textContent = value ?? ''; return td; } 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 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 || 'name'; const sortDir = dir || 'asc'; 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 templatesTable = UI.qs('#templatesTable'); if (templatesTable) { const { id, prefs } = getPrefs(templatesTable); state.tables.templates = new TableController({ table: templatesTable, 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.templatesList, sort, dir, { name: t => (t.name || '').toLowerCase(), }); return Promise.resolve(paginateItems(sorted, page, per_page)); }, renderRow: renderTemplateRow, }); } 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 = `${escapeHtml(e.message)}`; } 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); } }).catch(() => {}); } i18n.dict = window.I18N || {}; i18n.lang = window.APP_LANG || 'en'; initTabs(); setVersion(); 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('#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(); initTemplatesUi(); initTaskModalRouting(); initRulesUi(); bindDirtyInputs(); UI.initThemeToggle(); UI.bindThemePreference?.(() => applyI18n()); initUnsavedGuard(); state.settingsLoadPromise = loadSettings(); Promise.all([state.settingsLoadPromise, loadDiagnostics(), loadSnapshots()]) .then(() => { initTableControllers(); initLogsTable(); renderRootsList(); renderToolsList(); renderRulesList(); renderTemplatesList(); 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 ``; } return ``; }).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 initTemplatesUi() { const btnAdd = UI.qs('#btnAddTemplate'); if (!btnAdd) return; btnAdd.addEventListener('click', () => openTemplateModal(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'; state.templateEditId = null; 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' : ''; const sourceOptions = buildTaskSourcesOptions(current.sources || []); const ruleOptions = buildRuleOptions(current.rule_ids || []); const templateOptions = buildTemplateOptions(current.template_id || ''); body.innerHTML = `
${t('settings.tasks.field_sources', 'Sources')}
${sourceOptions}
${t('settings.tasks.field_rules', 'Rules')}
${ruleOptions}
${t('settings.tasks.hint', 'Sources + rules choose what to process. Template defines the action pipeline.')}
`; 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 = Array.from(body.querySelectorAll('[data-field="task_source"]')).filter(x => x.checked).map(x => x.value); const ruleIds = Array.from(body.querySelectorAll('[data-field="task_rule"]')).filter(x => x.checked).map(x => x.value); const templateId = body.querySelector('[data-field="task_template"]')?.value || ''; const updated = { id: state.taskEditId || normalizeTask({}).id, name, enabled, sources, rule_ids: ruleIds, template_id: templateId, }; 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 buildTaskSourcesOptions(selected) { const list = Array.isArray(selected) ? selected : []; const roots = state.rootsList || []; const options = []; const hasMovie = roots.some(r => r.type === 'movie'); const hasSeries = roots.some(r => r.type === 'series'); const hasStaging = roots.some(r => r.type === 'staging'); if (hasMovie) options.push({ value: 'movie', label: t('settings.tasks.source.movie', 'Movies') }); if (hasSeries) options.push({ value: 'series', label: t('settings.tasks.source.series', 'Series') }); if (hasStaging) options.push({ value: 'staging', label: t('settings.tasks.source.staging', 'Staging') }); if (state.pluginConfig?.sources?.transmission) { options.push({ value: 'transmission', label: t('settings.tasks.source.transmission', 'Transmission') }); } if (options.length === 0) { options.push({ value: 'staging', label: t('settings.tasks.source.staging', 'Staging') }); } return options .map(opt => { const isChecked = list.includes(opt.value) ? 'checked' : ''; return ``; }) .join(''); } function buildRuleOptions(selected) { const list = Array.isArray(selected) ? selected : []; const rules = state.rulesList || []; if (rules.length === 0) { return `
${t('settings.tasks.rules_empty', 'No rules yet')}
`; } return rules .map(r => { const isChecked = list.includes(r.id) ? 'checked' : ''; return ``; }) .join(''); } function buildTemplateOptions(selected) { const templates = state.templatesList || []; const options = templates .map(tpl => ``); return [``, ...options].join(''); } function renderTemplateRow(tbody, tpl) { const tr = document.createElement('tr'); tr.dataset.id = tpl.id; const tdName = document.createElement('td'); tdName.textContent = tpl.name || t('settings.templates.unnamed', 'Untitled'); const tdAction = document.createElement('td'); tdAction.textContent = tpl.action || ''; const tdStatus = document.createElement('td'); tdStatus.textContent = tpl.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', () => openTemplateModal(tpl.id)); const btnToggle = document.createElement('button'); btnToggle.className = 'btn'; btnToggle.textContent = tpl.enabled ? t('rules.disable', 'Disable') : t('rules.enable', 'Enable'); btnToggle.addEventListener('click', () => { tpl.enabled = !tpl.enabled; renderTemplatesList(); setDirty(true); }); const btnDel = document.createElement('button'); btnDel.className = 'btn'; btnDel.textContent = t('common.delete', 'Delete'); btnDel.addEventListener('click', () => { if (!confirm(t('settings.templates.confirm_delete', 'Delete template?'))) return; state.templatesList = state.templatesList.filter(x => x.id !== tpl.id); renderTemplatesList(); setDirty(true); }); tdActions.appendChild(btnEdit); tdActions.appendChild(btnToggle); tdActions.appendChild(btnDel); tr.appendChild(tdName); tr.appendChild(tdAction); tr.appendChild(tdStatus); tr.appendChild(tdActions); tbody.appendChild(tr); } function renderTemplatesList() { const table = state.tables.templates; if (table) { table.reload(); return; } const tbody = UI.qs('#templatesTable tbody'); if (!tbody) return; tbody.innerHTML = ''; state.templatesList.forEach(tpl => renderTemplateRow(tbody, tpl)); } function openTemplateModal(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 = null; state.rootEditId = null; state.templateEditId = (id !== null && id !== undefined) ? id : 'new'; const tpl = id ? state.templatesList.find(t => t.id === id) : null; const current = tpl || normalizeTemplate({}); title.textContent = t('settings.templates.modal_title', 'Template'); body.innerHTML = `
${t('settings.templates.hint', 'Describe the action pipeline (mapping, convert, normalize, export).')}
`; const enabledSel = body.querySelector('[data-field="template_enabled"]'); if (enabledSel) enabledSel.value = current.enabled ? '1' : '0'; modal.style.display = 'flex'; } function saveTemplateModal(body) { const name = body.querySelector('[data-field="template_name"]')?.value?.trim() || ''; const action = body.querySelector('[data-field="template_action"]')?.value?.trim() || ''; const enabled = body.querySelector('[data-field="template_enabled"]')?.value === '1'; const updated = { id: state.templateEditId || normalizeTemplate({}).id, name, action, enabled, }; const idx = state.templatesList.findIndex(t => t.id === updated.id); if (idx >= 0) { state.templatesList[idx] = updated; } else { state.templatesList.push(updated); } renderTemplatesList(); 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 = `
`; 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 `
`; } if (type === 'omdb') { return `
`; } if (type === 'tvdb') { return `
`; } if (type === 'kodi' || type === 'jellyfin') { const enabled = (type === 'kodi') ? exportsCfg.kodi?.enabled : exportsCfg.jellyfin?.enabled; return `
${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')}
`; } if (type === 'transmission') { return `
`; } 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.sources.confirm_delete', 'Delete source?'))) 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.sources.modal_title', 'Source'); body.innerHTML = `
`; 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}`; } }