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