final dev
This commit is contained in:
@@ -9,6 +9,7 @@ const logger = require('./logger').child('CRON');
|
||||
const notificationService = require('./notificationService');
|
||||
const settingsService = require('./settingsService');
|
||||
const wsService = require('./websocketService');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
// Maximale Zeilen pro Log-Eintrag (Output-Truncation)
|
||||
@@ -203,6 +204,12 @@ async function fetchAllJobsWithSource(db) {
|
||||
async function runCronJob(job) {
|
||||
const db = await getDb();
|
||||
const startedAt = new Date().toISOString();
|
||||
const cronActivityId = runtimeActivityService.startActivity('cron', {
|
||||
name: job?.name || `Cron #${job?.id || '?'}`,
|
||||
source: 'cron',
|
||||
cronJobId: job?.id || null,
|
||||
currentStep: 'Starte Cronjob'
|
||||
});
|
||||
|
||||
logger.info('cron:run:start', { cronJobId: job.id, name: job.name, sourceType: job.sourceType, sourceId: job.sourceId });
|
||||
|
||||
@@ -228,9 +235,23 @@ async function runCronJob(job) {
|
||||
if (job.sourceType === 'script') {
|
||||
const scriptService = require('./scriptService');
|
||||
const script = await scriptService.getScriptById(job.sourceId);
|
||||
const prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id });
|
||||
|
||||
runtimeActivityService.updateActivity(cronActivityId, {
|
||||
currentStepType: 'script',
|
||||
currentStep: `Skript: ${script.name}`,
|
||||
currentScriptName: script.name,
|
||||
scriptId: script.id
|
||||
});
|
||||
const scriptActivityId = runtimeActivityService.startActivity('script', {
|
||||
name: script.name,
|
||||
source: 'cron',
|
||||
scriptId: script.id,
|
||||
cronJobId: job.id,
|
||||
parentActivityId: cronActivityId,
|
||||
currentStep: `Cronjob: ${job.name}`
|
||||
});
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id });
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const { spawn } = require('child_process');
|
||||
const child = spawn(prepared.cmd, prepared.args, {
|
||||
@@ -249,15 +270,58 @@ async function runCronJob(job) {
|
||||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||
success = result.code === 0;
|
||||
if (!success) errorMessage = `Exit-Code ${result.code}`;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
exitCode: result.code,
|
||||
message: success ? null : errorMessage,
|
||||
output: output || null,
|
||||
stdout: result.stdout || null,
|
||||
stderr: result.stderr || null,
|
||||
errorMessage: success ? null : (errorMessage || null)
|
||||
});
|
||||
} catch (error) {
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
message: error?.message || 'Skriptfehler',
|
||||
errorMessage: error?.message || 'Skriptfehler'
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await prepared.cleanup();
|
||||
if (prepared?.cleanup) {
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
} else if (job.sourceType === 'chain') {
|
||||
const scriptChainService = require('./scriptChainService');
|
||||
const logLines = [];
|
||||
runtimeActivityService.updateActivity(cronActivityId, {
|
||||
currentStepType: 'chain',
|
||||
currentStep: `Kette: ${job.sourceName || `#${job.sourceId}`}`,
|
||||
currentScriptName: null,
|
||||
chainId: job.sourceId
|
||||
});
|
||||
const result = await scriptChainService.executeChain(
|
||||
job.sourceId,
|
||||
{ source: 'cron', cronJobId: job.id },
|
||||
{
|
||||
source: 'cron',
|
||||
cronJobId: job.id,
|
||||
runtimeParentActivityId: cronActivityId,
|
||||
onRuntimeStep: (payload = {}) => {
|
||||
const currentScriptName = payload?.stepType === 'script'
|
||||
? (payload?.scriptName || payload?.currentScriptName || null)
|
||||
: null;
|
||||
runtimeActivityService.updateActivity(cronActivityId, {
|
||||
currentStepType: payload?.stepType || 'chain',
|
||||
currentStep: payload?.currentStep || null,
|
||||
currentScriptName,
|
||||
scriptId: payload?.scriptId || null
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
appendLog: async (_source, line) => {
|
||||
logLines.push(line);
|
||||
@@ -267,7 +331,9 @@ async function runCronJob(job) {
|
||||
|
||||
output = logLines.join('\n');
|
||||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||
success = Array.isArray(result) ? result.every((r) => r.success !== false) : Boolean(result);
|
||||
success = result && typeof result === 'object'
|
||||
? !(Boolean(result.aborted) || Number(result.failed || 0) > 0)
|
||||
: Boolean(result);
|
||||
if (!success) errorMessage = 'Kette enthielt fehlgeschlagene Schritte.';
|
||||
} else {
|
||||
throw new Error(`Unbekannter source_type: ${job.sourceType}`);
|
||||
@@ -307,6 +373,17 @@ async function runCronJob(job) {
|
||||
);
|
||||
|
||||
logger.info('cron:run:done', { cronJobId: job.id, status, durationMs: new Date(finishedAt) - new Date(startedAt) });
|
||||
runtimeActivityService.completeActivity(cronActivityId, {
|
||||
status,
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
finishedAt,
|
||||
currentStep: null,
|
||||
currentScriptName: null,
|
||||
message: success ? 'Cronjob abgeschlossen' : (errorMessage || 'Cronjob fehlgeschlagen'),
|
||||
output: output || null,
|
||||
errorMessage: success ? null : (errorMessage || null)
|
||||
});
|
||||
|
||||
wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: status, lastRunAt: finishedAt, nextRunAt });
|
||||
|
||||
|
||||
@@ -47,13 +47,35 @@ function inspectDirectory(dirPath) {
|
||||
};
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(dirPath);
|
||||
// Fast path: only determine whether directory is empty, avoid loading all entries.
|
||||
let firstEntry = null;
|
||||
let openError = null;
|
||||
try {
|
||||
const dir = fs.opendirSync(dirPath);
|
||||
try {
|
||||
firstEntry = dir.readSync();
|
||||
} finally {
|
||||
dir.closeSync();
|
||||
}
|
||||
} catch (error) {
|
||||
openError = error;
|
||||
}
|
||||
if (openError) {
|
||||
const entries = fs.readdirSync(dirPath);
|
||||
return {
|
||||
path: dirPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isEmpty: entries.length === 0,
|
||||
entryCount: entries.length
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: dirPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isEmpty: entries.length === 0,
|
||||
entryCount: entries.length
|
||||
isEmpty: !firstEntry,
|
||||
entryCount: firstEntry ? null : 0
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -378,14 +400,40 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|
||||
};
|
||||
}
|
||||
|
||||
function enrichJobRow(job, settings = null) {
|
||||
function buildUnknownDirectoryStatus(dirPath = null) {
|
||||
return {
|
||||
path: dirPath || null,
|
||||
exists: null,
|
||||
isDirectory: null,
|
||||
isEmpty: null,
|
||||
entryCount: null
|
||||
};
|
||||
}
|
||||
|
||||
function buildUnknownFileStatus(filePath = null) {
|
||||
return {
|
||||
path: filePath || null,
|
||||
exists: null,
|
||||
isFile: null,
|
||||
sizeBytes: null
|
||||
};
|
||||
}
|
||||
|
||||
function enrichJobRow(job, settings = null, options = {}) {
|
||||
const includeFsChecks = options?.includeFsChecks !== false;
|
||||
const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null);
|
||||
const omdbInfo = parseJsonSafe(job.omdb_json, null);
|
||||
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
||||
const rawStatus = inspectDirectory(resolvedPaths.effectiveRawPath);
|
||||
const outputStatus = inspectOutputFile(resolvedPaths.effectiveOutputPath);
|
||||
const rawStatus = includeFsChecks
|
||||
? inspectDirectory(resolvedPaths.effectiveRawPath)
|
||||
: buildUnknownDirectoryStatus(resolvedPaths.effectiveRawPath);
|
||||
const outputStatus = includeFsChecks
|
||||
? inspectOutputFile(resolvedPaths.effectiveOutputPath)
|
||||
: buildUnknownFileStatus(resolvedPaths.effectiveOutputPath);
|
||||
const movieDirPath = resolvedPaths.effectiveOutputPath ? path.dirname(resolvedPaths.effectiveOutputPath) : null;
|
||||
const movieDirStatus = inspectDirectory(movieDirPath);
|
||||
const movieDirStatus = includeFsChecks
|
||||
? inspectDirectory(movieDirPath)
|
||||
: buildUnknownDirectoryStatus(movieDirPath);
|
||||
const makemkvInfo = resolvedPaths.makemkvInfo;
|
||||
const mediainfoInfo = resolvedPaths.mediainfoInfo;
|
||||
const encodePlan = resolvedPaths.encodePlan;
|
||||
@@ -750,8 +798,25 @@ class HistoryService {
|
||||
const db = await getDb();
|
||||
const where = [];
|
||||
const values = [];
|
||||
const includeFsChecks = filters?.includeFsChecks !== false;
|
||||
const rawStatuses = Array.isArray(filters?.statuses)
|
||||
? filters.statuses
|
||||
: (typeof filters?.statuses === 'string'
|
||||
? String(filters.statuses).split(',')
|
||||
: []);
|
||||
const normalizedStatuses = rawStatuses
|
||||
.map((value) => String(value || '').trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
const limitRaw = Number(filters?.limit);
|
||||
const limit = Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.trunc(limitRaw), 500)
|
||||
: 500;
|
||||
|
||||
if (filters.status) {
|
||||
if (normalizedStatuses.length > 0) {
|
||||
const placeholders = normalizedStatuses.map(() => '?').join(', ');
|
||||
where.push(`status IN (${placeholders})`);
|
||||
values.push(...normalizedStatuses);
|
||||
} else if (filters.status) {
|
||||
where.push('status = ?');
|
||||
values.push(filters.status);
|
||||
}
|
||||
@@ -770,7 +835,7 @@ class HistoryService {
|
||||
FROM jobs j
|
||||
${whereClause}
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT 500
|
||||
LIMIT ${limit}
|
||||
`,
|
||||
values
|
||||
),
|
||||
@@ -778,8 +843,8 @@ class HistoryService {
|
||||
]);
|
||||
|
||||
return jobs.map((job) => ({
|
||||
...enrichJobRow(job, settings),
|
||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||
...enrichJobRow(job, settings, { includeFsChecks }),
|
||||
log_count: includeFsChecks ? (hasProcessLogFile(job.id) ? 1 : 0) : 0
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -852,6 +917,7 @@ class HistoryService {
|
||||
|
||||
async getJobWithLogs(jobId, options = {}) {
|
||||
const db = await getDb();
|
||||
const includeFsChecks = options?.includeFsChecks !== false;
|
||||
const [job, settings] = await Promise.all([
|
||||
db.get('SELECT * FROM jobs WHERE id = ?', [jobId]),
|
||||
settingsService.getSettingsMap()
|
||||
@@ -868,12 +934,12 @@ class HistoryService {
|
||||
const includeLogs = Boolean(options.includeLogs);
|
||||
const includeAllLogs = Boolean(options.includeAllLogs);
|
||||
const shouldLoadLogs = includeLiveLog || includeLogs;
|
||||
const hasProcessLog = hasProcessLogFile(jobId);
|
||||
const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false;
|
||||
const baseLogCount = hasProcessLog ? 1 : 0;
|
||||
|
||||
if (!shouldLoadLogs) {
|
||||
return {
|
||||
...enrichJobRow(job, settings),
|
||||
...enrichJobRow(job, settings, { includeFsChecks }),
|
||||
log_count: baseLogCount,
|
||||
logs: [],
|
||||
log: '',
|
||||
@@ -892,7 +958,7 @@ class HistoryService {
|
||||
});
|
||||
|
||||
return {
|
||||
...enrichJobRow(job, settings),
|
||||
...enrichJobRow(job, settings, { includeFsChecks }),
|
||||
log_count: processLog.exists ? processLog.total : 0,
|
||||
logs: [],
|
||||
log: processLog.lines.join('\n'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
248
backend/src/services/runtimeActivityService.js
Normal file
248
backend/src/services/runtimeActivityService.js
Normal file
@@ -0,0 +1,248 @@
|
||||
const wsService = require('./websocketService');
|
||||
|
||||
const MAX_RECENT_ACTIVITIES = 120;
|
||||
const MAX_ACTIVITY_OUTPUT_CHARS = 12000;
|
||||
const MAX_ACTIVITY_TEXT_CHARS = 2000;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeText(value, { trim = true, maxChars = MAX_ACTIVITY_TEXT_CHARS } = {}) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
let text = String(value);
|
||||
if (trim) {
|
||||
text = text.trim();
|
||||
}
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
if (text.length > maxChars) {
|
||||
const suffix = trim ? ' ...[gekürzt]' : '\n...[gekürzt]';
|
||||
text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function sanitizeActivity(input = {}) {
|
||||
const source = input && typeof input === 'object' ? input : {};
|
||||
const normalizedOutcome = normalizeText(source.outcome, { trim: true, maxChars: 40 });
|
||||
return {
|
||||
id: normalizeNumber(source.id),
|
||||
type: String(source.type || '').trim().toLowerCase() || 'task',
|
||||
name: String(source.name || '').trim() || null,
|
||||
status: String(source.status || '').trim().toLowerCase() || 'running',
|
||||
source: String(source.source || '').trim() || null,
|
||||
message: String(source.message || '').trim() || null,
|
||||
currentStep: String(source.currentStep || '').trim() || null,
|
||||
currentStepType: String(source.currentStepType || '').trim() || null,
|
||||
currentScriptName: String(source.currentScriptName || '').trim() || null,
|
||||
stepIndex: normalizeNumber(source.stepIndex),
|
||||
stepTotal: normalizeNumber(source.stepTotal),
|
||||
parentActivityId: normalizeNumber(source.parentActivityId),
|
||||
jobId: normalizeNumber(source.jobId),
|
||||
cronJobId: normalizeNumber(source.cronJobId),
|
||||
chainId: normalizeNumber(source.chainId),
|
||||
scriptId: normalizeNumber(source.scriptId),
|
||||
canCancel: Boolean(source.canCancel),
|
||||
canNextStep: Boolean(source.canNextStep),
|
||||
outcome: normalizedOutcome ? String(normalizedOutcome).toLowerCase() : null,
|
||||
errorMessage: normalizeText(source.errorMessage, { trim: true, maxChars: MAX_ACTIVITY_TEXT_CHARS }),
|
||||
output: normalizeText(source.output, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
|
||||
stdout: normalizeText(source.stdout, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
|
||||
stderr: normalizeText(source.stderr, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
|
||||
stdoutTruncated: Boolean(source.stdoutTruncated),
|
||||
stderrTruncated: Boolean(source.stderrTruncated),
|
||||
startedAt: source.startedAt || nowIso(),
|
||||
finishedAt: source.finishedAt || null,
|
||||
durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
|
||||
exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
|
||||
success: source.success === null || source.success === undefined ? null : Boolean(source.success)
|
||||
};
|
||||
}
|
||||
|
||||
class RuntimeActivityService {
|
||||
constructor() {
|
||||
this.nextId = 1;
|
||||
this.active = new Map();
|
||||
this.recent = [];
|
||||
this.controls = new Map();
|
||||
}
|
||||
|
||||
buildSnapshot() {
|
||||
const active = Array.from(this.active.values())
|
||||
.sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
|
||||
const recent = [...this.recent]
|
||||
.sort((a, b) => String(b.finishedAt || b.startedAt || '').localeCompare(String(a.finishedAt || a.startedAt || '')));
|
||||
return {
|
||||
active,
|
||||
recent,
|
||||
updatedAt: nowIso()
|
||||
};
|
||||
}
|
||||
|
||||
broadcastSnapshot() {
|
||||
wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
|
||||
}
|
||||
|
||||
startActivity(type, payload = {}) {
|
||||
const id = this.nextId;
|
||||
this.nextId += 1;
|
||||
const activity = sanitizeActivity({
|
||||
...payload,
|
||||
id,
|
||||
type,
|
||||
status: 'running',
|
||||
outcome: 'running',
|
||||
startedAt: payload?.startedAt || nowIso(),
|
||||
finishedAt: null,
|
||||
durationMs: null,
|
||||
canCancel: Boolean(payload?.canCancel),
|
||||
canNextStep: Boolean(payload?.canNextStep)
|
||||
});
|
||||
this.active.set(id, activity);
|
||||
this.broadcastSnapshot();
|
||||
return id;
|
||||
}
|
||||
|
||||
updateActivity(activityId, patch = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
const current = this.active.get(id);
|
||||
const next = sanitizeActivity({
|
||||
...current,
|
||||
...patch,
|
||||
id: current.id,
|
||||
type: current.type,
|
||||
status: current.status === 'running' ? (patch?.status || current.status) : current.status,
|
||||
startedAt: current.startedAt
|
||||
});
|
||||
this.active.set(id, next);
|
||||
this.broadcastSnapshot();
|
||||
return next;
|
||||
}
|
||||
|
||||
completeActivity(activityId, payload = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
const current = this.active.get(id);
|
||||
const finishedAt = payload?.finishedAt || nowIso();
|
||||
const startedAtDate = new Date(current.startedAt);
|
||||
const finishedAtDate = new Date(finishedAt);
|
||||
const durationMs = Number.isFinite(startedAtDate.getTime()) && Number.isFinite(finishedAtDate.getTime())
|
||||
? Math.max(0, finishedAtDate.getTime() - startedAtDate.getTime())
|
||||
: null;
|
||||
const status = String(payload?.status || '').trim().toLowerCase() || (payload?.success === false ? 'error' : 'success');
|
||||
let outcome = String(payload?.outcome || '').trim().toLowerCase();
|
||||
if (!outcome) {
|
||||
if (Boolean(payload?.cancelled)) {
|
||||
outcome = 'cancelled';
|
||||
} else if (Boolean(payload?.skipped)) {
|
||||
outcome = 'skipped';
|
||||
} else {
|
||||
outcome = status === 'success' ? 'success' : 'error';
|
||||
}
|
||||
}
|
||||
const finalized = sanitizeActivity({
|
||||
...current,
|
||||
...payload,
|
||||
id: current.id,
|
||||
type: current.type,
|
||||
status,
|
||||
outcome,
|
||||
canCancel: false,
|
||||
canNextStep: false,
|
||||
finishedAt,
|
||||
durationMs
|
||||
});
|
||||
this.active.delete(id);
|
||||
this.controls.delete(id);
|
||||
this.recent.unshift(finalized);
|
||||
if (this.recent.length > MAX_RECENT_ACTIVITIES) {
|
||||
this.recent = this.recent.slice(0, MAX_RECENT_ACTIVITIES);
|
||||
}
|
||||
this.broadcastSnapshot();
|
||||
return finalized;
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
setControls(activityId, handlers = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return null;
|
||||
}
|
||||
const safeHandlers = {
|
||||
cancel: typeof handlers?.cancel === 'function' ? handlers.cancel : null,
|
||||
nextStep: typeof handlers?.nextStep === 'function' ? handlers.nextStep : null
|
||||
};
|
||||
this.controls.set(id, safeHandlers);
|
||||
return this.updateActivity(id, {
|
||||
canCancel: Boolean(safeHandlers.cancel),
|
||||
canNextStep: Boolean(safeHandlers.nextStep)
|
||||
});
|
||||
}
|
||||
|
||||
async invokeControl(activityId, control, payload = {}) {
|
||||
const id = normalizeNumber(activityId);
|
||||
if (!id || !this.active.has(id)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Aktivität nicht gefunden oder bereits abgeschlossen.'
|
||||
};
|
||||
}
|
||||
const handlers = this.controls.get(id) || {};
|
||||
const key = control === 'nextStep' ? 'nextStep' : 'cancel';
|
||||
const fn = handlers[key];
|
||||
if (typeof fn !== 'function') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'UNSUPPORTED',
|
||||
message: key === 'nextStep'
|
||||
? 'Nächster-Schritt ist für diese Aktivität nicht verfügbar.'
|
||||
: 'Abbrechen ist für diese Aktivität nicht verfügbar.'
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await fn(payload);
|
||||
return {
|
||||
ok: true,
|
||||
code: 'OK',
|
||||
result: result && typeof result === 'object' ? result : null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'FAILED',
|
||||
message: error?.message || 'Aktion fehlgeschlagen.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async requestCancel(activityId, payload = {}) {
|
||||
return this.invokeControl(activityId, 'cancel', payload);
|
||||
}
|
||||
|
||||
async requestNextStep(activityId, payload = {}) {
|
||||
return this.invokeControl(activityId, 'nextStep', payload);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RuntimeActivityService();
|
||||
@@ -1,6 +1,7 @@
|
||||
const { spawn } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('SCRIPT_CHAINS');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const CHAIN_NAME_MAX_LENGTH = 120;
|
||||
@@ -53,6 +54,29 @@ function mapStepRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function terminateChildProcess(child) {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
const forceKillTimer = setTimeout(() => {
|
||||
try {
|
||||
if (!child.killed) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
}, 2000);
|
||||
if (typeof forceKillTimer.unref === 'function') {
|
||||
forceKillTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
function validateSteps(rawSteps) {
|
||||
const steps = Array.isArray(rawSteps) ? rawSteps : [];
|
||||
const errors = [];
|
||||
@@ -382,102 +406,460 @@ class ScriptChainService {
|
||||
async executeChain(chainId, context = {}, { appendLog = null } = {}) {
|
||||
const chain = await this.getChainById(chainId);
|
||||
logger.info('chain:execute:start', { chainId, chainName: chain.name, steps: chain.steps.length });
|
||||
const totalSteps = chain.steps.length;
|
||||
const activityId = runtimeActivityService.startActivity('chain', {
|
||||
name: chain.name,
|
||||
source: context?.source || 'chain',
|
||||
chainId: chain.id,
|
||||
jobId: context?.jobId || null,
|
||||
cronJobId: context?.cronJobId || null,
|
||||
parentActivityId: context?.runtimeParentActivityId || null,
|
||||
currentStep: totalSteps > 0 ? `Schritt 1/${totalSteps}` : 'Keine Schritte'
|
||||
});
|
||||
const controlState = {
|
||||
cancelRequested: false,
|
||||
cancelReason: null,
|
||||
currentStepType: null,
|
||||
activeWaitResolve: null,
|
||||
activeChild: null,
|
||||
activeChildTermination: null
|
||||
};
|
||||
const emitRuntimeStep = (payload = {}) => {
|
||||
if (typeof context?.onRuntimeStep !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.onRuntimeStep({
|
||||
chainId: chain.id,
|
||||
chainName: chain.name,
|
||||
...payload
|
||||
});
|
||||
} catch (_error) {
|
||||
// ignore runtime callback errors
|
||||
}
|
||||
};
|
||||
const requestCancel = async (payload = {}) => {
|
||||
if (controlState.cancelRequested) {
|
||||
return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' };
|
||||
}
|
||||
controlState.cancelRequested = true;
|
||||
controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen';
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
message: 'Abbruch angefordert',
|
||||
currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures for control actions
|
||||
}
|
||||
}
|
||||
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
|
||||
controlState.activeWaitResolve('cancel');
|
||||
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
|
||||
controlState.activeChildTermination = 'cancel';
|
||||
terminateChildProcess(controlState.activeChild);
|
||||
}
|
||||
return { accepted: true, message: 'Abbruch angefordert.' };
|
||||
};
|
||||
const requestNextStep = async () => {
|
||||
if (controlState.cancelRequested) {
|
||||
return { accepted: false, message: 'Kette wird bereits abgebrochen.' };
|
||||
}
|
||||
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
|
||||
controlState.activeWaitResolve('skip');
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
message: 'Nächster Schritt angefordert (Wait übersprungen)'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Wait-Schritt manuell übersprungen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures for control actions
|
||||
}
|
||||
}
|
||||
return { accepted: true, message: 'Wait-Schritt übersprungen.' };
|
||||
}
|
||||
if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
|
||||
controlState.activeChildTermination = 'skip';
|
||||
terminateChildProcess(controlState.activeChild);
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript-Schritt manuell übersprungen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures for control actions
|
||||
}
|
||||
}
|
||||
return { accepted: true, message: 'Skript-Schritt wird übersprungen.' };
|
||||
}
|
||||
return { accepted: false, message: 'Kein aktiver Schritt zum Überspringen.' };
|
||||
};
|
||||
runtimeActivityService.setControls(activityId, {
|
||||
cancel: requestCancel,
|
||||
nextStep: requestNextStep
|
||||
});
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const step of chain.steps) {
|
||||
if (step.stepType === STEP_TYPE_WAIT) {
|
||||
const seconds = Math.max(1, Number(step.waitSeconds || 1));
|
||||
logger.info('chain:step:wait', { chainId, seconds });
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`);
|
||||
let completionPayload = null;
|
||||
let abortedByUser = false;
|
||||
try {
|
||||
for (let index = 0; index < chain.steps.length; index += 1) {
|
||||
if (controlState.cancelRequested) {
|
||||
abortedByUser = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
results.push({ stepType: 'wait', waitSeconds: seconds, success: true });
|
||||
} else if (step.stepType === STEP_TYPE_SCRIPT) {
|
||||
if (!step.scriptId) {
|
||||
logger.warn('chain:step:script-missing', { chainId, stepId: step.id });
|
||||
results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const scriptService = require('./scriptService');
|
||||
let script;
|
||||
try {
|
||||
script = await scriptService.getScriptById(step.scriptId);
|
||||
} catch (error) {
|
||||
logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
|
||||
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`);
|
||||
}
|
||||
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, {
|
||||
...context,
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
source: context?.source || 'chain'
|
||||
const step = chain.steps[index];
|
||||
const stepIndex = index + 1;
|
||||
if (step.stepType === STEP_TYPE_WAIT) {
|
||||
const seconds = Math.max(1, Number(step.waitSeconds || 1));
|
||||
const waitLabel = `Warte ${seconds} Sekunde(n)`;
|
||||
controlState.currentStepType = STEP_TYPE_WAIT;
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
currentStepType: 'wait',
|
||||
currentStep: waitLabel,
|
||||
currentScriptName: null,
|
||||
stepIndex,
|
||||
stepTotal: totalSteps
|
||||
});
|
||||
const run = await new Promise((resolve, reject) => {
|
||||
const child = spawn(prepared.cmd, prepared.args, {
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||||
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
emitRuntimeStep({
|
||||
stepType: 'wait',
|
||||
stepIndex,
|
||||
stepTotal: totalSteps,
|
||||
currentStep: waitLabel
|
||||
});
|
||||
|
||||
const success = run.code === 0;
|
||||
logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success });
|
||||
logger.info('chain:step:wait', { chainId, seconds });
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog(
|
||||
success ? 'SYSTEM' : 'ERROR',
|
||||
`Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}`
|
||||
);
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`);
|
||||
}
|
||||
results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' });
|
||||
|
||||
if (!success) {
|
||||
logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code });
|
||||
const waitOutcome = await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
controlState.activeWaitResolve = null;
|
||||
resolve('done');
|
||||
}, seconds * 1000);
|
||||
controlState.activeWaitResolve = (mode = 'done') => {
|
||||
clearTimeout(timer);
|
||||
controlState.activeWaitResolve = null;
|
||||
resolve(mode);
|
||||
};
|
||||
});
|
||||
controlState.currentStepType = null;
|
||||
if (waitOutcome === 'skip') {
|
||||
results.push({ stepType: 'wait', waitSeconds: seconds, success: true, skipped: true, reason: 'skipped_by_user' });
|
||||
continue;
|
||||
}
|
||||
if (waitOutcome === 'cancel' || controlState.cancelRequested) {
|
||||
abortedByUser = true;
|
||||
results.push({ stepType: 'wait', waitSeconds: seconds, success: false, aborted: true, reason: 'cancelled_by_user' });
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`);
|
||||
results.push({ stepType: 'wait', waitSeconds: seconds, success: true });
|
||||
} else if (step.stepType === STEP_TYPE_SCRIPT) {
|
||||
if (!step.scriptId) {
|
||||
logger.warn('chain:step:script-missing', { chainId, stepId: step.id });
|
||||
results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' });
|
||||
continue;
|
||||
}
|
||||
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message });
|
||||
break;
|
||||
} finally {
|
||||
if (prepared?.cleanup) {
|
||||
await prepared.cleanup();
|
||||
|
||||
const scriptService = require('./scriptService');
|
||||
let script;
|
||||
try {
|
||||
script = await scriptService.getScriptById(step.scriptId);
|
||||
} catch (error) {
|
||||
logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
|
||||
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' });
|
||||
continue;
|
||||
}
|
||||
|
||||
controlState.currentStepType = STEP_TYPE_SCRIPT;
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
currentStepType: 'script',
|
||||
currentStep: `Skript: ${script.name}`,
|
||||
currentScriptName: script.name,
|
||||
stepIndex,
|
||||
stepTotal: totalSteps,
|
||||
scriptId: script.id
|
||||
});
|
||||
emitRuntimeStep({
|
||||
stepType: 'script',
|
||||
stepIndex,
|
||||
stepTotal: totalSteps,
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
currentScriptName: script.name,
|
||||
currentStep: `Skript: ${script.name}`
|
||||
});
|
||||
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`);
|
||||
}
|
||||
|
||||
const scriptActivityId = runtimeActivityService.startActivity('script', {
|
||||
name: script.name,
|
||||
source: context?.source || 'chain',
|
||||
scriptId: script.id,
|
||||
chainId: chain.id,
|
||||
jobId: context?.jobId || null,
|
||||
cronJobId: context?.cronJobId || null,
|
||||
parentActivityId: activityId,
|
||||
currentStep: `Kette: ${chain.name}`
|
||||
});
|
||||
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, {
|
||||
...context,
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
source: context?.source || 'chain'
|
||||
});
|
||||
const run = await new Promise((resolve, reject) => {
|
||||
const child = spawn(prepared.cmd, prepared.args, {
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
controlState.activeChild = child;
|
||||
controlState.activeChildTermination = null;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||||
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||||
child.on('error', (error) => {
|
||||
controlState.activeChild = null;
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
const termination = controlState.activeChildTermination;
|
||||
controlState.activeChild = null;
|
||||
controlState.activeChildTermination = null;
|
||||
resolve({ code, signal, stdout, stderr, termination });
|
||||
});
|
||||
});
|
||||
controlState.currentStepType = null;
|
||||
|
||||
if (run.termination === 'skip') {
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'success',
|
||||
success: true,
|
||||
outcome: 'skipped',
|
||||
skipped: true,
|
||||
currentStep: null,
|
||||
message: 'Schritt übersprungen',
|
||||
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" übersprungen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures on skip path
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
stepType: 'script',
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'skipped_by_user'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (run.termination === 'cancel' || controlState.cancelRequested) {
|
||||
abortedByUser = true;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'cancelled',
|
||||
cancelled: true,
|
||||
currentStep: null,
|
||||
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
|
||||
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
|
||||
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures on cancel path
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
stepType: 'script',
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
success: false,
|
||||
aborted: true,
|
||||
reason: 'cancelled_by_user'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const success = run.code === 0;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
exitCode: run.code,
|
||||
currentStep: null,
|
||||
message: success ? null : `Fehler (Exit ${run.code})`,
|
||||
output: success ? null : [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
|
||||
stderr: success ? null : (run.stderr || null),
|
||||
stdout: success ? null : (run.stdout || null),
|
||||
errorMessage: success ? null : `Fehler (Exit ${run.code})`
|
||||
});
|
||||
logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success });
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog(
|
||||
success ? 'SYSTEM' : 'ERROR',
|
||||
`Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}`
|
||||
);
|
||||
}
|
||||
results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' });
|
||||
|
||||
if (!success) {
|
||||
logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code });
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
controlState.currentStepType = null;
|
||||
if (controlState.cancelRequested) {
|
||||
abortedByUser = true;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'cancelled',
|
||||
cancelled: true,
|
||||
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
|
||||
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
|
||||
});
|
||||
if (typeof appendLog === 'function') {
|
||||
try {
|
||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`);
|
||||
} catch (_error) {
|
||||
// ignore appendLog failures on cancel path
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
stepType: 'script',
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
success: false,
|
||||
aborted: true,
|
||||
reason: 'cancelled_by_user'
|
||||
});
|
||||
break;
|
||||
}
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
message: error?.message || 'unknown',
|
||||
errorMessage: error?.message || 'unknown'
|
||||
});
|
||||
logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
|
||||
if (typeof appendLog === 'function') {
|
||||
await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`);
|
||||
}
|
||||
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message });
|
||||
break;
|
||||
} finally {
|
||||
controlState.activeChild = null;
|
||||
controlState.activeChildTermination = null;
|
||||
if (prepared?.cleanup) {
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const skipped = results.filter((r) => r.skipped).length;
|
||||
const failed = results.filter((r) => !r.success && !r.skipped && !r.aborted).length;
|
||||
logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed, skipped, abortedByUser });
|
||||
if (abortedByUser) {
|
||||
completionPayload = {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'cancelled',
|
||||
cancelled: true,
|
||||
currentStep: null,
|
||||
currentScriptName: null,
|
||||
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
|
||||
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
|
||||
};
|
||||
emitRuntimeStep({
|
||||
finished: true,
|
||||
success: false,
|
||||
aborted: true,
|
||||
failed,
|
||||
succeeded
|
||||
});
|
||||
return {
|
||||
chainId,
|
||||
chainName: chain.name,
|
||||
steps: results.length,
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
aborted: true,
|
||||
abortedByUser: true,
|
||||
results
|
||||
};
|
||||
}
|
||||
completionPayload = {
|
||||
status: failed > 0 ? 'error' : 'success',
|
||||
success: failed === 0,
|
||||
outcome: failed > 0 ? 'error' : (skipped > 0 ? 'skipped' : 'success'),
|
||||
skipped: skipped > 0,
|
||||
currentStep: null,
|
||||
currentScriptName: null,
|
||||
message: failed > 0
|
||||
? `${failed} Schritt(e) fehlgeschlagen`
|
||||
: (skipped > 0
|
||||
? `${succeeded} Schritt(e) erfolgreich, ${skipped} übersprungen`
|
||||
: `${succeeded} Schritt(e) erfolgreich`)
|
||||
};
|
||||
emitRuntimeStep({
|
||||
finished: true,
|
||||
success: failed === 0,
|
||||
failed,
|
||||
succeeded
|
||||
});
|
||||
|
||||
return {
|
||||
chainId,
|
||||
chainName: chain.name,
|
||||
steps: results.length,
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
aborted: failed > 0,
|
||||
results
|
||||
};
|
||||
} catch (error) {
|
||||
completionPayload = {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
message: error?.message || 'unknown',
|
||||
errorMessage: error?.message || 'unknown',
|
||||
currentStep: null
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
runtimeActivityService.completeActivity(activityId, completionPayload || {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: 'error',
|
||||
message: 'Kette unerwartet beendet',
|
||||
errorMessage: 'Kette unerwartet beendet',
|
||||
currentStep: null
|
||||
});
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success && !r.skipped).length;
|
||||
logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed });
|
||||
|
||||
return {
|
||||
chainId,
|
||||
chainName: chain.name,
|
||||
steps: results.length,
|
||||
succeeded,
|
||||
failed,
|
||||
aborted: failed > 0,
|
||||
results
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('SCRIPTS');
|
||||
const runtimeActivityService = require('./runtimeActivityService');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
|
||||
const SCRIPT_NAME_MAX_LENGTH = 120;
|
||||
@@ -159,7 +160,7 @@ function appendWithCap(current, chunk, maxChars) {
|
||||
};
|
||||
}
|
||||
|
||||
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd() }) {
|
||||
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const child = spawn(cmd, args, {
|
||||
@@ -167,6 +168,13 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
if (typeof onChild === 'function') {
|
||||
try {
|
||||
onChild(child);
|
||||
} catch (_error) {
|
||||
// ignore observer errors
|
||||
}
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
@@ -473,18 +481,89 @@ class ScriptService {
|
||||
async testScript(scriptId, options = {}) {
|
||||
const script = await this.getScriptById(scriptId);
|
||||
const timeoutMs = Number(options?.timeoutMs);
|
||||
const effectiveTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS;
|
||||
const prepared = await this.createExecutableScriptFile(script, {
|
||||
source: 'settings_test',
|
||||
mode: 'test'
|
||||
});
|
||||
const activityId = runtimeActivityService.startActivity('script', {
|
||||
name: script.name,
|
||||
source: 'settings_test',
|
||||
scriptId: script.id,
|
||||
currentStep: 'Skript-Test läuft'
|
||||
});
|
||||
const controlState = {
|
||||
cancelRequested: false,
|
||||
cancelReason: null,
|
||||
child: null
|
||||
};
|
||||
runtimeActivityService.setControls(activityId, {
|
||||
cancel: async (payload = {}) => {
|
||||
if (controlState.cancelRequested) {
|
||||
return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' };
|
||||
}
|
||||
controlState.cancelRequested = true;
|
||||
controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen';
|
||||
runtimeActivityService.updateActivity(activityId, {
|
||||
message: 'Abbruch angefordert'
|
||||
});
|
||||
if (controlState.child) {
|
||||
try {
|
||||
controlState.child.kill('SIGTERM');
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
const forceKillTimer = setTimeout(() => {
|
||||
try {
|
||||
if (controlState.child && !controlState.child.killed) {
|
||||
controlState.child.kill('SIGKILL');
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
}, 2000);
|
||||
if (typeof forceKillTimer.unref === 'function') {
|
||||
forceKillTimer.unref();
|
||||
}
|
||||
}
|
||||
return { accepted: true, message: 'Abbruch angefordert.' };
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const run = await runProcessCapture({
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
onChild: (child) => {
|
||||
controlState.child = child;
|
||||
}
|
||||
});
|
||||
const cancelledByUser = controlState.cancelRequested;
|
||||
const success = !cancelledByUser && run.code === 0 && !run.timedOut;
|
||||
runtimeActivityService.completeActivity(activityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: cancelledByUser ? 'cancelled' : (success ? 'success' : 'error'),
|
||||
cancelled: cancelledByUser,
|
||||
exitCode: Number.isFinite(Number(run.code)) ? Number(run.code) : null,
|
||||
stdout: run.stdout || null,
|
||||
stderr: run.stderr || null,
|
||||
stdoutTruncated: Boolean(run.stdoutTruncated),
|
||||
stderrTruncated: Boolean(run.stderrTruncated),
|
||||
errorMessage: !success
|
||||
? (cancelledByUser
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (run.timedOut
|
||||
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
|
||||
: `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`))
|
||||
: null,
|
||||
message: cancelledByUser
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (run.timedOut
|
||||
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
|
||||
: (success ? 'Skript-Test abgeschlossen' : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`))
|
||||
});
|
||||
const success = run.code === 0 && !run.timedOut;
|
||||
return {
|
||||
scriptId: script.id,
|
||||
scriptName: script.name,
|
||||
@@ -498,7 +577,22 @@ class ScriptService {
|
||||
stdoutTruncated: run.stdoutTruncated,
|
||||
stderrTruncated: run.stderrTruncated
|
||||
};
|
||||
} catch (error) {
|
||||
runtimeActivityService.completeActivity(activityId, {
|
||||
status: 'error',
|
||||
success: false,
|
||||
outcome: controlState.cancelRequested ? 'cancelled' : 'error',
|
||||
cancelled: Boolean(controlState.cancelRequested),
|
||||
errorMessage: controlState.cancelRequested
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (error?.message || 'Skript-Test Fehler'),
|
||||
message: controlState.cancelRequested
|
||||
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
|
||||
: (error?.message || 'Skript-Test Fehler')
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
controlState.child = null;
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('SETTINGS');
|
||||
const {
|
||||
@@ -15,6 +15,14 @@ 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 SETTINGS_CACHE_TTL_MS = 15000;
|
||||
const HANDBRAKE_PRESET_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([
|
||||
'handbrake_command',
|
||||
'handbrake_preset',
|
||||
'handbrake_preset_bluray',
|
||||
'handbrake_preset_dvd'
|
||||
]);
|
||||
const SENSITIVE_SETTING_KEYS = new Set([
|
||||
'makemkv_registration_key',
|
||||
'omdb_api_key',
|
||||
@@ -230,6 +238,92 @@ function uniqueOrderedValues(values) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
function normalizeSettingKey(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function runCommandCapture(cmd, args = [], options = {}) {
|
||||
const timeoutMs = Math.max(0, Number(options.timeout || 0));
|
||||
const maxBuffer = Math.max(1024, Number(options.maxBuffer || 8 * 1024 * 1024));
|
||||
const argv = Array.isArray(args) ? args : [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let timer = null;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let totalBytes = 0;
|
||||
|
||||
const finish = (handler, payload) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
handler(payload);
|
||||
};
|
||||
|
||||
const child = spawn(cmd, argv, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const appendChunk = (chunk, target) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8');
|
||||
totalBytes += Buffer.byteLength(text, 'utf-8');
|
||||
if (totalBytes > maxBuffer) {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch (_error) {
|
||||
// ignore kill errors
|
||||
}
|
||||
finish(reject, new Error(`Command output exceeded ${maxBuffer} bytes.`));
|
||||
return;
|
||||
}
|
||||
if (target === 'stdout') {
|
||||
stdout += text;
|
||||
} else {
|
||||
stderr += text;
|
||||
}
|
||||
};
|
||||
|
||||
child.on('error', (error) => finish(reject, error));
|
||||
child.on('close', (status, signal) => {
|
||||
finish(resolve, {
|
||||
status,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr
|
||||
});
|
||||
});
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on('data', (chunk) => appendChunk(chunk, 'stdout'));
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (chunk) => appendChunk(chunk, 'stderr'));
|
||||
}
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch (_error) {
|
||||
// ignore kill errors
|
||||
}
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function uniquePresetEntries(entries) {
|
||||
const unique = [];
|
||||
const seenNames = new Set();
|
||||
@@ -466,20 +560,112 @@ function mapPresetEntriesToOptions(entries) {
|
||||
}
|
||||
|
||||
class SettingsService {
|
||||
constructor() {
|
||||
this.settingsSnapshotCache = {
|
||||
expiresAt: 0,
|
||||
snapshot: null,
|
||||
inFlight: null
|
||||
};
|
||||
this.handBrakePresetCache = {
|
||||
expiresAt: 0,
|
||||
cacheKey: null,
|
||||
payload: null,
|
||||
inFlight: null
|
||||
};
|
||||
}
|
||||
|
||||
buildSettingsSnapshot(flat = []) {
|
||||
const list = Array.isArray(flat) ? flat : [];
|
||||
const map = {};
|
||||
const byCategory = new Map();
|
||||
|
||||
for (const item of list) {
|
||||
map[item.key] = item.value;
|
||||
if (!byCategory.has(item.category)) {
|
||||
byCategory.set(item.category, []);
|
||||
}
|
||||
byCategory.get(item.category).push(item);
|
||||
}
|
||||
|
||||
return {
|
||||
flat: list,
|
||||
map,
|
||||
categorized: Array.from(byCategory.entries()).map(([category, settings]) => ({
|
||||
category,
|
||||
settings
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
invalidateHandBrakePresetCache() {
|
||||
this.handBrakePresetCache = {
|
||||
expiresAt: 0,
|
||||
cacheKey: null,
|
||||
payload: null,
|
||||
inFlight: null
|
||||
};
|
||||
}
|
||||
|
||||
invalidateSettingsCache(changedKeys = []) {
|
||||
this.settingsSnapshotCache = {
|
||||
expiresAt: 0,
|
||||
snapshot: null,
|
||||
inFlight: null
|
||||
};
|
||||
const normalizedKeys = Array.isArray(changedKeys)
|
||||
? changedKeys.map((key) => normalizeSettingKey(key)).filter(Boolean)
|
||||
: [];
|
||||
const shouldInvalidatePresets = normalizedKeys.some((key) => HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS.has(key));
|
||||
if (shouldInvalidatePresets) {
|
||||
this.invalidateHandBrakePresetCache();
|
||||
}
|
||||
}
|
||||
|
||||
buildHandBrakePresetCacheKey(map = {}) {
|
||||
const source = map && typeof map === 'object' ? map : {};
|
||||
return JSON.stringify({
|
||||
cmd: String(source.handbrake_command || 'HandBrakeCLI').trim(),
|
||||
bluray: String(source.handbrake_preset_bluray || '').trim(),
|
||||
dvd: String(source.handbrake_preset_dvd || '').trim(),
|
||||
fallback: String(source.handbrake_preset || '').trim()
|
||||
});
|
||||
}
|
||||
|
||||
async getSettingsSnapshot(options = {}) {
|
||||
const forceRefresh = Boolean(options?.forceRefresh);
|
||||
const now = Date.now();
|
||||
|
||||
if (!forceRefresh && this.settingsSnapshotCache.snapshot && this.settingsSnapshotCache.expiresAt > now) {
|
||||
return this.settingsSnapshotCache.snapshot;
|
||||
}
|
||||
if (!forceRefresh && this.settingsSnapshotCache.inFlight) {
|
||||
return this.settingsSnapshotCache.inFlight;
|
||||
}
|
||||
|
||||
let loadPromise = null;
|
||||
loadPromise = (async () => {
|
||||
const flat = await this.fetchFlatSettingsFromDb();
|
||||
const snapshot = this.buildSettingsSnapshot(flat);
|
||||
this.settingsSnapshotCache.snapshot = snapshot;
|
||||
this.settingsSnapshotCache.expiresAt = Date.now() + SETTINGS_CACHE_TTL_MS;
|
||||
return snapshot;
|
||||
})().finally(() => {
|
||||
if (this.settingsSnapshotCache.inFlight === loadPromise) {
|
||||
this.settingsSnapshotCache.inFlight = null;
|
||||
}
|
||||
});
|
||||
this.settingsSnapshotCache.inFlight = loadPromise;
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
async getSchemaRows() {
|
||||
const db = await getDb();
|
||||
return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
|
||||
}
|
||||
|
||||
async getSettingsMap() {
|
||||
const rows = await this.getFlatSettings();
|
||||
const map = {};
|
||||
|
||||
for (const row of rows) {
|
||||
map[row.key] = row.value;
|
||||
}
|
||||
|
||||
return map;
|
||||
async getSettingsMap(options = {}) {
|
||||
const snapshot = await this.getSettingsSnapshot(options);
|
||||
return { ...(snapshot?.map || {}) };
|
||||
}
|
||||
|
||||
normalizeMediaProfile(value) {
|
||||
@@ -530,7 +716,7 @@ class SettingsService {
|
||||
return this.resolveEffectiveToolSettings(map, mediaProfile);
|
||||
}
|
||||
|
||||
async getFlatSettings() {
|
||||
async fetchFlatSettingsFromDb() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
@@ -567,21 +753,14 @@ class SettingsService {
|
||||
}));
|
||||
}
|
||||
|
||||
async getCategorizedSettings() {
|
||||
const flat = await this.getFlatSettings();
|
||||
const byCategory = new Map();
|
||||
async getFlatSettings(options = {}) {
|
||||
const snapshot = await this.getSettingsSnapshot(options);
|
||||
return Array.isArray(snapshot?.flat) ? [...snapshot.flat] : [];
|
||||
}
|
||||
|
||||
for (const item of flat) {
|
||||
if (!byCategory.has(item.category)) {
|
||||
byCategory.set(item.category, []);
|
||||
}
|
||||
byCategory.get(item.category).push(item);
|
||||
}
|
||||
|
||||
return Array.from(byCategory.entries()).map(([category, settings]) => ({
|
||||
category,
|
||||
settings
|
||||
}));
|
||||
async getCategorizedSettings(options = {}) {
|
||||
const snapshot = await this.getSettingsSnapshot(options);
|
||||
return Array.isArray(snapshot?.categorized) ? [...snapshot.categorized] : [];
|
||||
}
|
||||
|
||||
async setSettingValue(key, rawValue) {
|
||||
@@ -619,6 +798,7 @@ class SettingsService {
|
||||
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
|
||||
applyRuntimeLogDirSetting(result.normalized);
|
||||
}
|
||||
this.invalidateSettingsCache([key]);
|
||||
|
||||
return {
|
||||
key,
|
||||
@@ -702,6 +882,7 @@ class SettingsService {
|
||||
applyRuntimeLogDirSetting(logDirChange.value);
|
||||
}
|
||||
|
||||
this.invalidateSettingsCache(normalizedEntries.map((item) => item.key));
|
||||
logger.info('settings:bulk-updated', { count: normalizedEntries.length });
|
||||
return normalizedEntries.map((item) => ({
|
||||
key: item.key,
|
||||
@@ -1141,8 +1322,7 @@ class SettingsService {
|
||||
return `disc:${map.makemkv_source_index ?? 0}`;
|
||||
}
|
||||
|
||||
async getHandBrakePresetOptions() {
|
||||
const map = await this.getSettingsMap();
|
||||
async loadHandBrakePresetOptionsFromCli(map = {}) {
|
||||
const configuredPresets = uniqueOrderedValues([
|
||||
map.handbrake_preset_bluray,
|
||||
map.handbrake_preset_dvd,
|
||||
@@ -1156,21 +1336,20 @@ class SettingsService {
|
||||
const args = [...baseArgs, '-z'];
|
||||
|
||||
try {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: 'utf-8',
|
||||
const result = await runCommandCapture(cmd, args, {
|
||||
timeout: HANDBRAKE_PRESET_LIST_TIMEOUT_MS,
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.timedOut) {
|
||||
return {
|
||||
source: 'fallback',
|
||||
message: `Preset-Liste konnte nicht geladen werden: ${result.error.message}`,
|
||||
message: 'Preset-Liste konnte nicht geladen werden (Timeout).',
|
||||
options: fallbackOptions
|
||||
};
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (Number(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);
|
||||
@@ -1226,6 +1405,65 @@ class SettingsService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async refreshHandBrakePresetCache(map = null, cacheKey = null) {
|
||||
const resolvedMap = map && typeof map === 'object'
|
||||
? map
|
||||
: await this.getSettingsMap();
|
||||
const resolvedCacheKey = String(cacheKey || this.buildHandBrakePresetCacheKey(resolvedMap));
|
||||
this.handBrakePresetCache.cacheKey = resolvedCacheKey;
|
||||
|
||||
let loadPromise = null;
|
||||
loadPromise = this.loadHandBrakePresetOptionsFromCli(resolvedMap)
|
||||
.then((payload) => {
|
||||
this.handBrakePresetCache.payload = payload;
|
||||
this.handBrakePresetCache.cacheKey = resolvedCacheKey;
|
||||
this.handBrakePresetCache.expiresAt = Date.now() + HANDBRAKE_PRESET_CACHE_TTL_MS;
|
||||
return payload;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.handBrakePresetCache.inFlight === loadPromise) {
|
||||
this.handBrakePresetCache.inFlight = null;
|
||||
}
|
||||
});
|
||||
this.handBrakePresetCache.inFlight = loadPromise;
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
async getHandBrakePresetOptions(options = {}) {
|
||||
const forceRefresh = Boolean(options?.forceRefresh);
|
||||
const map = options?.settingsMap && typeof options.settingsMap === 'object'
|
||||
? options.settingsMap
|
||||
: await this.getSettingsMap();
|
||||
const cacheKey = this.buildHandBrakePresetCacheKey(map);
|
||||
const now = Date.now();
|
||||
|
||||
if (
|
||||
!forceRefresh
|
||||
&& this.handBrakePresetCache.payload
|
||||
&& this.handBrakePresetCache.cacheKey === cacheKey
|
||||
&& this.handBrakePresetCache.expiresAt > now
|
||||
) {
|
||||
return this.handBrakePresetCache.payload;
|
||||
}
|
||||
|
||||
if (
|
||||
!forceRefresh
|
||||
&& this.handBrakePresetCache.payload
|
||||
&& this.handBrakePresetCache.cacheKey === cacheKey
|
||||
) {
|
||||
if (!this.handBrakePresetCache.inFlight) {
|
||||
void this.refreshHandBrakePresetCache(map, cacheKey);
|
||||
}
|
||||
return this.handBrakePresetCache.payload;
|
||||
}
|
||||
|
||||
if (this.handBrakePresetCache.inFlight && this.handBrakePresetCache.cacheKey === cacheKey && !forceRefresh) {
|
||||
return this.handBrakePresetCache.inFlight;
|
||||
}
|
||||
|
||||
return this.refreshHandBrakePresetCache(map, cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SettingsService();
|
||||
|
||||
Reference in New Issue
Block a user