0.16.0 Pre-Release Checks

This commit is contained in:
2026-04-29 07:39:49 +00:00
parent ff8a8f67bf
commit d679eface4
23 changed files with 2372 additions and 301 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.15.0-3",
"version": "0.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.15.0-3",
"version": "0.16.0",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.15.0-3",
"version": "0.16.0",
"private": true,
"type": "commonjs",
"scripts": {
+55 -1
View File
@@ -883,7 +883,13 @@ const SETTINGS_CATEGORY_MOVES = [
{ key: 'converter_movie_dir', category: 'Pfade' },
{ key: 'converter_audio_dir', category: 'Pfade' },
{ key: 'converter_output_template_video', category: 'Pfade' },
{ key: 'converter_output_template_audio', category: 'Pfade' }
{ key: 'converter_output_template_audio', category: 'Pfade' },
{ key: 'server_console_log_output_enabled', category: 'Logging' },
{ key: 'server_console_log_http_enabled', category: 'Logging' },
{ key: 'server_console_log_debug_enabled', category: 'Logging' },
{ key: 'server_console_log_info_enabled', category: 'Logging' },
{ key: 'server_console_log_warn_enabled', category: 'Logging' },
{ key: 'server_console_log_error_enabled', category: 'Logging' }
];
async function migrateSettingsSchemaMetadata(db) {
@@ -1049,6 +1055,48 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_output_enabled', 'Logging', 'Terminal-Ausgabe aktiv', 'boolean', 1, 'Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv.', 'true', '[]', '{}', 150)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_output_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_http_enabled', 'Logging', 'HTTP-Logs im Terminal', 'boolean', 1, 'Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert.', 'true', '[]', '{}', 151)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_http_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_debug_enabled', 'Logging', 'DEBUG im Terminal', 'boolean', 1, 'Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 152)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_debug_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_info_enabled', 'Logging', 'INFO im Terminal', 'boolean', 1, 'Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 153)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_info_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_warn_enabled', 'Logging', 'WARN im Terminal', 'boolean', 1, 'Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 154)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_warn_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('server_console_log_error_enabled', 'Logging', 'ERROR im Terminal', 'boolean', 1, 'Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 155)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_error_enabled', 'true')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
@@ -1288,6 +1336,12 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('generate_nfo_after_encode', 'Metadaten', 'NFO nach Encode erzeugen', 'boolean', 1, 'Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei.', 'false', '[]', '{}', 432)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('generate_nfo_after_encode', 'false')`);
await db.run(`
CREATE TABLE IF NOT EXISTS user_prefs (
key TEXT PRIMARY KEY,
+7
View File
@@ -16,6 +16,7 @@ const runtimeRoutes = require('./routes/runtimeRoutes');
const converterRoutes = require('./routes/converterRoutes');
const wsService = require('./services/websocketService');
const pipelineService = require('./services/pipelineService');
const settingsService = require('./services/settingsService');
const converterScanService = require('./services/converterScanService');
const cronService = require('./services/cronService');
const downloadService = require('./services/downloadService');
@@ -30,6 +31,12 @@ const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thum
async function start() {
logger.info('backend:start:init');
await initDatabase();
try {
const runtimeSettings = await settingsService.applyRuntimeSettings();
logger.info('backend:runtime-settings:applied', runtimeSettings);
} catch (error) {
logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) });
}
await tempCleanupService.init();
await pipelineService.init();
await converterScanService.startPolling();
+52 -4
View File
@@ -6,6 +6,21 @@ const logger = require('../services/logger').child('HISTORY_ROUTE');
const router = express.Router();
function parseSelectedJobIds(value, options = {}) {
const { hasExplicitValue = true } = options;
if (!hasExplicitValue) {
return null;
}
const sourceValues = Array.isArray(value)
? value
: String(value || '')
.split(',');
return sourceValues
.map((entry) => Number(entry))
.filter((entry) => Number.isFinite(entry) && entry > 0)
.map((entry) => Math.trunc(entry));
}
router.get(
'/',
asyncHandler(async (req, res) => {
@@ -147,12 +162,31 @@ router.post(
})
);
router.post(
'/:id/nfo/generate',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
logger.info('post:job:nfo:generate', { reqId: req.reqId, id });
const result = await historyService.generateJobNfo(id, {
mode: 'manual',
requireSettingDisabled: true,
failIfExists: true,
failIfOutputMissing: true
});
const job = await historyService.getJobWithLogs(id, { includeFsChecks: true });
res.json({ result, job });
})
);
router.post(
'/:id/delete-files',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const target = String(req.body?.target || 'both');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
});
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
? req.body.selectedRawPaths
.map((item) => String(item || '').trim())
@@ -169,12 +203,14 @@ router.post(
id,
target,
includeRelated,
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
const result = await historyService.deleteJobFiles(id, target, {
includeRelated,
selectedJobIds,
selectedRawPaths,
selectedMoviePaths
});
@@ -187,14 +223,18 @@ router.get(
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase());
const selectedJobIds = parseSelectedJobIds(req.query.selectedJobIds, {
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.query || {}, 'selectedJobIds')
});
logger.info('get:delete-preview', {
reqId: req.reqId,
id,
includeRelated
includeRelated,
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0
});
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
res.json({ preview });
})
);
@@ -205,6 +245,9 @@ router.post(
const id = Number(req.params.id);
const target = String(req.body?.target || 'none');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
});
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
@@ -226,6 +269,7 @@ router.post(
id,
target,
includeRelated,
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
requestedResetDriveState,
preserveRawForImportJobs,
requestedKeepDetectedDevice,
@@ -233,10 +277,12 @@ router.post(
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
const containsOrphanRawImportJob = Boolean(
preview?.flags?.containsOrphanRawImportJob
|| (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some((row) => Boolean(row?.orphanRawImport)))
|| (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some(
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
))
);
const resetDriveState = containsOrphanRawImportJob
? false
@@ -254,11 +300,13 @@ router.post(
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs
.filter((row) => Boolean(row?.selected))
.map((row) => String(row?.discDevice || '').trim())
.filter(Boolean)
: [];
const result = await historyService.deleteJob(id, target, {
includeRelated,
selectedJobIds,
selectedRawPaths,
selectedMoviePaths,
preserveRawForImportJobs
+415 -25
View File
@@ -123,6 +123,187 @@ function inspectOutputFile(filePath) {
}
}
function parseBooleanValue(value, fallback = false) {
if (value === null || value === undefined) {
return fallback;
}
if (typeof value === 'boolean') {
return value;
}
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) {
return fallback;
}
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false;
}
return fallback;
}
function resolveNfoPathFromOutputPath(outputPath) {
const normalizedOutputPath = String(outputPath || '').trim();
if (!normalizedOutputPath) {
return null;
}
const parsed = path.parse(normalizedOutputPath);
const extension = String(parsed.ext || '').trim();
const baseName = String(parsed.name || '').trim();
if (!extension || !baseName) {
return null;
}
return path.join(parsed.dir, `${baseName}.nfo`);
}
function inspectNfoFileStatus(nfoPath, options = {}) {
const includeFsChecks = options?.includeFsChecks !== false;
if (!nfoPath) {
return {
path: null,
exists: false,
isFile: false,
sizeBytes: null
};
}
if (!includeFsChecks) {
return {
path: nfoPath,
exists: null,
isFile: null,
sizeBytes: null
};
}
try {
const stat = fs.statSync(nfoPath);
return {
path: nfoPath,
exists: true,
isFile: stat.isFile(),
sizeBytes: stat.size
};
} catch (_error) {
return {
path: nfoPath,
exists: false,
isFile: false,
sizeBytes: null
};
}
}
function escapeXml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function resolveNfoMetadataFromJob(job = {}) {
const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {});
const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo);
const encodePlan = parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {});
const planMetadata = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
? encodePlan.metadata
: {};
const title = String(
job?.title
|| selectedMetadata?.title
|| selectedMetadata?.seriesTitle
|| planMetadata?.title
|| job?.detected_title
|| ''
).trim();
const rawYear = Number(
job?.year
?? selectedMetadata?.year
?? planMetadata?.year
?? null
);
const year = Number.isFinite(rawYear) && rawYear > 0
? Math.trunc(rawYear)
: null;
const imdbId = String(
job?.imdb_id
|| selectedMetadata?.imdbId
|| planMetadata?.imdbId
|| ''
).trim();
return {
title,
year,
imdbId
};
}
function buildNfoXml(metadata = {}) {
const title = escapeXml(metadata?.title || '');
const year = metadata?.year !== null && metadata?.year !== undefined
? escapeXml(String(metadata.year))
: '';
const imdbId = escapeXml(metadata?.imdbId || '');
return [
'<movie>',
` <title>${title}</title>`,
` <year>${year}</year>`,
` <imdbid>${imdbId}</imdbid>`,
'</movie>',
''
].join('\n');
}
function buildNfoStatusForJob({
job = {},
outputPath = null,
outputStatus = null,
includeFsChecks = true,
settings = {}
} = {}) {
const normalizedOutputPath = String(outputPath || '').trim() || null;
const nfoPath = resolveNfoPathFromOutputPath(normalizedOutputPath);
const nfoStatus = inspectNfoFileStatus(nfoPath, { includeFsChecks });
const nfoSettingEnabled = parseBooleanValue(
settings?.generate_nfo_after_encode,
false
);
const outputExists = Boolean(outputStatus?.exists);
const outputIsFile = Boolean(outputStatus?.isFile);
const canGenerateManual = !nfoSettingEnabled
&& Boolean(nfoPath)
&& outputExists
&& outputIsFile
&& !Boolean(nfoStatus?.exists);
let reason = null;
if (nfoSettingEnabled) {
reason = 'setting_enabled';
} else if (!nfoPath) {
reason = 'output_not_file';
} else if (!outputExists || !outputIsFile) {
reason = 'output_missing';
} else if (Boolean(nfoStatus?.exists)) {
reason = 'nfo_exists';
} else {
reason = 'eligible';
}
return {
settingEnabled: nfoSettingEnabled,
outputPath: normalizedOutputPath,
nfoPath,
outputExists: includeFsChecks ? outputExists : null,
outputIsFile: includeFsChecks ? outputIsFile : null,
nfoExists: includeFsChecks ? Boolean(nfoStatus?.exists) : null,
canGenerateManual,
reason
};
}
function parseInfoFromValue(value, fallback = null) {
if (!value) {
return fallback;
@@ -1044,6 +1225,13 @@ function enrichJobRow(job, settings = null, options = {}) {
? finishedWithOutput
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS')
);
const nfoStatus = buildNfoStatusForJob({
job,
outputPath: resolvedPaths.effectiveOutputPath,
outputStatus,
includeFsChecks,
settings
});
return {
...job,
@@ -1061,7 +1249,8 @@ function enrichJobRow(job, settings = null, options = {}) {
encodeSuccess,
rawStatus,
outputStatus,
movieDirStatus
movieDirStatus,
nfoStatus
};
}
@@ -2727,6 +2916,48 @@ function parseSourceJobIdFromPlan(encodePlanRaw) {
return sourceJobId || null;
}
function extractDiscNumberFromDeleteContext(row) {
const values = [
row?.raw_path,
row?.title,
row?.detected_title
];
for (const value of values) {
const raw = String(value || '').trim();
if (!raw) continue;
const match = raw.match(/(?:^|\W)D(?:ISC)?\s*[-_ ]?(\d+)(?:\W|$)/i)
|| raw.match(/-\s*D(\d+)\s*-\s*RAW/i);
const discNumber = normalizeJobIdValue(match?.[1]);
if (discNumber) {
return discNumber;
}
}
return null;
}
function resolveDeletePreviewRole(row) {
if (!row || typeof row !== 'object') {
return { roleKey: 'job', roleLabel: 'Job' };
}
if (isSeriesContainerRow(row) || isMultipartContainerRow(row)) {
return { roleKey: 'container', roleLabel: 'Container' };
}
if (isMultipartMergeRow(row)) {
return { roleKey: 'merge', roleLabel: 'Merge' };
}
if (isSeriesChildRow(row) || isMultipartChildRow(row)) {
const discNumber = extractDiscNumberFromDeleteContext(row);
return {
roleKey: 'disc',
roleLabel: discNumber ? `Disk ${discNumber}` : 'Disk/Child'
};
}
if (normalizeJobIdValue(row?.parent_job_id)) {
return { roleKey: 'child', roleLabel: 'Child-Job' };
}
return { roleKey: 'job', roleLabel: 'Einzeljob' };
}
function parseRetryLinkedJobIdsFromLogLines(lines = []) {
const jobIds = new Set();
const list = Array.isArray(lines) ? lines : [];
@@ -5104,6 +5335,124 @@ class HistoryService {
};
}
async generateJobNfo(jobId, options = {}) {
const normalizedJobId = normalizeJobIdValue(jobId);
if (!normalizedJobId) {
const error = new Error('Ungültige Job-ID.');
error.statusCode = 400;
throw error;
}
const mode = String(options?.mode || 'manual').trim().toLowerCase() || 'manual';
const requireSettingEnabled = Boolean(options?.requireSettingEnabled);
const requireSettingDisabled = Boolean(options?.requireSettingDisabled);
const failIfExists = Boolean(options?.failIfExists);
const failIfOutputMissing = Boolean(options?.failIfOutputMissing);
const [job, settings] = await Promise.all([
this.getJobById(normalizedJobId),
settingsService.getSettingsMap()
]);
if (!job) {
const error = new Error('Job nicht gefunden.');
error.statusCode = 404;
throw error;
}
const enriched = enrichJobRow(job, settings, { includeFsChecks: true });
const nfoStatus = enriched?.nfoStatus && typeof enriched.nfoStatus === 'object'
? enriched.nfoStatus
: buildNfoStatusForJob({
job: enriched,
outputPath: enriched?.output_path || null,
outputStatus: enriched?.outputStatus || null,
includeFsChecks: true,
settings
});
if (requireSettingEnabled && !nfoStatus.settingEnabled) {
return {
generated: false,
skipped: true,
reason: 'setting_disabled',
nfoStatus
};
}
if (requireSettingDisabled && nfoStatus.settingEnabled) {
const error = new Error('NFO-Generierung ist in den Settings aktiv. Manuelle Generierung ist nur bei deaktivierter Auto-NFO erlaubt.');
error.statusCode = 409;
throw error;
}
if (!nfoStatus.nfoPath) {
const error = new Error('Für diesen Job kann keine NFO-Datei erzeugt werden (Output ist keine Datei).');
error.statusCode = 409;
throw error;
}
if (!nfoStatus.outputExists || !nfoStatus.outputIsFile) {
if (failIfOutputMissing) {
const error = new Error('NFO kann nicht erzeugt werden: Output-Datei fehlt.');
error.statusCode = 409;
throw error;
}
return {
generated: false,
skipped: true,
reason: 'output_missing',
nfoStatus
};
}
if (nfoStatus.nfoExists) {
if (failIfExists) {
const error = new Error('NFO-Datei existiert bereits.');
error.statusCode = 409;
throw error;
}
return {
generated: false,
skipped: true,
reason: 'nfo_exists',
nfoStatus
};
}
const metadata = resolveNfoMetadataFromJob(enriched);
const xml = buildNfoXml(metadata);
fs.writeFileSync(nfoStatus.nfoPath, xml, 'utf8');
const refreshedNfoStatus = buildNfoStatusForJob({
job: enriched,
outputPath: enriched?.output_path || null,
outputStatus: enriched?.outputStatus || null,
includeFsChecks: true,
settings
});
await this.appendLog(
normalizedJobId,
'SYSTEM',
`NFO-Datei erstellt (${mode}): ${nfoStatus.nfoPath}`
).catch(() => {});
logger.info('job:nfo:generated', {
jobId: normalizedJobId,
mode,
nfoPath: nfoStatus.nfoPath
});
return {
generated: true,
skipped: false,
mode,
nfoPath: nfoStatus.nfoPath,
metadata,
nfoStatus: refreshedNfoStatus
};
}
async getJobArchiveDescriptor(jobId, target, options = {}) {
const normalizedJobId = normalizeJobIdValue(jobId);
if (!normalizedJobId) {
@@ -6649,14 +6998,7 @@ class HistoryService {
continue;
}
if (!inspection.exists) {
upsertMoviePath({
path: row.path,
exists: false,
isDirectory: false,
isFile: true,
jobIds: Array.from(row.jobIds),
sources
});
// Output candidates must stay file-based only. Missing paths are not actionable.
continue;
}
if (inspection.isDirectory) {
@@ -6710,6 +7052,11 @@ class HistoryService {
async getJobDeletePreview(jobId, options = {}) {
const includeRelated = options?.includeRelated !== false;
const requestedSelectedJobIds = Array.isArray(options?.selectedJobIds)
? options.selectedJobIds
.map((item) => normalizeJobIdValue(item))
.filter(Boolean)
: null;
const normalizedJobId = normalizeJobIdValue(jobId);
if (!normalizedJobId) {
const error = new Error('Ungültige Job-ID.');
@@ -6718,20 +7065,35 @@ class HistoryService {
}
const jobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, { includeRelated });
const allJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
const allJobIdSet = new Set(allJobIds);
const hasExplicitSelectedJobIds = Array.isArray(requestedSelectedJobIds);
const effectiveSelectedJobIds = includeRelated
? (hasExplicitSelectedJobIds
? requestedSelectedJobIds.filter((id) => allJobIdSet.has(id))
: allJobIds)
: [normalizedJobId];
const effectiveSelectedJobIdSet = new Set(effectiveSelectedJobIds);
const selectedJobs = jobs.filter((job) => effectiveSelectedJobIdSet.has(normalizeJobIdValue(job?.id)));
const settings = await settingsService.getSettingsMap();
const relatedJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
const [lineageArtifactsByJobId, outputFoldersByJobId] = await Promise.all([
this.listJobLineageArtifactsByJobIds(relatedJobIds),
this.listJobOutputFoldersByJobIds(relatedJobIds)
]);
const relatedJobIds = selectedJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
const [lineageArtifactsByJobId, outputFoldersByJobId] = relatedJobIds.length > 0
? await Promise.all([
this.listJobLineageArtifactsByJobIds(relatedJobIds),
this.listJobOutputFoldersByJobIds(relatedJobIds)
])
: [new Map(), new Map()];
const preview = this._buildDeletePreviewFromJobs(
jobs,
selectedJobs,
settings,
lineageArtifactsByJobId,
outputFoldersByJobId
);
const relatedJobs = jobs.map((job) => {
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
const roleMeta = resolveDeletePreviewRole(job);
const id = normalizeJobIdValue(job?.id);
return {
id: Number(job.id),
parentJobId: normalizeJobIdValue(job.parent_job_id),
@@ -6739,17 +7101,23 @@ class HistoryService {
status: String(job.status || '').trim() || null,
discDevice: String(job.disc_device || '').trim() || null,
isPrimary: Number(job.id) === normalizedJobId,
selected: Boolean(id && effectiveSelectedJobIdSet.has(id)),
roleKey: roleMeta.roleKey,
roleLabel: roleMeta.roleLabel,
createdAt: String(job.created_at || '').trim() || null,
orphanRawImport: isOrphanRawImportMakemkvInfo(mkInfo)
};
});
const orphanRawImportJobs = relatedJobs.filter((row) => Boolean(row?.orphanRawImport)).length;
const orphanRawImportJobs = relatedJobs.filter(
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
).length;
const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length;
const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length;
return {
jobId: normalizedJobId,
includeRelated,
selectedJobIds: effectiveSelectedJobIds,
relatedJobs,
pathCandidates: preview.pathCandidates,
protectedRoots: preview.protectedRoots,
@@ -6758,6 +7126,7 @@ class HistoryService {
},
counts: {
relatedJobs: relatedJobs.length,
selectedJobs: effectiveSelectedJobIds.length,
orphanRawImportJobs,
rawCandidates: preview.pathCandidates.raw.length,
movieCandidates: preview.pathCandidates.movie.length,
@@ -6976,6 +7345,11 @@ class HistoryService {
}
const includeRelated = Boolean(options?.includeRelated);
const selectedJobIds = Array.isArray(options?.selectedJobIds)
? options.selectedJobIds
.map((item) => normalizeJobIdValue(item))
.filter(Boolean)
: null;
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
? options.selectedRawPaths
.map((item) => String(item || '').trim())
@@ -6987,13 +7361,19 @@ class HistoryService {
.filter(Boolean)
: null;
if (includeRelated || (selectedRawPaths && selectedRawPaths.length > 0) || (selectedMoviePaths && selectedMoviePaths.length > 0)) {
const hasSelectedJobFilter = Array.isArray(options?.selectedJobIds);
if (
includeRelated
|| hasSelectedJobFilter
|| (selectedRawPaths && selectedRawPaths.length > 0)
|| (selectedMoviePaths && selectedMoviePaths.length > 0)
) {
const normalizedJobId = normalizeJobIdValue(jobId);
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated });
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated, selectedJobIds });
const summary = this._deletePathsFromPreview(preview, target, { selectedRawPaths, selectedMoviePaths });
const relatedJobIds = Array.from(new Set(
(Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [])
.map((row) => normalizeJobIdValue(row?.id))
(Array.isArray(preview?.selectedJobIds) ? preview.selectedJobIds : [])
.map((row) => normalizeJobIdValue(row))
.filter(Boolean)
));
@@ -7011,6 +7391,7 @@ class HistoryService {
logger.info('job:delete-files', {
jobId: normalizedJobId,
includeRelated,
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0,
summary
@@ -7782,6 +8163,11 @@ class HistoryService {
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
const normalizedSelectedJobIds = Array.isArray(options?.selectedJobIds)
? options.selectedJobIds
.map((item) => normalizeJobIdValue(item))
.filter(Boolean)
: null;
const includeRelated = Boolean(options?.includeRelated);
if (!includeRelated) {
@@ -7933,14 +8319,18 @@ class HistoryService {
const resolved = await this.getJobByIdOrReplacement(jobId);
const existing = resolved?.job || null;
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
let deleteJobIds = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs
.map((row) => normalizeJobIdValue(row?.id))
const preview = await this.getJobDeletePreview(normalizedJobId, {
includeRelated: true,
selectedJobIds: normalizedSelectedJobIds
});
let deleteJobIds = Array.isArray(preview?.selectedJobIds)
? preview.selectedJobIds
.map((row) => normalizeJobIdValue(row))
.filter(Boolean)
: [];
let rowById = new Map();
if (normalizedJobId && !deleteJobIds.includes(normalizedJobId)) {
const hasExplicitSelectedJobFilter = Array.isArray(normalizedSelectedJobIds);
if (!hasExplicitSelectedJobFilter && normalizedJobId && !deleteJobIds.includes(normalizedJobId)) {
deleteJobIds = [...deleteJobIds, normalizedJobId];
}
if (deleteJobIds.length > 0) {
+88 -1
View File
@@ -11,6 +11,23 @@ const LEVELS = {
};
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
let consoleConfig = {
enabled: true,
levels: {
debug: true,
info: true,
warn: true,
error: true
},
http: true
};
function normalizeBoolean(value, fallback = true) {
if (value === null || value === undefined) {
return fallback;
}
return Boolean(value);
}
function ensureLogDir(logDirPath) {
try {
@@ -118,6 +135,10 @@ function emit(level, scope, message, meta = null) {
const line = safeJson(payload);
writeLine(line);
if (!shouldEmitToConsole(normLevel, scope)) {
return;
}
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
if (normLevel === 'error') {
console.error(print, payload.meta ? payload.meta : '');
@@ -128,6 +149,24 @@ function emit(level, scope, message, meta = null) {
}
}
function shouldEmitToConsole(level, scope) {
if (!consoleConfig.enabled) {
return false;
}
const normalizedLevel = String(level || 'info').toLowerCase();
if (consoleConfig.levels[normalizedLevel] === false) {
return false;
}
const normalizedScope = String(scope || '').trim().toUpperCase();
if (normalizedScope === 'HTTP' && !consoleConfig.http) {
return false;
}
return true;
}
function child(scope) {
return {
debug(message, meta) {
@@ -145,7 +184,55 @@ function child(scope) {
};
}
function setConsoleOutputEnabled(enabled) {
consoleConfig.enabled = Boolean(enabled);
return consoleConfig.enabled;
}
function isConsoleOutputEnabled() {
return consoleConfig.enabled;
}
function configureConsoleOutput(options = {}) {
const opts = options && typeof options === 'object' ? options : {};
if (Object.prototype.hasOwnProperty.call(opts, 'enabled')) {
consoleConfig.enabled = normalizeBoolean(opts.enabled, true);
}
if (opts.levels && typeof opts.levels === 'object') {
const sourceLevels = opts.levels;
for (const level of Object.keys(LEVELS)) {
if (Object.prototype.hasOwnProperty.call(sourceLevels, level)) {
consoleConfig.levels[level] = normalizeBoolean(sourceLevels[level], true);
}
}
}
if (Object.prototype.hasOwnProperty.call(opts, 'http')) {
consoleConfig.http = normalizeBoolean(opts.http, true);
}
return getConsoleOutputConfig();
}
function getConsoleOutputConfig() {
return {
enabled: Boolean(consoleConfig.enabled),
levels: {
debug: Boolean(consoleConfig.levels.debug),
info: Boolean(consoleConfig.levels.info),
warn: Boolean(consoleConfig.levels.warn),
error: Boolean(consoleConfig.levels.error)
},
http: Boolean(consoleConfig.http)
};
}
module.exports = {
child,
emit
emit,
setConsoleOutputEnabled,
isConsoleOutputEnabled,
configureConsoleOutput,
getConsoleOutputConfig
};
File diff suppressed because it is too large Load Diff
+73 -2
View File
@@ -3,12 +3,14 @@ const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { getDb } = require('../db/database');
const logger = require('./logger').child('SETTINGS');
const loggerService = require('./logger');
const logger = loggerService.child('SETTINGS');
const {
parseJson,
normalizeValueByType,
serializeValueByType,
validateSetting
validateSetting,
toBoolean
} = require('../utils/validators');
const { splitArgs } = require('../utils/commandLine');
const { setLogRootDir } = require('./logPathService');
@@ -59,6 +61,20 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
const LOG_DIR_SETTING_KEY = 'log_dir';
const SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY = 'server_console_log_output_enabled';
const SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY = 'server_console_log_http_enabled';
const SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY = 'server_console_log_debug_enabled';
const SERVER_CONSOLE_LOG_INFO_ENABLED_KEY = 'server_console_log_info_enabled';
const SERVER_CONSOLE_LOG_WARN_ENABLED_KEY = 'server_console_log_warn_enabled';
const SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY = 'server_console_log_error_enabled';
const SERVER_CONSOLE_LOG_SETTING_KEYS = new Set([
SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY,
SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY,
SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY,
SERVER_CONSOLE_LOG_INFO_ENABLED_KEY,
SERVER_CONSOLE_LOG_WARN_ENABLED_KEY,
SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY
]);
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd', 'audiobook'];
const PROFILED_SETTINGS = {
raw_dir: {
@@ -168,6 +184,35 @@ function applyRuntimeLogDirSetting(rawValue) {
}
}
function normalizeBooleanSetting(rawValue, fallback = true) {
const normalizedRaw = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
if (normalizedRaw === null || normalizedRaw === undefined || normalizedRaw === '') {
return fallback;
}
return toBoolean(normalizedRaw);
}
function applyRuntimeServerConsoleLoggingSettingsFromMap(settingsMap = {}) {
const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
const enabled = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY], true);
const http = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY], true);
const debug = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY], true);
const info = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_INFO_ENABLED_KEY], true);
const warn = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_WARN_ENABLED_KEY], true);
const error = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY], true);
const applied = loggerService.configureConsoleOutput({
enabled,
http,
levels: {
debug,
info,
warn,
error
}
});
return applied;
}
function isImmutableSettingKey(key) {
return IMMUTABLE_SETTING_KEYS.has(String(key || '').trim().toLowerCase());
}
@@ -814,6 +859,21 @@ class SettingsService {
return { ...(snapshot?.map || {}) };
}
applyRuntimeSettingsFromMap(settingsMap = {}) {
const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
const logDir = applyRuntimeLogDirSetting(source[LOG_DIR_SETTING_KEY]);
const consoleLogConfig = applyRuntimeServerConsoleLoggingSettingsFromMap(source);
return {
logDir,
serverConsoleLogConfig: consoleLogConfig
};
}
async applyRuntimeSettings() {
const map = await this.getSettingsMap();
return this.applyRuntimeSettingsFromMap(map);
}
normalizeMediaProfile(value) {
return normalizeMediaProfileValue(value);
}
@@ -1014,6 +1074,10 @@ class SettingsService {
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
applyRuntimeLogDirSetting(result.normalized);
}
if (SERVER_CONSOLE_LOG_SETTING_KEYS.has(String(key || '').trim().toLowerCase())) {
const map = await this.getSettingsMap({ forceRefresh: true });
applyRuntimeServerConsoleLoggingSettingsFromMap(map);
}
this.invalidateSettingsCache([key]);
return {
@@ -1107,6 +1171,13 @@ class SettingsService {
if (logDirChange) {
applyRuntimeLogDirSetting(logDirChange.value);
}
const consoleOutputChange = normalizedEntries.find(
(item) => SERVER_CONSOLE_LOG_SETTING_KEYS.has(String(item?.key || '').trim().toLowerCase())
);
if (consoleOutputChange) {
const map = await this.getSettingsMap({ forceRefresh: true });
applyRuntimeServerConsoleLoggingSettingsFromMap(map);
}
this.invalidateSettingsCache(normalizedEntries.map((item) => item.key));
logger.info('settings:bulk-updated', { count: normalizedEntries.length });