final dev
This commit is contained in:
@@ -11,6 +11,7 @@ const settingsRoutes = require('./routes/settingsRoutes');
|
|||||||
const pipelineRoutes = require('./routes/pipelineRoutes');
|
const pipelineRoutes = require('./routes/pipelineRoutes');
|
||||||
const historyRoutes = require('./routes/historyRoutes');
|
const historyRoutes = require('./routes/historyRoutes');
|
||||||
const cronRoutes = require('./routes/cronRoutes');
|
const cronRoutes = require('./routes/cronRoutes');
|
||||||
|
const runtimeRoutes = require('./routes/runtimeRoutes');
|
||||||
const wsService = require('./services/websocketService');
|
const wsService = require('./services/websocketService');
|
||||||
const pipelineService = require('./services/pipelineService');
|
const pipelineService = require('./services/pipelineService');
|
||||||
const cronService = require('./services/cronService');
|
const cronService = require('./services/cronService');
|
||||||
@@ -38,6 +39,7 @@ async function start() {
|
|||||||
app.use('/api/pipeline', pipelineRoutes);
|
app.use('/api/pipeline', pipelineRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
app.use('/api/crons', cronRoutes);
|
app.use('/api/crons', cronRoutes);
|
||||||
|
app.use('/api/runtime', runtimeRoutes);
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,30 @@ const router = express.Router();
|
|||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
const parsedLimit = Number(req.query.limit);
|
||||||
|
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
||||||
|
? Math.trunc(parsedLimit)
|
||||||
|
: null;
|
||||||
|
const statuses = String(req.query.statuses || '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => String(value || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
|
||||||
logger.info('get:jobs', {
|
logger.info('get:jobs', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
status: req.query.status,
|
status: req.query.status,
|
||||||
search: req.query.search
|
statuses: statuses.length > 0 ? statuses : null,
|
||||||
|
search: req.query.search,
|
||||||
|
limit,
|
||||||
|
lite
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobs = await historyService.getJobs({
|
const jobs = await historyService.getJobs({
|
||||||
status: req.query.status,
|
status: req.query.status,
|
||||||
search: req.query.search
|
statuses,
|
||||||
|
search: req.query.search,
|
||||||
|
limit,
|
||||||
|
includeFsChecks: !lite
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ jobs });
|
res.json({ jobs });
|
||||||
@@ -122,10 +137,12 @@ router.get(
|
|||||||
const includeLiveLog = ['1', 'true', 'yes'].includes(String(req.query.includeLiveLog || '').toLowerCase());
|
const includeLiveLog = ['1', 'true', 'yes'].includes(String(req.query.includeLiveLog || '').toLowerCase());
|
||||||
const includeLogs = ['1', 'true', 'yes'].includes(String(req.query.includeLogs || '').toLowerCase());
|
const includeLogs = ['1', 'true', 'yes'].includes(String(req.query.includeLogs || '').toLowerCase());
|
||||||
const includeAllLogs = ['1', 'true', 'yes'].includes(String(req.query.includeAllLogs || '').toLowerCase());
|
const includeAllLogs = ['1', 'true', 'yes'].includes(String(req.query.includeAllLogs || '').toLowerCase());
|
||||||
|
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
|
||||||
const parsedTail = Number(req.query.logTailLines);
|
const parsedTail = Number(req.query.logTailLines);
|
||||||
const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0
|
const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0
|
||||||
? Math.trunc(parsedTail)
|
? Math.trunc(parsedTail)
|
||||||
: null;
|
: null;
|
||||||
|
const includeFsChecks = !(lite || includeLiveLog);
|
||||||
|
|
||||||
logger.info('get:job-detail', {
|
logger.info('get:job-detail', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
@@ -133,13 +150,16 @@ router.get(
|
|||||||
includeLiveLog,
|
includeLiveLog,
|
||||||
includeLogs,
|
includeLogs,
|
||||||
includeAllLogs,
|
includeAllLogs,
|
||||||
logTailLines
|
logTailLines,
|
||||||
|
lite,
|
||||||
|
includeFsChecks
|
||||||
});
|
});
|
||||||
const job = await historyService.getJobWithLogs(id, {
|
const job = await historyService.getJobWithLogs(id, {
|
||||||
includeLiveLog,
|
includeLiveLog,
|
||||||
includeLogs,
|
includeLogs,
|
||||||
includeAllLogs,
|
includeAllLogs,
|
||||||
logTailLines
|
logTailLines,
|
||||||
|
includeFsChecks
|
||||||
});
|
});
|
||||||
if (!job) {
|
if (!job) {
|
||||||
const error = new Error('Job nicht gefunden.');
|
const error = new Error('Job nicht gefunden.');
|
||||||
|
|||||||
56
backend/src/routes/runtimeRoutes.js
Normal file
56
backend/src/routes/runtimeRoutes.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
|
const runtimeActivityService = require('../services/runtimeActivityService');
|
||||||
|
const logger = require('../services/logger').child('RUNTIME_ROUTE');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/activities',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:runtime:activities', { reqId: req.reqId });
|
||||||
|
const snapshot = runtimeActivityService.getSnapshot();
|
||||||
|
res.json(snapshot);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/activities/:id/cancel',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const activityId = Number(req.params.id);
|
||||||
|
const reason = String(req.body?.reason || '').trim() || null;
|
||||||
|
logger.info('post:runtime:activities:cancel', { reqId: req.reqId, activityId, reason });
|
||||||
|
const action = await runtimeActivityService.requestCancel(activityId, { reason });
|
||||||
|
if (!action?.ok) {
|
||||||
|
const error = new Error(action?.message || 'Abbrechen fehlgeschlagen.');
|
||||||
|
error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
action: action.result || null,
|
||||||
|
snapshot: runtimeActivityService.getSnapshot()
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/activities/:id/next-step',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const activityId = Number(req.params.id);
|
||||||
|
logger.info('post:runtime:activities:next-step', { reqId: req.reqId, activityId });
|
||||||
|
const action = await runtimeActivityService.requestNextStep(activityId, {});
|
||||||
|
if (!action?.ok) {
|
||||||
|
const error = new Error(action?.message || 'Nächster Schritt fehlgeschlagen.');
|
||||||
|
error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
action: action.result || null,
|
||||||
|
snapshot: runtimeActivityService.getSnapshot()
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -9,6 +9,7 @@ const logger = require('./logger').child('CRON');
|
|||||||
const notificationService = require('./notificationService');
|
const notificationService = require('./notificationService');
|
||||||
const settingsService = require('./settingsService');
|
const settingsService = require('./settingsService');
|
||||||
const wsService = require('./websocketService');
|
const wsService = require('./websocketService');
|
||||||
|
const runtimeActivityService = require('./runtimeActivityService');
|
||||||
const { errorToMeta } = require('../utils/errorMeta');
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
// Maximale Zeilen pro Log-Eintrag (Output-Truncation)
|
// Maximale Zeilen pro Log-Eintrag (Output-Truncation)
|
||||||
@@ -203,6 +204,12 @@ async function fetchAllJobsWithSource(db) {
|
|||||||
async function runCronJob(job) {
|
async function runCronJob(job) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const startedAt = new Date().toISOString();
|
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 });
|
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') {
|
if (job.sourceType === 'script') {
|
||||||
const scriptService = require('./scriptService');
|
const scriptService = require('./scriptService');
|
||||||
const script = await scriptService.getScriptById(job.sourceId);
|
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 {
|
try {
|
||||||
|
prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id });
|
||||||
const result = await new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const child = spawn(prepared.cmd, prepared.args, {
|
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]';
|
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||||
success = result.code === 0;
|
success = result.code === 0;
|
||||||
if (!success) errorMessage = `Exit-Code ${result.code}`;
|
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 {
|
} finally {
|
||||||
await prepared.cleanup();
|
if (prepared?.cleanup) {
|
||||||
|
await prepared.cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (job.sourceType === 'chain') {
|
} else if (job.sourceType === 'chain') {
|
||||||
const scriptChainService = require('./scriptChainService');
|
const scriptChainService = require('./scriptChainService');
|
||||||
const logLines = [];
|
const logLines = [];
|
||||||
|
runtimeActivityService.updateActivity(cronActivityId, {
|
||||||
|
currentStepType: 'chain',
|
||||||
|
currentStep: `Kette: ${job.sourceName || `#${job.sourceId}`}`,
|
||||||
|
currentScriptName: null,
|
||||||
|
chainId: job.sourceId
|
||||||
|
});
|
||||||
const result = await scriptChainService.executeChain(
|
const result = await scriptChainService.executeChain(
|
||||||
job.sourceId,
|
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) => {
|
appendLog: async (_source, line) => {
|
||||||
logLines.push(line);
|
logLines.push(line);
|
||||||
@@ -267,7 +331,9 @@ async function runCronJob(job) {
|
|||||||
|
|
||||||
output = logLines.join('\n');
|
output = logLines.join('\n');
|
||||||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
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.';
|
if (!success) errorMessage = 'Kette enthielt fehlgeschlagene Schritte.';
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unbekannter source_type: ${job.sourceType}`);
|
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) });
|
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 });
|
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 {
|
return {
|
||||||
path: dirPath,
|
path: dirPath,
|
||||||
exists: true,
|
exists: true,
|
||||||
isDirectory: true,
|
isDirectory: true,
|
||||||
isEmpty: entries.length === 0,
|
isEmpty: !firstEntry,
|
||||||
entryCount: entries.length
|
entryCount: firstEntry ? null : 0
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
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 handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null);
|
||||||
const omdbInfo = parseJsonSafe(job.omdb_json, null);
|
const omdbInfo = parseJsonSafe(job.omdb_json, null);
|
||||||
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
||||||
const rawStatus = inspectDirectory(resolvedPaths.effectiveRawPath);
|
const rawStatus = includeFsChecks
|
||||||
const outputStatus = inspectOutputFile(resolvedPaths.effectiveOutputPath);
|
? 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 movieDirPath = resolvedPaths.effectiveOutputPath ? path.dirname(resolvedPaths.effectiveOutputPath) : null;
|
||||||
const movieDirStatus = inspectDirectory(movieDirPath);
|
const movieDirStatus = includeFsChecks
|
||||||
|
? inspectDirectory(movieDirPath)
|
||||||
|
: buildUnknownDirectoryStatus(movieDirPath);
|
||||||
const makemkvInfo = resolvedPaths.makemkvInfo;
|
const makemkvInfo = resolvedPaths.makemkvInfo;
|
||||||
const mediainfoInfo = resolvedPaths.mediainfoInfo;
|
const mediainfoInfo = resolvedPaths.mediainfoInfo;
|
||||||
const encodePlan = resolvedPaths.encodePlan;
|
const encodePlan = resolvedPaths.encodePlan;
|
||||||
@@ -750,8 +798,25 @@ class HistoryService {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const where = [];
|
const where = [];
|
||||||
const values = [];
|
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 = ?');
|
where.push('status = ?');
|
||||||
values.push(filters.status);
|
values.push(filters.status);
|
||||||
}
|
}
|
||||||
@@ -770,7 +835,7 @@ class HistoryService {
|
|||||||
FROM jobs j
|
FROM jobs j
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY j.created_at DESC
|
ORDER BY j.created_at DESC
|
||||||
LIMIT 500
|
LIMIT ${limit}
|
||||||
`,
|
`,
|
||||||
values
|
values
|
||||||
),
|
),
|
||||||
@@ -778,8 +843,8 @@ class HistoryService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return jobs.map((job) => ({
|
return jobs.map((job) => ({
|
||||||
...enrichJobRow(job, settings),
|
...enrichJobRow(job, settings, { includeFsChecks }),
|
||||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
log_count: includeFsChecks ? (hasProcessLogFile(job.id) ? 1 : 0) : 0
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,6 +917,7 @@ class HistoryService {
|
|||||||
|
|
||||||
async getJobWithLogs(jobId, options = {}) {
|
async getJobWithLogs(jobId, options = {}) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
const includeFsChecks = options?.includeFsChecks !== false;
|
||||||
const [job, settings] = await Promise.all([
|
const [job, settings] = await Promise.all([
|
||||||
db.get('SELECT * FROM jobs WHERE id = ?', [jobId]),
|
db.get('SELECT * FROM jobs WHERE id = ?', [jobId]),
|
||||||
settingsService.getSettingsMap()
|
settingsService.getSettingsMap()
|
||||||
@@ -868,12 +934,12 @@ class HistoryService {
|
|||||||
const includeLogs = Boolean(options.includeLogs);
|
const includeLogs = Boolean(options.includeLogs);
|
||||||
const includeAllLogs = Boolean(options.includeAllLogs);
|
const includeAllLogs = Boolean(options.includeAllLogs);
|
||||||
const shouldLoadLogs = includeLiveLog || includeLogs;
|
const shouldLoadLogs = includeLiveLog || includeLogs;
|
||||||
const hasProcessLog = hasProcessLogFile(jobId);
|
const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false;
|
||||||
const baseLogCount = hasProcessLog ? 1 : 0;
|
const baseLogCount = hasProcessLog ? 1 : 0;
|
||||||
|
|
||||||
if (!shouldLoadLogs) {
|
if (!shouldLoadLogs) {
|
||||||
return {
|
return {
|
||||||
...enrichJobRow(job, settings),
|
...enrichJobRow(job, settings, { includeFsChecks }),
|
||||||
log_count: baseLogCount,
|
log_count: baseLogCount,
|
||||||
logs: [],
|
logs: [],
|
||||||
log: '',
|
log: '',
|
||||||
@@ -892,7 +958,7 @@ class HistoryService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...enrichJobRow(job, settings),
|
...enrichJobRow(job, settings, { includeFsChecks }),
|
||||||
log_count: processLog.exists ? processLog.total : 0,
|
log_count: processLog.exists ? processLog.total : 0,
|
||||||
logs: [],
|
logs: [],
|
||||||
log: processLog.lines.join('\n'),
|
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 { spawn } = require('child_process');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const logger = require('./logger').child('SCRIPT_CHAINS');
|
const logger = require('./logger').child('SCRIPT_CHAINS');
|
||||||
|
const runtimeActivityService = require('./runtimeActivityService');
|
||||||
const { errorToMeta } = require('../utils/errorMeta');
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
const CHAIN_NAME_MAX_LENGTH = 120;
|
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) {
|
function validateSteps(rawSteps) {
|
||||||
const steps = Array.isArray(rawSteps) ? rawSteps : [];
|
const steps = Array.isArray(rawSteps) ? rawSteps : [];
|
||||||
const errors = [];
|
const errors = [];
|
||||||
@@ -382,102 +406,460 @@ class ScriptChainService {
|
|||||||
async executeChain(chainId, context = {}, { appendLog = null } = {}) {
|
async executeChain(chainId, context = {}, { appendLog = null } = {}) {
|
||||||
const chain = await this.getChainById(chainId);
|
const chain = await this.getChainById(chainId);
|
||||||
logger.info('chain:execute:start', { chainId, chainName: chain.name, steps: chain.steps.length });
|
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 = [];
|
const results = [];
|
||||||
|
let completionPayload = null;
|
||||||
for (const step of chain.steps) {
|
let abortedByUser = false;
|
||||||
if (step.stepType === STEP_TYPE_WAIT) {
|
try {
|
||||||
const seconds = Math.max(1, Number(step.waitSeconds || 1));
|
for (let index = 0; index < chain.steps.length; index += 1) {
|
||||||
logger.info('chain:step:wait', { chainId, seconds });
|
if (controlState.cancelRequested) {
|
||||||
if (typeof appendLog === 'function') {
|
abortedByUser = true;
|
||||||
await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`);
|
break;
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
const step = chain.steps[index];
|
||||||
results.push({ stepType: 'wait', waitSeconds: seconds, success: true });
|
const stepIndex = index + 1;
|
||||||
} else if (step.stepType === STEP_TYPE_SCRIPT) {
|
if (step.stepType === STEP_TYPE_WAIT) {
|
||||||
if (!step.scriptId) {
|
const seconds = Math.max(1, Number(step.waitSeconds || 1));
|
||||||
logger.warn('chain:step:script-missing', { chainId, stepId: step.id });
|
const waitLabel = `Warte ${seconds} Sekunde(n)`;
|
||||||
results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' });
|
controlState.currentStepType = STEP_TYPE_WAIT;
|
||||||
continue;
|
runtimeActivityService.updateActivity(activityId, {
|
||||||
}
|
currentStepType: 'wait',
|
||||||
|
currentStep: waitLabel,
|
||||||
const scriptService = require('./scriptService');
|
currentScriptName: null,
|
||||||
let script;
|
stepIndex,
|
||||||
try {
|
stepTotal: totalSteps
|
||||||
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 run = await new Promise((resolve, reject) => {
|
emitRuntimeStep({
|
||||||
const child = spawn(prepared.cmd, prepared.args, {
|
stepType: 'wait',
|
||||||
env: process.env,
|
stepIndex,
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stepTotal: totalSteps,
|
||||||
});
|
currentStep: waitLabel
|
||||||
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 }));
|
|
||||||
});
|
});
|
||||||
|
logger.info('chain:step:wait', { chainId, seconds });
|
||||||
const success = run.code === 0;
|
|
||||||
logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success });
|
|
||||||
if (typeof appendLog === 'function') {
|
if (typeof appendLog === 'function') {
|
||||||
await appendLog(
|
await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`);
|
||||||
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 || '' });
|
const waitOutcome = await new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
if (!success) {
|
controlState.activeWaitResolve = null;
|
||||||
logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
results.push({ stepType: 'wait', waitSeconds: seconds, success: true });
|
||||||
logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
|
} else if (step.stepType === STEP_TYPE_SCRIPT) {
|
||||||
if (typeof appendLog === 'function') {
|
if (!step.scriptId) {
|
||||||
await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`);
|
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;
|
const scriptService = require('./scriptService');
|
||||||
} finally {
|
let script;
|
||||||
if (prepared?.cleanup) {
|
try {
|
||||||
await prepared.cleanup();
|
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 { spawn } = require('child_process');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const logger = require('./logger').child('SCRIPTS');
|
const logger = require('./logger').child('SCRIPTS');
|
||||||
|
const runtimeActivityService = require('./runtimeActivityService');
|
||||||
const { errorToMeta } = require('../utils/errorMeta');
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
const SCRIPT_NAME_MAX_LENGTH = 120;
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const child = spawn(cmd, args, {
|
const child = spawn(cmd, args, {
|
||||||
@@ -167,6 +168,13 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
|||||||
env: process.env,
|
env: process.env,
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
|
if (typeof onChild === 'function') {
|
||||||
|
try {
|
||||||
|
onChild(child);
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore observer errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
@@ -473,18 +481,89 @@ class ScriptService {
|
|||||||
async testScript(scriptId, options = {}) {
|
async testScript(scriptId, options = {}) {
|
||||||
const script = await this.getScriptById(scriptId);
|
const script = await this.getScriptById(scriptId);
|
||||||
const timeoutMs = Number(options?.timeoutMs);
|
const timeoutMs = Number(options?.timeoutMs);
|
||||||
|
const effectiveTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS;
|
||||||
const prepared = await this.createExecutableScriptFile(script, {
|
const prepared = await this.createExecutableScriptFile(script, {
|
||||||
source: 'settings_test',
|
source: 'settings_test',
|
||||||
mode: '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 {
|
try {
|
||||||
const run = await runProcessCapture({
|
const run = await runProcessCapture({
|
||||||
cmd: prepared.cmd,
|
cmd: prepared.cmd,
|
||||||
args: prepared.args,
|
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 {
|
return {
|
||||||
scriptId: script.id,
|
scriptId: script.id,
|
||||||
scriptName: script.name,
|
scriptName: script.name,
|
||||||
@@ -498,7 +577,22 @@ class ScriptService {
|
|||||||
stdoutTruncated: run.stdoutTruncated,
|
stdoutTruncated: run.stdoutTruncated,
|
||||||
stderrTruncated: run.stderrTruncated
|
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 {
|
} finally {
|
||||||
|
controlState.child = null;
|
||||||
await prepared.cleanup();
|
await prepared.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawnSync } = require('child_process');
|
const { spawn, spawnSync } = require('child_process');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const logger = require('./logger').child('SETTINGS');
|
const logger = require('./logger').child('SETTINGS');
|
||||||
const {
|
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 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 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([
|
const SENSITIVE_SETTING_KEYS = new Set([
|
||||||
'makemkv_registration_key',
|
'makemkv_registration_key',
|
||||||
'omdb_api_key',
|
'omdb_api_key',
|
||||||
@@ -230,6 +238,92 @@ function uniqueOrderedValues(values) {
|
|||||||
return unique;
|
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) {
|
function uniquePresetEntries(entries) {
|
||||||
const unique = [];
|
const unique = [];
|
||||||
const seenNames = new Set();
|
const seenNames = new Set();
|
||||||
@@ -466,20 +560,112 @@ function mapPresetEntriesToOptions(entries) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SettingsService {
|
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() {
|
async getSchemaRows() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
|
return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSettingsMap() {
|
async getSettingsMap(options = {}) {
|
||||||
const rows = await this.getFlatSettings();
|
const snapshot = await this.getSettingsSnapshot(options);
|
||||||
const map = {};
|
return { ...(snapshot?.map || {}) };
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
map[row.key] = row.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeMediaProfile(value) {
|
normalizeMediaProfile(value) {
|
||||||
@@ -530,7 +716,7 @@ class SettingsService {
|
|||||||
return this.resolveEffectiveToolSettings(map, mediaProfile);
|
return this.resolveEffectiveToolSettings(map, mediaProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFlatSettings() {
|
async fetchFlatSettingsFromDb() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.all(
|
const rows = await db.all(
|
||||||
`
|
`
|
||||||
@@ -567,21 +753,14 @@ class SettingsService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCategorizedSettings() {
|
async getFlatSettings(options = {}) {
|
||||||
const flat = await this.getFlatSettings();
|
const snapshot = await this.getSettingsSnapshot(options);
|
||||||
const byCategory = new Map();
|
return Array.isArray(snapshot?.flat) ? [...snapshot.flat] : [];
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of flat) {
|
async getCategorizedSettings(options = {}) {
|
||||||
if (!byCategory.has(item.category)) {
|
const snapshot = await this.getSettingsSnapshot(options);
|
||||||
byCategory.set(item.category, []);
|
return Array.isArray(snapshot?.categorized) ? [...snapshot.categorized] : [];
|
||||||
}
|
|
||||||
byCategory.get(item.category).push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(byCategory.entries()).map(([category, settings]) => ({
|
|
||||||
category,
|
|
||||||
settings
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSettingValue(key, rawValue) {
|
async setSettingValue(key, rawValue) {
|
||||||
@@ -619,6 +798,7 @@ class SettingsService {
|
|||||||
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
|
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
|
||||||
applyRuntimeLogDirSetting(result.normalized);
|
applyRuntimeLogDirSetting(result.normalized);
|
||||||
}
|
}
|
||||||
|
this.invalidateSettingsCache([key]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
@@ -702,6 +882,7 @@ class SettingsService {
|
|||||||
applyRuntimeLogDirSetting(logDirChange.value);
|
applyRuntimeLogDirSetting(logDirChange.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.invalidateSettingsCache(normalizedEntries.map((item) => item.key));
|
||||||
logger.info('settings:bulk-updated', { count: normalizedEntries.length });
|
logger.info('settings:bulk-updated', { count: normalizedEntries.length });
|
||||||
return normalizedEntries.map((item) => ({
|
return normalizedEntries.map((item) => ({
|
||||||
key: item.key,
|
key: item.key,
|
||||||
@@ -1141,8 +1322,7 @@ class SettingsService {
|
|||||||
return `disc:${map.makemkv_source_index ?? 0}`;
|
return `disc:${map.makemkv_source_index ?? 0}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHandBrakePresetOptions() {
|
async loadHandBrakePresetOptionsFromCli(map = {}) {
|
||||||
const map = await this.getSettingsMap();
|
|
||||||
const configuredPresets = uniqueOrderedValues([
|
const configuredPresets = uniqueOrderedValues([
|
||||||
map.handbrake_preset_bluray,
|
map.handbrake_preset_bluray,
|
||||||
map.handbrake_preset_dvd,
|
map.handbrake_preset_dvd,
|
||||||
@@ -1156,21 +1336,20 @@ class SettingsService {
|
|||||||
const args = [...baseArgs, '-z'];
|
const args = [...baseArgs, '-z'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = spawnSync(cmd, args, {
|
const result = await runCommandCapture(cmd, args, {
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: HANDBRAKE_PRESET_LIST_TIMEOUT_MS,
|
timeout: HANDBRAKE_PRESET_LIST_TIMEOUT_MS,
|
||||||
maxBuffer: 8 * 1024 * 1024
|
maxBuffer: 8 * 1024 * 1024
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.timedOut) {
|
||||||
return {
|
return {
|
||||||
source: 'fallback',
|
source: 'fallback',
|
||||||
message: `Preset-Liste konnte nicht geladen werden: ${result.error.message}`,
|
message: 'Preset-Liste konnte nicht geladen werden (Timeout).',
|
||||||
options: fallbackOptions
|
options: fallbackOptions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status !== 0) {
|
if (Number(result.status) !== 0) {
|
||||||
const stderr = String(result.stderr || '').trim();
|
const stderr = String(result.stderr || '').trim();
|
||||||
const stdout = String(result.stdout || '').trim();
|
const stdout = String(result.stdout || '').trim();
|
||||||
const detail = (stderr || stdout || `exit=${result.status}`).slice(0, 280);
|
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();
|
module.exports = new SettingsService();
|
||||||
|
|||||||
@@ -1,4 +1,81 @@
|
|||||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||||
|
const GET_RESPONSE_CACHE = new Map();
|
||||||
|
|
||||||
|
function invalidateCachedGet(prefixes = []) {
|
||||||
|
const list = Array.isArray(prefixes) ? prefixes.filter(Boolean) : [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
GET_RESPONSE_CACHE.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of GET_RESPONSE_CACHE.keys()) {
|
||||||
|
if (list.some((prefix) => key.startsWith(prefix))) {
|
||||||
|
GET_RESPONSE_CACHE.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCachedGet(path, ttlMs) {
|
||||||
|
const cacheKey = String(path || '');
|
||||||
|
const nextEntry = GET_RESPONSE_CACHE.get(cacheKey) || {
|
||||||
|
value: undefined,
|
||||||
|
expiresAt: 0,
|
||||||
|
promise: null
|
||||||
|
};
|
||||||
|
const nextPromise = request(path)
|
||||||
|
.then((payload) => {
|
||||||
|
GET_RESPONSE_CACHE.set(cacheKey, {
|
||||||
|
value: payload,
|
||||||
|
expiresAt: Date.now() + Math.max(1000, Number(ttlMs || 0)),
|
||||||
|
promise: null
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const current = GET_RESPONSE_CACHE.get(cacheKey);
|
||||||
|
if (current && current.promise === nextPromise) {
|
||||||
|
GET_RESPONSE_CACHE.set(cacheKey, {
|
||||||
|
value: current.value,
|
||||||
|
expiresAt: current.expiresAt || 0,
|
||||||
|
promise: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
GET_RESPONSE_CACHE.set(cacheKey, {
|
||||||
|
value: nextEntry.value,
|
||||||
|
expiresAt: nextEntry.expiresAt || 0,
|
||||||
|
promise: nextPromise
|
||||||
|
});
|
||||||
|
return nextPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCachedGet(path, options = {}) {
|
||||||
|
const ttlMs = Math.max(1000, Number(options?.ttlMs || 0));
|
||||||
|
const forceRefresh = Boolean(options?.forceRefresh);
|
||||||
|
const cacheKey = String(path || '');
|
||||||
|
const current = GET_RESPONSE_CACHE.get(cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!forceRefresh && current && current.value !== undefined) {
|
||||||
|
if (current.expiresAt > now) {
|
||||||
|
return current.value;
|
||||||
|
}
|
||||||
|
if (!current.promise) {
|
||||||
|
void refreshCachedGet(path, ttlMs);
|
||||||
|
}
|
||||||
|
return current.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && current?.promise) {
|
||||||
|
return current.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshCachedGet(path, ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterMutationInvalidate(prefixes = []) {
|
||||||
|
invalidateCachedGet(prefixes);
|
||||||
|
}
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
@@ -33,89 +110,121 @@ async function request(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getSettings() {
|
getSettings(options = {}) {
|
||||||
return request('/settings');
|
return requestCachedGet('/settings', {
|
||||||
|
ttlMs: 5 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
},
|
},
|
||||||
getHandBrakePresets() {
|
getHandBrakePresets(options = {}) {
|
||||||
return request('/settings/handbrake-presets');
|
return requestCachedGet('/settings/handbrake-presets', {
|
||||||
|
ttlMs: 10 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
},
|
},
|
||||||
getScripts() {
|
getScripts(options = {}) {
|
||||||
return request('/settings/scripts');
|
return requestCachedGet('/settings/scripts', {
|
||||||
|
ttlMs: 2 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
},
|
},
|
||||||
createScript(payload = {}) {
|
async createScript(payload = {}) {
|
||||||
return request('/settings/scripts', {
|
const result = await request('/settings/scripts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload || {})
|
body: JSON.stringify(payload || {})
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
reorderScripts(orderedScriptIds = []) {
|
async reorderScripts(orderedScriptIds = []) {
|
||||||
return request('/settings/scripts/reorder', {
|
const result = await request('/settings/scripts/reorder', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
orderedScriptIds: Array.isArray(orderedScriptIds) ? orderedScriptIds : []
|
orderedScriptIds: Array.isArray(orderedScriptIds) ? orderedScriptIds : []
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
updateScript(scriptId, payload = {}) {
|
async updateScript(scriptId, payload = {}) {
|
||||||
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload || {})
|
body: JSON.stringify(payload || {})
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
deleteScript(scriptId) {
|
async deleteScript(scriptId) {
|
||||||
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
testScript(scriptId) {
|
testScript(scriptId) {
|
||||||
return request(`/settings/scripts/${encodeURIComponent(scriptId)}/test`, {
|
return request(`/settings/scripts/${encodeURIComponent(scriptId)}/test`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getScriptChains() {
|
getScriptChains(options = {}) {
|
||||||
return request('/settings/script-chains');
|
return requestCachedGet('/settings/script-chains', {
|
||||||
|
ttlMs: 2 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
},
|
},
|
||||||
createScriptChain(payload = {}) {
|
async createScriptChain(payload = {}) {
|
||||||
return request('/settings/script-chains', {
|
const result = await request('/settings/script-chains', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
reorderScriptChains(orderedChainIds = []) {
|
async reorderScriptChains(orderedChainIds = []) {
|
||||||
return request('/settings/script-chains/reorder', {
|
const result = await request('/settings/script-chains/reorder', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
orderedChainIds: Array.isArray(orderedChainIds) ? orderedChainIds : []
|
orderedChainIds: Array.isArray(orderedChainIds) ? orderedChainIds : []
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
updateScriptChain(chainId, payload = {}) {
|
async updateScriptChain(chainId, payload = {}) {
|
||||||
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
deleteScriptChain(chainId) {
|
async deleteScriptChain(chainId) {
|
||||||
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
testScriptChain(chainId) {
|
testScriptChain(chainId) {
|
||||||
return request(`/settings/script-chains/${encodeURIComponent(chainId)}/test`, {
|
return request(`/settings/script-chains/${encodeURIComponent(chainId)}/test`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
updateSetting(key, value) {
|
async updateSetting(key, value) {
|
||||||
return request(`/settings/${encodeURIComponent(key)}`, {
|
const result = await request(`/settings/${encodeURIComponent(key)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ value })
|
body: JSON.stringify({ value })
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings', '/settings/handbrake-presets']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
updateSettingsBulk(settings) {
|
async updateSettingsBulk(settings) {
|
||||||
return request('/settings', {
|
const result = await request('/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ settings })
|
body: JSON.stringify({ settings })
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings', '/settings/handbrake-presets']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
testPushover(payload = {}) {
|
testPushover(payload = {}) {
|
||||||
return request('/settings/pushover/test', {
|
return request('/settings/pushover/test', {
|
||||||
@@ -126,91 +235,143 @@ export const api = {
|
|||||||
getPipelineState() {
|
getPipelineState() {
|
||||||
return request('/pipeline/state');
|
return request('/pipeline/state');
|
||||||
},
|
},
|
||||||
analyzeDisc() {
|
getRuntimeActivities() {
|
||||||
return request('/pipeline/analyze', {
|
return request('/runtime/activities');
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
rescanDisc() {
|
cancelRuntimeActivity(activityId, payload = {}) {
|
||||||
return request('/pipeline/rescan-disc', {
|
return request(`/runtime/activities/${encodeURIComponent(activityId)}/cancel`, {
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
searchOmdb(q) {
|
|
||||||
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
|
||||||
},
|
|
||||||
selectMetadata(payload) {
|
|
||||||
return request('/pipeline/select-metadata', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
startJob(jobId) {
|
|
||||||
return request(`/pipeline/start/${jobId}`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
confirmEncodeReview(jobId, payload = {}) {
|
|
||||||
return request(`/pipeline/confirm-encode/${jobId}`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload || {})
|
body: JSON.stringify(payload || {})
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
cancelPipeline(jobId = null) {
|
requestRuntimeNextStep(activityId, payload = {}) {
|
||||||
return request('/pipeline/cancel', {
|
return request(`/runtime/activities/${encodeURIComponent(activityId)}/next-step`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async analyzeDisc() {
|
||||||
|
const result = await request('/pipeline/analyze', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async rescanDisc() {
|
||||||
|
const result = await request('/pipeline/rescan-disc', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
searchOmdb(q) {
|
||||||
|
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
||||||
|
},
|
||||||
|
async selectMetadata(payload) {
|
||||||
|
const result = await request('/pipeline/select-metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async startJob(jobId) {
|
||||||
|
const result = await request(`/pipeline/start/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async confirmEncodeReview(jobId, payload = {}) {
|
||||||
|
const result = await request(`/pipeline/confirm-encode/${jobId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async cancelPipeline(jobId = null) {
|
||||||
|
const result = await request('/pipeline/cancel', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ jobId })
|
body: JSON.stringify({ jobId })
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
retryJob(jobId) {
|
async retryJob(jobId) {
|
||||||
return request(`/pipeline/retry/${jobId}`, {
|
const result = await request(`/pipeline/retry/${jobId}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
resumeReadyJob(jobId) {
|
async resumeReadyJob(jobId) {
|
||||||
return request(`/pipeline/resume-ready/${jobId}`, {
|
const result = await request(`/pipeline/resume-ready/${jobId}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
reencodeJob(jobId) {
|
async reencodeJob(jobId) {
|
||||||
return request(`/pipeline/reencode/${jobId}`, {
|
const result = await request(`/pipeline/reencode/${jobId}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
restartReviewFromRaw(jobId) {
|
async restartReviewFromRaw(jobId) {
|
||||||
return request(`/pipeline/restart-review/${jobId}`, {
|
const result = await request(`/pipeline/restart-review/${jobId}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
restartEncodeWithLastSettings(jobId) {
|
async restartEncodeWithLastSettings(jobId) {
|
||||||
return request(`/pipeline/restart-encode/${jobId}`, {
|
const result = await request(`/pipeline/restart-encode/${jobId}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
getPipelineQueue() {
|
getPipelineQueue() {
|
||||||
return request('/pipeline/queue');
|
return request('/pipeline/queue');
|
||||||
},
|
},
|
||||||
reorderPipelineQueue(orderedEntryIds = []) {
|
async reorderPipelineQueue(orderedEntryIds = []) {
|
||||||
return request('/pipeline/queue/reorder', {
|
const result = await request('/pipeline/queue/reorder', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ orderedEntryIds: Array.isArray(orderedEntryIds) ? orderedEntryIds : [] })
|
body: JSON.stringify({ orderedEntryIds: Array.isArray(orderedEntryIds) ? orderedEntryIds : [] })
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
addQueueEntry(payload = {}) {
|
async addQueueEntry(payload = {}) {
|
||||||
return request('/pipeline/queue/entry', {
|
const result = await request('/pipeline/queue/entry', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
removeQueueEntry(entryId) {
|
async removeQueueEntry(entryId) {
|
||||||
return request(`/pipeline/queue/entry/${encodeURIComponent(entryId)}`, {
|
const result = await request(`/pipeline/queue/entry/${encodeURIComponent(entryId)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
getJobs(params = {}) {
|
getJobs(params = {}) {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params.status) query.set('status', params.status);
|
if (params.status) query.set('status', params.status);
|
||||||
|
if (Array.isArray(params.statuses) && params.statuses.length > 0) {
|
||||||
|
query.set('statuses', params.statuses.join(','));
|
||||||
|
}
|
||||||
if (params.search) query.set('search', params.search);
|
if (params.search) query.set('search', params.search);
|
||||||
|
if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) {
|
||||||
|
query.set('limit', String(Math.trunc(Number(params.limit))));
|
||||||
|
}
|
||||||
|
if (params.lite) {
|
||||||
|
query.set('lite', '1');
|
||||||
|
}
|
||||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
return request(`/history${suffix}`);
|
return request(`/history${suffix}`);
|
||||||
},
|
},
|
||||||
@@ -224,32 +385,43 @@ export const api = {
|
|||||||
getOrphanRawFolders() {
|
getOrphanRawFolders() {
|
||||||
return request('/history/orphan-raw');
|
return request('/history/orphan-raw');
|
||||||
},
|
},
|
||||||
importOrphanRawFolder(rawPath) {
|
async importOrphanRawFolder(rawPath) {
|
||||||
return request('/history/orphan-raw/import', {
|
const result = await request('/history/orphan-raw/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ rawPath })
|
body: JSON.stringify({ rawPath })
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
assignJobOmdb(jobId, payload = {}) {
|
async assignJobOmdb(jobId, payload = {}) {
|
||||||
return request(`/history/${jobId}/omdb/assign`, {
|
const result = await request(`/history/${jobId}/omdb/assign`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload || {})
|
body: JSON.stringify(payload || {})
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
deleteJobFiles(jobId, target = 'both') {
|
async deleteJobFiles(jobId, target = 'both') {
|
||||||
return request(`/history/${jobId}/delete-files`, {
|
const result = await request(`/history/${jobId}/delete-files`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ target })
|
body: JSON.stringify({ target })
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
deleteJobEntry(jobId, target = 'none') {
|
async deleteJobEntry(jobId, target = 'none') {
|
||||||
return request(`/history/${jobId}/delete`, {
|
const result = await request(`/history/${jobId}/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ target })
|
body: JSON.stringify({ target })
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
getJob(jobId, options = {}) {
|
getJob(jobId, options = {}) {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
|
const includeLiveLog = Boolean(options.includeLiveLog);
|
||||||
|
const includeLogs = Boolean(options.includeLogs);
|
||||||
|
const includeAllLogs = Boolean(options.includeAllLogs);
|
||||||
if (options.includeLiveLog) {
|
if (options.includeLiveLog) {
|
||||||
query.set('includeLiveLog', '1');
|
query.set('includeLiveLog', '1');
|
||||||
}
|
}
|
||||||
@@ -262,31 +434,51 @@ export const api = {
|
|||||||
if (Number.isFinite(Number(options.logTailLines)) && Number(options.logTailLines) > 0) {
|
if (Number.isFinite(Number(options.logTailLines)) && Number(options.logTailLines) > 0) {
|
||||||
query.set('logTailLines', String(Math.trunc(Number(options.logTailLines))));
|
query.set('logTailLines', String(Math.trunc(Number(options.logTailLines))));
|
||||||
}
|
}
|
||||||
|
if (options.lite) {
|
||||||
|
query.set('lite', '1');
|
||||||
|
}
|
||||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
return request(`/history/${jobId}${suffix}`);
|
const path = `/history/${jobId}${suffix}`;
|
||||||
|
const canUseCache = !includeLiveLog && !includeLogs && !includeAllLogs;
|
||||||
|
if (!canUseCache) {
|
||||||
|
return request(path);
|
||||||
|
}
|
||||||
|
return requestCachedGet(path, {
|
||||||
|
ttlMs: 8000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── User Presets ───────────────────────────────────────────────────────────
|
// ── User Presets ───────────────────────────────────────────────────────────
|
||||||
getUserPresets(mediaType = null) {
|
getUserPresets(mediaType = null, options = {}) {
|
||||||
const suffix = mediaType ? `?media_type=${encodeURIComponent(mediaType)}` : '';
|
const suffix = mediaType ? `?media_type=${encodeURIComponent(mediaType)}` : '';
|
||||||
return request(`/settings/user-presets${suffix}`);
|
return requestCachedGet(`/settings/user-presets${suffix}`, {
|
||||||
|
ttlMs: 2 * 60 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
},
|
},
|
||||||
createUserPreset(payload = {}) {
|
async createUserPreset(payload = {}) {
|
||||||
return request('/settings/user-presets', {
|
const result = await request('/settings/user-presets', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/user-presets']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
updateUserPreset(id, payload = {}) {
|
async updateUserPreset(id, payload = {}) {
|
||||||
return request(`/settings/user-presets/${encodeURIComponent(id)}`, {
|
const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/user-presets']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
deleteUserPreset(id) {
|
async deleteUserPreset(id) {
|
||||||
return request(`/settings/user-presets/${encodeURIComponent(id)}`, {
|
const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
afterMutationInvalidate(['/settings/user-presets']);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Cron Jobs ──────────────────────────────────────────────────────────────
|
// ── Cron Jobs ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
@@ -32,6 +32,23 @@ function StatusBadge({ status }) {
|
|||||||
return <span className={`cron-status cron-status--${info.cls}`}>{info.label}</span>;
|
return <span className={`cron-status cron-status--${info.cls}`}>{info.label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeActiveCronRuns(rawPayload) {
|
||||||
|
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
||||||
|
const active = Array.isArray(payload.active) ? payload.active : [];
|
||||||
|
return active
|
||||||
|
.map((item) => (item && typeof item === 'object' ? item : null))
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((item) => String(item.type || '').trim().toLowerCase() === 'cron')
|
||||||
|
.map((item) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
cronJobId: Number(item.cronJobId || 0),
|
||||||
|
currentStep: String(item.currentStep || '').trim() || null,
|
||||||
|
currentScriptName: String(item.currentScriptName || '').trim() || null,
|
||||||
|
startedAt: item.startedAt || null
|
||||||
|
}))
|
||||||
|
.filter((item) => Number.isFinite(item.cronJobId) && item.cronJobId > 0);
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
const EMPTY_FORM = {
|
||||||
name: '',
|
name: '',
|
||||||
cronExpression: '',
|
cronExpression: '',
|
||||||
@@ -50,6 +67,7 @@ export default function CronJobsTab({ onWsMessage }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [scripts, setScripts] = useState([]);
|
const [scripts, setScripts] = useState([]);
|
||||||
const [chains, setChains] = useState([]);
|
const [chains, setChains] = useState([]);
|
||||||
|
const [activeCronRuns, setActiveCronRuns] = useState([]);
|
||||||
|
|
||||||
// Editor-Dialog
|
// Editor-Dialog
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
@@ -76,14 +94,18 @@ export default function CronJobsTab({ onWsMessage }) {
|
|||||||
const loadAll = useCallback(async () => {
|
const loadAll = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [cronResp, scriptsResp, chainsResp] = await Promise.allSettled([
|
const [cronResp, scriptsResp, chainsResp, runtimeResp] = await Promise.allSettled([
|
||||||
api.getCronJobs(),
|
api.getCronJobs(),
|
||||||
api.getScripts(),
|
api.getScripts(),
|
||||||
api.getScriptChains()
|
api.getScriptChains(),
|
||||||
|
api.getRuntimeActivities()
|
||||||
]);
|
]);
|
||||||
if (cronResp.status === 'fulfilled') setJobs(cronResp.value?.jobs || []);
|
if (cronResp.status === 'fulfilled') setJobs(cronResp.value?.jobs || []);
|
||||||
if (scriptsResp.status === 'fulfilled') setScripts(scriptsResp.value?.scripts || []);
|
if (scriptsResp.status === 'fulfilled') setScripts(scriptsResp.value?.scripts || []);
|
||||||
if (chainsResp.status === 'fulfilled') setChains(chainsResp.value?.chains || []);
|
if (chainsResp.status === 'fulfilled') setChains(chainsResp.value?.chains || []);
|
||||||
|
if (runtimeResp.status === 'fulfilled') {
|
||||||
|
setActiveCronRuns(normalizeActiveCronRuns(runtimeResp.value));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -93,6 +115,36 @@ export default function CronJobsTab({ onWsMessage }) {
|
|||||||
loadAll();
|
loadAll();
|
||||||
}, [loadAll]);
|
}, [loadAll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const refreshRuntime = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getRuntimeActivities();
|
||||||
|
if (!cancelled) {
|
||||||
|
setActiveCronRuns(normalizeActiveCronRuns(response));
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore polling errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const interval = setInterval(refreshRuntime, 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeCronRunByJobId = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const item of activeCronRuns) {
|
||||||
|
if (!item?.cronJobId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(item.cronJobId, item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [activeCronRuns]);
|
||||||
|
|
||||||
// WebSocket: Cronjob-Updates empfangen
|
// WebSocket: Cronjob-Updates empfangen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onWsMessage) return;
|
if (!onWsMessage) return;
|
||||||
@@ -279,6 +331,7 @@ export default function CronJobsTab({ onWsMessage }) {
|
|||||||
<div className="cron-list">
|
<div className="cron-list">
|
||||||
{jobs.map((job) => {
|
{jobs.map((job) => {
|
||||||
const isBusy = busyId === job.id;
|
const isBusy = busyId === job.id;
|
||||||
|
const activeCronRun = activeCronRunByJobId.get(Number(job.id)) || null;
|
||||||
return (
|
return (
|
||||||
<div key={job.id} className={`cron-item${job.enabled ? '' : ' cron-item--disabled'}`}>
|
<div key={job.id} className={`cron-item${job.enabled ? '' : ' cron-item--disabled'}`}>
|
||||||
<div className="cron-item-header">
|
<div className="cron-item-header">
|
||||||
@@ -305,6 +358,17 @@ export default function CronJobsTab({ onWsMessage }) {
|
|||||||
<span className="cron-meta-label">Nächster Lauf:</span>
|
<span className="cron-meta-label">Nächster Lauf:</span>
|
||||||
<span className="cron-meta-value">{formatDateTime(job.nextRunAt)}</span>
|
<span className="cron-meta-value">{formatDateTime(job.nextRunAt)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
{activeCronRun ? (
|
||||||
|
<span className="cron-meta-entry">
|
||||||
|
<span className="cron-meta-label">Aktuell:</span>
|
||||||
|
<span className="cron-meta-value">
|
||||||
|
<StatusBadge status="running" />
|
||||||
|
{activeCronRun.currentScriptName
|
||||||
|
? `Skript: ${activeCronRun.currentScriptName}`
|
||||||
|
: (activeCronRun.currentStep || 'Ausführung läuft')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cron-item-toggles">
|
<div className="cron-item-toggles">
|
||||||
|
|||||||
@@ -54,6 +54,127 @@ function ScriptSummarySection({ title, summary }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeIdList(values) {
|
||||||
|
const list = Array.isArray(values) ? values : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const value of list) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = Math.trunc(parsed);
|
||||||
|
const key = String(id);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
output.push(id);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellQuote(value) {
|
||||||
|
const raw = String(value ?? '');
|
||||||
|
if (raw.length === 0) {
|
||||||
|
return "''";
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return `'${raw.replace(/'/g, `'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExecutedHandBrakeCommand(handbrakeInfo) {
|
||||||
|
const cmd = String(handbrakeInfo?.cmd || '').trim();
|
||||||
|
const args = Array.isArray(handbrakeInfo?.args) ? handbrakeInfo.args : [];
|
||||||
|
if (!cmd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${cmd} ${args.map((arg) => shellQuote(arg)).join(' ')}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfiguredScriptAndChainSelection(job) {
|
||||||
|
const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
||||||
|
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {};
|
||||||
|
const scriptNameById = new Map();
|
||||||
|
const chainNameById = new Map();
|
||||||
|
|
||||||
|
const addScriptHint = (idValue, nameValue) => {
|
||||||
|
const id = normalizeIdList([idValue])[0] || null;
|
||||||
|
const name = String(nameValue || '').trim();
|
||||||
|
if (!id || !name || scriptNameById.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scriptNameById.set(id, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChainHint = (idValue, nameValue) => {
|
||||||
|
const id = normalizeIdList([idValue])[0] || null;
|
||||||
|
const name = String(nameValue || '').trim();
|
||||||
|
if (!id || !name || chainNameById.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chainNameById.set(id, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addScriptHintsFromList = (list) => {
|
||||||
|
for (const item of (Array.isArray(list) ? list : [])) {
|
||||||
|
addScriptHint(item?.id ?? item?.scriptId, item?.name ?? item?.scriptName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChainHintsFromList = (list) => {
|
||||||
|
for (const item of (Array.isArray(list) ? list : [])) {
|
||||||
|
addChainHint(item?.id ?? item?.chainId, item?.name ?? item?.chainName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addScriptHintsFromList(plan?.preEncodeScripts);
|
||||||
|
addScriptHintsFromList(plan?.postEncodeScripts);
|
||||||
|
addChainHintsFromList(plan?.preEncodeChains);
|
||||||
|
addChainHintsFromList(plan?.postEncodeChains);
|
||||||
|
|
||||||
|
const scriptSummaries = [handbrakeInfo?.preEncodeScripts, handbrakeInfo?.postEncodeScripts];
|
||||||
|
for (const summary of scriptSummaries) {
|
||||||
|
const results = Array.isArray(summary?.results) ? summary.results : [];
|
||||||
|
for (const result of results) {
|
||||||
|
addScriptHint(result?.scriptId, result?.scriptName);
|
||||||
|
addChainHint(result?.chainId, result?.chainName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preScriptIds = normalizeIdList([
|
||||||
|
...(Array.isArray(plan?.preEncodeScriptIds) ? plan.preEncodeScriptIds : []),
|
||||||
|
...(Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts.map((item) => item?.id ?? item?.scriptId) : [])
|
||||||
|
]);
|
||||||
|
const postScriptIds = normalizeIdList([
|
||||||
|
...(Array.isArray(plan?.postEncodeScriptIds) ? plan.postEncodeScriptIds : []),
|
||||||
|
...(Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts.map((item) => item?.id ?? item?.scriptId) : [])
|
||||||
|
]);
|
||||||
|
const preChainIds = normalizeIdList([
|
||||||
|
...(Array.isArray(plan?.preEncodeChainIds) ? plan.preEncodeChainIds : []),
|
||||||
|
...(Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains.map((item) => item?.id ?? item?.chainId) : [])
|
||||||
|
]);
|
||||||
|
const postChainIds = normalizeIdList([
|
||||||
|
...(Array.isArray(plan?.postEncodeChainIds) ? plan.postEncodeChainIds : []),
|
||||||
|
...(Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains.map((item) => item?.id ?? item?.chainId) : [])
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preScriptIds,
|
||||||
|
postScriptIds,
|
||||||
|
preChainIds,
|
||||||
|
postChainIds,
|
||||||
|
preScripts: preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`),
|
||||||
|
postScripts: postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`),
|
||||||
|
preChains: preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`),
|
||||||
|
postChains: postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`),
|
||||||
|
scriptCatalog: Array.from(scriptNameById.entries()).map(([id, name]) => ({ id, name })),
|
||||||
|
chainCatalog: Array.from(chainNameById.entries()).map(([id, name]) => ({ id, name }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMediaType(job) {
|
function resolveMediaType(job) {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
job?.mediaType,
|
job?.mediaType,
|
||||||
@@ -205,6 +326,25 @@ export default function JobDetailDialog({
|
|||||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||||
|
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
||||||
|
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|
||||||
|
|| configuredSelection.postScriptIds.length > 0
|
||||||
|
|| configuredSelection.preChainIds.length > 0
|
||||||
|
|| configuredSelection.postChainIds.length > 0;
|
||||||
|
const reviewPreEncodeItems = [
|
||||||
|
...configuredSelection.preScriptIds.map((id) => ({ type: 'script', id })),
|
||||||
|
...configuredSelection.preChainIds.map((id) => ({ type: 'chain', id }))
|
||||||
|
];
|
||||||
|
const reviewPostEncodeItems = [
|
||||||
|
...configuredSelection.postScriptIds.map((id) => ({ type: 'script', id })),
|
||||||
|
...configuredSelection.postChainIds.map((id) => ({ type: 'chain', id }))
|
||||||
|
];
|
||||||
|
const encodePlanUserPreset = job?.encodePlan?.userPreset && typeof job.encodePlan.userPreset === 'object'
|
||||||
|
? job.encodePlan.userPreset
|
||||||
|
: null;
|
||||||
|
const encodePlanUserPresetId = Number(encodePlanUserPreset?.id);
|
||||||
|
const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : [];
|
||||||
|
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -335,6 +475,42 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{hasConfiguredSelection || encodePlanUserPreset ? (
|
||||||
|
<section className="job-meta-block job-meta-block-full">
|
||||||
|
<h4>Hinterlegte Encode-Auswahl</h4>
|
||||||
|
<div className="job-configured-selection-grid">
|
||||||
|
<div>
|
||||||
|
<strong>Pre-Encode Skripte:</strong> {configuredSelection.preScripts.length > 0 ? configuredSelection.preScripts.join(' | ') : '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Pre-Encode Ketten:</strong> {configuredSelection.preChains.length > 0 ? configuredSelection.preChains.join(' | ') : '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Post-Encode Skripte:</strong> {configuredSelection.postScripts.length > 0 ? configuredSelection.postScripts.join(' | ') : '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Post-Encode Ketten:</strong> {configuredSelection.postChains.length > 0 ? configuredSelection.postChains.join(' | ') : '-'}
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-col-span-2">
|
||||||
|
<strong>User-Preset:</strong>{' '}
|
||||||
|
{encodePlanUserPreset
|
||||||
|
? `${encodePlanUserPreset.name || '-'} | Preset=${encodePlanUserPreset.handbrakePreset || '-'} | ExtraArgs=${encodePlanUserPreset.extraArgs || '-'}`
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{executedHandBrakeCommand ? (
|
||||||
|
<section className="job-meta-block job-meta-block-full">
|
||||||
|
<h4>Ausgeführter Encode-Befehl</h4>
|
||||||
|
<div className="handbrake-command-preview">
|
||||||
|
<small><strong>HandBrakeCLI (tatsächlich gestartet):</strong></small>
|
||||||
|
<pre>{executedHandBrakeCommand}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
|
{(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
|
||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Skripte</h4>
|
<h4>Skripte</h4>
|
||||||
@@ -356,7 +532,18 @@ export default function JobDetailDialog({
|
|||||||
{job.encodePlan ? (
|
{job.encodePlan ? (
|
||||||
<>
|
<>
|
||||||
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
||||||
<MediaInfoReviewPanel review={job.encodePlan} />
|
<MediaInfoReviewPanel
|
||||||
|
review={job.encodePlan}
|
||||||
|
commandOutputPath={job.output_path || null}
|
||||||
|
availableScripts={configuredSelection.scriptCatalog}
|
||||||
|
availableChains={configuredSelection.chainCatalog}
|
||||||
|
preEncodeItems={reviewPreEncodeItems}
|
||||||
|
postEncodeItems={reviewPostEncodeItems}
|
||||||
|
userPresets={reviewUserPresets}
|
||||||
|
selectedUserPresetId={Number.isFinite(encodePlanUserPresetId) && encodePlanUserPresetId > 0
|
||||||
|
? Math.trunc(encodePlanUserPresetId)
|
||||||
|
: null}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -684,6 +684,14 @@ function normalizeScriptId(value) {
|
|||||||
return Math.trunc(parsed);
|
return Math.trunc(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeChainId(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeScriptIdList(values) {
|
function normalizeScriptIdList(values) {
|
||||||
const list = Array.isArray(values) ? values : [];
|
const list = Array.isArray(values) ? values : [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -762,8 +770,8 @@ export default function MediaInfoReviewPanel({
|
|||||||
.filter((item) => item.id !== null && item.name.length > 0);
|
.filter((item) => item.id !== null && item.name.length > 0);
|
||||||
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
|
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
|
||||||
const chainCatalog = (Array.isArray(availableChains) ? availableChains : [])
|
const chainCatalog = (Array.isArray(availableChains) ? availableChains : [])
|
||||||
.map((item) => ({ id: Number(item?.id), name: String(item?.name || '').trim() }))
|
.map((item) => ({ id: normalizeChainId(item?.id), name: String(item?.name || '').trim() }))
|
||||||
.filter((item) => Number.isFinite(item.id) && item.id > 0 && item.name.length > 0);
|
.filter((item) => item.id !== null && item.name.length > 0);
|
||||||
const chainById = new Map(chainCatalog.map((item) => [item.id, item]));
|
const chainById = new Map(chainCatalog.map((item) => [item.id, item]));
|
||||||
|
|
||||||
const makeHandleDrop = (items, onReorder) => (event, targetIndex) => {
|
const makeHandleDrop = (items, onReorder) => (event, targetIndex) => {
|
||||||
@@ -884,13 +892,29 @@ export default function MediaInfoReviewPanel({
|
|||||||
? (scriptObj?.name || `Skript #${item.id}`)
|
? (scriptObj?.name || `Skript #${item.id}`)
|
||||||
: (chainObj?.name || `Kette #${item.id}`);
|
: (chainObj?.name || `Kette #${item.id}`);
|
||||||
const usedScriptIds = new Set(
|
const usedScriptIds = new Set(
|
||||||
preEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
|
preEncodeItems
|
||||||
|
.filter((it, i) => it.type === 'script' && i !== rowIndex)
|
||||||
|
.map((it) => normalizeScriptId(it.id))
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.map((id) => String(id))
|
||||||
|
);
|
||||||
|
const usedChainIds = new Set(
|
||||||
|
preEncodeItems
|
||||||
|
.filter((it, i) => it.type === 'chain' && i !== rowIndex)
|
||||||
|
.map((it) => normalizeChainId(it.id))
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.map((id) => String(id))
|
||||||
);
|
);
|
||||||
const scriptOptions = scriptCatalog.map((s) => ({
|
const scriptOptions = scriptCatalog.map((s) => ({
|
||||||
label: s.name,
|
label: s.name,
|
||||||
value: s.id,
|
value: s.id,
|
||||||
disabled: usedScriptIds.has(String(s.id))
|
disabled: usedScriptIds.has(String(s.id))
|
||||||
}));
|
}));
|
||||||
|
const chainOptions = chainCatalog.map((c) => ({
|
||||||
|
label: c.name,
|
||||||
|
value: c.id,
|
||||||
|
disabled: usedChainIds.has(String(c.id))
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`pre-item-${rowIndex}-${item.type}-${item.id}`}
|
key={`pre-item-${rowIndex}-${item.type}-${item.id}`}
|
||||||
@@ -926,7 +950,15 @@ export default function MediaInfoReviewPanel({
|
|||||||
className="full-width"
|
className="full-width"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="post-script-chain-name">{name}</span>
|
<Dropdown
|
||||||
|
value={normalizeChainId(item.id)}
|
||||||
|
options={chainOptions}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
optionDisabled="disabled"
|
||||||
|
onChange={(event) => onChangePreEncodeItem?.(rowIndex, 'chain', event.value)}
|
||||||
|
className="full-width"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeItem?.(rowIndex)} />
|
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeItem?.(rowIndex)} />
|
||||||
</>
|
</>
|
||||||
@@ -980,13 +1012,29 @@ export default function MediaInfoReviewPanel({
|
|||||||
? (scriptObj?.name || `Skript #${item.id}`)
|
? (scriptObj?.name || `Skript #${item.id}`)
|
||||||
: (chainObj?.name || `Kette #${item.id}`);
|
: (chainObj?.name || `Kette #${item.id}`);
|
||||||
const usedScriptIds = new Set(
|
const usedScriptIds = new Set(
|
||||||
postEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
|
postEncodeItems
|
||||||
|
.filter((it, i) => it.type === 'script' && i !== rowIndex)
|
||||||
|
.map((it) => normalizeScriptId(it.id))
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.map((id) => String(id))
|
||||||
|
);
|
||||||
|
const usedChainIds = new Set(
|
||||||
|
postEncodeItems
|
||||||
|
.filter((it, i) => it.type === 'chain' && i !== rowIndex)
|
||||||
|
.map((it) => normalizeChainId(it.id))
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.map((id) => String(id))
|
||||||
);
|
);
|
||||||
const scriptOptions = scriptCatalog.map((s) => ({
|
const scriptOptions = scriptCatalog.map((s) => ({
|
||||||
label: s.name,
|
label: s.name,
|
||||||
value: s.id,
|
value: s.id,
|
||||||
disabled: usedScriptIds.has(String(s.id))
|
disabled: usedScriptIds.has(String(s.id))
|
||||||
}));
|
}));
|
||||||
|
const chainOptions = chainCatalog.map((c) => ({
|
||||||
|
label: c.name,
|
||||||
|
value: c.id,
|
||||||
|
disabled: usedChainIds.has(String(c.id))
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`post-item-${rowIndex}-${item.type}-${item.id}`}
|
key={`post-item-${rowIndex}-${item.type}-${item.id}`}
|
||||||
@@ -1022,7 +1070,15 @@ export default function MediaInfoReviewPanel({
|
|||||||
className="full-width"
|
className="full-width"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="post-script-chain-name">{name}</span>
|
<Dropdown
|
||||||
|
value={normalizeChainId(item.id)}
|
||||||
|
options={chainOptions}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
optionDisabled="disabled"
|
||||||
|
onChange={(event) => onChangePostEncodeItem?.(rowIndex, 'chain', event.value)}
|
||||||
|
className="full-width"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeItem?.(rowIndex)} />
|
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeItem?.(rowIndex)} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -98,6 +98,46 @@ function normalizeChainId(value) {
|
|||||||
return Math.trunc(parsed);
|
return Math.trunc(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMediaProfile(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePipelineMediaProfile(pipeline, mediaInfoReview) {
|
||||||
|
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
||||||
|
const device = context?.device && typeof context.device === 'object' ? context.device : {};
|
||||||
|
const review = mediaInfoReview && typeof mediaInfoReview === 'object' ? mediaInfoReview : {};
|
||||||
|
const candidates = [
|
||||||
|
context?.mediaProfile,
|
||||||
|
context?.media_profile,
|
||||||
|
review?.mediaProfile,
|
||||||
|
review?.media_profile,
|
||||||
|
device?.mediaProfile,
|
||||||
|
device?.media_profile,
|
||||||
|
device?.profile,
|
||||||
|
device?.type
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeMediaProfile(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function isBurnedSubtitleTrack(track) {
|
function isBurnedSubtitleTrack(track) {
|
||||||
const flags = Array.isArray(track?.subtitlePreviewFlags)
|
const flags = Array.isArray(track?.subtitlePreviewFlags)
|
||||||
? track.subtitlePreviewFlags
|
? track.subtitlePreviewFlags
|
||||||
@@ -115,6 +155,7 @@ function isBurnedSubtitleTrack(track) {
|
|||||||
function buildDefaultTrackSelection(review) {
|
function buildDefaultTrackSelection(review) {
|
||||||
const titles = Array.isArray(review?.titles) ? review.titles : [];
|
const titles = Array.isArray(review?.titles) ? review.titles : [];
|
||||||
const selection = {};
|
const selection = {};
|
||||||
|
const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId);
|
||||||
|
|
||||||
for (const title of titles) {
|
for (const title of titles) {
|
||||||
const titleId = normalizeTitleId(title?.id);
|
const titleId = normalizeTitleId(title?.id);
|
||||||
@@ -122,15 +163,27 @@ function buildDefaultTrackSelection(review) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
|
||||||
|
const subtitleTracks = Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [];
|
||||||
|
const isEncodeInputTitle = Boolean(
|
||||||
|
title?.selectedForEncode
|
||||||
|
|| title?.encodeInput
|
||||||
|
|| (reviewEncodeInputTitleId && reviewEncodeInputTitleId === titleId)
|
||||||
|
);
|
||||||
|
const audioSelectionSource = isEncodeInputTitle
|
||||||
|
? audioTracks.filter((track) => Boolean(track?.selectedForEncode))
|
||||||
|
: audioTracks.filter((track) => Boolean(track?.selectedByRule));
|
||||||
|
const subtitleSelectionSource = isEncodeInputTitle
|
||||||
|
? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode))
|
||||||
|
: subtitleTracks.filter((track) => Boolean(track?.selectedByRule));
|
||||||
|
|
||||||
selection[titleId] = {
|
selection[titleId] = {
|
||||||
audioTrackIds: normalizeTrackIdList(
|
audioTrackIds: normalizeTrackIdList(
|
||||||
(Array.isArray(title?.audioTracks) ? title.audioTracks : [])
|
audioSelectionSource.map((track) => track?.id)
|
||||||
.filter((track) => Boolean(track?.selectedByRule))
|
|
||||||
.map((track) => track?.id)
|
|
||||||
),
|
),
|
||||||
subtitleTrackIds: normalizeTrackIdList(
|
subtitleTrackIds: normalizeTrackIdList(
|
||||||
(Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
|
subtitleSelectionSource
|
||||||
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
|
.filter((track) => !isBurnedSubtitleTrack(track))
|
||||||
.map((track) => track?.id)
|
.map((track) => track?.id)
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -235,10 +288,9 @@ export default function PipelineStatusCard({
|
|||||||
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
||||||
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
|
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
|
||||||
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
|
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
|
||||||
const reviewConfirmed = Boolean(pipeline?.context?.reviewConfirmed || mediaInfoReview?.reviewConfirmed);
|
|
||||||
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
||||||
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
||||||
const jobMediaProfile = String(pipeline?.context?.mediaProfile || '').trim().toLowerCase() || null;
|
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
|
||||||
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
|
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
|
||||||
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
||||||
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
||||||
@@ -319,8 +371,16 @@ export default function PipelineStatusCard({
|
|||||||
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
|
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
|
||||||
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
|
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
|
||||||
]);
|
]);
|
||||||
setSelectedUserPresetId(null);
|
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
|
||||||
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
setSelectedUserPresetId(Number.isFinite(userPresetId) && userPresetId > 0 ? Math.trunc(userPresetId) : null);
|
||||||
|
}, [
|
||||||
|
mediaInfoReview?.encodeInputTitleId,
|
||||||
|
mediaInfoReview?.generatedAt,
|
||||||
|
mediaInfoReview?.reviewConfirmedAt,
|
||||||
|
mediaInfoReview?.prefilledFromPreviousRunAt,
|
||||||
|
mediaInfoReview?.userPreset?.id,
|
||||||
|
retryJobId
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentTitleId = normalizeTitleId(selectedEncodeTitleId);
|
const currentTitleId = normalizeTitleId(selectedEncodeTitleId);
|
||||||
@@ -348,10 +408,11 @@ export default function PipelineStatusCard({
|
|||||||
|
|
||||||
// Filter user presets by job media profile ('all' presets always shown)
|
// Filter user presets by job media profile ('all' presets always shown)
|
||||||
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
||||||
|
const presetMediaType = normalizeMediaProfile(p?.mediaType) || 'all';
|
||||||
if (!jobMediaProfile) {
|
if (!jobMediaProfile) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return p.mediaType === 'all' || p.mediaType === jobMediaProfile;
|
return presetMediaType === 'all' || presetMediaType === jobMediaProfile;
|
||||||
});
|
});
|
||||||
const canStartReadyJob = isPreRipReview
|
const canStartReadyJob = isPreRipReview
|
||||||
? Boolean(retryJobId)
|
? Boolean(retryJobId)
|
||||||
@@ -592,12 +653,6 @@ export default function PipelineStatusCard({
|
|||||||
icon="pi pi-play"
|
icon="pi pi-play"
|
||||||
severity="success"
|
severity="success"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const requiresAutoConfirm = !reviewConfirmed;
|
|
||||||
if (!requiresAutoConfirm) {
|
|
||||||
await onStart(retryJobId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
encodeTitleId,
|
encodeTitleId,
|
||||||
selectedTrackSelection,
|
selectedTrackSelection,
|
||||||
|
|||||||
@@ -82,6 +82,103 @@ function formatUpdatedAt(value) {
|
|||||||
return date.toLocaleString('de-DE');
|
return date.toLocaleString('de-DE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDurationMs(value) {
|
||||||
|
const ms = Number(value);
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${Math.round(ms)} ms`;
|
||||||
|
}
|
||||||
|
const seconds = Math.round(ms / 1000);
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const restSeconds = seconds % 60;
|
||||||
|
return `${minutes}m ${restSeconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuntimeActivitiesPayload(rawPayload) {
|
||||||
|
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
||||||
|
const normalizeItem = (item) => {
|
||||||
|
const source = item && typeof item === 'object' ? item : {};
|
||||||
|
const parsedId = Number(source.id);
|
||||||
|
const id = Number.isFinite(parsedId) && parsedId > 0 ? Math.trunc(parsedId) : null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: String(source.type || '').trim().toLowerCase() || 'task',
|
||||||
|
name: String(source.name || '').trim() || '-',
|
||||||
|
status: String(source.status || '').trim().toLowerCase() || 'running',
|
||||||
|
outcome: String(source.outcome || '').trim().toLowerCase() || null,
|
||||||
|
source: String(source.source || '').trim() || null,
|
||||||
|
message: String(source.message || '').trim() || null,
|
||||||
|
errorMessage: String(source.errorMessage || '').trim() || null,
|
||||||
|
currentStep: String(source.currentStep || '').trim() || null,
|
||||||
|
currentScriptName: String(source.currentScriptName || '').trim() || null,
|
||||||
|
output: source.output != null ? String(source.output) : null,
|
||||||
|
stdout: source.stdout != null ? String(source.stdout) : null,
|
||||||
|
stderr: source.stderr != null ? String(source.stderr) : null,
|
||||||
|
stdoutTruncated: Boolean(source.stdoutTruncated),
|
||||||
|
stderrTruncated: Boolean(source.stderrTruncated),
|
||||||
|
exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
|
||||||
|
startedAt: source.startedAt || null,
|
||||||
|
finishedAt: source.finishedAt || null,
|
||||||
|
durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
|
||||||
|
jobId: Number.isFinite(Number(source.jobId)) && Number(source.jobId) > 0 ? Math.trunc(Number(source.jobId)) : null,
|
||||||
|
cronJobId: Number.isFinite(Number(source.cronJobId)) && Number(source.cronJobId) > 0 ? Math.trunc(Number(source.cronJobId)) : null,
|
||||||
|
canCancel: Boolean(source.canCancel),
|
||||||
|
canNextStep: Boolean(source.canNextStep)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const active = (Array.isArray(payload.active) ? payload.active : []).map(normalizeItem);
|
||||||
|
const recent = (Array.isArray(payload.recent) ? payload.recent : []).map(normalizeItem);
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
recent,
|
||||||
|
updatedAt: payload.updatedAt || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeTypeLabel(type) {
|
||||||
|
const normalized = String(type || '').trim().toLowerCase();
|
||||||
|
if (normalized === 'script') return 'Skript';
|
||||||
|
if (normalized === 'chain') return 'Kette';
|
||||||
|
if (normalized === 'cron') return 'Cronjob';
|
||||||
|
return normalized || 'Task';
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeStatusMeta(status) {
|
||||||
|
const normalized = String(status || '').trim().toLowerCase();
|
||||||
|
if (normalized === 'running') return { label: 'Läuft', severity: 'warning' };
|
||||||
|
if (normalized === 'success') return { label: 'Abgeschlossen', severity: 'success' };
|
||||||
|
if (normalized === 'error') return { label: 'Fehler', severity: 'danger' };
|
||||||
|
return { label: normalized || '-', severity: 'secondary' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeOutcomeMeta(outcome, status) {
|
||||||
|
const normalized = String(outcome || '').trim().toLowerCase();
|
||||||
|
if (normalized === 'success') return { label: 'Erfolg', severity: 'success' };
|
||||||
|
if (normalized === 'error') return { label: 'Fehler', severity: 'danger' };
|
||||||
|
if (normalized === 'cancelled') return { label: 'Abgebrochen', severity: 'warning' };
|
||||||
|
if (normalized === 'skipped') return { label: 'Übersprungen', severity: 'info' };
|
||||||
|
return runtimeStatusMeta(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRuntimeOutputDetails(item) {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const hasRelevantExitCode = Number.isFinite(Number(item.exitCode)) && Number(item.exitCode) !== 0;
|
||||||
|
return Boolean(
|
||||||
|
String(item.errorMessage || '').trim()
|
||||||
|
|| String(item.output || '').trim()
|
||||||
|
|| String(item.stdout || '').trim()
|
||||||
|
|| String(item.stderr || '').trim()
|
||||||
|
|| hasRelevantExitCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeHardwareMonitoringPayload(rawPayload) {
|
function normalizeHardwareMonitoringPayload(rawPayload) {
|
||||||
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
||||||
return {
|
return {
|
||||||
@@ -172,6 +269,71 @@ function queueEntryLabel(item) {
|
|||||||
return item.title || `Job #${item.jobId}`;
|
return item.title || `Job #${item.jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeQueueNameList(values) {
|
||||||
|
const list = Array.isArray(values) ? values : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const name = String(item || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
output.push(name);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQueueScriptSummary(item) {
|
||||||
|
const source = item?.scriptSummary && typeof item.scriptSummary === 'object' ? item.scriptSummary : {};
|
||||||
|
return {
|
||||||
|
preScripts: normalizeQueueNameList(source.preScripts),
|
||||||
|
postScripts: normalizeQueueNameList(source.postScripts),
|
||||||
|
preChains: normalizeQueueNameList(source.preChains),
|
||||||
|
postChains: normalizeQueueNameList(source.postChains)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasQueueScriptSummary(item) {
|
||||||
|
const summary = normalizeQueueScriptSummary(item);
|
||||||
|
return summary.preScripts.length > 0
|
||||||
|
|| summary.postScripts.length > 0
|
||||||
|
|| summary.preChains.length > 0
|
||||||
|
|| summary.postChains.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueueJobScriptSummary({ item }) {
|
||||||
|
const summary = normalizeQueueScriptSummary(item);
|
||||||
|
const groups = [
|
||||||
|
{ key: 'pre-scripts', icon: 'pi pi-code', label: 'Pre-Encode Skripte', values: summary.preScripts },
|
||||||
|
{ key: 'post-scripts', icon: 'pi pi-code', label: 'Post-Encode Skripte', values: summary.postScripts },
|
||||||
|
{ key: 'pre-chains', icon: 'pi pi-link', label: 'Pre-Encode Ketten', values: summary.preChains },
|
||||||
|
{ key: 'post-chains', icon: 'pi pi-link', label: 'Post-Encode Ketten', values: summary.postChains }
|
||||||
|
].filter((group) => group.values.length > 0);
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="queue-job-script-details">
|
||||||
|
{groups.map((group) => {
|
||||||
|
const text = group.values.join(' | ');
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="queue-job-script-group">
|
||||||
|
<strong><i className={group.icon} /> {group.label}</strong>
|
||||||
|
<small title={text}>{text}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getAnalyzeContext(job) {
|
function getAnalyzeContext(job) {
|
||||||
return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
|
return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
|
||||||
? job.makemkvInfo.analyzeContext
|
? job.makemkvInfo.analyzeContext
|
||||||
@@ -397,10 +559,14 @@ export default function DashboardPage({
|
|||||||
const [draggingQueueEntryId, setDraggingQueueEntryId] = useState(null);
|
const [draggingQueueEntryId, setDraggingQueueEntryId] = useState(null);
|
||||||
const [insertQueueDialog, setInsertQueueDialog] = useState({ visible: false, afterEntryId: null });
|
const [insertQueueDialog, setInsertQueueDialog] = useState({ visible: false, afterEntryId: null });
|
||||||
const [liveJobLog, setLiveJobLog] = useState('');
|
const [liveJobLog, setLiveJobLog] = useState('');
|
||||||
|
const [runtimeActivities, setRuntimeActivities] = useState(() => normalizeRuntimeActivitiesPayload(null));
|
||||||
|
const [runtimeLoading, setRuntimeLoading] = useState(false);
|
||||||
|
const [runtimeActionBusyKeys, setRuntimeActionBusyKeys] = useState(() => new Set());
|
||||||
const [jobsLoading, setJobsLoading] = useState(false);
|
const [jobsLoading, setJobsLoading] = useState(false);
|
||||||
const [dashboardJobs, setDashboardJobs] = useState([]);
|
const [dashboardJobs, setDashboardJobs] = useState([]);
|
||||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||||
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
||||||
|
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
||||||
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
||||||
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
@@ -438,7 +604,11 @@ export default function DashboardPage({
|
|||||||
setJobsLoading(true);
|
setJobsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [jobsResponse, queueResponse] = await Promise.allSettled([
|
const [jobsResponse, queueResponse] = await Promise.allSettled([
|
||||||
api.getJobs(),
|
api.getJobs({
|
||||||
|
statuses: Array.from(dashboardStatuses),
|
||||||
|
limit: 160,
|
||||||
|
lite: true
|
||||||
|
}),
|
||||||
api.getPipelineQueue()
|
api.getPipelineQueue()
|
||||||
]);
|
]);
|
||||||
const allJobs = jobsResponse.status === 'fulfilled'
|
const allJobs = jobsResponse.status === 'fulfilled'
|
||||||
@@ -453,7 +623,7 @@ export default function DashboardPage({
|
|||||||
|
|
||||||
if (currentPipelineJobId && !next.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
if (currentPipelineJobId && !next.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
||||||
try {
|
try {
|
||||||
const active = await api.getJob(currentPipelineJobId);
|
const active = await api.getJob(currentPipelineJobId, { lite: true });
|
||||||
if (active?.job) {
|
if (active?.job) {
|
||||||
next.unshift(active.job);
|
next.unshift(active.job);
|
||||||
}
|
}
|
||||||
@@ -501,6 +671,35 @@ export default function DashboardPage({
|
|||||||
void loadDashboardJobs();
|
void loadDashboardJobs();
|
||||||
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId]);
|
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async (silent = false) => {
|
||||||
|
try {
|
||||||
|
const response = await api.getRuntimeActivities();
|
||||||
|
if (!cancelled) {
|
||||||
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response));
|
||||||
|
if (!silent) {
|
||||||
|
setRuntimeLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
if (!cancelled && !silent) {
|
||||||
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(null));
|
||||||
|
setRuntimeLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setRuntimeLoading(true);
|
||||||
|
void load(false);
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void load(true);
|
||||||
|
}, 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedExpanded = normalizeJobId(expandedJobId);
|
const normalizedExpanded = normalizeJobId(expandedJobId);
|
||||||
const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
|
const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
|
||||||
@@ -529,7 +728,7 @@ export default function DashboardPage({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const refreshLiveLog = async () => {
|
const refreshLiveLog = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.getJob(currentPipelineJobId, { includeLiveLog: true });
|
const response = await api.getJob(currentPipelineJobId, { includeLiveLog: true, lite: true });
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLiveJobLog(response?.job?.log || '');
|
setLiveJobLog(response?.job?.log || '');
|
||||||
}
|
}
|
||||||
@@ -730,19 +929,55 @@ export default function DashboardPage({
|
|||||||
await api.cancelPipeline(cancelledJobId);
|
await api.cancelPipeline(cancelledJobId);
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
if (cancelledState === 'ENCODING' && cancelledJobId) {
|
let latestCancelledJob = null;
|
||||||
|
const fetchLatestCancelledJob = async () => {
|
||||||
|
if (!cancelledJobId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const latestResponse = await api.getJob(cancelledJobId, { lite: true });
|
||||||
|
return latestResponse?.job && typeof latestResponse.job === 'object'
|
||||||
|
? latestResponse.job
|
||||||
|
: null;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
latestCancelledJob = await fetchLatestCancelledJob();
|
||||||
|
if (cancelledState === 'ENCODING') {
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
const latestStatus = String(
|
||||||
|
latestCancelledJob?.status
|
||||||
|
|| latestCancelledJob?.last_state
|
||||||
|
|| ''
|
||||||
|
).trim().toUpperCase();
|
||||||
|
if (latestStatus && latestStatus !== 'ENCODING') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await wait(250);
|
||||||
|
latestCancelledJob = await fetchLatestCancelledJob();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const latestStatus = String(
|
||||||
|
latestCancelledJob?.status
|
||||||
|
|| latestCancelledJob?.last_state
|
||||||
|
|| ''
|
||||||
|
).trim().toUpperCase();
|
||||||
|
const autoPreparedForRestart = cancelledState === 'ENCODING' && latestStatus === 'READY_TO_ENCODE';
|
||||||
|
if (cancelledState === 'ENCODING' && cancelledJobId && !autoPreparedForRestart) {
|
||||||
setCancelCleanupDialog({
|
setCancelCleanupDialog({
|
||||||
visible: true,
|
visible: true,
|
||||||
jobId: cancelledJobId,
|
jobId: cancelledJobId,
|
||||||
target: 'movie',
|
target: 'movie',
|
||||||
path: cancelledJob?.output_path || null
|
path: latestCancelledJob?.output_path || cancelledJob?.output_path || null
|
||||||
});
|
});
|
||||||
} else if (cancelledState === 'RIPPING' && cancelledJobId) {
|
} else if (cancelledState === 'RIPPING' && cancelledJobId) {
|
||||||
setCancelCleanupDialog({
|
setCancelCleanupDialog({
|
||||||
visible: true,
|
visible: true,
|
||||||
jobId: cancelledJobId,
|
jobId: cancelledJobId,
|
||||||
target: 'raw',
|
target: 'raw',
|
||||||
path: cancelledJob?.raw_path || null
|
path: latestCancelledJob?.raw_path || cancelledJob?.raw_path || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -797,16 +1032,27 @@ export default function DashboardPage({
|
|||||||
setJobBusy(normalizedJobId, true);
|
setJobBusy(normalizedJobId, true);
|
||||||
try {
|
try {
|
||||||
if (startOptions.ensureConfirmed) {
|
if (startOptions.ensureConfirmed) {
|
||||||
await api.confirmEncodeReview(normalizedJobId, {
|
const confirmPayload = {
|
||||||
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
||||||
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
||||||
selectedPostEncodeScriptIds: startOptions.selectedPostEncodeScriptIds ?? [],
|
|
||||||
selectedPreEncodeScriptIds: startOptions.selectedPreEncodeScriptIds ?? [],
|
|
||||||
selectedPostEncodeChainIds: startOptions.selectedPostEncodeChainIds ?? [],
|
|
||||||
selectedPreEncodeChainIds: startOptions.selectedPreEncodeChainIds ?? [],
|
|
||||||
selectedUserPresetId: startOptions.selectedUserPresetId ?? null,
|
|
||||||
skipPipelineStateUpdate: true
|
skipPipelineStateUpdate: true
|
||||||
});
|
};
|
||||||
|
if (startOptions.selectedPostEncodeScriptIds !== undefined) {
|
||||||
|
confirmPayload.selectedPostEncodeScriptIds = startOptions.selectedPostEncodeScriptIds;
|
||||||
|
}
|
||||||
|
if (startOptions.selectedPreEncodeScriptIds !== undefined) {
|
||||||
|
confirmPayload.selectedPreEncodeScriptIds = startOptions.selectedPreEncodeScriptIds;
|
||||||
|
}
|
||||||
|
if (startOptions.selectedPostEncodeChainIds !== undefined) {
|
||||||
|
confirmPayload.selectedPostEncodeChainIds = startOptions.selectedPostEncodeChainIds;
|
||||||
|
}
|
||||||
|
if (startOptions.selectedPreEncodeChainIds !== undefined) {
|
||||||
|
confirmPayload.selectedPreEncodeChainIds = startOptions.selectedPreEncodeChainIds;
|
||||||
|
}
|
||||||
|
if (startOptions.selectedUserPresetId !== undefined) {
|
||||||
|
confirmPayload.selectedUserPresetId = startOptions.selectedUserPresetId;
|
||||||
|
}
|
||||||
|
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
|
||||||
}
|
}
|
||||||
const response = await api.startJob(normalizedJobId);
|
const response = await api.startJob(normalizedJobId);
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
@@ -1083,6 +1329,49 @@ export default function DashboardPage({
|
|||||||
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
||||||
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
||||||
const canReorderQueue = queuedJobs.length > 1 && !queueReorderBusy;
|
const canReorderQueue = queuedJobs.length > 1 && !queueReorderBusy;
|
||||||
|
const buildRunningQueueScriptKey = (jobId) => `running-${normalizeJobId(jobId) || '-'}`;
|
||||||
|
const buildQueuedQueueScriptKey = (entryId) => `queued-${Number(entryId) || '-'}`;
|
||||||
|
const toggleQueueScriptDetails = (key) => {
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedQueueScriptKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const validKeys = new Set();
|
||||||
|
for (const item of queueRunningJobs) {
|
||||||
|
if (!hasQueueScriptSummary(item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validKeys.add(buildRunningQueueScriptKey(item?.jobId));
|
||||||
|
}
|
||||||
|
for (const item of queuedJobs) {
|
||||||
|
if (String(item?.type || 'job') !== 'job' || !hasQueueScriptSummary(item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validKeys.add(buildQueuedQueueScriptKey(item?.entryId));
|
||||||
|
}
|
||||||
|
setExpandedQueueScriptKeys((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set();
|
||||||
|
for (const key of prev) {
|
||||||
|
if (validKeys.has(key)) {
|
||||||
|
next.add(key);
|
||||||
|
} else {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [queueRunningJobs, queuedJobs]);
|
||||||
const queuedJobIdSet = useMemo(() => {
|
const queuedJobIdSet = useMemo(() => {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
for (const item of queuedJobs) {
|
for (const item of queuedJobs) {
|
||||||
@@ -1094,6 +1383,66 @@ export default function DashboardPage({
|
|||||||
return set;
|
return set;
|
||||||
}, [queuedJobs]);
|
}, [queuedJobs]);
|
||||||
|
|
||||||
|
const setRuntimeActionBusy = (activityId, action, busyFlag) => {
|
||||||
|
const key = `${Number(activityId) || 0}:${String(action || '')}`;
|
||||||
|
setRuntimeActionBusyKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (busyFlag) {
|
||||||
|
next.add(key);
|
||||||
|
} else {
|
||||||
|
next.delete(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRuntimeActionBusy = (activityId, action) => runtimeActionBusyKeys.has(
|
||||||
|
`${Number(activityId) || 0}:${String(action || '')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRuntimeControl = async (item, action) => {
|
||||||
|
const activityId = Number(item?.id);
|
||||||
|
if (!Number.isFinite(activityId) || activityId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedAction = String(action || '').trim().toLowerCase();
|
||||||
|
const actionLabel = normalizedAction === 'next-step' ? 'Nächster Schritt' : 'Abbrechen';
|
||||||
|
setRuntimeActionBusy(activityId, normalizedAction, true);
|
||||||
|
try {
|
||||||
|
const response = normalizedAction === 'next-step'
|
||||||
|
? await api.requestRuntimeNextStep(activityId)
|
||||||
|
: await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Dashboard' });
|
||||||
|
if (response?.snapshot) {
|
||||||
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot));
|
||||||
|
} else {
|
||||||
|
const fresh = await api.getRuntimeActivities();
|
||||||
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(fresh));
|
||||||
|
}
|
||||||
|
const accepted = response?.action?.accepted !== false;
|
||||||
|
const actionMessage = String(response?.action?.message || '').trim();
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: accepted ? 'info' : 'warn',
|
||||||
|
summary: actionLabel,
|
||||||
|
detail: actionMessage || (accepted ? 'Aktion ausgelöst.' : 'Aktion aktuell nicht möglich.'),
|
||||||
|
life: 2600
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: actionLabel,
|
||||||
|
detail: error?.message || 'Aktion fehlgeschlagen.',
|
||||||
|
life: 3200
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setRuntimeActionBusy(activityId, normalizedAction, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeActiveItems = Array.isArray(runtimeActivities?.active) ? runtimeActivities.active : [];
|
||||||
|
const runtimeRecentItems = Array.isArray(runtimeActivities?.recent)
|
||||||
|
? runtimeActivities.recent.slice(0, 8)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
@@ -1288,12 +1637,37 @@ export default function DashboardPage({
|
|||||||
{queueRunningJobs.length === 0 ? (
|
{queueRunningJobs.length === 0 ? (
|
||||||
<small>Keine laufenden Jobs.</small>
|
<small>Keine laufenden Jobs.</small>
|
||||||
) : (
|
) : (
|
||||||
queueRunningJobs.map((item) => (
|
queueRunningJobs.map((item) => {
|
||||||
<div key={`running-${item.jobId}`} className="pipeline-queue-item running">
|
const hasScriptSummary = hasQueueScriptSummary(item);
|
||||||
<strong>#{item.jobId} | {item.title || `Job #${item.jobId}`}</strong>
|
const detailKey = buildRunningQueueScriptKey(item?.jobId);
|
||||||
<small>{getStatusLabel(item.status)}</small>
|
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
||||||
</div>
|
return (
|
||||||
))
|
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
||||||
|
<div className="pipeline-queue-item running queue-job-item">
|
||||||
|
<div className="pipeline-queue-item-main">
|
||||||
|
<strong>
|
||||||
|
#{item.jobId} | {item.title || `Job #${item.jobId}`}
|
||||||
|
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
||||||
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
|
</strong>
|
||||||
|
<small>{getStatusLabel(item.status)}</small>
|
||||||
|
</div>
|
||||||
|
{hasScriptSummary ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="queue-job-expand-btn"
|
||||||
|
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
|
||||||
|
aria-expanded={detailsExpanded}
|
||||||
|
onClick={() => toggleQueueScriptDetails(detailKey)}
|
||||||
|
>
|
||||||
|
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pipeline-queue-col">
|
<div className="pipeline-queue-col">
|
||||||
@@ -1316,6 +1690,9 @@ export default function DashboardPage({
|
|||||||
const entryId = Number(item?.entryId);
|
const entryId = Number(item?.entryId);
|
||||||
const isNonJob = item.type && item.type !== 'job';
|
const isNonJob = item.type && item.type !== 'job';
|
||||||
const isDragging = Number(draggingQueueEntryId) === entryId;
|
const isDragging = Number(draggingQueueEntryId) === entryId;
|
||||||
|
const hasScriptSummary = !isNonJob && hasQueueScriptSummary(item);
|
||||||
|
const detailKey = buildQueuedQueueScriptKey(entryId);
|
||||||
|
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
||||||
return (
|
return (
|
||||||
<div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
|
<div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
|
||||||
<div
|
<div
|
||||||
@@ -1351,25 +1728,43 @@ export default function DashboardPage({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="pipeline-queue-item-actions">
|
||||||
icon="pi pi-times"
|
{hasScriptSummary ? (
|
||||||
severity="danger"
|
<button
|
||||||
text
|
type="button"
|
||||||
rounded
|
className="queue-job-expand-btn"
|
||||||
size="small"
|
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
|
||||||
className="pipeline-queue-remove-btn"
|
aria-expanded={detailsExpanded}
|
||||||
disabled={queueReorderBusy}
|
onClick={(event) => {
|
||||||
onClick={(event) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
event.stopPropagation();
|
||||||
event.stopPropagation();
|
toggleQueueScriptDetails(detailKey);
|
||||||
if (isNonJob) {
|
}}
|
||||||
void handleRemoveQueueEntry(entryId);
|
>
|
||||||
} else {
|
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
|
||||||
void handleRemoveQueuedJob(item.jobId);
|
</button>
|
||||||
}
|
) : null}
|
||||||
}}
|
<Button
|
||||||
/>
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
className="pipeline-queue-remove-btn"
|
||||||
|
disabled={queueReorderBusy}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isNonJob) {
|
||||||
|
void handleRemoveQueueEntry(entryId);
|
||||||
|
} else {
|
||||||
|
void handleRemoveQueuedJob(item.jobId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="queue-insert-btn"
|
className="queue-insert-btn"
|
||||||
@@ -1387,6 +1782,150 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Skript- / Cron-Status" subTitle="Laufende und zuletzt abgeschlossene Skript-, Ketten- und Cron-Ausführungen.">
|
||||||
|
<div className="runtime-activity-meta">
|
||||||
|
<Tag value={`Laufend: ${runtimeActiveItems.length}`} severity={runtimeActiveItems.length > 0 ? 'warning' : 'success'} />
|
||||||
|
<Tag value={`Zuletzt: ${runtimeRecentItems.length}`} severity="info" />
|
||||||
|
<Tag value={`Update: ${formatUpdatedAt(runtimeActivities?.updatedAt)}`} severity="secondary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{runtimeLoading && runtimeActiveItems.length === 0 && runtimeRecentItems.length === 0 ? (
|
||||||
|
<p>Aktivitäten werden geladen ...</p>
|
||||||
|
) : (
|
||||||
|
<div className="runtime-activity-grid">
|
||||||
|
<div className="runtime-activity-col">
|
||||||
|
<h4>Aktiv</h4>
|
||||||
|
{runtimeActiveItems.length === 0 ? (
|
||||||
|
<small>Keine laufenden Skript-/Ketten-/Cron-Ausführungen.</small>
|
||||||
|
) : (
|
||||||
|
<div className="runtime-activity-list">
|
||||||
|
{runtimeActiveItems.map((item, index) => {
|
||||||
|
const statusMeta = runtimeStatusMeta(item?.status);
|
||||||
|
const canCancel = Boolean(item?.canCancel);
|
||||||
|
const canNextStep = String(item?.type || '').trim().toLowerCase() === 'chain' && Boolean(item?.canNextStep);
|
||||||
|
const cancelBusy = isRuntimeActionBusy(item?.id, 'cancel');
|
||||||
|
const nextStepBusy = isRuntimeActionBusy(item?.id, 'next-step');
|
||||||
|
return (
|
||||||
|
<div key={`runtime-active-${item?.id || index}`} className="runtime-activity-item">
|
||||||
|
<div className="runtime-activity-head">
|
||||||
|
<strong>{item?.name || '-'}</strong>
|
||||||
|
<div className="runtime-activity-tags">
|
||||||
|
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
|
||||||
|
<Tag value={statusMeta.label} severity={statusMeta.severity} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
Quelle: {item?.source || '-'}
|
||||||
|
{item?.jobId ? ` | Job #${item.jobId}` : ''}
|
||||||
|
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
|
||||||
|
</small>
|
||||||
|
{item?.currentStep ? <small>Schritt: {item.currentStep}</small> : null}
|
||||||
|
{item?.currentScriptName ? <small>Laufendes Skript: {item.currentScriptName}</small> : null}
|
||||||
|
{item?.message ? <small>{item.message}</small> : null}
|
||||||
|
<small>Gestartet: {formatUpdatedAt(item?.startedAt)}</small>
|
||||||
|
{canCancel || canNextStep ? (
|
||||||
|
<div className="runtime-activity-actions">
|
||||||
|
{canNextStep ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
icon="pi pi-step-forward"
|
||||||
|
label="Nächster Schritt"
|
||||||
|
outlined
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
loading={nextStepBusy}
|
||||||
|
disabled={cancelBusy}
|
||||||
|
onClick={() => {
|
||||||
|
void handleRuntimeControl(item, 'next-step');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canCancel ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
icon="pi pi-stop"
|
||||||
|
label="Abbrechen"
|
||||||
|
outlined
|
||||||
|
severity="danger"
|
||||||
|
size="small"
|
||||||
|
loading={cancelBusy}
|
||||||
|
disabled={nextStepBusy}
|
||||||
|
onClick={() => {
|
||||||
|
void handleRuntimeControl(item, 'cancel');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="runtime-activity-col">
|
||||||
|
<h4>Zuletzt abgeschlossen</h4>
|
||||||
|
{runtimeRecentItems.length === 0 ? (
|
||||||
|
<small>Keine abgeschlossenen Einträge vorhanden.</small>
|
||||||
|
) : (
|
||||||
|
<div className="runtime-activity-list">
|
||||||
|
{runtimeRecentItems.map((item, index) => {
|
||||||
|
const outcomeMeta = runtimeOutcomeMeta(item?.outcome, item?.status);
|
||||||
|
return (
|
||||||
|
<div key={`runtime-recent-${item?.id || index}`} className="runtime-activity-item done">
|
||||||
|
<div className="runtime-activity-head">
|
||||||
|
<strong>{item?.name || '-'}</strong>
|
||||||
|
<div className="runtime-activity-tags">
|
||||||
|
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
|
||||||
|
<Tag value={outcomeMeta.label} severity={outcomeMeta.severity} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
Quelle: {item?.source || '-'}
|
||||||
|
{item?.jobId ? ` | Job #${item.jobId}` : ''}
|
||||||
|
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
|
||||||
|
</small>
|
||||||
|
{Number.isFinite(Number(item?.exitCode)) ? <small>Exit-Code: {item.exitCode}</small> : null}
|
||||||
|
{item?.message ? <small>{item.message}</small> : null}
|
||||||
|
{item?.errorMessage ? <small className="error-text">{item.errorMessage}</small> : null}
|
||||||
|
{hasRuntimeOutputDetails(item) ? (
|
||||||
|
<details className="runtime-activity-details">
|
||||||
|
<summary>Details anzeigen</summary>
|
||||||
|
{item?.output ? (
|
||||||
|
<div className="runtime-activity-details-block">
|
||||||
|
<small><strong>Ausgabe:</strong></small>
|
||||||
|
<pre>{item.output}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item?.stderr ? (
|
||||||
|
<div className="runtime-activity-details-block">
|
||||||
|
<small><strong>stderr:</strong>{item?.stderrTruncated ? ' (gekürzt)' : ''}</small>
|
||||||
|
<pre>{item.stderr}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item?.stdout ? (
|
||||||
|
<div className="runtime-activity-details-block">
|
||||||
|
<small><strong>stdout:</strong>{item?.stdoutTruncated ? ' (gekürzt)' : ''}</small>
|
||||||
|
<pre>{item.stdout}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
<small>
|
||||||
|
Ende: {formatUpdatedAt(item?.finishedAt || item?.startedAt)}
|
||||||
|
{item?.durationMs != null ? ` | Dauer: ${formatDurationMs(item.durationMs)}` : ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
||||||
{jobsLoading ? (
|
{jobsLoading ? (
|
||||||
<p>Jobs werden geladen ...</p>
|
<p>Jobs werden geladen ...</p>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Dialog } from 'primereact/dialog';
|
|||||||
import { TabView, TabPanel } from 'primereact/tabview';
|
import { TabView, TabPanel } from 'primereact/tabview';
|
||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
import { InputTextarea } from 'primereact/inputtextarea';
|
import { InputTextarea } from 'primereact/inputtextarea';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||||
import CronJobsTab from '../components/CronJobsTab';
|
import CronJobsTab from '../components/CronJobsTab';
|
||||||
@@ -51,6 +52,58 @@ function reorderListById(items, sourceId, targetIndex) {
|
|||||||
return { changed: true, next };
|
return { changed: true, next };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildHandBrakePresetSelectOptions(sourceOptions, extraValues = []) {
|
||||||
|
const rawOptions = Array.isArray(sourceOptions) ? sourceOptions : [];
|
||||||
|
const rawExtraValues = Array.isArray(extraValues) ? extraValues : [];
|
||||||
|
const normalizedOptions = [];
|
||||||
|
const seenValues = new Set();
|
||||||
|
const seenGroupLabels = new Set();
|
||||||
|
|
||||||
|
const addGroupOption = (option) => {
|
||||||
|
const rawLabel = String(option?.label || '').trim();
|
||||||
|
if (!rawLabel || seenGroupLabels.has(rawLabel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenGroupLabels.add(rawLabel);
|
||||||
|
normalizedOptions.push({
|
||||||
|
...option,
|
||||||
|
label: rawLabel,
|
||||||
|
value: String(option?.value || `__group__${rawLabel.toLowerCase().replace(/\s+/g, '_')}`),
|
||||||
|
disabled: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSelectableOption = (optionValue, optionLabel = optionValue, option = null) => {
|
||||||
|
const value = String(optionValue || '').trim();
|
||||||
|
if (seenValues.has(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenValues.add(value);
|
||||||
|
normalizedOptions.push({
|
||||||
|
...(option && typeof option === 'object' ? option : {}),
|
||||||
|
label: String(optionLabel ?? value),
|
||||||
|
value,
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
normalizedOptions.push({ label: '(kein Preset – nur CLI-Parameter)', value: '', disabled: false });
|
||||||
|
seenValues.add('');
|
||||||
|
|
||||||
|
for (const option of rawOptions) {
|
||||||
|
if (option?.disabled) {
|
||||||
|
addGroupOption(option);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addSelectableOption(option?.value, option?.label, option);
|
||||||
|
}
|
||||||
|
for (const value of rawExtraValues) {
|
||||||
|
addSelectableOption(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
function injectHandBrakePresetOptions(categories, presetPayload) {
|
function injectHandBrakePresetOptions(categories, presetPayload) {
|
||||||
const list = Array.isArray(categories) ? categories : [];
|
const list = Array.isArray(categories) ? categories : [];
|
||||||
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
||||||
@@ -62,50 +115,10 @@ function injectHandBrakePresetOptions(categories, presetPayload) {
|
|||||||
if (!presetSettingKeys.has(String(setting?.key || '').trim().toLowerCase())) {
|
if (!presetSettingKeys.has(String(setting?.key || '').trim().toLowerCase())) {
|
||||||
return setting;
|
return setting;
|
||||||
}
|
}
|
||||||
|
const normalizedOptions = buildHandBrakePresetSelectOptions(sourceOptions, [
|
||||||
const normalizedOptions = [];
|
setting?.value,
|
||||||
const seenValues = new Set();
|
setting?.defaultValue
|
||||||
const seenGroupLabels = new Set();
|
]);
|
||||||
const addGroupOption = (option) => {
|
|
||||||
const rawLabel = String(option?.label || '').trim();
|
|
||||||
if (!rawLabel || seenGroupLabels.has(rawLabel)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seenGroupLabels.add(rawLabel);
|
|
||||||
normalizedOptions.push({
|
|
||||||
...option,
|
|
||||||
label: rawLabel,
|
|
||||||
value: String(option?.value || `__group__${rawLabel.toLowerCase().replace(/\s+/g, '_')}`),
|
|
||||||
disabled: true
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const addSelectableOption = (optionValue, optionLabel = optionValue, option = null) => {
|
|
||||||
const value = String(optionValue || '').trim();
|
|
||||||
if (!value || seenValues.has(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seenValues.add(value);
|
|
||||||
normalizedOptions.push({
|
|
||||||
...(option && typeof option === 'object' ? option : {}),
|
|
||||||
label: String(optionLabel ?? value),
|
|
||||||
value,
|
|
||||||
disabled: false
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// "(kein Preset)" immer als erste Option — ermöglicht reinen CLI-Betrieb
|
|
||||||
normalizedOptions.push({ label: '(kein Preset – nur CLI-Parameter)', value: '', disabled: false });
|
|
||||||
seenValues.add('');
|
|
||||||
|
|
||||||
for (const option of sourceOptions) {
|
|
||||||
if (option?.disabled) {
|
|
||||||
addGroupOption(option);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
addSelectableOption(option?.value, option?.label, option);
|
|
||||||
}
|
|
||||||
addSelectableOption(setting?.value);
|
|
||||||
addSelectableOption(setting?.defaultValue);
|
|
||||||
|
|
||||||
if (normalizedOptions.length <= 1) {
|
if (normalizedOptions.length <= 1) {
|
||||||
return setting;
|
return setting;
|
||||||
@@ -170,9 +183,18 @@ export default function SettingsPage() {
|
|||||||
description: ''
|
description: ''
|
||||||
});
|
});
|
||||||
const [userPresetErrors, setUserPresetErrors] = useState({});
|
const [userPresetErrors, setUserPresetErrors] = useState({});
|
||||||
|
const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]);
|
||||||
|
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
|
const userPresetHandBrakeOptions = useMemo(
|
||||||
|
() => buildHandBrakePresetSelectOptions(
|
||||||
|
handBrakePresetSourceOptions,
|
||||||
|
[userPresetEditor.handbrakePreset]
|
||||||
|
),
|
||||||
|
[handBrakePresetSourceOptions, userPresetEditor.handbrakePreset]
|
||||||
|
);
|
||||||
|
|
||||||
const loadScripts = async ({ silent = false } = {}) => {
|
const loadScripts = async ({ silent = false } = {}) => {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setScriptsLoading(true);
|
setScriptsLoading(true);
|
||||||
@@ -298,37 +320,18 @@ export default function SettingsPage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse] = await Promise.allSettled([
|
const settingsResponse = await api.getSettings();
|
||||||
api.getSettings(),
|
let nextCategories = settingsResponse?.categories || [];
|
||||||
api.getHandBrakePresets(),
|
|
||||||
api.getScripts(),
|
|
||||||
api.getScriptChains()
|
|
||||||
]);
|
|
||||||
if (settingsResponse.status !== 'fulfilled') {
|
|
||||||
throw settingsResponse.reason;
|
|
||||||
}
|
|
||||||
let nextCategories = settingsResponse.value?.categories || [];
|
|
||||||
const presetPayload = presetsResponse.status === 'fulfilled' ? presetsResponse.value : null;
|
|
||||||
nextCategories = injectHandBrakePresetOptions(nextCategories, presetPayload);
|
|
||||||
if (presetsResponse.status === 'fulfilled' && presetsResponse.value?.message) {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: presetsResponse.value?.source === 'fallback' ? 'warn' : 'info',
|
|
||||||
summary: 'HandBrake Presets',
|
|
||||||
detail: presetsResponse.value.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (presetsResponse.status === 'rejected') {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'warn',
|
|
||||||
summary: 'HandBrake Presets',
|
|
||||||
detail: 'Preset-Liste konnte nicht geladen werden. Aktueller Wert bleibt auswählbar.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const values = buildValuesMap(nextCategories);
|
const values = buildValuesMap(nextCategories);
|
||||||
setCategories(nextCategories);
|
setCategories(nextCategories);
|
||||||
setInitialValues(values);
|
setInitialValues(values);
|
||||||
setDraftValues(values);
|
setDraftValues(values);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
|
||||||
|
const presetsPromise = api.getHandBrakePresets();
|
||||||
|
const scriptsPromise = api.getScripts();
|
||||||
|
const chainsPromise = api.getScriptChains();
|
||||||
|
const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]);
|
||||||
if (scriptsResponse.status === 'fulfilled') {
|
if (scriptsResponse.status === 'fulfilled') {
|
||||||
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
|
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
|
||||||
} else {
|
} else {
|
||||||
@@ -341,6 +344,27 @@ export default function SettingsPage() {
|
|||||||
if (chainsResponse.status === 'fulfilled') {
|
if (chainsResponse.status === 'fulfilled') {
|
||||||
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
|
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
presetsPromise
|
||||||
|
.then((presetPayload) => {
|
||||||
|
setHandBrakePresetSourceOptions(Array.isArray(presetPayload?.options) ? presetPayload.options : []);
|
||||||
|
setCategories((prevCategories) => injectHandBrakePresetOptions(prevCategories, presetPayload));
|
||||||
|
if (presetPayload?.message) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: presetPayload?.source === 'fallback' ? 'warn' : 'info',
|
||||||
|
summary: 'HandBrake Presets',
|
||||||
|
detail: presetPayload.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setHandBrakePresetSourceOptions([]);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'HandBrake Presets',
|
||||||
|
detail: 'Preset-Liste konnte nicht geladen werden. Aktueller Wert bleibt auswählbar.'
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1511,44 +1535,36 @@ export default function SettingsPage() {
|
|||||||
) : userPresets.length === 0 ? (
|
) : userPresets.length === 0 ? (
|
||||||
<p style={{ marginTop: '1rem' }}>Keine Presets vorhanden. Lege ein neues Preset an.</p>
|
<p style={{ marginTop: '1rem' }}>Keine Presets vorhanden. Lege ein neues Preset an.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="script-list" style={{ marginTop: '1rem' }}>
|
<div className="script-list script-list--reorderable" style={{ marginTop: '1rem' }}>
|
||||||
{userPresets.map((preset) => (
|
{userPresets.map((preset) => (
|
||||||
<div key={preset.id} className="script-list-item">
|
<div key={preset.id} className="script-list-item">
|
||||||
<div className="script-list-main">
|
<div className="script-list-main">
|
||||||
<div className="script-title-line">
|
<div className="script-title-line">
|
||||||
<strong>#{preset.id} – {preset.name}</strong>
|
<strong className="script-id-title">#{preset.id} - {preset.name}</strong>
|
||||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', opacity: 0.7 }}>
|
<span className="preset-media-type-tag">
|
||||||
{preset.mediaType === 'bluray' ? 'Blu-ray'
|
{preset.mediaType === 'bluray' ? 'Blu-ray'
|
||||||
: preset.mediaType === 'dvd' ? 'DVD'
|
: preset.mediaType === 'dvd' ? 'DVD'
|
||||||
: preset.mediaType === 'other' ? 'Sonstiges'
|
: preset.mediaType === 'other' ? 'Sonstiges'
|
||||||
: 'Universell'}
|
: 'Universell'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{preset.description && <small style={{ display: 'block', marginTop: '0.2rem', opacity: 0.8 }}>{preset.description}</small>}
|
<small className="preset-description-line" title={preset.description || ''}>
|
||||||
<div style={{ marginTop: '0.3rem', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
{preset.description || '-'}
|
||||||
{preset.handbrakePreset
|
</small>
|
||||||
? <span><span style={{ opacity: 0.6 }}>Preset:</span> {preset.handbrakePreset}</span>
|
|
||||||
: <span style={{ opacity: 0.5 }}>(kein Preset-Name)</span>}
|
|
||||||
{preset.extraArgs && (
|
|
||||||
<span style={{ marginLeft: '1rem' }}><span style={{ opacity: 0.6 }}>Args:</span> {preset.extraArgs}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="script-list-actions">
|
<div className="script-list-actions script-list-actions--two">
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-pencil"
|
icon="pi pi-pencil"
|
||||||
|
label="Bearbeiten"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
rounded
|
|
||||||
title="Bearbeiten"
|
|
||||||
onClick={() => openEditUserPreset(preset)}
|
onClick={() => openEditUserPreset(preset)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
|
label="Löschen"
|
||||||
severity="danger"
|
severity="danger"
|
||||||
outlined
|
outlined
|
||||||
rounded
|
|
||||||
title="Löschen"
|
|
||||||
onClick={() => handleDeleteUserPreset(preset.id)}
|
onClick={() => handleDeleteUserPreset(preset.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1594,11 +1610,16 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="preset-hb-preset" style={{ display: 'block', marginBottom: '0.3rem' }}>HandBrake Preset (-Z)</label>
|
<label htmlFor="preset-hb-preset" style={{ display: 'block', marginBottom: '0.3rem' }}>HandBrake Preset (-Z)</label>
|
||||||
<InputText
|
<Dropdown
|
||||||
id="preset-hb-preset"
|
id="preset-hb-preset"
|
||||||
value={userPresetEditor.handbrakePreset}
|
value={userPresetEditor.handbrakePreset}
|
||||||
onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, handbrakePreset: e.target.value }))}
|
options={userPresetHandBrakeOptions}
|
||||||
placeholder="z.B. H.264 MKV 1080p30 (leer = kein Preset)"
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
optionDisabled="disabled"
|
||||||
|
onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, handbrakePreset: String(e.value || '') }))}
|
||||||
|
placeholder="Preset auswählen"
|
||||||
|
showClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -637,6 +637,12 @@ body {
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pipeline-queue-item.queue-job-item {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pipeline-queue-item.queued {
|
.pipeline-queue-item.queued {
|
||||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -662,6 +668,63 @@ body {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pipeline-queue-item-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-job-expand-btn {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-job-expand-btn:hover {
|
||||||
|
border-color: var(--rip-brown-600);
|
||||||
|
color: var(--rip-brown-700);
|
||||||
|
background: var(--rip-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-job-script-details {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-job-script-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-job-script-group strong {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-job-script-group small {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--rip-ink);
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.pipeline-queue-entry-wrap {
|
.pipeline-queue-entry-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1258,6 +1321,10 @@ body {
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.script-list-actions--two {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.script-list-actions .p-button {
|
.script-list-actions .p-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1273,6 +1340,22 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preset-media-type-tag {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-description-line {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.script-editor-fields {
|
.script-editor-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
@@ -1672,6 +1755,19 @@ body {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-configured-selection-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-configured-selection-grid > div {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.job-status-icon {
|
.job-status-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1917,7 +2013,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-script-row.editable {
|
.post-script-row.editable {
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-type-icon {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
width: 1rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-script-drag-handle {
|
.post-script-drag-handle {
|
||||||
@@ -2094,6 +2197,7 @@ body {
|
|||||||
.media-review-meta,
|
.media-review-meta,
|
||||||
.media-track-grid,
|
.media-track-grid,
|
||||||
.job-meta-grid,
|
.job-meta-grid,
|
||||||
|
.job-configured-selection-grid,
|
||||||
.job-film-info-grid,
|
.job-film-info-grid,
|
||||||
.table-filters,
|
.table-filters,
|
||||||
.history-dv-toolbar,
|
.history-dv-toolbar,
|
||||||
@@ -2133,7 +2237,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-script-row.editable {
|
.post-script-row.editable {
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orphan-path-cell {
|
.orphan-path-cell {
|
||||||
@@ -2785,3 +2889,96 @@ body {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-activity-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-item {
|
||||||
|
border: 1px solid var(--surface-border, #d8d3c6);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--surface-card, #f8f6ef);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-item.done {
|
||||||
|
opacity: 0.94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-details {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--rip-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-details-block {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-activity-details-block pre {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
padding: 0.55rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--surface-border, #d8d3c6);
|
||||||
|
background: #1f1f1f;
|
||||||
|
color: #e8e8e8;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.runtime-activity-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user