Skript Integration + UI Anpassungen
This commit is contained in:
@@ -260,6 +260,18 @@ const defaultSchema = [
|
||||
validation: { minLength: 1 },
|
||||
orderIndex: 340
|
||||
},
|
||||
{
|
||||
key: 'output_folder_template',
|
||||
category: 'Tools',
|
||||
label: 'Ordnername Template',
|
||||
type: 'string',
|
||||
required: 0,
|
||||
description: 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template verwenden.',
|
||||
defaultValue: '',
|
||||
options: [],
|
||||
validation: {},
|
||||
orderIndex: 345
|
||||
},
|
||||
{
|
||||
key: 'omdb_api_key',
|
||||
category: 'Metadaten',
|
||||
|
||||
@@ -94,15 +94,20 @@ router.post(
|
||||
const jobId = Number(req.params.jobId);
|
||||
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
||||
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
||||
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
|
||||
logger.info('post:confirm-encode', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
selectedEncodeTitleId,
|
||||
selectedTrackSelectionProvided: Boolean(selectedTrackSelection)
|
||||
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
||||
selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds)
|
||||
? selectedPostEncodeScriptIds.length
|
||||
: 0
|
||||
});
|
||||
const job = await pipelineService.confirmEncodeReview(jobId, {
|
||||
selectedEncodeTitleId,
|
||||
selectedTrackSelection
|
||||
selectedTrackSelection,
|
||||
selectedPostEncodeScriptIds
|
||||
});
|
||||
res.json({ job });
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const settingsService = require('../services/settingsService');
|
||||
const scriptService = require('../services/scriptService');
|
||||
const notificationService = require('../services/notificationService');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const wsService = require('../services/websocketService');
|
||||
@@ -25,6 +26,83 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
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(
|
||||
'/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.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.put(
|
||||
'/:key',
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ const { getDb } = require('../db/database');
|
||||
const settingsService = require('./settingsService');
|
||||
const historyService = require('./historyService');
|
||||
const omdbService = require('./omdbService');
|
||||
const scriptService = require('./scriptService');
|
||||
const wsService = require('./websocketService');
|
||||
const diskDetectionService = require('./diskDetectionService');
|
||||
const notificationService = require('./notificationService');
|
||||
@@ -42,30 +43,47 @@ function withTimestampBeforeExtension(targetPath, suffix) {
|
||||
return path.join(dir, `${base}_${suffix}${ext}`);
|
||||
}
|
||||
|
||||
function buildOutputPathFromJob(settings, job, fallbackJobId = null) {
|
||||
function resolveOutputTemplateValues(job, fallbackJobId = null) {
|
||||
return {
|
||||
title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'),
|
||||
year: job.year || new Date().getFullYear(),
|
||||
imdbId: job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb')
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOutputFileName(settings, values) {
|
||||
const fileTemplate = settings.filename_template || '${title} (${year})';
|
||||
return sanitizeFileName(renderTemplate(fileTemplate, values));
|
||||
}
|
||||
|
||||
function resolveFinalOutputFolderName(settings, values) {
|
||||
const folderTemplateRaw = String(settings.output_folder_template || '').trim();
|
||||
const fallbackTemplate = settings.filename_template || '${title} (${year})';
|
||||
const folderTemplate = folderTemplateRaw || fallbackTemplate;
|
||||
return sanitizeFileName(renderTemplate(folderTemplate, values));
|
||||
}
|
||||
|
||||
function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) {
|
||||
const movieDir = settings.movie_dir;
|
||||
const title = job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
||||
const year = job.year || new Date().getFullYear();
|
||||
const imdbId = job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
||||
const template = settings.filename_template || '${title} (${year})';
|
||||
const folderName = sanitizeFileName(
|
||||
renderTemplate('${title} (${year})', {
|
||||
title,
|
||||
year,
|
||||
imdbId
|
||||
})
|
||||
);
|
||||
const baseName = sanitizeFileName(
|
||||
renderTemplate(template, {
|
||||
title,
|
||||
year,
|
||||
imdbId
|
||||
})
|
||||
);
|
||||
const ext = settings.output_extension || 'mkv';
|
||||
const values = resolveOutputTemplateValues(job, fallbackJobId);
|
||||
const folderName = resolveFinalOutputFolderName(settings, values);
|
||||
const baseName = resolveOutputFileName(settings, values);
|
||||
const ext = String(settings.output_extension || 'mkv').trim() || 'mkv';
|
||||
return path.join(movieDir, folderName, `${baseName}.${ext}`);
|
||||
}
|
||||
|
||||
function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) {
|
||||
const movieDir = settings.movie_dir;
|
||||
const values = resolveOutputTemplateValues(job, fallbackJobId);
|
||||
const baseName = resolveOutputFileName(settings, values);
|
||||
const ext = String(settings.output_extension || 'mkv').trim() || 'mkv';
|
||||
const numericJobId = Number(fallbackJobId || job?.id || 0);
|
||||
const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
|
||||
? `Incomplete_job-${numericJobId}`
|
||||
: 'Incomplete_job-unknown';
|
||||
return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`);
|
||||
}
|
||||
|
||||
function ensureUniqueOutputPath(outputPath) {
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
return outputPath;
|
||||
@@ -81,6 +99,65 @@ function ensureUniqueOutputPath(outputPath) {
|
||||
return attempt;
|
||||
}
|
||||
|
||||
function moveFileWithFallback(sourcePath, targetPath) {
|
||||
try {
|
||||
fs.renameSync(sourcePath, targetPath);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EXDEV') {
|
||||
throw error;
|
||||
}
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
fs.unlinkSync(sourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
function removeDirectoryIfEmpty(directoryPath) {
|
||||
try {
|
||||
const entries = fs.readdirSync(directoryPath);
|
||||
if (entries.length === 0) {
|
||||
fs.rmdirSync(directoryPath);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Best effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFinalOutputPath) {
|
||||
const sourcePath = String(incompleteOutputPath || '').trim();
|
||||
if (!sourcePath) {
|
||||
throw new Error('Encode-Finalisierung fehlgeschlagen: temporärer Output-Pfad fehlt.');
|
||||
}
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`Encode-Finalisierung fehlgeschlagen: temporäre Datei fehlt (${sourcePath}).`);
|
||||
}
|
||||
|
||||
const plannedTargetPath = String(preferredFinalOutputPath || '').trim();
|
||||
if (!plannedTargetPath) {
|
||||
throw new Error('Encode-Finalisierung fehlgeschlagen: finaler Output-Pfad fehlt.');
|
||||
}
|
||||
|
||||
const sourceResolved = path.resolve(sourcePath);
|
||||
const targetPath = ensureUniqueOutputPath(plannedTargetPath);
|
||||
const targetResolved = path.resolve(targetPath);
|
||||
const outputPathWithTimestamp = targetPath !== plannedTargetPath;
|
||||
|
||||
if (sourceResolved === targetResolved) {
|
||||
return {
|
||||
outputPath: targetPath,
|
||||
outputPathWithTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(targetPath));
|
||||
moveFileWithFallback(sourcePath, targetPath);
|
||||
removeDirectoryIfEmpty(path.dirname(sourcePath));
|
||||
|
||||
return {
|
||||
outputPath: targetPath,
|
||||
outputPathWithTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
function truncateLine(value, max = 180) {
|
||||
const raw = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (raw.length <= max) {
|
||||
@@ -1556,6 +1633,26 @@ function normalizeTrackIdList(rawList) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeScriptIdList(rawList) {
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const item of list) {
|
||||
const value = Number(item);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalized = Math.trunc(value);
|
||||
const key = String(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
|
||||
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null;
|
||||
if (!plan || !Array.isArray(plan.titles)) {
|
||||
@@ -3806,7 +3903,10 @@ class PipelineService extends EventEmitter {
|
||||
logger.info('confirmEncodeReview:requested', {
|
||||
jobId,
|
||||
selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null,
|
||||
selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection)
|
||||
selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection),
|
||||
selectedPostEncodeScriptIdsCount: Array.isArray(options?.selectedPostEncodeScriptIds)
|
||||
? options.selectedPostEncodeScriptIds.length
|
||||
: 0
|
||||
});
|
||||
|
||||
const job = await historyService.getJobById(jobId);
|
||||
@@ -3837,6 +3937,13 @@ class PipelineService extends EventEmitter {
|
||||
options?.selectedTrackSelection || null
|
||||
);
|
||||
planForConfirm = trackSelectionResult.plan;
|
||||
const hasExplicitPostScriptSelection = options?.selectedPostEncodeScriptIds !== undefined;
|
||||
const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection
|
||||
? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || [])
|
||||
: normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []);
|
||||
const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, {
|
||||
strict: true
|
||||
});
|
||||
const confirmedMode = String(planForConfirm?.mode || encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||
const isPreRipMode = confirmedMode === 'pre_rip' || Boolean(planForConfirm?.preRip);
|
||||
|
||||
@@ -3848,6 +3955,11 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
const confirmedPlan = {
|
||||
...planForConfirm,
|
||||
postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)),
|
||||
postEncodeScripts: selectedPostEncodeScripts.map((item) => ({
|
||||
id: Number(item.id),
|
||||
name: item.name
|
||||
})),
|
||||
reviewConfirmed: true,
|
||||
reviewConfirmedAt: nowIso()
|
||||
};
|
||||
@@ -3869,6 +3981,7 @@ class PipelineService extends EventEmitter {
|
||||
`Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}`
|
||||
+ ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.`
|
||||
+ ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.`
|
||||
+ ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.`
|
||||
);
|
||||
|
||||
await this.setState('READY_TO_ENCODE', {
|
||||
@@ -4245,6 +4358,160 @@ class PipelineService extends EventEmitter {
|
||||
return enrichedReview;
|
||||
}
|
||||
|
||||
async runPostEncodeScripts(jobId, encodePlan, context = {}) {
|
||||
const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
|
||||
if (scriptIds.length === 0) {
|
||||
return {
|
||||
configured: 0,
|
||||
attempted: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
results: []
|
||||
};
|
||||
}
|
||||
|
||||
const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false });
|
||||
const scriptById = new Map(scripts.map((item) => [Number(item.id), item]));
|
||||
const results = [];
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
let aborted = false;
|
||||
let abortReason = null;
|
||||
let failedScriptName = null;
|
||||
let failedScriptId = null;
|
||||
const titleForPush = context?.jobTitle || `Job #${jobId}`;
|
||||
|
||||
for (let index = 0; index < scriptIds.length; index += 1) {
|
||||
const scriptId = scriptIds[index];
|
||||
const script = scriptById.get(Number(scriptId));
|
||||
if (!script) {
|
||||
failed += 1;
|
||||
aborted = true;
|
||||
failedScriptId = Number(scriptId);
|
||||
failedScriptName = `Script #${scriptId}`;
|
||||
abortReason = `Post-Encode Skript #${scriptId} wurde nicht gefunden (${index + 1}/${scriptIds.length}).`;
|
||||
await historyService.appendLog(jobId, 'SYSTEM', abortReason);
|
||||
results.push({
|
||||
scriptId,
|
||||
scriptName: null,
|
||||
status: 'ERROR',
|
||||
error: 'missing'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`
|
||||
);
|
||||
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, {
|
||||
source: 'post_encode',
|
||||
mode: context?.mode || null,
|
||||
jobId,
|
||||
jobTitle: context?.jobTitle || null,
|
||||
inputPath: context?.inputPath || null,
|
||||
outputPath: context?.outputPath || null,
|
||||
rawPath: context?.rawPath || null
|
||||
});
|
||||
const runInfo = await this.runCommand({
|
||||
jobId,
|
||||
stage: 'ENCODING',
|
||||
source: 'POST_ENCODE_SCRIPT',
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
argsForLog: prepared.argsForLog
|
||||
});
|
||||
|
||||
succeeded += 1;
|
||||
results.push({
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
status: 'SUCCESS',
|
||||
runInfo
|
||||
});
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Encode Skript erfolgreich: ${script.name}`
|
||||
);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
aborted = true;
|
||||
failedScriptId = Number(script.id);
|
||||
failedScriptName = script.name;
|
||||
abortReason = error?.message || 'unknown';
|
||||
results.push({
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
status: 'ERROR',
|
||||
error: abortReason
|
||||
});
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Encode Skript fehlgeschlagen: ${script.name} (${abortReason})`
|
||||
);
|
||||
logger.warn('encode:post-script:failed', {
|
||||
jobId,
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
break;
|
||||
} finally {
|
||||
if (prepared?.cleanup) {
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
const executedScriptIds = new Set(results.map((item) => Number(item?.scriptId)));
|
||||
for (const pendingScriptId of scriptIds) {
|
||||
const numericId = Number(pendingScriptId);
|
||||
if (executedScriptIds.has(numericId)) {
|
||||
continue;
|
||||
}
|
||||
const pendingScript = scriptById.get(numericId);
|
||||
skipped += 1;
|
||||
results.push({
|
||||
scriptId: numericId,
|
||||
scriptName: pendingScript?.name || null,
|
||||
status: 'SKIPPED_ABORTED'
|
||||
});
|
||||
}
|
||||
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Encode Skriptkette abgebrochen nach Fehler in ${failedScriptName || `Script #${failedScriptId || 'unknown'}`}.`
|
||||
);
|
||||
void this.notifyPushover('job_error', {
|
||||
title: 'Ripster - Post-Encode Skriptfehler',
|
||||
message: `${titleForPush}: ${failedScriptName || `Script #${failedScriptId || 'unknown'}`} fehlgeschlagen (${abortReason || 'unknown'}). Skriptkette abgebrochen.`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
configured: scriptIds.length,
|
||||
attempted: scriptIds.length - skipped,
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
aborted,
|
||||
abortReason,
|
||||
failedScriptId,
|
||||
failedScriptName,
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
async startEncodingFromPrepared(jobId) {
|
||||
this.ensureNotBusy('startEncodingFromPrepared');
|
||||
logger.info('encode:start-from-prepared', { jobId });
|
||||
@@ -4284,10 +4551,9 @@ class PipelineService extends EventEmitter {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const preferredOutputPath = buildOutputPathFromJob(settings, job, jobId);
|
||||
const outputPath = ensureUniqueOutputPath(preferredOutputPath);
|
||||
const outputPathWithTimestamp = outputPath !== preferredOutputPath;
|
||||
ensureDir(path.dirname(outputPath));
|
||||
const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, job, jobId);
|
||||
const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, job, jobId);
|
||||
ensureDir(path.dirname(incompleteOutputPath));
|
||||
|
||||
await this.setState('ENCODING', {
|
||||
activeJobId: jobId,
|
||||
@@ -4298,7 +4564,7 @@ class PipelineService extends EventEmitter {
|
||||
jobId,
|
||||
mode,
|
||||
inputPath,
|
||||
outputPath,
|
||||
outputPath: incompleteOutputPath,
|
||||
reviewConfirmed: true,
|
||||
mediaInfoReview: encodePlan || null,
|
||||
selectedMetadata: {
|
||||
@@ -4313,27 +4579,25 @@ class PipelineService extends EventEmitter {
|
||||
await historyService.updateJob(jobId, {
|
||||
status: 'ENCODING',
|
||||
last_state: 'ENCODING',
|
||||
output_path: outputPath,
|
||||
output_path: incompleteOutputPath,
|
||||
encode_input_path: inputPath
|
||||
});
|
||||
|
||||
if (outputPathWithTimestamp) {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Output existierte bereits. Neuer Output-Pfad mit Timestamp: ${outputPath}`
|
||||
);
|
||||
}
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Temporärer Encode-Output: ${incompleteOutputPath} (wird nach erfolgreichem Encode in den finalen Zielordner verschoben).`
|
||||
);
|
||||
|
||||
if (mode === 'reencode') {
|
||||
void this.notifyPushover('reencode_started', {
|
||||
title: 'Ripster - Re-Encode gestartet',
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}`
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
|
||||
});
|
||||
} else {
|
||||
void this.notifyPushover('encoding_started', {
|
||||
title: 'Ripster - Encoding gestartet',
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}`
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4407,7 +4671,7 @@ class PipelineService extends EventEmitter {
|
||||
handBrakeTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId ?? encodePlan?.encodeInputTitleId);
|
||||
}
|
||||
}
|
||||
const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, outputPath, {
|
||||
const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, incompleteOutputPath, {
|
||||
trackSelection,
|
||||
titleId: handBrakeTitleId
|
||||
});
|
||||
@@ -4434,39 +4698,98 @@ class PipelineService extends EventEmitter {
|
||||
args: handBrakeConfig.args,
|
||||
parser: parseHandBrakeProgress
|
||||
});
|
||||
const outputFinalization = finalizeOutputPathForCompletedEncode(
|
||||
incompleteOutputPath,
|
||||
preferredFinalOutputPath
|
||||
);
|
||||
const finalizedOutputPath = outputFinalization.outputPath;
|
||||
if (outputFinalization.outputPathWithTimestamp) {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Finaler Output existierte bereits. Neuer Zielpfad mit Timestamp: ${finalizedOutputPath}`
|
||||
);
|
||||
}
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Encode-Output finalisiert: ${finalizedOutputPath}`
|
||||
);
|
||||
let postEncodeScriptsSummary = {
|
||||
configured: 0,
|
||||
attempted: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
results: []
|
||||
};
|
||||
try {
|
||||
postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, {
|
||||
mode,
|
||||
jobTitle: job.title || job.detected_title || null,
|
||||
inputPath,
|
||||
outputPath: finalizedOutputPath,
|
||||
rawPath: job.raw_path || null
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('encode:post-script:summary-failed', {
|
||||
jobId,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
|
||||
);
|
||||
}
|
||||
if (postEncodeScriptsSummary.configured > 0) {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}`
|
||||
);
|
||||
}
|
||||
const handbrakeInfoWithPostScripts = {
|
||||
...handbrakeInfo,
|
||||
postEncodeScripts: postEncodeScriptsSummary
|
||||
};
|
||||
|
||||
await historyService.updateJob(jobId, {
|
||||
handbrake_info_json: JSON.stringify(handbrakeInfo),
|
||||
handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts),
|
||||
status: 'FINISHED',
|
||||
last_state: 'FINISHED',
|
||||
end_time: nowIso(),
|
||||
output_path: outputPath,
|
||||
output_path: finalizedOutputPath,
|
||||
error_message: null
|
||||
});
|
||||
|
||||
logger.info('encoding:finished', { jobId, mode, outputPath });
|
||||
logger.info('encoding:finished', { jobId, mode, outputPath: finalizedOutputPath });
|
||||
const finishedStatusTextBase = mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen';
|
||||
const finishedStatusText = postEncodeScriptsSummary.failed > 0
|
||||
? `${finishedStatusTextBase} (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)`
|
||||
: finishedStatusTextBase;
|
||||
|
||||
await this.setState('FINISHED', {
|
||||
activeJobId: jobId,
|
||||
progress: 100,
|
||||
eta: null,
|
||||
statusText: mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen',
|
||||
statusText: finishedStatusText,
|
||||
context: {
|
||||
jobId,
|
||||
mode,
|
||||
outputPath
|
||||
outputPath: finalizedOutputPath
|
||||
}
|
||||
});
|
||||
|
||||
if (mode === 'reencode') {
|
||||
void this.notifyPushover('reencode_finished', {
|
||||
title: 'Ripster - Re-Encode abgeschlossen',
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}`
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}`
|
||||
});
|
||||
} else {
|
||||
void this.notifyPushover('job_finished', {
|
||||
title: 'Ripster - Job abgeschlossen',
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}`
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4510,6 +4833,9 @@ class PipelineService extends EventEmitter {
|
||||
const preRipTrackSelectionPayload = hasPreRipConfirmedSelection
|
||||
? extractManualSelectionPayloadFromPlan(preRipPlanBeforeRip)
|
||||
: null;
|
||||
const preRipPostEncodeScriptIds = hasPreRipConfirmedSelection
|
||||
? normalizeScriptIdList(preRipPlanBeforeRip?.postEncodeScriptIds || [])
|
||||
: [];
|
||||
const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job);
|
||||
const selectedTitleId = playlistDecision.selectedTitleId;
|
||||
const selectedPlaylist = playlistDecision.selectedPlaylist;
|
||||
@@ -4693,7 +5019,8 @@ class PipelineService extends EventEmitter {
|
||||
);
|
||||
await this.confirmEncodeReview(jobId, {
|
||||
selectedEncodeTitleId: review?.encodeInputTitleId || null,
|
||||
selectedTrackSelection: preRipTrackSelectionPayload || null
|
||||
selectedTrackSelection: preRipTrackSelectionPayload || null,
|
||||
selectedPostEncodeScriptIds: preRipPostEncodeScriptIds
|
||||
});
|
||||
const autoStartResult = await this.startPreparedJob(jobId);
|
||||
logger.info('rip:auto-encode-started', {
|
||||
|
||||
435
backend/src/services/scriptService.js
Normal file
435
backend/src/services/scriptService.js
Normal file
@@ -0,0 +1,435 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('SCRIPTS');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const SCRIPT_NAME_MAX_LENGTH = 120;
|
||||
const SCRIPT_BODY_MAX_LENGTH = 200000;
|
||||
const SCRIPT_TEST_TIMEOUT_MS = 120000;
|
||||
const SCRIPT_OUTPUT_MAX_CHARS = 150000;
|
||||
|
||||
function normalizeScriptId(rawValue) {
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
function normalizeScriptIdList(rawList) {
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const item of list) {
|
||||
const normalized = normalizeScriptId(item);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const key = String(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeScriptName(rawValue) {
|
||||
return String(rawValue || '').trim();
|
||||
}
|
||||
|
||||
function normalizeScriptBody(rawValue) {
|
||||
return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
}
|
||||
|
||||
function createValidationError(message, details = null) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
if (details) {
|
||||
error.details = details;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function validateScriptPayload(payload, { partial = false } = {}) {
|
||||
const body = payload && typeof payload === 'object' ? payload : {};
|
||||
const hasName = Object.prototype.hasOwnProperty.call(body, 'name');
|
||||
const hasScriptBody = Object.prototype.hasOwnProperty.call(body, 'scriptBody');
|
||||
const normalized = {};
|
||||
const errors = [];
|
||||
|
||||
if (!partial || hasName) {
|
||||
const name = normalizeScriptName(body.name);
|
||||
if (!name) {
|
||||
errors.push({ field: 'name', message: 'Name darf nicht leer sein.' });
|
||||
} else if (name.length > SCRIPT_NAME_MAX_LENGTH) {
|
||||
errors.push({ field: 'name', message: `Name darf maximal ${SCRIPT_NAME_MAX_LENGTH} Zeichen enthalten.` });
|
||||
} else {
|
||||
normalized.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!partial || hasScriptBody) {
|
||||
const scriptBody = normalizeScriptBody(body.scriptBody);
|
||||
if (!scriptBody.trim()) {
|
||||
errors.push({ field: 'scriptBody', message: 'Skript darf nicht leer sein.' });
|
||||
} else if (scriptBody.length > SCRIPT_BODY_MAX_LENGTH) {
|
||||
errors.push({ field: 'scriptBody', message: `Skript darf maximal ${SCRIPT_BODY_MAX_LENGTH} Zeichen enthalten.` });
|
||||
} else {
|
||||
normalized.scriptBody = scriptBody;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw createValidationError('Skript ist ungültig.', errors);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapScriptRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: String(row.name || ''),
|
||||
scriptBody: String(row.script_body || ''),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function quoteForBashSingle(value) {
|
||||
return `'${String(value || '').replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function buildScriptEnvironment(context = {}) {
|
||||
const now = new Date().toISOString();
|
||||
const entries = {
|
||||
RIPSTER_SCRIPT_RUN_AT: now,
|
||||
RIPSTER_JOB_ID: context?.jobId ?? '',
|
||||
RIPSTER_JOB_TITLE: context?.jobTitle ?? '',
|
||||
RIPSTER_MODE: context?.mode ?? '',
|
||||
RIPSTER_INPUT_PATH: context?.inputPath ?? '',
|
||||
RIPSTER_OUTPUT_PATH: context?.outputPath ?? '',
|
||||
RIPSTER_RAW_PATH: context?.rawPath ?? '',
|
||||
RIPSTER_SCRIPT_ID: context?.scriptId ?? '',
|
||||
RIPSTER_SCRIPT_NAME: context?.scriptName ?? '',
|
||||
RIPSTER_SCRIPT_SOURCE: context?.source ?? ''
|
||||
};
|
||||
|
||||
const output = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
output[key] = String(value ?? '');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildScriptWrapper(scriptBody, context = {}) {
|
||||
const envVars = buildScriptEnvironment(context);
|
||||
const exportLines = Object.entries(envVars)
|
||||
.map(([key, value]) => `export ${key}=${quoteForBashSingle(value)}`)
|
||||
.join('\n');
|
||||
// Wait for potential background jobs started by the script before returning.
|
||||
return `${exportLines}\n\n${String(scriptBody || '')}\n\nwait\n`;
|
||||
}
|
||||
|
||||
function appendWithCap(current, chunk, maxChars) {
|
||||
const value = String(chunk || '');
|
||||
if (!value) {
|
||||
return { value: current, truncated: false };
|
||||
}
|
||||
const currentText = String(current || '');
|
||||
if (currentText.length >= maxChars) {
|
||||
return { value: currentText, truncated: true };
|
||||
}
|
||||
const available = maxChars - currentText.length;
|
||||
if (value.length <= available) {
|
||||
return { value: `${currentText}${value}`, truncated: false };
|
||||
}
|
||||
return {
|
||||
value: `${currentText}${value.slice(0, available)}`,
|
||||
truncated: true
|
||||
};
|
||||
}
|
||||
|
||||
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd() }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
let timedOut = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, 2000);
|
||||
}, Math.max(1000, Number(timeoutMs || SCRIPT_TEST_TIMEOUT_MS)));
|
||||
|
||||
const onData = (streamName, chunk) => {
|
||||
if (streamName === 'stdout') {
|
||||
const next = appendWithCap(stdout, chunk, SCRIPT_OUTPUT_MAX_CHARS);
|
||||
stdout = next.value;
|
||||
stdoutTruncated = stdoutTruncated || next.truncated;
|
||||
} else {
|
||||
const next = appendWithCap(stderr, chunk, SCRIPT_OUTPUT_MAX_CHARS);
|
||||
stderr = next.value;
|
||||
stderrTruncated = stderrTruncated || next.truncated;
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on('data', (chunk) => onData('stdout', chunk));
|
||||
child.stderr?.on('data', (chunk) => onData('stderr', chunk));
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
const endedAt = Date.now();
|
||||
resolve({
|
||||
code: Number.isFinite(Number(code)) ? Number(code) : null,
|
||||
signal: signal || null,
|
||||
durationMs: Math.max(0, endedAt - startedAt),
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
stdoutTruncated,
|
||||
stderrTruncated
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class ScriptService {
|
||||
async listScripts() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, script_body, created_at, updated_at
|
||||
FROM scripts
|
||||
ORDER BY LOWER(name) ASC, id ASC
|
||||
`
|
||||
);
|
||||
return rows.map(mapScriptRow);
|
||||
}
|
||||
|
||||
async getScriptById(scriptId) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const db = await getDb();
|
||||
const row = await db.get(
|
||||
`
|
||||
SELECT id, name, script_body, created_at, updated_at
|
||||
FROM scripts
|
||||
WHERE id = ?
|
||||
`,
|
||||
[normalizedId]
|
||||
);
|
||||
if (!row) {
|
||||
const error = new Error(`Skript #${normalizedId} wurde nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return mapScriptRow(row);
|
||||
}
|
||||
|
||||
async createScript(payload = {}) {
|
||||
const normalized = validateScriptPayload(payload, { partial: false });
|
||||
const db = await getDb();
|
||||
try {
|
||||
const result = await db.run(
|
||||
`
|
||||
INSERT INTO scripts (name, script_body, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`,
|
||||
[normalized.name, normalized.scriptBody]
|
||||
);
|
||||
return this.getScriptById(result.lastID);
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
|
||||
{ field: 'name', message: 'Name muss eindeutig sein.' }
|
||||
]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateScript(scriptId, payload = {}) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const normalized = validateScriptPayload(payload, { partial: false });
|
||||
const db = await getDb();
|
||||
await this.getScriptById(normalizedId);
|
||||
try {
|
||||
await db.run(
|
||||
`
|
||||
UPDATE scripts
|
||||
SET
|
||||
name = ?,
|
||||
script_body = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
[normalized.name, normalized.scriptBody, normalizedId]
|
||||
);
|
||||
} catch (error) {
|
||||
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
|
||||
{ field: 'name', message: 'Name muss eindeutig sein.' }
|
||||
]);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.getScriptById(normalizedId);
|
||||
}
|
||||
|
||||
async deleteScript(scriptId) {
|
||||
const normalizedId = normalizeScriptId(scriptId);
|
||||
if (!normalizedId) {
|
||||
throw createValidationError('Ungültige scriptId.');
|
||||
}
|
||||
const db = await getDb();
|
||||
const existing = await this.getScriptById(normalizedId);
|
||||
await db.run('DELETE FROM scripts WHERE id = ?', [normalizedId]);
|
||||
return existing;
|
||||
}
|
||||
|
||||
async getScriptsByIds(rawIds = []) {
|
||||
const ids = normalizeScriptIdList(rawIds);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const db = await getDb();
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT id, name, script_body, created_at, updated_at
|
||||
FROM scripts
|
||||
WHERE id IN (${placeholders})
|
||||
`,
|
||||
ids
|
||||
);
|
||||
const byId = new Map(rows.map((row) => [Number(row.id), mapScriptRow(row)]));
|
||||
return ids.map((id) => byId.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
async resolveScriptsByIds(rawIds = [], options = {}) {
|
||||
const ids = normalizeScriptIdList(rawIds);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const strict = options?.strict !== false;
|
||||
const scripts = await this.getScriptsByIds(ids);
|
||||
if (!strict) {
|
||||
return scripts;
|
||||
}
|
||||
const foundIds = new Set(scripts.map((item) => Number(item.id)));
|
||||
const missing = ids.filter((id) => !foundIds.has(Number(id)));
|
||||
if (missing.length > 0) {
|
||||
throw createValidationError(`Skript(e) nicht gefunden: ${missing.join(', ')}`, [
|
||||
{ field: 'selectedPostEncodeScriptIds', message: `Nicht gefunden: ${missing.join(', ')}` }
|
||||
]);
|
||||
}
|
||||
return scripts;
|
||||
}
|
||||
|
||||
async createExecutableScriptFile(script, context = {}) {
|
||||
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
|
||||
const scriptBody = normalizeScriptBody(script?.scriptBody);
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-'));
|
||||
const scriptPath = path.join(tempDir, 'script.sh');
|
||||
const wrapped = buildScriptWrapper(scriptBody, {
|
||||
...context,
|
||||
scriptId: script?.id ?? context?.scriptId ?? '',
|
||||
scriptName: name,
|
||||
source: context?.source || 'post_encode'
|
||||
});
|
||||
|
||||
await fs.promises.writeFile(scriptPath, wrapped, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o700
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
logger.warn('script:temp-cleanup-failed', {
|
||||
scriptId: script?.id ?? null,
|
||||
scriptName: name,
|
||||
tempDir,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
scriptPath,
|
||||
cmd: '/usr/bin/env',
|
||||
args: ['bash', scriptPath],
|
||||
argsForLog: ['bash', `<script:${name}>`],
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
|
||||
async testScript(scriptId, options = {}) {
|
||||
const script = await this.getScriptById(scriptId);
|
||||
const timeoutMs = Number(options?.timeoutMs);
|
||||
const prepared = await this.createExecutableScriptFile(script, {
|
||||
source: 'settings_test',
|
||||
mode: 'test'
|
||||
});
|
||||
|
||||
try {
|
||||
const run = await runProcessCapture({
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS
|
||||
});
|
||||
const success = run.code === 0 && !run.timedOut;
|
||||
return {
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
success,
|
||||
exitCode: run.code,
|
||||
signal: run.signal,
|
||||
timedOut: run.timedOut,
|
||||
durationMs: run.durationMs,
|
||||
stdout: run.stdout,
|
||||
stderr: run.stderr,
|
||||
stdoutTruncated: run.stdoutTruncated,
|
||||
stderrTruncated: run.stderrTruncated
|
||||
};
|
||||
} finally {
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ScriptService();
|
||||
@@ -14,6 +14,7 @@ const { splitArgs } = require('../utils/commandLine');
|
||||
const { setLogRootDir } = require('./logPathService');
|
||||
|
||||
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
||||
const HANDBRAKE_PRESET_LIST_TIMEOUT_MS = 30000;
|
||||
const SENSITIVE_SETTING_KEYS = new Set([
|
||||
'makemkv_registration_key',
|
||||
'omdb_api_key',
|
||||
@@ -132,6 +133,200 @@ function buildFallbackPresetProfile(presetName, message = null) {
|
||||
};
|
||||
}
|
||||
|
||||
function stripAnsiEscapeCodes(value) {
|
||||
return String(value || '').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
||||
}
|
||||
|
||||
function uniqueOrderedValues(values) {
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
for (const value of values || []) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
unique.push(normalized);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function uniquePresetEntries(entries) {
|
||||
const unique = [];
|
||||
const seenNames = new Set();
|
||||
for (const entry of entries || []) {
|
||||
const name = String(entry?.name || '').trim();
|
||||
if (!name || seenNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
seenNames.add(name);
|
||||
const categoryRaw = entry?.category;
|
||||
const category = categoryRaw === null || categoryRaw === undefined
|
||||
? null
|
||||
: String(categoryRaw).trim() || null;
|
||||
unique.push({ name, category });
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function normalizePresetListLines(rawOutput) {
|
||||
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||
const normalized = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const sanitized = stripAnsiEscapeCodes(line || '').replace(/\r/g, '');
|
||||
if (!sanitized.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\[[^\]]+\]/.test(sanitized)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
/^\s*(Cannot load|Compile-time|qsv:|HandBrake \d|Opening |No title found|libhb:|hb_init:|thread |bdj\.c:|stream:|scan:|bd:|libdvdnav:|libdvdread:)/i
|
||||
.test(sanitized)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (/^\s*HandBrake has exited\.?\s*$/i.test(sanitized)) {
|
||||
continue;
|
||||
}
|
||||
const leadingWhitespace = (sanitized.match(/^[\t ]*/) || [''])[0];
|
||||
const indentation = leadingWhitespace.replace(/\t/g, ' ').length;
|
||||
const text = sanitized.trim();
|
||||
normalized.push({ indentation, text });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parsePlusTreePresetEntries(lines) {
|
||||
const plusEntries = [];
|
||||
for (const line of lines || []) {
|
||||
const match = String(line?.text || '').match(/^\+\s+(.+?)\s*$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
plusEntries.push({
|
||||
indentation: Number(line?.indentation || 0),
|
||||
name: String(match[1] || '').trim()
|
||||
});
|
||||
}
|
||||
|
||||
if (plusEntries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const leafEntries = [];
|
||||
for (let index = 0; index < plusEntries.length; index += 1) {
|
||||
const current = plusEntries[index];
|
||||
const next = plusEntries[index + 1];
|
||||
const hasChildren = Boolean(next) && next.indentation > current.indentation;
|
||||
if (!hasChildren) {
|
||||
let category = null;
|
||||
for (let parentIndex = index - 1; parentIndex >= 0; parentIndex -= 1) {
|
||||
const candidate = plusEntries[parentIndex];
|
||||
if (candidate.indentation < current.indentation) {
|
||||
category = candidate.name || null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
leafEntries.push({
|
||||
name: current.name,
|
||||
category
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return uniquePresetEntries(leafEntries);
|
||||
}
|
||||
|
||||
function parseSlashTreePresetEntries(lines) {
|
||||
const list = Array.isArray(lines) ? lines : [];
|
||||
const presetEntries = [];
|
||||
let currentCategoryIndent = null;
|
||||
let currentCategoryName = null;
|
||||
let currentPresetIndent = null;
|
||||
|
||||
for (const line of list) {
|
||||
const indentation = Number(line?.indentation || 0);
|
||||
const text = String(line?.text || '').trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (text.endsWith('/')) {
|
||||
currentCategoryIndent = indentation;
|
||||
currentCategoryName = String(text.slice(0, -1) || '').trim() || null;
|
||||
currentPresetIndent = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentCategoryIndent === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indentation <= currentCategoryIndent) {
|
||||
currentCategoryIndent = null;
|
||||
currentCategoryName = null;
|
||||
currentPresetIndent = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentPresetIndent === null) {
|
||||
currentPresetIndent = indentation;
|
||||
}
|
||||
|
||||
if (indentation === currentPresetIndent) {
|
||||
presetEntries.push({
|
||||
name: text,
|
||||
category: currentCategoryName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return uniquePresetEntries(presetEntries);
|
||||
}
|
||||
|
||||
function parseHandBrakePresetEntriesFromListOutput(rawOutput) {
|
||||
const lines = normalizePresetListLines(rawOutput);
|
||||
const plusTreeEntries = parsePlusTreePresetEntries(lines);
|
||||
if (plusTreeEntries.length > 0) {
|
||||
return plusTreeEntries;
|
||||
}
|
||||
return parseSlashTreePresetEntries(lines);
|
||||
}
|
||||
|
||||
function mapPresetEntriesToOptions(entries) {
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
const options = [];
|
||||
const seenCategories = new Set();
|
||||
const INDENT = '\u00A0\u00A0\u00A0';
|
||||
|
||||
for (const entry of list) {
|
||||
const name = String(entry?.name || '').trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const category = entry?.category ? String(entry.category).trim() : '';
|
||||
if (category && !seenCategories.has(category)) {
|
||||
seenCategories.add(category);
|
||||
options.push({
|
||||
label: `${category}/`,
|
||||
value: `__group__${category.toLowerCase().replace(/\s+/g, '_')}`,
|
||||
disabled: true,
|
||||
category
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: category ? `${INDENT}${name}` : name,
|
||||
value: name,
|
||||
category: category || null
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
class SettingsService {
|
||||
async getSchemaRows() {
|
||||
const db = await getDb();
|
||||
@@ -705,6 +900,85 @@ class SettingsService {
|
||||
|
||||
return `disc:${map.makemkv_source_index ?? 0}`;
|
||||
}
|
||||
|
||||
async getHandBrakePresetOptions() {
|
||||
const map = await this.getSettingsMap();
|
||||
const configuredPreset = String(map.handbrake_preset || '').trim();
|
||||
const fallbackOptions = configuredPreset
|
||||
? [{ label: configuredPreset, value: configuredPreset }]
|
||||
: [];
|
||||
const rawCommand = String(map.handbrake_command || 'HandBrakeCLI').trim();
|
||||
const commandTokens = splitArgs(rawCommand);
|
||||
const cmd = commandTokens[0] || 'HandBrakeCLI';
|
||||
const baseArgs = commandTokens.slice(1);
|
||||
const args = [...baseArgs, '-z'];
|
||||
|
||||
try {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: HANDBRAKE_PRESET_LIST_TIMEOUT_MS,
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
source: 'fallback',
|
||||
message: `Preset-Liste konnte nicht geladen werden: ${result.error.message}`,
|
||||
options: fallbackOptions
|
||||
};
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = String(result.stderr || '').trim();
|
||||
const stdout = String(result.stdout || '').trim();
|
||||
const detail = (stderr || stdout || `exit=${result.status}`).slice(0, 280);
|
||||
return {
|
||||
source: 'fallback',
|
||||
message: `Preset-Liste konnte nicht geladen werden (${detail})`,
|
||||
options: fallbackOptions
|
||||
};
|
||||
}
|
||||
|
||||
const combinedOutput = `${String(result.stdout || '')}\n${String(result.stderr || '')}`;
|
||||
const entries = parseHandBrakePresetEntriesFromListOutput(combinedOutput);
|
||||
const options = mapPresetEntriesToOptions(entries);
|
||||
if (options.length === 0) {
|
||||
return {
|
||||
source: 'fallback',
|
||||
message: 'Preset-Liste konnte aus HandBrakeCLI -z nicht geparst werden.',
|
||||
options: fallbackOptions
|
||||
};
|
||||
}
|
||||
if (!configuredPreset) {
|
||||
return {
|
||||
source: 'handbrake-cli',
|
||||
message: null,
|
||||
options
|
||||
};
|
||||
}
|
||||
|
||||
const hasConfiguredPreset = options.some((option) => option.value === configuredPreset);
|
||||
if (hasConfiguredPreset) {
|
||||
return {
|
||||
source: 'handbrake-cli',
|
||||
message: null,
|
||||
options
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'handbrake-cli',
|
||||
message: `Aktuell gesetztes Preset "${configuredPreset}" wurde in HandBrakeCLI -z nicht gefunden.`,
|
||||
options: [{ label: configuredPreset, value: configuredPreset }, ...options]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
source: 'fallback',
|
||||
message: `Preset-Liste konnte nicht geladen werden: ${error.message}`,
|
||||
options: fallbackOptions
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SettingsService();
|
||||
|
||||
Reference in New Issue
Block a user