595 lines
19 KiB
JavaScript
595 lines
19 KiB
JavaScript
const express = require('express');
|
|
const asyncHandler = require('../middleware/asyncHandler');
|
|
const settingsService = require('../services/settingsService');
|
|
const scriptService = require('../services/scriptService');
|
|
const scriptChainService = require('../services/scriptChainService');
|
|
const notificationService = require('../services/notificationService');
|
|
const pipelineService = require('../services/pipelineService');
|
|
const wsService = require('../services/websocketService');
|
|
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
|
const userPresetService = require('../services/userPresetService');
|
|
const activationBytesService = require('../services/activationBytesService');
|
|
const diskDetectionService = require('../services/diskDetectionService');
|
|
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
|
|
const { fetchCurrentBetaKey } = require('../services/makemkvKeyService');
|
|
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
|
|
|
const { getDb } = require('../db/database');
|
|
|
|
const router = express.Router();
|
|
|
|
function isSensitiveSettingKey(key) {
|
|
const normalized = String(key || '').trim().toLowerCase();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return /(token|password|secret|api_key|registration_key|pushover_user|subscriber_pin)/i.test(normalized);
|
|
}
|
|
|
|
router.get(
|
|
'/',
|
|
asyncHandler(async (req, res) => {
|
|
logger.debug('get:settings', { reqId: req.reqId });
|
|
const categories = await settingsService.getCategorizedSettings();
|
|
res.json({ categories });
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
'/effective-paths',
|
|
asyncHandler(async (req, res) => {
|
|
logger.debug('get:settings:effective-paths', { reqId: req.reqId });
|
|
const paths = await settingsService.getEffectivePaths();
|
|
res.json(paths);
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
'/handbrake-presets',
|
|
asyncHandler(async (req, res) => {
|
|
logger.debug('get:settings:handbrake-presets', { reqId: req.reqId });
|
|
const presets = await settingsService.getHandBrakePresetOptions();
|
|
res.json(presets);
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
'/makemkv/beta-key',
|
|
asyncHandler(async (req, res) => {
|
|
logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId });
|
|
const result = await fetchCurrentBetaKey();
|
|
res.json({
|
|
betaKey: result.key,
|
|
sourceUrl: result.sourceUrl
|
|
});
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/makemkv/beta-key/apply',
|
|
asyncHandler(async (req, res) => {
|
|
logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId });
|
|
const force = req.body?.force === true;
|
|
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: true });
|
|
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
|
|
const betaKeyResult = await fetchCurrentBetaKey();
|
|
|
|
if (!force && existingKey && existingKey !== betaKeyResult.key) {
|
|
const syncResult = await settingsService.syncMakeMKVRegistrationKeyFromSettings({
|
|
settingsMap: currentSettings
|
|
});
|
|
res.json({
|
|
applied: false,
|
|
preservedExistingKey: true,
|
|
reason: 'existing_key',
|
|
betaKey: betaKeyResult.key,
|
|
sourceUrl: betaKeyResult.sourceUrl,
|
|
settingsFilePath: syncResult?.path || null
|
|
});
|
|
return;
|
|
}
|
|
|
|
const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKeyResult.key);
|
|
wsService.broadcast('SETTINGS_UPDATED', updated);
|
|
|
|
res.json({
|
|
applied: true,
|
|
preservedExistingKey: false,
|
|
setting: updated,
|
|
betaKey: betaKeyResult.key,
|
|
sourceUrl: betaKeyResult.sourceUrl
|
|
});
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
'/scripts',
|
|
asyncHandler(async (req, res) => {
|
|
logger.debug('get:settings:scripts', { reqId: req.reqId });
|
|
const scripts = await scriptService.listScripts();
|
|
res.json({ scripts });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/scripts',
|
|
asyncHandler(async (req, res) => {
|
|
const payload = req.body || {};
|
|
logger.info('post:settings:scripts:create', {
|
|
reqId: req.reqId,
|
|
name: String(payload?.name || '').trim() || null,
|
|
scriptBodyLength: String(payload?.scriptBody || '').length
|
|
});
|
|
const script = await scriptService.createScript(payload);
|
|
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'created', id: script.id });
|
|
res.status(201).json({ script });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/scripts/reorder',
|
|
asyncHandler(async (req, res) => {
|
|
const orderedScriptIds = Array.isArray(req.body?.orderedScriptIds) ? req.body.orderedScriptIds : [];
|
|
logger.info('post:settings:scripts:reorder', {
|
|
reqId: req.reqId,
|
|
count: orderedScriptIds.length
|
|
});
|
|
const scripts = await scriptService.reorderScripts(orderedScriptIds);
|
|
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'reordered', count: scripts.length });
|
|
res.json({ scripts });
|
|
})
|
|
);
|
|
|
|
router.put(
|
|
'/scripts/:id',
|
|
asyncHandler(async (req, res) => {
|
|
const scriptId = Number(req.params.id);
|
|
const payload = req.body || {};
|
|
logger.info('put:settings:scripts:update', {
|
|
reqId: req.reqId,
|
|
scriptId,
|
|
name: String(payload?.name || '').trim() || null,
|
|
scriptBodyLength: String(payload?.scriptBody || '').length
|
|
});
|
|
const script = await scriptService.updateScript(scriptId, payload);
|
|
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'updated', id: script.id });
|
|
res.json({ script });
|
|
})
|
|
);
|
|
|
|
router.delete(
|
|
'/scripts/:id',
|
|
asyncHandler(async (req, res) => {
|
|
const scriptId = Number(req.params.id);
|
|
logger.info('delete:settings:scripts', {
|
|
reqId: req.reqId,
|
|
scriptId
|
|
});
|
|
const removed = await scriptService.deleteScript(scriptId);
|
|
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'deleted', id: removed.id });
|
|
res.json({ removed });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/scripts/:id/test',
|
|
asyncHandler(async (req, res) => {
|
|
const scriptId = Number(req.params.id);
|
|
logger.info('post:settings:scripts:test', {
|
|
reqId: req.reqId,
|
|
scriptId
|
|
});
|
|
const result = await scriptService.testScript(scriptId);
|
|
res.json({ result });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/script-chains/:id/test',
|
|
asyncHandler(async (req, res) => {
|
|
const chainId = Number(req.params.id);
|
|
logger.info('post:settings:script-chains:test', { reqId: req.reqId, chainId });
|
|
const result = await scriptChainService.executeChain(chainId, { source: 'settings_test', mode: 'test' });
|
|
res.json({ result });
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
'/script-chains',
|
|
asyncHandler(async (req, res) => {
|
|
logger.debug('get:settings:script-chains', { reqId: req.reqId });
|
|
const chains = await scriptChainService.listChains();
|
|
res.json({ chains });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/script-chains',
|
|
asyncHandler(async (req, res) => {
|
|
const payload = req.body || {};
|
|
logger.info('post:settings:script-chains:create', { reqId: req.reqId, name: payload?.name });
|
|
const chain = await scriptChainService.createChain(payload);
|
|
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'created', id: chain.id });
|
|
res.status(201).json({ chain });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/script-chains/reorder',
|
|
asyncHandler(async (req, res) => {
|
|
const orderedChainIds = Array.isArray(req.body?.orderedChainIds) ? req.body.orderedChainIds : [];
|
|
logger.info('post:settings:script-chains:reorder', {
|
|
reqId: req.reqId,
|
|
count: orderedChainIds.length
|
|
});
|
|
const chains = await scriptChainService.reorderChains(orderedChainIds);
|
|
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'reordered', count: chains.length });
|
|
res.json({ chains });
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
'/script-chains/:id',
|
|
asyncHandler(async (req, res) => {
|
|
const chainId = Number(req.params.id);
|
|
logger.debug('get:settings:script-chains:one', { reqId: req.reqId, chainId });
|
|
const chain = await scriptChainService.getChainById(chainId);
|
|
res.json({ chain });
|
|
})
|
|
);
|
|
|
|
router.put(
|
|
'/script-chains/:id',
|
|
asyncHandler(async (req, res) => {
|
|
const chainId = Number(req.params.id);
|
|
const payload = req.body || {};
|
|
logger.info('put:settings:script-chains:update', { reqId: req.reqId, chainId, name: payload?.name });
|
|
const chain = await scriptChainService.updateChain(chainId, payload);
|
|
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'updated', id: chain.id });
|
|
res.json({ chain });
|
|
})
|
|
);
|
|
|
|
router.delete(
|
|
'/script-chains/:id',
|
|
asyncHandler(async (req, res) => {
|
|
const chainId = Number(req.params.id);
|
|
logger.info('delete:settings:script-chains', { reqId: req.reqId, chainId });
|
|
const removed = await scriptChainService.deleteChain(chainId);
|
|
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'deleted', id: removed.id });
|
|
res.json({ removed });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/coverart/recover',
|
|
asyncHandler(async (req, res) => {
|
|
logger.info('post:settings:coverart:recover', { reqId: req.reqId });
|
|
const result = await coverArtRecoveryService.runNow({
|
|
trigger: 'manual',
|
|
force: true,
|
|
logFailures: true
|
|
});
|
|
res.json({
|
|
result,
|
|
scheduler: coverArtRecoveryService.getStatus()
|
|
});
|
|
})
|
|
);
|
|
|
|
router.put(
|
|
'/:key',
|
|
asyncHandler(async (req, res) => {
|
|
const { key } = req.params;
|
|
const { value } = req.body;
|
|
|
|
logger.info('put:setting', {
|
|
reqId: req.reqId,
|
|
key,
|
|
value: isSensitiveSettingKey(key) ? '[redacted]' : value
|
|
});
|
|
const updated = await settingsService.setSettingValue(key, value);
|
|
let reviewRefresh = null;
|
|
try {
|
|
reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave([key]);
|
|
if (reviewRefresh?.triggered) {
|
|
logger.info('put:setting:review-refresh-started', {
|
|
reqId: req.reqId,
|
|
key,
|
|
jobId: reviewRefresh.jobId
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.warn('put:setting:review-refresh-failed', {
|
|
reqId: req.reqId,
|
|
key,
|
|
error: {
|
|
name: error?.name,
|
|
message: error?.message
|
|
}
|
|
});
|
|
reviewRefresh = {
|
|
triggered: false,
|
|
reason: 'refresh_error',
|
|
message: error?.message || 'unknown'
|
|
};
|
|
}
|
|
try {
|
|
await hardwareMonitorService.handleSettingsChanged([key]);
|
|
} catch (error) {
|
|
logger.warn('put:setting:hardware-monitor-refresh-failed', {
|
|
reqId: req.reqId,
|
|
key,
|
|
error: {
|
|
name: error?.name,
|
|
message: error?.message
|
|
}
|
|
});
|
|
}
|
|
try {
|
|
await coverArtRecoveryService.handleSettingsChanged([key]);
|
|
} catch (error) {
|
|
logger.warn('put:setting:coverart-scheduler-refresh-failed', {
|
|
reqId: req.reqId,
|
|
key,
|
|
error: {
|
|
name: error?.name,
|
|
message: error?.message
|
|
}
|
|
});
|
|
}
|
|
wsService.broadcast('SETTINGS_UPDATED', updated);
|
|
|
|
res.json({ setting: updated, reviewRefresh });
|
|
})
|
|
);
|
|
|
|
router.put(
|
|
'/',
|
|
asyncHandler(async (req, res) => {
|
|
const { settings } = req.body || {};
|
|
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
const error = new Error('settings fehlt oder ist ungültig.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
logger.info('put:settings:bulk', { reqId: req.reqId, count: Object.keys(settings).length });
|
|
const changes = await settingsService.setSettingsBulk(settings);
|
|
let reviewRefresh = null;
|
|
try {
|
|
reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave(changes.map((item) => item.key));
|
|
if (reviewRefresh?.triggered) {
|
|
logger.info('put:settings:bulk:review-refresh-started', {
|
|
reqId: req.reqId,
|
|
jobId: reviewRefresh.jobId,
|
|
relevantKeys: reviewRefresh.relevantKeys
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.warn('put:settings:bulk:review-refresh-failed', {
|
|
reqId: req.reqId,
|
|
error: {
|
|
name: error?.name,
|
|
message: error?.message
|
|
}
|
|
});
|
|
reviewRefresh = {
|
|
triggered: false,
|
|
reason: 'refresh_error',
|
|
message: error?.message || 'unknown'
|
|
};
|
|
}
|
|
try {
|
|
await hardwareMonitorService.handleSettingsChanged(changes.map((item) => item.key));
|
|
} catch (error) {
|
|
logger.warn('put:settings:bulk:hardware-monitor-refresh-failed', {
|
|
reqId: req.reqId,
|
|
error: {
|
|
name: error?.name,
|
|
message: error?.message
|
|
}
|
|
});
|
|
}
|
|
try {
|
|
await coverArtRecoveryService.handleSettingsChanged(changes.map((item) => item.key));
|
|
} catch (error) {
|
|
logger.warn('put:settings:bulk:coverart-scheduler-refresh-failed', {
|
|
reqId: req.reqId,
|
|
error: {
|
|
name: error?.name,
|
|
message: error?.message
|
|
}
|
|
});
|
|
}
|
|
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
|
|
|
|
res.json({ changes, reviewRefresh });
|
|
})
|
|
);
|
|
|
|
// ── User Presets ──────────────────────────────────────────────────────────────
|
|
|
|
router.get(
|
|
'/user-presets',
|
|
asyncHandler(async (req, res) => {
|
|
const mediaType = req.query.media_type || null;
|
|
logger.debug('get:user-presets', { reqId: req.reqId, mediaType });
|
|
const presets = await userPresetService.listPresets(mediaType);
|
|
res.json({ presets });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/user-presets',
|
|
asyncHandler(async (req, res) => {
|
|
const payload = req.body || {};
|
|
logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name });
|
|
const preset = await userPresetService.createPreset(payload);
|
|
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id });
|
|
res.status(201).json({ preset });
|
|
})
|
|
);
|
|
|
|
router.put(
|
|
'/user-presets/:id',
|
|
asyncHandler(async (req, res) => {
|
|
const presetId = Number(req.params.id);
|
|
const payload = req.body || {};
|
|
logger.info('put:user-presets:update', { reqId: req.reqId, presetId });
|
|
const preset = await userPresetService.updatePreset(presetId, payload);
|
|
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id });
|
|
res.json({ preset });
|
|
})
|
|
);
|
|
|
|
router.delete(
|
|
'/user-presets/:id',
|
|
asyncHandler(async (req, res) => {
|
|
const presetId = Number(req.params.id);
|
|
logger.info('delete:user-presets', { reqId: req.reqId, presetId });
|
|
const removed = await userPresetService.deletePreset(presetId);
|
|
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id });
|
|
res.json({ removed });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/pushover/test',
|
|
asyncHandler(async (req, res) => {
|
|
const title = req.body?.title;
|
|
const message = req.body?.message;
|
|
logger.info('post:pushover:test', {
|
|
reqId: req.reqId,
|
|
hasTitle: Boolean(title),
|
|
hasMessage: Boolean(message)
|
|
});
|
|
const result = await notificationService.sendTest({ title, message });
|
|
res.json({ result });
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
'/activation-bytes',
|
|
asyncHandler(async (req, res) => {
|
|
logger.debug('get:settings:activation-bytes', { reqId: req.reqId });
|
|
const entries = await activationBytesService.listCachedEntries();
|
|
res.json({ entries });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/activation-bytes',
|
|
asyncHandler(async (req, res) => {
|
|
const { checksum, activationBytes } = req.body || {};
|
|
if (!checksum || !activationBytes) {
|
|
const error = new Error('checksum und activationBytes sind erforderlich');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
logger.debug('post:settings:activation-bytes', { reqId: req.reqId, checksum });
|
|
const saved = await activationBytesService.saveActivationBytes(checksum, activationBytes);
|
|
res.json({ success: true, checksum, activationBytes: saved });
|
|
})
|
|
);
|
|
|
|
// ── Optical drive scan ────────────────────────────────────────────────────────
|
|
router.get(
|
|
'/drives',
|
|
asyncHandler(async (req, res) => {
|
|
logger.debug('get:settings:drives', { reqId: req.reqId });
|
|
const devices = await diskDetectionService.getBlockDeviceInfo();
|
|
const drives = devices
|
|
.filter((d) => d.type === 'rom')
|
|
.map((d) => {
|
|
const name = String(d.name || '');
|
|
const devicePath = d.path || (name ? `/dev/${name}` : null);
|
|
const resolvedIndex = Number(d.makemkvIndex);
|
|
const discIndex = Number.isFinite(resolvedIndex) && resolvedIndex >= 0
|
|
? Math.trunc(resolvedIndex)
|
|
: null;
|
|
return {
|
|
path: devicePath,
|
|
name,
|
|
model: d.model || null,
|
|
discIndex,
|
|
discArg: discIndex != null ? `disc:${discIndex}` : null
|
|
};
|
|
})
|
|
.filter((d) => d.path);
|
|
res.json({ drives });
|
|
})
|
|
);
|
|
|
|
router.post(
|
|
'/drives/force-unlock',
|
|
asyncHandler(async (req, res) => {
|
|
const { devicePath, all } = req.body || {};
|
|
const settings = await settingsService.getSettingsMap();
|
|
const expertMode = ['1', 'true', 'yes', 'on'].includes(String(settings?.ui_expert_mode || '').toLowerCase());
|
|
if (!expertMode) {
|
|
const error = new Error('Expertenmodus erforderlich.');
|
|
error.statusCode = 403;
|
|
throw error;
|
|
}
|
|
|
|
const devices = await diskDetectionService.getBlockDeviceInfo();
|
|
const drives = devices
|
|
.filter((d) => d.type === 'rom')
|
|
.map((d) => {
|
|
const name = String(d.name || '');
|
|
return d.path || (name ? `/dev/${name}` : null);
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const activeLockPaths = (diskDetectionService.getActiveLocks?.() || [])
|
|
.map((entry) => String(entry?.path || '').trim())
|
|
.filter(Boolean);
|
|
|
|
const targetPaths = all
|
|
? Array.from(new Set([...drives, ...activeLockPaths]))
|
|
: [String(devicePath || '').trim()].filter(Boolean);
|
|
if (targetPaths.length === 0) {
|
|
const error = new Error('Kein Laufwerk angegeben.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const results = [];
|
|
for (const target of targetPaths) {
|
|
const result = await pipelineService.forceUnlockDrive(target, { reason: 'settings_force_unlock' });
|
|
results.push(result);
|
|
}
|
|
|
|
res.json({ results });
|
|
})
|
|
);
|
|
|
|
// User preferences (UI state, persisted per-installation)
|
|
router.get(
|
|
'/prefs/:key',
|
|
asyncHandler(async (req, res) => {
|
|
const { key } = req.params;
|
|
const db = await getDb();
|
|
const row = await db.get('SELECT value FROM user_prefs WHERE key = ?', [key]);
|
|
res.json({ key, value: row ? row.value : null });
|
|
})
|
|
);
|
|
|
|
router.put(
|
|
'/prefs/:key',
|
|
asyncHandler(async (req, res) => {
|
|
const { key } = req.params;
|
|
const { value } = req.body || {};
|
|
const db = await getDb();
|
|
await db.run(
|
|
`INSERT INTO user_prefs (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
|
[key, value ?? null]
|
|
);
|
|
res.json({ key, value: value ?? null });
|
|
})
|
|
);
|
|
|
|
module.exports = router;
|