Skript Integration + UI Anpassungen
This commit is contained in:
@@ -260,6 +260,18 @@ const defaultSchema = [
|
|||||||
validation: { minLength: 1 },
|
validation: { minLength: 1 },
|
||||||
orderIndex: 340
|
orderIndex: 340
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'output_folder_template',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Ordnername Template',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template verwenden.',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 345
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'omdb_api_key',
|
key: 'omdb_api_key',
|
||||||
category: 'Metadaten',
|
category: 'Metadaten',
|
||||||
|
|||||||
@@ -94,15 +94,20 @@ router.post(
|
|||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
||||||
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
||||||
|
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
|
||||||
logger.info('post:confirm-encode', {
|
logger.info('post:confirm-encode', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
jobId,
|
jobId,
|
||||||
selectedEncodeTitleId,
|
selectedEncodeTitleId,
|
||||||
selectedTrackSelectionProvided: Boolean(selectedTrackSelection)
|
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
||||||
|
selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds)
|
||||||
|
? selectedPostEncodeScriptIds.length
|
||||||
|
: 0
|
||||||
});
|
});
|
||||||
const job = await pipelineService.confirmEncodeReview(jobId, {
|
const job = await pipelineService.confirmEncodeReview(jobId, {
|
||||||
selectedEncodeTitleId,
|
selectedEncodeTitleId,
|
||||||
selectedTrackSelection
|
selectedTrackSelection,
|
||||||
|
selectedPostEncodeScriptIds
|
||||||
});
|
});
|
||||||
res.json({ job });
|
res.json({ job });
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const asyncHandler = require('../middleware/asyncHandler');
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
const settingsService = require('../services/settingsService');
|
const settingsService = require('../services/settingsService');
|
||||||
|
const scriptService = require('../services/scriptService');
|
||||||
const notificationService = require('../services/notificationService');
|
const notificationService = require('../services/notificationService');
|
||||||
const pipelineService = require('../services/pipelineService');
|
const pipelineService = require('../services/pipelineService');
|
||||||
const wsService = require('../services/websocketService');
|
const wsService = require('../services/websocketService');
|
||||||
@@ -25,6 +26,83 @@ router.get(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/handbrake-presets',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:settings:handbrake-presets', { reqId: req.reqId });
|
||||||
|
const presets = await settingsService.getHandBrakePresetOptions();
|
||||||
|
res.json(presets);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/scripts',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:settings:scripts', { reqId: req.reqId });
|
||||||
|
const scripts = await scriptService.listScripts();
|
||||||
|
res.json({ scripts });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/scripts',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const payload = req.body || {};
|
||||||
|
logger.info('post:settings:scripts:create', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
name: String(payload?.name || '').trim() || null,
|
||||||
|
scriptBodyLength: String(payload?.scriptBody || '').length
|
||||||
|
});
|
||||||
|
const script = await scriptService.createScript(payload);
|
||||||
|
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'created', id: script.id });
|
||||||
|
res.status(201).json({ script });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/scripts/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const scriptId = Number(req.params.id);
|
||||||
|
const payload = req.body || {};
|
||||||
|
logger.info('put:settings:scripts:update', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
scriptId,
|
||||||
|
name: String(payload?.name || '').trim() || null,
|
||||||
|
scriptBodyLength: String(payload?.scriptBody || '').length
|
||||||
|
});
|
||||||
|
const script = await scriptService.updateScript(scriptId, payload);
|
||||||
|
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'updated', id: script.id });
|
||||||
|
res.json({ script });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/scripts/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const scriptId = Number(req.params.id);
|
||||||
|
logger.info('delete:settings:scripts', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
scriptId
|
||||||
|
});
|
||||||
|
const removed = await scriptService.deleteScript(scriptId);
|
||||||
|
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'deleted', id: removed.id });
|
||||||
|
res.json({ removed });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/scripts/:id/test',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const scriptId = Number(req.params.id);
|
||||||
|
logger.info('post:settings:scripts:test', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
scriptId
|
||||||
|
});
|
||||||
|
const result = await scriptService.testScript(scriptId);
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
'/:key',
|
'/:key',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const { getDb } = require('../db/database');
|
|||||||
const settingsService = require('./settingsService');
|
const settingsService = require('./settingsService');
|
||||||
const historyService = require('./historyService');
|
const historyService = require('./historyService');
|
||||||
const omdbService = require('./omdbService');
|
const omdbService = require('./omdbService');
|
||||||
|
const scriptService = require('./scriptService');
|
||||||
const wsService = require('./websocketService');
|
const wsService = require('./websocketService');
|
||||||
const diskDetectionService = require('./diskDetectionService');
|
const diskDetectionService = require('./diskDetectionService');
|
||||||
const notificationService = require('./notificationService');
|
const notificationService = require('./notificationService');
|
||||||
@@ -42,30 +43,47 @@ function withTimestampBeforeExtension(targetPath, suffix) {
|
|||||||
return path.join(dir, `${base}_${suffix}${ext}`);
|
return path.join(dir, `${base}_${suffix}${ext}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOutputPathFromJob(settings, job, fallbackJobId = null) {
|
function resolveOutputTemplateValues(job, fallbackJobId = null) {
|
||||||
|
return {
|
||||||
|
title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'),
|
||||||
|
year: job.year || new Date().getFullYear(),
|
||||||
|
imdbId: job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOutputFileName(settings, values) {
|
||||||
|
const fileTemplate = settings.filename_template || '${title} (${year})';
|
||||||
|
return sanitizeFileName(renderTemplate(fileTemplate, values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFinalOutputFolderName(settings, values) {
|
||||||
|
const folderTemplateRaw = String(settings.output_folder_template || '').trim();
|
||||||
|
const fallbackTemplate = settings.filename_template || '${title} (${year})';
|
||||||
|
const folderTemplate = folderTemplateRaw || fallbackTemplate;
|
||||||
|
return sanitizeFileName(renderTemplate(folderTemplate, values));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) {
|
||||||
const movieDir = settings.movie_dir;
|
const movieDir = settings.movie_dir;
|
||||||
const title = job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
const values = resolveOutputTemplateValues(job, fallbackJobId);
|
||||||
const year = job.year || new Date().getFullYear();
|
const folderName = resolveFinalOutputFolderName(settings, values);
|
||||||
const imdbId = job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
const baseName = resolveOutputFileName(settings, values);
|
||||||
const template = settings.filename_template || '${title} (${year})';
|
const ext = String(settings.output_extension || 'mkv').trim() || 'mkv';
|
||||||
const folderName = sanitizeFileName(
|
|
||||||
renderTemplate('${title} (${year})', {
|
|
||||||
title,
|
|
||||||
year,
|
|
||||||
imdbId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const baseName = sanitizeFileName(
|
|
||||||
renderTemplate(template, {
|
|
||||||
title,
|
|
||||||
year,
|
|
||||||
imdbId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const ext = settings.output_extension || 'mkv';
|
|
||||||
return path.join(movieDir, folderName, `${baseName}.${ext}`);
|
return path.join(movieDir, folderName, `${baseName}.${ext}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) {
|
||||||
|
const movieDir = settings.movie_dir;
|
||||||
|
const values = resolveOutputTemplateValues(job, fallbackJobId);
|
||||||
|
const baseName = resolveOutputFileName(settings, values);
|
||||||
|
const ext = String(settings.output_extension || 'mkv').trim() || 'mkv';
|
||||||
|
const numericJobId = Number(fallbackJobId || job?.id || 0);
|
||||||
|
const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
|
||||||
|
? `Incomplete_job-${numericJobId}`
|
||||||
|
: 'Incomplete_job-unknown';
|
||||||
|
return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureUniqueOutputPath(outputPath) {
|
function ensureUniqueOutputPath(outputPath) {
|
||||||
if (!fs.existsSync(outputPath)) {
|
if (!fs.existsSync(outputPath)) {
|
||||||
return outputPath;
|
return outputPath;
|
||||||
@@ -81,6 +99,65 @@ function ensureUniqueOutputPath(outputPath) {
|
|||||||
return attempt;
|
return attempt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveFileWithFallback(sourcePath, targetPath) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(sourcePath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'EXDEV') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
fs.unlinkSync(sourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDirectoryIfEmpty(directoryPath) {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(directoryPath);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
fs.rmdirSync(directoryPath);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Best effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFinalOutputPath) {
|
||||||
|
const sourcePath = String(incompleteOutputPath || '').trim();
|
||||||
|
if (!sourcePath) {
|
||||||
|
throw new Error('Encode-Finalisierung fehlgeschlagen: temporärer Output-Pfad fehlt.');
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Encode-Finalisierung fehlgeschlagen: temporäre Datei fehlt (${sourcePath}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannedTargetPath = String(preferredFinalOutputPath || '').trim();
|
||||||
|
if (!plannedTargetPath) {
|
||||||
|
throw new Error('Encode-Finalisierung fehlgeschlagen: finaler Output-Pfad fehlt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceResolved = path.resolve(sourcePath);
|
||||||
|
const targetPath = ensureUniqueOutputPath(plannedTargetPath);
|
||||||
|
const targetResolved = path.resolve(targetPath);
|
||||||
|
const outputPathWithTimestamp = targetPath !== plannedTargetPath;
|
||||||
|
|
||||||
|
if (sourceResolved === targetResolved) {
|
||||||
|
return {
|
||||||
|
outputPath: targetPath,
|
||||||
|
outputPathWithTimestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(path.dirname(targetPath));
|
||||||
|
moveFileWithFallback(sourcePath, targetPath);
|
||||||
|
removeDirectoryIfEmpty(path.dirname(sourcePath));
|
||||||
|
|
||||||
|
return {
|
||||||
|
outputPath: targetPath,
|
||||||
|
outputPathWithTimestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function truncateLine(value, max = 180) {
|
function truncateLine(value, max = 180) {
|
||||||
const raw = String(value || '').replace(/\s+/g, ' ').trim();
|
const raw = String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
if (raw.length <= max) {
|
if (raw.length <= max) {
|
||||||
@@ -1556,6 +1633,26 @@ function normalizeTrackIdList(rawList) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeScriptIdList(rawList) {
|
||||||
|
const list = Array.isArray(rawList) ? rawList : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const value = Number(item);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = Math.trunc(value);
|
||||||
|
const key = String(normalized);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
output.push(normalized);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
|
function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
|
||||||
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null;
|
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null;
|
||||||
if (!plan || !Array.isArray(plan.titles)) {
|
if (!plan || !Array.isArray(plan.titles)) {
|
||||||
@@ -3806,7 +3903,10 @@ class PipelineService extends EventEmitter {
|
|||||||
logger.info('confirmEncodeReview:requested', {
|
logger.info('confirmEncodeReview:requested', {
|
||||||
jobId,
|
jobId,
|
||||||
selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null,
|
selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null,
|
||||||
selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection)
|
selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection),
|
||||||
|
selectedPostEncodeScriptIdsCount: Array.isArray(options?.selectedPostEncodeScriptIds)
|
||||||
|
? options.selectedPostEncodeScriptIds.length
|
||||||
|
: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const job = await historyService.getJobById(jobId);
|
const job = await historyService.getJobById(jobId);
|
||||||
@@ -3837,6 +3937,13 @@ class PipelineService extends EventEmitter {
|
|||||||
options?.selectedTrackSelection || null
|
options?.selectedTrackSelection || null
|
||||||
);
|
);
|
||||||
planForConfirm = trackSelectionResult.plan;
|
planForConfirm = trackSelectionResult.plan;
|
||||||
|
const hasExplicitPostScriptSelection = options?.selectedPostEncodeScriptIds !== undefined;
|
||||||
|
const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection
|
||||||
|
? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || [])
|
||||||
|
: normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []);
|
||||||
|
const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, {
|
||||||
|
strict: true
|
||||||
|
});
|
||||||
const confirmedMode = String(planForConfirm?.mode || encodePlan?.mode || 'rip').trim().toLowerCase();
|
const confirmedMode = String(planForConfirm?.mode || encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||||
const isPreRipMode = confirmedMode === 'pre_rip' || Boolean(planForConfirm?.preRip);
|
const isPreRipMode = confirmedMode === 'pre_rip' || Boolean(planForConfirm?.preRip);
|
||||||
|
|
||||||
@@ -3848,6 +3955,11 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
const confirmedPlan = {
|
const confirmedPlan = {
|
||||||
...planForConfirm,
|
...planForConfirm,
|
||||||
|
postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)),
|
||||||
|
postEncodeScripts: selectedPostEncodeScripts.map((item) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
name: item.name
|
||||||
|
})),
|
||||||
reviewConfirmed: true,
|
reviewConfirmed: true,
|
||||||
reviewConfirmedAt: nowIso()
|
reviewConfirmedAt: nowIso()
|
||||||
};
|
};
|
||||||
@@ -3869,6 +3981,7 @@ class PipelineService extends EventEmitter {
|
|||||||
`Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}`
|
`Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}`
|
||||||
+ ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.`
|
+ ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.`
|
||||||
+ ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.`
|
+ ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.`
|
||||||
|
+ ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.`
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.setState('READY_TO_ENCODE', {
|
await this.setState('READY_TO_ENCODE', {
|
||||||
@@ -4245,6 +4358,160 @@ class PipelineService extends EventEmitter {
|
|||||||
return enrichedReview;
|
return enrichedReview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runPostEncodeScripts(jobId, encodePlan, context = {}) {
|
||||||
|
const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
|
||||||
|
if (scriptIds.length === 0) {
|
||||||
|
return {
|
||||||
|
configured: 0,
|
||||||
|
attempted: 0,
|
||||||
|
succeeded: 0,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
results: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false });
|
||||||
|
const scriptById = new Map(scripts.map((item) => [Number(item.id), item]));
|
||||||
|
const results = [];
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let aborted = false;
|
||||||
|
let abortReason = null;
|
||||||
|
let failedScriptName = null;
|
||||||
|
let failedScriptId = null;
|
||||||
|
const titleForPush = context?.jobTitle || `Job #${jobId}`;
|
||||||
|
|
||||||
|
for (let index = 0; index < scriptIds.length; index += 1) {
|
||||||
|
const scriptId = scriptIds[index];
|
||||||
|
const script = scriptById.get(Number(scriptId));
|
||||||
|
if (!script) {
|
||||||
|
failed += 1;
|
||||||
|
aborted = true;
|
||||||
|
failedScriptId = Number(scriptId);
|
||||||
|
failedScriptName = `Script #${scriptId}`;
|
||||||
|
abortReason = `Post-Encode Skript #${scriptId} wurde nicht gefunden (${index + 1}/${scriptIds.length}).`;
|
||||||
|
await historyService.appendLog(jobId, 'SYSTEM', abortReason);
|
||||||
|
results.push({
|
||||||
|
scriptId,
|
||||||
|
scriptName: null,
|
||||||
|
status: 'ERROR',
|
||||||
|
error: 'missing'
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`Post-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`
|
||||||
|
);
|
||||||
|
|
||||||
|
let prepared = null;
|
||||||
|
try {
|
||||||
|
prepared = await scriptService.createExecutableScriptFile(script, {
|
||||||
|
source: 'post_encode',
|
||||||
|
mode: context?.mode || null,
|
||||||
|
jobId,
|
||||||
|
jobTitle: context?.jobTitle || null,
|
||||||
|
inputPath: context?.inputPath || null,
|
||||||
|
outputPath: context?.outputPath || null,
|
||||||
|
rawPath: context?.rawPath || null
|
||||||
|
});
|
||||||
|
const runInfo = await this.runCommand({
|
||||||
|
jobId,
|
||||||
|
stage: 'ENCODING',
|
||||||
|
source: 'POST_ENCODE_SCRIPT',
|
||||||
|
cmd: prepared.cmd,
|
||||||
|
args: prepared.args,
|
||||||
|
argsForLog: prepared.argsForLog
|
||||||
|
});
|
||||||
|
|
||||||
|
succeeded += 1;
|
||||||
|
results.push({
|
||||||
|
scriptId: script.id,
|
||||||
|
scriptName: script.name,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
runInfo
|
||||||
|
});
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`Post-Encode Skript erfolgreich: ${script.name}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
aborted = true;
|
||||||
|
failedScriptId = Number(script.id);
|
||||||
|
failedScriptName = script.name;
|
||||||
|
abortReason = error?.message || 'unknown';
|
||||||
|
results.push({
|
||||||
|
scriptId: script.id,
|
||||||
|
scriptName: script.name,
|
||||||
|
status: 'ERROR',
|
||||||
|
error: abortReason
|
||||||
|
});
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`Post-Encode Skript fehlgeschlagen: ${script.name} (${abortReason})`
|
||||||
|
);
|
||||||
|
logger.warn('encode:post-script:failed', {
|
||||||
|
jobId,
|
||||||
|
scriptId: script.id,
|
||||||
|
scriptName: script.name,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} finally {
|
||||||
|
if (prepared?.cleanup) {
|
||||||
|
await prepared.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aborted) {
|
||||||
|
const executedScriptIds = new Set(results.map((item) => Number(item?.scriptId)));
|
||||||
|
for (const pendingScriptId of scriptIds) {
|
||||||
|
const numericId = Number(pendingScriptId);
|
||||||
|
if (executedScriptIds.has(numericId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pendingScript = scriptById.get(numericId);
|
||||||
|
skipped += 1;
|
||||||
|
results.push({
|
||||||
|
scriptId: numericId,
|
||||||
|
scriptName: pendingScript?.name || null,
|
||||||
|
status: 'SKIPPED_ABORTED'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`Post-Encode Skriptkette abgebrochen nach Fehler in ${failedScriptName || `Script #${failedScriptId || 'unknown'}`}.`
|
||||||
|
);
|
||||||
|
void this.notifyPushover('job_error', {
|
||||||
|
title: 'Ripster - Post-Encode Skriptfehler',
|
||||||
|
message: `${titleForPush}: ${failedScriptName || `Script #${failedScriptId || 'unknown'}`} fehlgeschlagen (${abortReason || 'unknown'}). Skriptkette abgebrochen.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: scriptIds.length,
|
||||||
|
attempted: scriptIds.length - skipped,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
skipped,
|
||||||
|
aborted,
|
||||||
|
abortReason,
|
||||||
|
failedScriptId,
|
||||||
|
failedScriptName,
|
||||||
|
results
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async startEncodingFromPrepared(jobId) {
|
async startEncodingFromPrepared(jobId) {
|
||||||
this.ensureNotBusy('startEncodingFromPrepared');
|
this.ensureNotBusy('startEncodingFromPrepared');
|
||||||
logger.info('encode:start-from-prepared', { jobId });
|
logger.info('encode:start-from-prepared', { jobId });
|
||||||
@@ -4284,10 +4551,9 @@ class PipelineService extends EventEmitter {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredOutputPath = buildOutputPathFromJob(settings, job, jobId);
|
const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, job, jobId);
|
||||||
const outputPath = ensureUniqueOutputPath(preferredOutputPath);
|
const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, job, jobId);
|
||||||
const outputPathWithTimestamp = outputPath !== preferredOutputPath;
|
ensureDir(path.dirname(incompleteOutputPath));
|
||||||
ensureDir(path.dirname(outputPath));
|
|
||||||
|
|
||||||
await this.setState('ENCODING', {
|
await this.setState('ENCODING', {
|
||||||
activeJobId: jobId,
|
activeJobId: jobId,
|
||||||
@@ -4298,7 +4564,7 @@ class PipelineService extends EventEmitter {
|
|||||||
jobId,
|
jobId,
|
||||||
mode,
|
mode,
|
||||||
inputPath,
|
inputPath,
|
||||||
outputPath,
|
outputPath: incompleteOutputPath,
|
||||||
reviewConfirmed: true,
|
reviewConfirmed: true,
|
||||||
mediaInfoReview: encodePlan || null,
|
mediaInfoReview: encodePlan || null,
|
||||||
selectedMetadata: {
|
selectedMetadata: {
|
||||||
@@ -4313,27 +4579,25 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.updateJob(jobId, {
|
await historyService.updateJob(jobId, {
|
||||||
status: 'ENCODING',
|
status: 'ENCODING',
|
||||||
last_state: 'ENCODING',
|
last_state: 'ENCODING',
|
||||||
output_path: outputPath,
|
output_path: incompleteOutputPath,
|
||||||
encode_input_path: inputPath
|
encode_input_path: inputPath
|
||||||
});
|
});
|
||||||
|
|
||||||
if (outputPathWithTimestamp) {
|
await historyService.appendLog(
|
||||||
await historyService.appendLog(
|
jobId,
|
||||||
jobId,
|
'SYSTEM',
|
||||||
'SYSTEM',
|
`Temporärer Encode-Output: ${incompleteOutputPath} (wird nach erfolgreichem Encode in den finalen Zielordner verschoben).`
|
||||||
`Output existierte bereits. Neuer Output-Pfad mit Timestamp: ${outputPath}`
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'reencode') {
|
if (mode === 'reencode') {
|
||||||
void this.notifyPushover('reencode_started', {
|
void this.notifyPushover('reencode_started', {
|
||||||
title: 'Ripster - Re-Encode gestartet',
|
title: 'Ripster - Re-Encode gestartet',
|
||||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}`
|
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
void this.notifyPushover('encoding_started', {
|
void this.notifyPushover('encoding_started', {
|
||||||
title: 'Ripster - Encoding gestartet',
|
title: 'Ripster - Encoding gestartet',
|
||||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}`
|
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4407,7 +4671,7 @@ class PipelineService extends EventEmitter {
|
|||||||
handBrakeTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId ?? encodePlan?.encodeInputTitleId);
|
handBrakeTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId ?? encodePlan?.encodeInputTitleId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, outputPath, {
|
const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, incompleteOutputPath, {
|
||||||
trackSelection,
|
trackSelection,
|
||||||
titleId: handBrakeTitleId
|
titleId: handBrakeTitleId
|
||||||
});
|
});
|
||||||
@@ -4434,39 +4698,98 @@ class PipelineService extends EventEmitter {
|
|||||||
args: handBrakeConfig.args,
|
args: handBrakeConfig.args,
|
||||||
parser: parseHandBrakeProgress
|
parser: parseHandBrakeProgress
|
||||||
});
|
});
|
||||||
|
const outputFinalization = finalizeOutputPathForCompletedEncode(
|
||||||
|
incompleteOutputPath,
|
||||||
|
preferredFinalOutputPath
|
||||||
|
);
|
||||||
|
const finalizedOutputPath = outputFinalization.outputPath;
|
||||||
|
if (outputFinalization.outputPathWithTimestamp) {
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`Finaler Output existierte bereits. Neuer Zielpfad mit Timestamp: ${finalizedOutputPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`Encode-Output finalisiert: ${finalizedOutputPath}`
|
||||||
|
);
|
||||||
|
let postEncodeScriptsSummary = {
|
||||||
|
configured: 0,
|
||||||
|
attempted: 0,
|
||||||
|
succeeded: 0,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
results: []
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, {
|
||||||
|
mode,
|
||||||
|
jobTitle: job.title || job.detected_title || null,
|
||||||
|
inputPath,
|
||||||
|
outputPath: finalizedOutputPath,
|
||||||
|
rawPath: job.raw_path || null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('encode:post-script:summary-failed', {
|
||||||
|
jobId,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (postEncodeScriptsSummary.configured > 0) {
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const handbrakeInfoWithPostScripts = {
|
||||||
|
...handbrakeInfo,
|
||||||
|
postEncodeScripts: postEncodeScriptsSummary
|
||||||
|
};
|
||||||
|
|
||||||
await historyService.updateJob(jobId, {
|
await historyService.updateJob(jobId, {
|
||||||
handbrake_info_json: JSON.stringify(handbrakeInfo),
|
handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts),
|
||||||
status: 'FINISHED',
|
status: 'FINISHED',
|
||||||
last_state: 'FINISHED',
|
last_state: 'FINISHED',
|
||||||
end_time: nowIso(),
|
end_time: nowIso(),
|
||||||
output_path: outputPath,
|
output_path: finalizedOutputPath,
|
||||||
error_message: null
|
error_message: null
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('encoding:finished', { jobId, mode, outputPath });
|
logger.info('encoding:finished', { jobId, mode, outputPath: finalizedOutputPath });
|
||||||
|
const finishedStatusTextBase = mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen';
|
||||||
|
const finishedStatusText = postEncodeScriptsSummary.failed > 0
|
||||||
|
? `${finishedStatusTextBase} (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)`
|
||||||
|
: finishedStatusTextBase;
|
||||||
|
|
||||||
await this.setState('FINISHED', {
|
await this.setState('FINISHED', {
|
||||||
activeJobId: jobId,
|
activeJobId: jobId,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
eta: null,
|
eta: null,
|
||||||
statusText: mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen',
|
statusText: finishedStatusText,
|
||||||
context: {
|
context: {
|
||||||
jobId,
|
jobId,
|
||||||
mode,
|
mode,
|
||||||
outputPath
|
outputPath: finalizedOutputPath
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mode === 'reencode') {
|
if (mode === 'reencode') {
|
||||||
void this.notifyPushover('reencode_finished', {
|
void this.notifyPushover('reencode_finished', {
|
||||||
title: 'Ripster - Re-Encode abgeschlossen',
|
title: 'Ripster - Re-Encode abgeschlossen',
|
||||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}`
|
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}`
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
void this.notifyPushover('job_finished', {
|
void this.notifyPushover('job_finished', {
|
||||||
title: 'Ripster - Job abgeschlossen',
|
title: 'Ripster - Job abgeschlossen',
|
||||||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}`
|
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4510,6 +4833,9 @@ class PipelineService extends EventEmitter {
|
|||||||
const preRipTrackSelectionPayload = hasPreRipConfirmedSelection
|
const preRipTrackSelectionPayload = hasPreRipConfirmedSelection
|
||||||
? extractManualSelectionPayloadFromPlan(preRipPlanBeforeRip)
|
? extractManualSelectionPayloadFromPlan(preRipPlanBeforeRip)
|
||||||
: null;
|
: null;
|
||||||
|
const preRipPostEncodeScriptIds = hasPreRipConfirmedSelection
|
||||||
|
? normalizeScriptIdList(preRipPlanBeforeRip?.postEncodeScriptIds || [])
|
||||||
|
: [];
|
||||||
const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job);
|
const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job);
|
||||||
const selectedTitleId = playlistDecision.selectedTitleId;
|
const selectedTitleId = playlistDecision.selectedTitleId;
|
||||||
const selectedPlaylist = playlistDecision.selectedPlaylist;
|
const selectedPlaylist = playlistDecision.selectedPlaylist;
|
||||||
@@ -4693,7 +5019,8 @@ class PipelineService extends EventEmitter {
|
|||||||
);
|
);
|
||||||
await this.confirmEncodeReview(jobId, {
|
await this.confirmEncodeReview(jobId, {
|
||||||
selectedEncodeTitleId: review?.encodeInputTitleId || null,
|
selectedEncodeTitleId: review?.encodeInputTitleId || null,
|
||||||
selectedTrackSelection: preRipTrackSelectionPayload || null
|
selectedTrackSelection: preRipTrackSelectionPayload || null,
|
||||||
|
selectedPostEncodeScriptIds: preRipPostEncodeScriptIds
|
||||||
});
|
});
|
||||||
const autoStartResult = await this.startPreparedJob(jobId);
|
const autoStartResult = await this.startPreparedJob(jobId);
|
||||||
logger.info('rip:auto-encode-started', {
|
logger.info('rip:auto-encode-started', {
|
||||||
|
|||||||
435
backend/src/services/scriptService.js
Normal file
435
backend/src/services/scriptService.js
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
|
const logger = require('./logger').child('SCRIPTS');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
const SCRIPT_NAME_MAX_LENGTH = 120;
|
||||||
|
const SCRIPT_BODY_MAX_LENGTH = 200000;
|
||||||
|
const SCRIPT_TEST_TIMEOUT_MS = 120000;
|
||||||
|
const SCRIPT_OUTPUT_MAX_CHARS = 150000;
|
||||||
|
|
||||||
|
function normalizeScriptId(rawValue) {
|
||||||
|
const value = Number(rawValue);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScriptIdList(rawList) {
|
||||||
|
const list = Array.isArray(rawList) ? rawList : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const normalized = normalizeScriptId(item);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(normalized);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
output.push(normalized);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScriptName(rawValue) {
|
||||||
|
return String(rawValue || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScriptBody(rawValue) {
|
||||||
|
return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createValidationError(message, details = null) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.statusCode = 400;
|
||||||
|
if (details) {
|
||||||
|
error.details = details;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateScriptPayload(payload, { partial = false } = {}) {
|
||||||
|
const body = payload && typeof payload === 'object' ? payload : {};
|
||||||
|
const hasName = Object.prototype.hasOwnProperty.call(body, 'name');
|
||||||
|
const hasScriptBody = Object.prototype.hasOwnProperty.call(body, 'scriptBody');
|
||||||
|
const normalized = {};
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!partial || hasName) {
|
||||||
|
const name = normalizeScriptName(body.name);
|
||||||
|
if (!name) {
|
||||||
|
errors.push({ field: 'name', message: 'Name darf nicht leer sein.' });
|
||||||
|
} else if (name.length > SCRIPT_NAME_MAX_LENGTH) {
|
||||||
|
errors.push({ field: 'name', message: `Name darf maximal ${SCRIPT_NAME_MAX_LENGTH} Zeichen enthalten.` });
|
||||||
|
} else {
|
||||||
|
normalized.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partial || hasScriptBody) {
|
||||||
|
const scriptBody = normalizeScriptBody(body.scriptBody);
|
||||||
|
if (!scriptBody.trim()) {
|
||||||
|
errors.push({ field: 'scriptBody', message: 'Skript darf nicht leer sein.' });
|
||||||
|
} else if (scriptBody.length > SCRIPT_BODY_MAX_LENGTH) {
|
||||||
|
errors.push({ field: 'scriptBody', message: `Skript darf maximal ${SCRIPT_BODY_MAX_LENGTH} Zeichen enthalten.` });
|
||||||
|
} else {
|
||||||
|
normalized.scriptBody = scriptBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw createValidationError('Skript ist ungültig.', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapScriptRow(row) {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
name: String(row.name || ''),
|
||||||
|
scriptBody: String(row.script_body || ''),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteForBashSingle(value) {
|
||||||
|
return `'${String(value || '').replace(/'/g, `'\"'\"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScriptEnvironment(context = {}) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const entries = {
|
||||||
|
RIPSTER_SCRIPT_RUN_AT: now,
|
||||||
|
RIPSTER_JOB_ID: context?.jobId ?? '',
|
||||||
|
RIPSTER_JOB_TITLE: context?.jobTitle ?? '',
|
||||||
|
RIPSTER_MODE: context?.mode ?? '',
|
||||||
|
RIPSTER_INPUT_PATH: context?.inputPath ?? '',
|
||||||
|
RIPSTER_OUTPUT_PATH: context?.outputPath ?? '',
|
||||||
|
RIPSTER_RAW_PATH: context?.rawPath ?? '',
|
||||||
|
RIPSTER_SCRIPT_ID: context?.scriptId ?? '',
|
||||||
|
RIPSTER_SCRIPT_NAME: context?.scriptName ?? '',
|
||||||
|
RIPSTER_SCRIPT_SOURCE: context?.source ?? ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = {};
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
output[key] = String(value ?? '');
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScriptWrapper(scriptBody, context = {}) {
|
||||||
|
const envVars = buildScriptEnvironment(context);
|
||||||
|
const exportLines = Object.entries(envVars)
|
||||||
|
.map(([key, value]) => `export ${key}=${quoteForBashSingle(value)}`)
|
||||||
|
.join('\n');
|
||||||
|
// Wait for potential background jobs started by the script before returning.
|
||||||
|
return `${exportLines}\n\n${String(scriptBody || '')}\n\nwait\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendWithCap(current, chunk, maxChars) {
|
||||||
|
const value = String(chunk || '');
|
||||||
|
if (!value) {
|
||||||
|
return { value: current, truncated: false };
|
||||||
|
}
|
||||||
|
const currentText = String(current || '');
|
||||||
|
if (currentText.length >= maxChars) {
|
||||||
|
return { value: currentText, truncated: true };
|
||||||
|
}
|
||||||
|
const available = maxChars - currentText.length;
|
||||||
|
if (value.length <= available) {
|
||||||
|
return { value: `${currentText}${value}`, truncated: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: `${currentText}${value.slice(0, available)}`,
|
||||||
|
truncated: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd() }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const child = spawn(cmd, args, {
|
||||||
|
cwd,
|
||||||
|
env: process.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let stdoutTruncated = false;
|
||||||
|
let stderrTruncated = false;
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!child.killed) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}, Math.max(1000, Number(timeoutMs || SCRIPT_TEST_TIMEOUT_MS)));
|
||||||
|
|
||||||
|
const onData = (streamName, chunk) => {
|
||||||
|
if (streamName === 'stdout') {
|
||||||
|
const next = appendWithCap(stdout, chunk, SCRIPT_OUTPUT_MAX_CHARS);
|
||||||
|
stdout = next.value;
|
||||||
|
stdoutTruncated = stdoutTruncated || next.truncated;
|
||||||
|
} else {
|
||||||
|
const next = appendWithCap(stderr, chunk, SCRIPT_OUTPUT_MAX_CHARS);
|
||||||
|
stderr = next.value;
|
||||||
|
stderrTruncated = stderrTruncated || next.truncated;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout?.on('data', (chunk) => onData('stdout', chunk));
|
||||||
|
child.stderr?.on('data', (chunk) => onData('stderr', chunk));
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code, signal) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const endedAt = Date.now();
|
||||||
|
resolve({
|
||||||
|
code: Number.isFinite(Number(code)) ? Number(code) : null,
|
||||||
|
signal: signal || null,
|
||||||
|
durationMs: Math.max(0, endedAt - startedAt),
|
||||||
|
timedOut,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
stdoutTruncated,
|
||||||
|
stderrTruncated
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScriptService {
|
||||||
|
async listScripts() {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.all(
|
||||||
|
`
|
||||||
|
SELECT id, name, script_body, created_at, updated_at
|
||||||
|
FROM scripts
|
||||||
|
ORDER BY LOWER(name) ASC, id ASC
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return rows.map(mapScriptRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptById(scriptId) {
|
||||||
|
const normalizedId = normalizeScriptId(scriptId);
|
||||||
|
if (!normalizedId) {
|
||||||
|
throw createValidationError('Ungültige scriptId.');
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
const row = await db.get(
|
||||||
|
`
|
||||||
|
SELECT id, name, script_body, created_at, updated_at
|
||||||
|
FROM scripts
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
[normalizedId]
|
||||||
|
);
|
||||||
|
if (!row) {
|
||||||
|
const error = new Error(`Skript #${normalizedId} wurde nicht gefunden.`);
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return mapScriptRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createScript(payload = {}) {
|
||||||
|
const normalized = validateScriptPayload(payload, { partial: false });
|
||||||
|
const db = await getDb();
|
||||||
|
try {
|
||||||
|
const result = await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO scripts (name, script_body, created_at, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`,
|
||||||
|
[normalized.name, normalized.scriptBody]
|
||||||
|
);
|
||||||
|
return this.getScriptById(result.lastID);
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||||
|
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
|
||||||
|
{ field: 'name', message: 'Name muss eindeutig sein.' }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateScript(scriptId, payload = {}) {
|
||||||
|
const normalizedId = normalizeScriptId(scriptId);
|
||||||
|
if (!normalizedId) {
|
||||||
|
throw createValidationError('Ungültige scriptId.');
|
||||||
|
}
|
||||||
|
const normalized = validateScriptPayload(payload, { partial: false });
|
||||||
|
const db = await getDb();
|
||||||
|
await this.getScriptById(normalizedId);
|
||||||
|
try {
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
UPDATE scripts
|
||||||
|
SET
|
||||||
|
name = ?,
|
||||||
|
script_body = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
[normalized.name, normalized.scriptBody, normalizedId]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||||
|
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
|
||||||
|
{ field: 'name', message: 'Name muss eindeutig sein.' }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.getScriptById(normalizedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteScript(scriptId) {
|
||||||
|
const normalizedId = normalizeScriptId(scriptId);
|
||||||
|
if (!normalizedId) {
|
||||||
|
throw createValidationError('Ungültige scriptId.');
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
const existing = await this.getScriptById(normalizedId);
|
||||||
|
await db.run('DELETE FROM scripts WHERE id = ?', [normalizedId]);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptsByIds(rawIds = []) {
|
||||||
|
const ids = normalizeScriptIdList(rawIds);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
|
const rows = await db.all(
|
||||||
|
`
|
||||||
|
SELECT id, name, script_body, created_at, updated_at
|
||||||
|
FROM scripts
|
||||||
|
WHERE id IN (${placeholders})
|
||||||
|
`,
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
const byId = new Map(rows.map((row) => [Number(row.id), mapScriptRow(row)]));
|
||||||
|
return ids.map((id) => byId.get(id)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveScriptsByIds(rawIds = [], options = {}) {
|
||||||
|
const ids = normalizeScriptIdList(rawIds);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const strict = options?.strict !== false;
|
||||||
|
const scripts = await this.getScriptsByIds(ids);
|
||||||
|
if (!strict) {
|
||||||
|
return scripts;
|
||||||
|
}
|
||||||
|
const foundIds = new Set(scripts.map((item) => Number(item.id)));
|
||||||
|
const missing = ids.filter((id) => !foundIds.has(Number(id)));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw createValidationError(`Skript(e) nicht gefunden: ${missing.join(', ')}`, [
|
||||||
|
{ field: 'selectedPostEncodeScriptIds', message: `Nicht gefunden: ${missing.join(', ')}` }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return scripts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createExecutableScriptFile(script, context = {}) {
|
||||||
|
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
|
||||||
|
const scriptBody = normalizeScriptBody(script?.scriptBody);
|
||||||
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-'));
|
||||||
|
const scriptPath = path.join(tempDir, 'script.sh');
|
||||||
|
const wrapped = buildScriptWrapper(scriptBody, {
|
||||||
|
...context,
|
||||||
|
scriptId: script?.id ?? context?.scriptId ?? '',
|
||||||
|
scriptName: name,
|
||||||
|
source: context?.source || 'post_encode'
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.promises.writeFile(scriptPath, wrapped, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
mode: 0o700
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('script:temp-cleanup-failed', {
|
||||||
|
scriptId: script?.id ?? null,
|
||||||
|
scriptName: name,
|
||||||
|
tempDir,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tempDir,
|
||||||
|
scriptPath,
|
||||||
|
cmd: '/usr/bin/env',
|
||||||
|
args: ['bash', scriptPath],
|
||||||
|
argsForLog: ['bash', `<script:${name}>`],
|
||||||
|
cleanup
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async testScript(scriptId, options = {}) {
|
||||||
|
const script = await this.getScriptById(scriptId);
|
||||||
|
const timeoutMs = Number(options?.timeoutMs);
|
||||||
|
const prepared = await this.createExecutableScriptFile(script, {
|
||||||
|
source: 'settings_test',
|
||||||
|
mode: 'test'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const run = await runProcessCapture({
|
||||||
|
cmd: prepared.cmd,
|
||||||
|
args: prepared.args,
|
||||||
|
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS
|
||||||
|
});
|
||||||
|
const success = run.code === 0 && !run.timedOut;
|
||||||
|
return {
|
||||||
|
scriptId: script.id,
|
||||||
|
scriptName: script.name,
|
||||||
|
success,
|
||||||
|
exitCode: run.code,
|
||||||
|
signal: run.signal,
|
||||||
|
timedOut: run.timedOut,
|
||||||
|
durationMs: run.durationMs,
|
||||||
|
stdout: run.stdout,
|
||||||
|
stderr: run.stderr,
|
||||||
|
stdoutTruncated: run.stdoutTruncated,
|
||||||
|
stderrTruncated: run.stderrTruncated
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await prepared.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ScriptService();
|
||||||
@@ -14,6 +14,7 @@ const { splitArgs } = require('../utils/commandLine');
|
|||||||
const { setLogRootDir } = require('./logPathService');
|
const { 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 SENSITIVE_SETTING_KEYS = new Set([
|
const SENSITIVE_SETTING_KEYS = new Set([
|
||||||
'makemkv_registration_key',
|
'makemkv_registration_key',
|
||||||
'omdb_api_key',
|
'omdb_api_key',
|
||||||
@@ -132,6 +133,200 @@ function buildFallbackPresetProfile(presetName, message = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripAnsiEscapeCodes(value) {
|
||||||
|
return String(value || '').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueOrderedValues(values) {
|
||||||
|
const unique = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const value of values || []) {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
if (!normalized || seen.has(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
unique.push(normalized);
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniquePresetEntries(entries) {
|
||||||
|
const unique = [];
|
||||||
|
const seenNames = new Set();
|
||||||
|
for (const entry of entries || []) {
|
||||||
|
const name = String(entry?.name || '').trim();
|
||||||
|
if (!name || seenNames.has(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenNames.add(name);
|
||||||
|
const categoryRaw = entry?.category;
|
||||||
|
const category = categoryRaw === null || categoryRaw === undefined
|
||||||
|
? null
|
||||||
|
: String(categoryRaw).trim() || null;
|
||||||
|
unique.push({ name, category });
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePresetListLines(rawOutput) {
|
||||||
|
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||||
|
const normalized = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const sanitized = stripAnsiEscapeCodes(line || '').replace(/\r/g, '');
|
||||||
|
if (!sanitized.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\s*\[[^\]]+\]/.test(sanitized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/^\s*(Cannot load|Compile-time|qsv:|HandBrake \d|Opening |No title found|libhb:|hb_init:|thread |bdj\.c:|stream:|scan:|bd:|libdvdnav:|libdvdread:)/i
|
||||||
|
.test(sanitized)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\s*HandBrake has exited\.?\s*$/i.test(sanitized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const leadingWhitespace = (sanitized.match(/^[\t ]*/) || [''])[0];
|
||||||
|
const indentation = leadingWhitespace.replace(/\t/g, ' ').length;
|
||||||
|
const text = sanitized.trim();
|
||||||
|
normalized.push({ indentation, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlusTreePresetEntries(lines) {
|
||||||
|
const plusEntries = [];
|
||||||
|
for (const line of lines || []) {
|
||||||
|
const match = String(line?.text || '').match(/^\+\s+(.+?)\s*$/);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
plusEntries.push({
|
||||||
|
indentation: Number(line?.indentation || 0),
|
||||||
|
name: String(match[1] || '').trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plusEntries.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const leafEntries = [];
|
||||||
|
for (let index = 0; index < plusEntries.length; index += 1) {
|
||||||
|
const current = plusEntries[index];
|
||||||
|
const next = plusEntries[index + 1];
|
||||||
|
const hasChildren = Boolean(next) && next.indentation > current.indentation;
|
||||||
|
if (!hasChildren) {
|
||||||
|
let category = null;
|
||||||
|
for (let parentIndex = index - 1; parentIndex >= 0; parentIndex -= 1) {
|
||||||
|
const candidate = plusEntries[parentIndex];
|
||||||
|
if (candidate.indentation < current.indentation) {
|
||||||
|
category = candidate.name || null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leafEntries.push({
|
||||||
|
name: current.name,
|
||||||
|
category
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniquePresetEntries(leafEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSlashTreePresetEntries(lines) {
|
||||||
|
const list = Array.isArray(lines) ? lines : [];
|
||||||
|
const presetEntries = [];
|
||||||
|
let currentCategoryIndent = null;
|
||||||
|
let currentCategoryName = null;
|
||||||
|
let currentPresetIndent = null;
|
||||||
|
|
||||||
|
for (const line of list) {
|
||||||
|
const indentation = Number(line?.indentation || 0);
|
||||||
|
const text = String(line?.text || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.endsWith('/')) {
|
||||||
|
currentCategoryIndent = indentation;
|
||||||
|
currentCategoryName = String(text.slice(0, -1) || '').trim() || null;
|
||||||
|
currentPresetIndent = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCategoryIndent === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indentation <= currentCategoryIndent) {
|
||||||
|
currentCategoryIndent = null;
|
||||||
|
currentCategoryName = null;
|
||||||
|
currentPresetIndent = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPresetIndent === null) {
|
||||||
|
currentPresetIndent = indentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indentation === currentPresetIndent) {
|
||||||
|
presetEntries.push({
|
||||||
|
name: text,
|
||||||
|
category: currentCategoryName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniquePresetEntries(presetEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHandBrakePresetEntriesFromListOutput(rawOutput) {
|
||||||
|
const lines = normalizePresetListLines(rawOutput);
|
||||||
|
const plusTreeEntries = parsePlusTreePresetEntries(lines);
|
||||||
|
if (plusTreeEntries.length > 0) {
|
||||||
|
return plusTreeEntries;
|
||||||
|
}
|
||||||
|
return parseSlashTreePresetEntries(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPresetEntriesToOptions(entries) {
|
||||||
|
const list = Array.isArray(entries) ? entries : [];
|
||||||
|
const options = [];
|
||||||
|
const seenCategories = new Set();
|
||||||
|
const INDENT = '\u00A0\u00A0\u00A0';
|
||||||
|
|
||||||
|
for (const entry of list) {
|
||||||
|
const name = String(entry?.name || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const category = entry?.category ? String(entry.category).trim() : '';
|
||||||
|
if (category && !seenCategories.has(category)) {
|
||||||
|
seenCategories.add(category);
|
||||||
|
options.push({
|
||||||
|
label: `${category}/`,
|
||||||
|
value: `__group__${category.toLowerCase().replace(/\s+/g, '_')}`,
|
||||||
|
disabled: true,
|
||||||
|
category
|
||||||
|
});
|
||||||
|
}
|
||||||
|
options.push({
|
||||||
|
label: category ? `${INDENT}${name}` : name,
|
||||||
|
value: name,
|
||||||
|
category: category || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
class SettingsService {
|
class SettingsService {
|
||||||
async getSchemaRows() {
|
async getSchemaRows() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
@@ -705,6 +900,85 @@ class SettingsService {
|
|||||||
|
|
||||||
return `disc:${map.makemkv_source_index ?? 0}`;
|
return `disc:${map.makemkv_source_index ?? 0}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getHandBrakePresetOptions() {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const configuredPreset = String(map.handbrake_preset || '').trim();
|
||||||
|
const fallbackOptions = configuredPreset
|
||||||
|
? [{ label: configuredPreset, value: configuredPreset }]
|
||||||
|
: [];
|
||||||
|
const rawCommand = String(map.handbrake_command || 'HandBrakeCLI').trim();
|
||||||
|
const commandTokens = splitArgs(rawCommand);
|
||||||
|
const cmd = commandTokens[0] || 'HandBrakeCLI';
|
||||||
|
const baseArgs = commandTokens.slice(1);
|
||||||
|
const args = [...baseArgs, '-z'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(cmd, args, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: HANDBRAKE_PRESET_LIST_TIMEOUT_MS,
|
||||||
|
maxBuffer: 8 * 1024 * 1024
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return {
|
||||||
|
source: 'fallback',
|
||||||
|
message: `Preset-Liste konnte nicht geladen werden: ${result.error.message}`,
|
||||||
|
options: fallbackOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = String(result.stderr || '').trim();
|
||||||
|
const stdout = String(result.stdout || '').trim();
|
||||||
|
const detail = (stderr || stdout || `exit=${result.status}`).slice(0, 280);
|
||||||
|
return {
|
||||||
|
source: 'fallback',
|
||||||
|
message: `Preset-Liste konnte nicht geladen werden (${detail})`,
|
||||||
|
options: fallbackOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedOutput = `${String(result.stdout || '')}\n${String(result.stderr || '')}`;
|
||||||
|
const entries = parseHandBrakePresetEntriesFromListOutput(combinedOutput);
|
||||||
|
const options = mapPresetEntriesToOptions(entries);
|
||||||
|
if (options.length === 0) {
|
||||||
|
return {
|
||||||
|
source: 'fallback',
|
||||||
|
message: 'Preset-Liste konnte aus HandBrakeCLI -z nicht geparst werden.',
|
||||||
|
options: fallbackOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!configuredPreset) {
|
||||||
|
return {
|
||||||
|
source: 'handbrake-cli',
|
||||||
|
message: null,
|
||||||
|
options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasConfiguredPreset = options.some((option) => option.value === configuredPreset);
|
||||||
|
if (hasConfiguredPreset) {
|
||||||
|
return {
|
||||||
|
source: 'handbrake-cli',
|
||||||
|
message: null,
|
||||||
|
options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'handbrake-cli',
|
||||||
|
message: `Aktuell gesetztes Preset "${configuredPreset}" wurde in HandBrakeCLI -z nicht gefunden.`,
|
||||||
|
options: [{ label: configuredPreset, value: configuredPreset }, ...options]
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
source: 'fallback',
|
||||||
|
message: `Preset-Liste konnte nicht geladen werden: ${error.message}`,
|
||||||
|
options: fallbackOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new SettingsService();
|
module.exports = new SettingsService();
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ CREATE TABLE jobs (
|
|||||||
CREATE INDEX idx_jobs_status ON jobs(status);
|
CREATE INDEX idx_jobs_status ON jobs(status);
|
||||||
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC);
|
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE scripts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
script_body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_scripts_name ON scripts(name);
|
||||||
|
|
||||||
CREATE TABLE pipeline_state (
|
CREATE TABLE pipeline_state (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
state TEXT NOT NULL,
|
state TEXT NOT NULL,
|
||||||
|
|||||||
@@ -36,6 +36,34 @@ export const api = {
|
|||||||
getSettings() {
|
getSettings() {
|
||||||
return request('/settings');
|
return request('/settings');
|
||||||
},
|
},
|
||||||
|
getHandBrakePresets() {
|
||||||
|
return request('/settings/handbrake-presets');
|
||||||
|
},
|
||||||
|
getScripts() {
|
||||||
|
return request('/settings/scripts');
|
||||||
|
},
|
||||||
|
createScript(payload = {}) {
|
||||||
|
return request('/settings/scripts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateScript(scriptId, payload = {}) {
|
||||||
|
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteScript(scriptId) {
|
||||||
|
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
testScript(scriptId) {
|
||||||
|
return request(`/settings/scripts/${encodeURIComponent(scriptId)}/test`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
updateSetting(key, value) {
|
updateSetting(key, value) {
|
||||||
return request(`/settings/${encodeURIComponent(key)}`, {
|
return request(`/settings/${encodeURIComponent(key)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ function buildToolSections(settings) {
|
|||||||
{
|
{
|
||||||
id: 'output',
|
id: 'output',
|
||||||
title: 'Output',
|
title: 'Output',
|
||||||
description: 'Container-Format und Dateinamen-Template.',
|
description: 'Container-Format sowie Datei- und Ordnernamen-Template.',
|
||||||
match: (key) => key === 'output_extension' || key === 'filename_template'
|
match: (key) => key === 'output_extension' || key === 'filename_template' || key === 'output_folder_template'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -95,6 +95,10 @@ function buildSectionsForCategory(categoryName, settings) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHandBrakePresetSetting(setting) {
|
||||||
|
return String(setting?.key || '').trim().toLowerCase() === 'handbrake_preset';
|
||||||
|
}
|
||||||
|
|
||||||
export default function DynamicSettingsForm({
|
export default function DynamicSettingsForm({
|
||||||
categories,
|
categories,
|
||||||
values,
|
values,
|
||||||
@@ -194,11 +198,24 @@ export default function DynamicSettingsForm({
|
|||||||
options={setting.options}
|
options={setting.options}
|
||||||
optionLabel="label"
|
optionLabel="label"
|
||||||
optionValue="value"
|
optionValue="value"
|
||||||
|
optionDisabled="disabled"
|
||||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<small>{setting.description || ''}</small>
|
<small>{setting.description || ''}</small>
|
||||||
|
{isHandBrakePresetSetting(setting) ? (
|
||||||
|
<small>
|
||||||
|
Preset-Erklärung:{' '}
|
||||||
|
<a
|
||||||
|
href="https://handbrake.fr/docs/en/latest/technical/official-presets.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
HandBrake Official Presets
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
<small className="error-text">{error}</small>
|
<small className="error-text">{error}</small>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { Tag } from 'primereact/tag';
|
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||||
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
|
|
||||||
function JsonView({ title, value }) {
|
function JsonView({ title, value }) {
|
||||||
return (
|
return (
|
||||||
@@ -12,6 +13,67 @@ function JsonView({ title, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMediaType(job) {
|
||||||
|
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
||||||
|
return raw === 'bluray' ? 'bluray' : 'disc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeMeta(status) {
|
||||||
|
const normalized = String(status || '').trim().toUpperCase();
|
||||||
|
if (normalized === 'FINISHED') {
|
||||||
|
return { label: normalized, icon: 'pi-check-circle', tone: 'success' };
|
||||||
|
}
|
||||||
|
if (normalized === 'ERROR') {
|
||||||
|
return { label: normalized, icon: 'pi-times-circle', tone: 'danger' };
|
||||||
|
}
|
||||||
|
if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') {
|
||||||
|
return { label: normalized, icon: 'pi-play-circle', tone: 'info' };
|
||||||
|
}
|
||||||
|
if (normalized === 'WAITING_FOR_USER_DECISION') {
|
||||||
|
return { label: normalized, icon: 'pi-exclamation-circle', tone: 'warning' };
|
||||||
|
}
|
||||||
|
if (normalized === 'METADATA_SELECTION') {
|
||||||
|
return { label: normalized, icon: 'pi-list', tone: 'warning' };
|
||||||
|
}
|
||||||
|
if (normalized === 'ANALYZING') {
|
||||||
|
return { label: normalized, icon: 'pi-search', tone: 'warning' };
|
||||||
|
}
|
||||||
|
if (normalized === 'RIPPING') {
|
||||||
|
return { label: normalized, icon: 'pi-download', tone: 'warning' };
|
||||||
|
}
|
||||||
|
if (normalized === 'MEDIAINFO_CHECK') {
|
||||||
|
return { label: normalized, icon: 'pi-sliders-h', tone: 'warning' };
|
||||||
|
}
|
||||||
|
if (normalized === 'ENCODING') {
|
||||||
|
return { label: normalized, icon: 'pi-cog', tone: 'warning' };
|
||||||
|
}
|
||||||
|
return { label: normalized || '-', icon: 'pi-info-circle', tone: 'secondary' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function omdbField(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
return raw || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function omdbRottenTomatoesScore(omdbInfo) {
|
||||||
|
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
|
||||||
|
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === 'rotten tomatoes');
|
||||||
|
return omdbField(entry?.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoolState({ value }) {
|
||||||
|
const isTrue = Boolean(value);
|
||||||
|
return isTrue ? (
|
||||||
|
<span className="job-step-inline-ok" title="Ja">
|
||||||
|
<i className="pi pi-check-circle" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="job-step-inline-no" title="Nein">
|
||||||
|
<i className="pi pi-times-circle" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function JobDetailDialog({
|
export default function JobDetailDialog({
|
||||||
visible,
|
visible,
|
||||||
job,
|
job,
|
||||||
@@ -46,6 +108,12 @@ export default function JobDetailDialog({
|
|||||||
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
||||||
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||||
const logTruncated = Boolean(logMeta?.truncated);
|
const logTruncated = Boolean(logMeta?.truncated);
|
||||||
|
const mediaType = resolveMediaType(job);
|
||||||
|
const mediaTypeLabel = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges Medium';
|
||||||
|
const mediaTypeIcon = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
||||||
|
const mediaTypeAlt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
||||||
|
const statusMeta = statusBadgeMeta(job?.status);
|
||||||
|
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -68,22 +136,80 @@ export default function JobDetailDialog({
|
|||||||
<div className="poster-large poster-fallback">Kein Poster</div>
|
<div className="poster-large poster-fallback">Kein Poster</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="job-meta-grid">
|
<div className="job-film-info-grid">
|
||||||
|
<section className="job-meta-block job-meta-block-film">
|
||||||
|
<h4>Film-Infos</h4>
|
||||||
|
<div className="job-meta-list">
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Titel:</strong>
|
||||||
|
<span>{job.title || job.detected_title || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Jahr:</strong>
|
||||||
|
<span>{job.year || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>IMDb:</strong>
|
||||||
|
<span>{job.imdb_id || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>OMDb Match:</strong>
|
||||||
|
<BoolState value={job.selected_from_omdb} />
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Medium:</strong>
|
||||||
|
<span className="job-step-cell">
|
||||||
|
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
|
||||||
|
<span>{mediaTypeLabel}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="job-meta-block job-meta-block-film">
|
||||||
|
<h4>OMDb Details</h4>
|
||||||
|
<div className="job-meta-list">
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Regisseur:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.Director)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Schauspieler:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.Actors)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Laufzeit:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.Runtime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Genre:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.Genre)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>Rotten Tomatoes:</strong>
|
||||||
|
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>imdbRating:</strong>
|
||||||
|
<span>{omdbField(omdbInfo?.imdbRating)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="job-meta-block job-meta-block-full">
|
||||||
|
<h4>Job-Infos</h4>
|
||||||
|
<div className="job-meta-grid job-meta-grid-compact">
|
||||||
<div>
|
<div>
|
||||||
<strong>Titel:</strong> {job.title || job.detected_title || '-'}
|
<strong>Aktueller Status:</strong>{' '}
|
||||||
</div>
|
<span
|
||||||
<div>
|
className={`job-status-icon tone-${statusMeta.tone}`}
|
||||||
<strong>Jahr:</strong> {job.year || '-'}
|
title={statusMeta.label}
|
||||||
</div>
|
aria-label={statusMeta.label}
|
||||||
<div>
|
>
|
||||||
<strong>IMDb:</strong> {job.imdb_id || '-'}
|
<i className={`pi ${statusMeta.icon}`} aria-hidden="true" />
|
||||||
</div>
|
</span>
|
||||||
<div>
|
|
||||||
<strong>OMDb Match:</strong>{' '}
|
|
||||||
<Tag value={job.selected_from_omdb ? 'Ja' : 'Nein'} severity={job.selected_from_omdb ? 'success' : 'secondary'} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Status:</strong> <Tag value={job.status} />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Start:</strong> {job.start_time || '-'}
|
<strong>Start:</strong> {job.start_time || '-'}
|
||||||
@@ -101,40 +227,29 @@ export default function JobDetailDialog({
|
|||||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Mediainfo bestätigt:</strong> {job.encode_review_confirmed ? 'ja' : 'nein'}
|
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>RAW vorhanden:</strong> {job.rawStatus?.exists ? 'ja' : 'nein'}
|
<strong>Movie Datei vorhanden:</strong> <BoolState value={job.outputStatus?.exists} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>RAW leer:</strong> {job.rawStatus?.isEmpty === null ? '-' : job.rawStatus?.isEmpty ? 'ja' : 'nein'}
|
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Movie Datei vorhanden:</strong> {job.outputStatus?.exists ? 'ja' : 'nein'}
|
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="job-meta-col-span-2">
|
||||||
<strong>Movie-Dir leer:</strong> {job.movieDirStatus?.isEmpty === null ? '-' : job.movieDirStatus?.isEmpty ? 'ja' : 'nein'}
|
<strong>Letzter Fehler:</strong> {job.error_message || '-'}
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Backup erfolgreich:</strong>{' '}
|
|
||||||
{job?.backupSuccess ? <span className="job-step-inline-ok"><i className="pi pi-check-circle" aria-hidden="true" /><span>ja</span></span> : 'nein'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Encode erfolgreich:</strong>{' '}
|
|
||||||
{job?.encodeSuccess ? <span className="job-step-inline-ok"><i className="pi pi-check-circle" aria-hidden="true" /><span>ja</span></span> : 'nein'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Fehler:</strong> {job.error_message || '-'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="job-json-grid">
|
<div className="job-json-grid">
|
||||||
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
||||||
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
||||||
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
|
|
||||||
<JsonView title="Mediainfo Info" value={job.mediainfoInfo} />
|
<JsonView title="Mediainfo Info" value={job.mediainfoInfo} />
|
||||||
<JsonView title="Encode Plan" value={job.encodePlan} />
|
<JsonView title="Encode Plan" value={job.encodePlan} />
|
||||||
|
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{job.encodePlan ? (
|
{job.encodePlan ? (
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
|
|
||||||
function formatDuration(minutes) {
|
function formatDuration(minutes) {
|
||||||
const value = Number(minutes || 0);
|
const value = Number(minutes || 0);
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
@@ -636,15 +639,50 @@ function normalizeTitleId(value) {
|
|||||||
return Math.trunc(parsed);
|
return Math.trunc(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeScriptId(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScriptIdList(values) {
|
||||||
|
const list = Array.isArray(values) ? values : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const value of list) {
|
||||||
|
const normalized = normalizeScriptId(value);
|
||||||
|
if (normalized === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(normalized);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
output.push(normalized);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MediaInfoReviewPanel({
|
export default function MediaInfoReviewPanel({
|
||||||
review,
|
review,
|
||||||
|
presetDisplayValue = '',
|
||||||
commandOutputPath = null,
|
commandOutputPath = null,
|
||||||
selectedEncodeTitleId = null,
|
selectedEncodeTitleId = null,
|
||||||
allowTitleSelection = false,
|
allowTitleSelection = false,
|
||||||
onSelectEncodeTitle = null,
|
onSelectEncodeTitle = null,
|
||||||
allowTrackSelection = false,
|
allowTrackSelection = false,
|
||||||
trackSelectionByTitle = {},
|
trackSelectionByTitle = {},
|
||||||
onTrackSelectionChange = null
|
onTrackSelectionChange = null,
|
||||||
|
availablePostScripts = [],
|
||||||
|
selectedPostEncodeScriptIds = [],
|
||||||
|
allowPostScriptSelection = false,
|
||||||
|
onAddPostEncodeScript = null,
|
||||||
|
onChangePostEncodeScript = null,
|
||||||
|
onRemovePostEncodeScript = null,
|
||||||
|
onReorderPostEncodeScript = null
|
||||||
}) {
|
}) {
|
||||||
if (!review) {
|
if (!review) {
|
||||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||||
@@ -656,11 +694,35 @@ export default function MediaInfoReviewPanel({
|
|||||||
const processedFiles = Number(review.processedFiles || titles.length || 0);
|
const processedFiles = Number(review.processedFiles || titles.length || 0);
|
||||||
const totalFiles = Number(review.totalFiles || titles.length || 0);
|
const totalFiles = Number(review.totalFiles || titles.length || 0);
|
||||||
const playlistRecommendation = review.playlistRecommendation || null;
|
const playlistRecommendation = review.playlistRecommendation || null;
|
||||||
|
const presetLabel = String(presetDisplayValue || review.selectors?.preset || '').trim() || '-';
|
||||||
|
const scriptRows = normalizeScriptIdList(selectedPostEncodeScriptIds);
|
||||||
|
const scriptCatalog = (Array.isArray(availablePostScripts) ? availablePostScripts : [])
|
||||||
|
.map((item) => ({
|
||||||
|
id: normalizeScriptId(item?.id),
|
||||||
|
name: String(item?.name || '').trim()
|
||||||
|
}))
|
||||||
|
.filter((item) => item.id !== null && item.name.length > 0);
|
||||||
|
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
|
||||||
|
const canAddScriptRow = allowPostScriptSelection && scriptCatalog.length > 0 && scriptRows.length < scriptCatalog.length;
|
||||||
|
const canReorderScriptRows = allowPostScriptSelection && scriptRows.length > 1;
|
||||||
|
|
||||||
|
const handleScriptDrop = (event, targetIndex) => {
|
||||||
|
if (!allowPostScriptSelection || typeof onReorderPostEncodeScript !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const fromText = event.dataTransfer?.getData('text/plain');
|
||||||
|
const fromIndex = Number(fromText);
|
||||||
|
if (!Number.isInteger(fromIndex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onReorderPostEncodeScript(fromIndex, targetIndex);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="media-review-wrap">
|
<div className="media-review-wrap">
|
||||||
<div className="media-review-meta">
|
<div className="media-review-meta">
|
||||||
<div><strong>Preset:</strong> {review.selectors?.preset || '-'}</div>
|
<div><strong>Preset:</strong> {presetLabel}</div>
|
||||||
<div><strong>Extra Args:</strong> {review.selectors?.extraArgs || '(keine)'}</div>
|
<div><strong>Extra Args:</strong> {review.selectors?.extraArgs || '(keine)'}</div>
|
||||||
<div><strong>Preset-Profil:</strong> {review.selectors?.presetProfileSource || '-'}</div>
|
<div><strong>Preset-Profil:</strong> {review.selectors?.presetProfileSource || '-'}</div>
|
||||||
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
|
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
|
||||||
@@ -695,6 +757,87 @@ export default function MediaInfoReviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="post-script-box">
|
||||||
|
<h4>Post-Encode Scripte (optional)</h4>
|
||||||
|
{scriptCatalog.length === 0 ? (
|
||||||
|
<small>Keine Scripte konfiguriert. In den Settings unter "Scripte" anlegen.</small>
|
||||||
|
) : null}
|
||||||
|
{scriptRows.length === 0 ? (
|
||||||
|
<small>Keine Post-Encode Scripte ausgewählt.</small>
|
||||||
|
) : null}
|
||||||
|
{scriptRows.map((scriptId, rowIndex) => {
|
||||||
|
const script = scriptById.get(scriptId) || null;
|
||||||
|
const selectedInOtherRows = new Set(
|
||||||
|
scriptRows.filter((id, index) => index !== rowIndex).map((id) => String(id))
|
||||||
|
);
|
||||||
|
const options = scriptCatalog.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
disabled: selectedInOtherRows.has(String(item.id))
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`post-script-row-${rowIndex}-${scriptId}`}
|
||||||
|
className={`post-script-row${allowPostScriptSelection ? ' editable' : ''}`}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!canReorderScriptRows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={(event) => handleScriptDrop(event, rowIndex)}
|
||||||
|
>
|
||||||
|
{allowPostScriptSelection ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`post-script-drag-handle pi pi-bars${canReorderScriptRows ? '' : ' disabled'}`}
|
||||||
|
title={canReorderScriptRows ? 'Ziehen zum Umordnen' : 'Mindestens zwei Scripte zum Umordnen'}
|
||||||
|
draggable={canReorderScriptRows}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (!canReorderScriptRows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', String(rowIndex));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
value={scriptId}
|
||||||
|
options={options}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
optionDisabled="disabled"
|
||||||
|
onChange={(event) => onChangePostEncodeScript?.(rowIndex, event.value)}
|
||||||
|
className="full-width"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onRemovePostEncodeScript?.(rowIndex)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<small>{`${rowIndex + 1}. ${script?.name || `Script #${scriptId}`}`}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{canAddScriptRow ? (
|
||||||
|
<Button
|
||||||
|
label="Script hinzufügen"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => onAddPostEncodeScript?.()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<small>Ausführung erfolgt nur nach erfolgreichem Encode, strikt nacheinander in genau dieser Reihenfolge (Drag-and-Drop möglich).</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4>Titel</h4>
|
<h4>Titel</h4>
|
||||||
<div className="media-title-list">
|
<div className="media-title-list">
|
||||||
{titles.length === 0 ? (
|
{titles.length === 0 ? (
|
||||||
|
|||||||
@@ -65,6 +65,33 @@ function normalizeTrackIdList(values) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeScriptId(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScriptIdList(values) {
|
||||||
|
const list = Array.isArray(values) ? values : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const value of list) {
|
||||||
|
const normalized = normalizeScriptId(value);
|
||||||
|
if (normalized === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(normalized);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
output.push(normalized);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
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 = {};
|
||||||
@@ -108,6 +135,23 @@ function buildSettingsMap(categories) {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPresetDisplayMap(options) {
|
||||||
|
const map = {};
|
||||||
|
const list = Array.isArray(options) ? options : [];
|
||||||
|
for (const option of list) {
|
||||||
|
if (!option || option.disabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = String(option.value || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const category = String(option.category || '').trim();
|
||||||
|
map[value] = category ? `${category}/${value}` : value;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeFileName(input) {
|
function sanitizeFileName(input) {
|
||||||
return String(input || 'untitled')
|
return String(input || 'untitled')
|
||||||
.replace(/[\\/:*?"<>|]/g, '_')
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
@@ -135,9 +179,10 @@ function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
|
|||||||
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
||||||
const year = metadata?.year || new Date().getFullYear();
|
const year = metadata?.year || new Date().getFullYear();
|
||||||
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
||||||
const template = settings?.filename_template || '${title} (${year})';
|
const fileTemplate = settings?.filename_template || '${title} (${year})';
|
||||||
const folderName = sanitizeFileName(renderTemplate('${title} (${year})', { title, year, imdbId }));
|
const folderTemplate = String(settings?.output_folder_template || '').trim() || fileTemplate;
|
||||||
const baseName = sanitizeFileName(renderTemplate(template, { title, year, imdbId }));
|
const folderName = sanitizeFileName(renderTemplate(folderTemplate, { title, year, imdbId }));
|
||||||
|
const baseName = sanitizeFileName(renderTemplate(fileTemplate, { title, year, imdbId }));
|
||||||
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
|
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
|
||||||
const root = movieDir.replace(/\/+$/g, '');
|
const root = movieDir.replace(/\/+$/g, '');
|
||||||
return `${root}/${folderName}/${baseName}.${ext}`;
|
return `${root}/${folderName}/${baseName}.${ext}`;
|
||||||
@@ -171,18 +216,43 @@ export default function PipelineStatusCard({
|
|||||||
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
||||||
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
||||||
const [settingsMap, setSettingsMap] = useState({});
|
const [settingsMap, setSettingsMap] = useState({});
|
||||||
|
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
||||||
|
const [scriptCatalog, setScriptCatalog] = useState([]);
|
||||||
|
const [selectedPostEncodeScriptIds, setSelectedPostEncodeScriptIds] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.getSettings();
|
const [settingsResponse, presetsResponse, scriptsResponse] = await Promise.allSettled([
|
||||||
|
api.getSettings(),
|
||||||
|
api.getHandBrakePresets(),
|
||||||
|
api.getScripts()
|
||||||
|
]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSettingsMap(buildSettingsMap(response?.categories || []));
|
const categories = settingsResponse.status === 'fulfilled'
|
||||||
|
? (settingsResponse.value?.categories || [])
|
||||||
|
: [];
|
||||||
|
setSettingsMap(buildSettingsMap(categories));
|
||||||
|
const presetOptions = presetsResponse.status === 'fulfilled'
|
||||||
|
? (presetsResponse.value?.options || [])
|
||||||
|
: [];
|
||||||
|
setPresetDisplayMap(buildPresetDisplayMap(presetOptions));
|
||||||
|
const scripts = scriptsResponse.status === 'fulfilled'
|
||||||
|
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
|
||||||
|
: [];
|
||||||
|
setScriptCatalog(
|
||||||
|
scripts.map((item) => ({
|
||||||
|
id: item?.id,
|
||||||
|
name: item?.name
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSettingsMap({});
|
setSettingsMap({});
|
||||||
|
setPresetDisplayMap({});
|
||||||
|
setScriptCatalog([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -196,6 +266,9 @@ export default function PipelineStatusCard({
|
|||||||
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
|
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
|
||||||
setSelectedEncodeTitleId(fromReview);
|
setSelectedEncodeTitleId(fromReview);
|
||||||
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
||||||
|
setSelectedPostEncodeScriptIds(
|
||||||
|
normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || [])
|
||||||
|
);
|
||||||
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -312,6 +385,13 @@ export default function PipelineStatusCard({
|
|||||||
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
|
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
|
||||||
[settingsMap, selectedMetadata, retryJobId]
|
[settingsMap, selectedMetadata, retryJobId]
|
||||||
);
|
);
|
||||||
|
const presetDisplayValue = useMemo(() => {
|
||||||
|
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
|
||||||
|
if (!preset) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return presetDisplayMap[preset] || preset;
|
||||||
|
}, [mediaInfoReview?.selectors?.preset, presetDisplayMap]);
|
||||||
const buildSelectedTrackSelectionForCurrentTitle = () => {
|
const buildSelectedTrackSelectionForCurrentTitle = () => {
|
||||||
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId);
|
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId);
|
||||||
const selectionEntry = encodeTitleId
|
const selectionEntry = encodeTitleId
|
||||||
@@ -329,9 +409,11 @@ export default function PipelineStatusCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
const selectedPostScriptIds = normalizeScriptIdList(selectedPostEncodeScriptIds);
|
||||||
return {
|
return {
|
||||||
encodeTitleId,
|
encodeTitleId,
|
||||||
selectedTrackSelection
|
selectedTrackSelection,
|
||||||
|
selectedPostScriptIds
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -399,11 +481,16 @@ export default function PipelineStatusCard({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { encodeTitleId, selectedTrackSelection } = buildSelectedTrackSelectionForCurrentTitle();
|
const {
|
||||||
|
encodeTitleId,
|
||||||
|
selectedTrackSelection,
|
||||||
|
selectedPostScriptIds
|
||||||
|
} = buildSelectedTrackSelectionForCurrentTitle();
|
||||||
await onStart(retryJobId, {
|
await onStart(retryJobId, {
|
||||||
ensureConfirmed: true,
|
ensureConfirmed: true,
|
||||||
selectedEncodeTitleId: encodeTitleId,
|
selectedEncodeTitleId: encodeTitleId,
|
||||||
selectedTrackSelection
|
selectedTrackSelection,
|
||||||
|
selectedPostEncodeScriptIds: selectedPostScriptIds
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
@@ -561,6 +648,7 @@ export default function PipelineStatusCard({
|
|||||||
) : null}
|
) : null}
|
||||||
<MediaInfoReviewPanel
|
<MediaInfoReviewPanel
|
||||||
review={mediaInfoReview}
|
review={mediaInfoReview}
|
||||||
|
presetDisplayValue={presetDisplayValue}
|
||||||
commandOutputPath={commandOutputPath}
|
commandOutputPath={commandOutputPath}
|
||||||
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||||
allowTitleSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
allowTitleSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||||
@@ -594,6 +682,72 @@ export default function PipelineStatusCard({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
availablePostScripts={scriptCatalog}
|
||||||
|
selectedPostEncodeScriptIds={selectedPostEncodeScriptIds}
|
||||||
|
allowPostScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||||
|
onAddPostEncodeScript={() => {
|
||||||
|
setSelectedPostEncodeScriptIds((prev) => {
|
||||||
|
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||||
|
const selectedSet = new Set(normalizedCurrent.map((id) => String(id)));
|
||||||
|
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
|
||||||
|
.map((item) => normalizeScriptId(item?.id))
|
||||||
|
.find((id) => id !== null && !selectedSet.has(String(id)));
|
||||||
|
if (nextCandidate === undefined || nextCandidate === null) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
return [...normalizedCurrent, nextCandidate];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onChangePostEncodeScript={(rowIndex, nextScriptId) => {
|
||||||
|
setSelectedPostEncodeScriptIds((prev) => {
|
||||||
|
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||||
|
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
const normalizedScriptId = normalizeScriptId(nextScriptId);
|
||||||
|
if (normalizedScriptId === null) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
const duplicateAtOtherIndex = normalizedCurrent.some((id, idx) =>
|
||||||
|
idx !== rowIndex && String(id) === String(normalizedScriptId)
|
||||||
|
);
|
||||||
|
if (duplicateAtOtherIndex) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
const next = [...normalizedCurrent];
|
||||||
|
next[rowIndex] = normalizedScriptId;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onRemovePostEncodeScript={(rowIndex) => {
|
||||||
|
setSelectedPostEncodeScriptIds((prev) => {
|
||||||
|
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||||
|
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
return normalizedCurrent.filter((_, idx) => idx !== rowIndex);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onReorderPostEncodeScript={(fromIndex, toIndex) => {
|
||||||
|
setSelectedPostEncodeScriptIds((prev) => {
|
||||||
|
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||||
|
const from = Number(fromIndex);
|
||||||
|
const to = Number(toIndex);
|
||||||
|
if (!Number.isInteger(from) || !Number.isInteger(to)) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
if (from < 0 || to < 0 || from >= normalizedCurrent.length || to >= normalizedCurrent.length) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
if (from === to) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
const next = [...normalizedCurrent];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -424,7 +424,8 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
|
|||||||
if (startOptions.ensureConfirmed) {
|
if (startOptions.ensureConfirmed) {
|
||||||
await api.confirmEncodeReview(normalizedJobId, {
|
await api.confirmEncodeReview(normalizedJobId, {
|
||||||
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
||||||
selectedTrackSelection: startOptions.selectedTrackSelection ?? null
|
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
||||||
|
selectedPostEncodeScriptIds: startOptions.selectedPostEncodeScriptIds ?? []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await api.startJob(normalizedJobId);
|
await api.startJob(normalizedJobId);
|
||||||
@@ -438,12 +439,18 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmReview = async (jobId, selectedEncodeTitleId = null, selectedTrackSelection = null) => {
|
const handleConfirmReview = async (
|
||||||
|
jobId,
|
||||||
|
selectedEncodeTitleId = null,
|
||||||
|
selectedTrackSelection = null,
|
||||||
|
selectedPostEncodeScriptIds = undefined
|
||||||
|
) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.confirmEncodeReview(jobId, {
|
await api.confirmEncodeReview(jobId, {
|
||||||
selectedEncodeTitleId,
|
selectedEncodeTitleId,
|
||||||
selectedTrackSelection
|
selectedTrackSelection,
|
||||||
|
selectedPostEncodeScriptIds
|
||||||
});
|
});
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { Toast } from 'primereact/toast';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import JobDetailDialog from '../components/JobDetailDialog';
|
import JobDetailDialog from '../components/JobDetailDialog';
|
||||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||||
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ label: 'Alle', value: '' },
|
{ label: 'Alle', value: '' },
|
||||||
@@ -34,6 +36,11 @@ function statusSeverity(status) {
|
|||||||
return 'secondary';
|
return 'secondary';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMediaType(row) {
|
||||||
|
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
||||||
|
return raw === 'bluray' ? 'bluray' : 'disc';
|
||||||
|
}
|
||||||
|
|
||||||
export default function DatabasePage() {
|
export default function DatabasePage() {
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
const [orphanRows, setOrphanRows] = useState([]);
|
const [orphanRows, setOrphanRows] = useState([]);
|
||||||
@@ -425,6 +432,19 @@ export default function DatabasePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stateBody = (row) => <Tag value={row.status} severity={statusSeverity(row.status)} />;
|
const stateBody = (row) => <Tag value={row.status} severity={statusSeverity(row.status)} />;
|
||||||
|
const mediaBody = (row) => {
|
||||||
|
const mediaType = resolveMediaType(row);
|
||||||
|
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
||||||
|
const alt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
||||||
|
const title = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges Medium';
|
||||||
|
const label = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges';
|
||||||
|
return (
|
||||||
|
<span className="job-step-cell">
|
||||||
|
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
const orphanTitleBody = (row) => (
|
const orphanTitleBody = (row) => (
|
||||||
<div>
|
<div>
|
||||||
<div><strong>{row.title || '-'}</strong></div>
|
<div><strong>{row.title || '-'}</strong></div>
|
||||||
@@ -483,6 +503,7 @@ export default function DatabasePage() {
|
|||||||
>
|
>
|
||||||
<Column field="id" header="ID" style={{ width: '6rem' }} />
|
<Column field="id" header="ID" style={{ width: '6rem' }} />
|
||||||
<Column header="Bild" body={posterBody} style={{ width: '7rem' }} />
|
<Column header="Bild" body={posterBody} style={{ width: '7rem' }} />
|
||||||
|
<Column header="Medium" body={mediaBody} style={{ width: '10rem' }} />
|
||||||
<Column header="Titel" body={titleBody} style={{ minWidth: '18rem' }} />
|
<Column header="Titel" body={titleBody} style={{ minWidth: '18rem' }} />
|
||||||
<Column header="Status" body={stateBody} style={{ width: '11rem' }} />
|
<Column header="Status" body={stateBody} style={{ width: '11rem' }} />
|
||||||
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
|
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
|
import { Dialog } from 'primereact/dialog';
|
||||||
|
import { TabView, TabPanel } from 'primereact/tabview';
|
||||||
|
import { InputText } from 'primereact/inputtext';
|
||||||
|
import { InputTextarea } from 'primereact/inputtextarea';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||||
|
|
||||||
@@ -22,26 +26,154 @@ function isSameValue(a, b) {
|
|||||||
return a === b;
|
return a === b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function injectHandBrakePresetOptions(categories, presetPayload) {
|
||||||
|
const list = Array.isArray(categories) ? categories : [];
|
||||||
|
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
||||||
|
|
||||||
|
return list.map((category) => ({
|
||||||
|
...category,
|
||||||
|
settings: (category?.settings || []).map((setting) => {
|
||||||
|
if (setting?.key !== 'handbrake_preset') {
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (!value || seenValues.has(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenValues.add(value);
|
||||||
|
normalizedOptions.push({
|
||||||
|
...(option && typeof option === 'object' ? option : {}),
|
||||||
|
label: String(optionLabel ?? value),
|
||||||
|
value,
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 === 0) {
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...setting,
|
||||||
|
type: 'select',
|
||||||
|
options: normalizedOptions
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testingPushover, setTestingPushover] = useState(false);
|
const [testingPushover, setTestingPushover] = useState(false);
|
||||||
|
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||||
const [initialValues, setInitialValues] = useState({});
|
const [initialValues, setInitialValues] = useState({});
|
||||||
const [draftValues, setDraftValues] = useState({});
|
const [draftValues, setDraftValues] = useState({});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
const [scripts, setScripts] = useState([]);
|
||||||
|
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||||
|
const [scriptSaving, setScriptSaving] = useState(false);
|
||||||
|
const [scriptActionBusyId, setScriptActionBusyId] = useState(null);
|
||||||
|
const [scriptEditor, setScriptEditor] = useState({
|
||||||
|
mode: 'none',
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
scriptBody: ''
|
||||||
|
});
|
||||||
|
const [scriptErrors, setScriptErrors] = useState({});
|
||||||
|
const [lastScriptTestResult, setLastScriptTestResult] = useState(null);
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
|
const loadScripts = async ({ silent = false } = {}) => {
|
||||||
|
if (!silent) {
|
||||||
|
setScriptsLoading(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await api.getScripts();
|
||||||
|
const next = Array.isArray(response?.scripts) ? response.scripts : [];
|
||||||
|
setScripts(next);
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Script-Liste', detail: error.message });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
setScriptsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.getSettings();
|
const [settingsResponse, presetsResponse, scriptsResponse] = await Promise.allSettled([
|
||||||
const nextCategories = response.categories || [];
|
api.getSettings(),
|
||||||
|
api.getHandBrakePresets(),
|
||||||
|
api.getScripts()
|
||||||
|
]);
|
||||||
|
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({});
|
||||||
|
if (scriptsResponse.status === 'fulfilled') {
|
||||||
|
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
|
||||||
|
} else {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Scripte',
|
||||||
|
detail: 'Script-Liste konnte nicht geladen werden.'
|
||||||
|
});
|
||||||
|
}
|
||||||
} 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 {
|
||||||
@@ -148,56 +280,404 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScriptEditorChange = (key, value) => {
|
||||||
|
setScriptEditor((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
|
setScriptErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: null
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearScriptEditor = () => {
|
||||||
|
setScriptEditor({
|
||||||
|
mode: 'none',
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
scriptBody: ''
|
||||||
|
});
|
||||||
|
setScriptErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCreateScript = () => {
|
||||||
|
setScriptEditor({
|
||||||
|
mode: 'create',
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
scriptBody: ''
|
||||||
|
});
|
||||||
|
setScriptErrors({});
|
||||||
|
setLastScriptTestResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditScript = (script) => {
|
||||||
|
setScriptEditor({
|
||||||
|
mode: 'edit',
|
||||||
|
id: script?.id || null,
|
||||||
|
name: script?.name || '',
|
||||||
|
scriptBody: script?.scriptBody || ''
|
||||||
|
});
|
||||||
|
setScriptErrors({});
|
||||||
|
setLastScriptTestResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveScript = async () => {
|
||||||
|
if (scriptEditor?.mode !== 'create' && scriptEditor?.mode !== 'edit') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name: String(scriptEditor?.name || '').trim(),
|
||||||
|
scriptBody: String(scriptEditor?.scriptBody || '')
|
||||||
|
};
|
||||||
|
setScriptSaving(true);
|
||||||
|
try {
|
||||||
|
if (scriptEditor?.id) {
|
||||||
|
await api.updateScript(scriptEditor.id, payload);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Scripte',
|
||||||
|
detail: 'Script aktualisiert.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api.createScript(payload);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Scripte',
|
||||||
|
detail: 'Script angelegt.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loadScripts({ silent: true });
|
||||||
|
setScriptErrors({});
|
||||||
|
clearScriptEditor();
|
||||||
|
} catch (error) {
|
||||||
|
const details = Array.isArray(error?.details) ? error.details : [];
|
||||||
|
if (details.length > 0) {
|
||||||
|
const nextErrors = {};
|
||||||
|
for (const item of details) {
|
||||||
|
if (item?.field) {
|
||||||
|
nextErrors[item.field] = item.message || 'Ungültiger Wert';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setScriptErrors(nextErrors);
|
||||||
|
}
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Script speichern fehlgeschlagen',
|
||||||
|
detail: error.message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setScriptSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteScript = async (script) => {
|
||||||
|
const scriptId = Number(script?.id);
|
||||||
|
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = window.confirm(`Script "${script?.name || scriptId}" wirklich löschen?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setScriptActionBusyId(scriptId);
|
||||||
|
try {
|
||||||
|
await api.deleteScript(scriptId);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Scripte',
|
||||||
|
detail: 'Script gelöscht.'
|
||||||
|
});
|
||||||
|
await loadScripts({ silent: true });
|
||||||
|
if (scriptEditor?.mode === 'edit' && Number(scriptEditor?.id) === scriptId) {
|
||||||
|
clearScriptEditor();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Script löschen fehlgeschlagen',
|
||||||
|
detail: error.message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setScriptActionBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestScript = async (script) => {
|
||||||
|
const scriptId = Number(script?.id);
|
||||||
|
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setScriptActionBusyId(scriptId);
|
||||||
|
try {
|
||||||
|
const response = await api.testScript(scriptId);
|
||||||
|
const result = response?.result || null;
|
||||||
|
setLastScriptTestResult(result);
|
||||||
|
if (result?.success) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Script-Test',
|
||||||
|
detail: `"${script?.name || scriptId}" erfolgreich ausgeführt.`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Script-Test',
|
||||||
|
detail: `"${script?.name || scriptId}" fehlgeschlagen (exit=${result?.exitCode ?? 'n/a'}).`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Script-Test fehlgeschlagen',
|
||||||
|
detail: error.message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setScriptActionBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
<Card title="Einstellungen" subTitle="Änderungen werden erst beim Speichern in die Datenbank übernommen">
|
<Card title="Einstellungen" subTitle="Änderungen werden erst beim Speichern in die Datenbank übernommen">
|
||||||
<div className="actions-row">
|
<TabView
|
||||||
<Button
|
className="settings-root-tabview"
|
||||||
label="Änderungen speichern"
|
activeIndex={activeTabIndex}
|
||||||
icon="pi pi-save"
|
onTabChange={(event) => setActiveTabIndex(Number(event.index || 0))}
|
||||||
onClick={handleSave}
|
>
|
||||||
loading={saving}
|
<TabPanel header="Konfiguration">
|
||||||
disabled={!hasUnsavedChanges}
|
<div className="actions-row">
|
||||||
/>
|
<Button
|
||||||
<Button
|
label="Änderungen speichern"
|
||||||
label="Änderungen verwerfen"
|
icon="pi pi-save"
|
||||||
icon="pi pi-undo"
|
onClick={handleSave}
|
||||||
severity="secondary"
|
loading={saving}
|
||||||
outlined
|
disabled={!hasUnsavedChanges}
|
||||||
onClick={handleDiscard}
|
/>
|
||||||
disabled={!hasUnsavedChanges || saving}
|
<Button
|
||||||
/>
|
label="Änderungen verwerfen"
|
||||||
<Button
|
icon="pi pi-undo"
|
||||||
label="Neu laden"
|
severity="secondary"
|
||||||
icon="pi pi-refresh"
|
outlined
|
||||||
severity="secondary"
|
onClick={handleDiscard}
|
||||||
onClick={load}
|
disabled={!hasUnsavedChanges || saving}
|
||||||
loading={loading}
|
/>
|
||||||
disabled={saving}
|
<Button
|
||||||
/>
|
label="Neu laden"
|
||||||
<Button
|
icon="pi pi-refresh"
|
||||||
label="PushOver Test"
|
severity="secondary"
|
||||||
icon="pi pi-send"
|
onClick={load}
|
||||||
severity="info"
|
loading={loading}
|
||||||
onClick={handlePushoverTest}
|
disabled={saving}
|
||||||
loading={testingPushover}
|
/>
|
||||||
disabled={saving}
|
<Button
|
||||||
/>
|
label="PushOver Test"
|
||||||
</div>
|
icon="pi pi-send"
|
||||||
|
severity="info"
|
||||||
|
onClick={handlePushoverTest}
|
||||||
|
loading={testingPushover}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p>Lade Settings ...</p>
|
<p>Lade Settings ...</p>
|
||||||
) : (
|
) : (
|
||||||
<DynamicSettingsForm
|
<DynamicSettingsForm
|
||||||
categories={categories}
|
categories={categories}
|
||||||
values={draftValues}
|
values={draftValues}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
dirtyKeys={dirtyKeys}
|
dirtyKeys={dirtyKeys}
|
||||||
onChange={handleFieldChange}
|
onChange={handleFieldChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel header="Scripte">
|
||||||
|
<div className="script-manager-wrap">
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
label="Neues Skript hinzufügen"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
onClick={startCreateScript}
|
||||||
|
severity="success"
|
||||||
|
outlined
|
||||||
|
disabled={scriptSaving || scriptEditor?.mode === 'create'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Scripts neu laden"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
onClick={() => loadScripts()}
|
||||||
|
loading={scriptsLoading}
|
||||||
|
disabled={scriptSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small>
|
||||||
|
Die ausgewählten Scripts werden später pro Job nach erfolgreichem Encode in Reihenfolge ausgeführt.
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<div className="script-list-box">
|
||||||
|
<h4>Verfügbare Scripts</h4>
|
||||||
|
{scriptsLoading ? (
|
||||||
|
<p>Lade Scripts ...</p>
|
||||||
|
) : (
|
||||||
|
<div className="script-list">
|
||||||
|
{scriptEditor?.mode === 'create' ? (
|
||||||
|
<div className="script-list-item script-list-item-editing">
|
||||||
|
<div className="script-list-main">
|
||||||
|
<div className="script-title-line">
|
||||||
|
<strong className="script-id-title">NEU - Titel</strong>
|
||||||
|
<InputText
|
||||||
|
id="script-name-new"
|
||||||
|
value={scriptEditor?.name || ''}
|
||||||
|
onChange={(event) => handleScriptEditorChange('name', event.target.value)}
|
||||||
|
placeholder="z.B. Library Refresh"
|
||||||
|
className="script-title-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{scriptErrors?.name ? <small className="error-text">{scriptErrors.name}</small> : null}
|
||||||
|
</div>
|
||||||
|
<div className="script-editor-fields">
|
||||||
|
<label htmlFor="script-body-new">Bash Script</label>
|
||||||
|
<InputTextarea
|
||||||
|
id="script-body-new"
|
||||||
|
value={scriptEditor?.scriptBody || ''}
|
||||||
|
onChange={(event) => handleScriptEditorChange('scriptBody', event.target.value)}
|
||||||
|
rows={12}
|
||||||
|
autoResize={false}
|
||||||
|
placeholder={'#!/usr/bin/env bash\necho "Post-Encode Script"'}
|
||||||
|
/>
|
||||||
|
{scriptErrors?.scriptBody ? <small className="error-text">{scriptErrors.scriptBody}</small> : null}
|
||||||
|
</div>
|
||||||
|
<div className="script-list-actions">
|
||||||
|
<Button
|
||||||
|
label="Speichern"
|
||||||
|
icon="pi pi-save"
|
||||||
|
onClick={handleSaveScript}
|
||||||
|
loading={scriptSaving}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Verwerfen"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={clearScriptEditor}
|
||||||
|
disabled={scriptSaving}
|
||||||
|
/>
|
||||||
|
<span className="script-action-spacer" aria-hidden />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{scripts.length === 0 ? <p>Keine Scripts vorhanden.</p> : null}
|
||||||
|
|
||||||
|
{scripts.map((script) => {
|
||||||
|
return (
|
||||||
|
<div key={script.id} className="script-list-item">
|
||||||
|
<div className="script-list-main">
|
||||||
|
<strong className="script-id-title">{`ID #${script.id} - ${script.name}`}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="script-list-actions">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
label="Bearbeiten"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => startEditScript(script)}
|
||||||
|
disabled={Boolean(scriptActionBusyId) || scriptSaving || scriptEditor?.mode === 'create'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-play"
|
||||||
|
label="Test"
|
||||||
|
severity="info"
|
||||||
|
onClick={() => handleTestScript(script)}
|
||||||
|
loading={scriptActionBusyId === script.id}
|
||||||
|
disabled={Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
label="Löschen"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => handleDeleteScript(script)}
|
||||||
|
loading={scriptActionBusyId === script.id}
|
||||||
|
disabled={Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
header={scriptEditor?.id ? `Script bearbeiten (#${scriptEditor.id})` : 'Script bearbeiten'}
|
||||||
|
visible={scriptEditor?.mode === 'edit'}
|
||||||
|
onHide={clearScriptEditor}
|
||||||
|
style={{ width: 'min(52rem, calc(100vw - 1.5rem))' }}
|
||||||
|
className="script-edit-dialog"
|
||||||
|
dismissableMask
|
||||||
|
draggable={false}
|
||||||
|
>
|
||||||
|
<div className="script-editor-fields">
|
||||||
|
<label htmlFor="script-edit-name">Name</label>
|
||||||
|
<InputText
|
||||||
|
id="script-edit-name"
|
||||||
|
value={scriptEditor?.name || ''}
|
||||||
|
onChange={(event) => handleScriptEditorChange('name', event.target.value)}
|
||||||
|
placeholder="z.B. Library Refresh"
|
||||||
|
/>
|
||||||
|
{scriptErrors?.name ? <small className="error-text">{scriptErrors.name}</small> : null}
|
||||||
|
<label htmlFor="script-edit-body">Bash Script</label>
|
||||||
|
<InputTextarea
|
||||||
|
id="script-edit-body"
|
||||||
|
value={scriptEditor?.scriptBody || ''}
|
||||||
|
onChange={(event) => handleScriptEditorChange('scriptBody', event.target.value)}
|
||||||
|
rows={14}
|
||||||
|
autoResize={false}
|
||||||
|
placeholder={'#!/usr/bin/env bash\necho "Post-Encode Script"'}
|
||||||
|
/>
|
||||||
|
{scriptErrors?.scriptBody ? <small className="error-text">{scriptErrors.scriptBody}</small> : null}
|
||||||
|
</div>
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
label="Script aktualisieren"
|
||||||
|
icon="pi pi-save"
|
||||||
|
onClick={handleSaveScript}
|
||||||
|
loading={scriptSaving}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Abbrechen"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={clearScriptEditor}
|
||||||
|
disabled={scriptSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{lastScriptTestResult ? (
|
||||||
|
<div className="script-test-result">
|
||||||
|
<h4>Letzter Script-Test: {lastScriptTestResult.scriptName}</h4>
|
||||||
|
<small>
|
||||||
|
Status: {lastScriptTestResult.success ? 'SUCCESS' : 'ERROR'}
|
||||||
|
{' | '}exit={lastScriptTestResult.exitCode ?? 'n/a'}
|
||||||
|
{' | '}timeout={lastScriptTestResult.timedOut ? 'ja' : 'nein'}
|
||||||
|
{' | '}dauer={Number(lastScriptTestResult.durationMs || 0)}ms
|
||||||
|
</small>
|
||||||
|
<pre>{`${lastScriptTestResult.stdout || ''}${lastScriptTestResult.stderr ? `\n${lastScriptTestResult.stderr}` : ''}`.trim() || 'Keine Ausgabe.'}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
</TabView>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -317,6 +317,19 @@ body {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-step-inline-no {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
color: #9c2d2d;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-step-inline-no .pi {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.job-step-cell {
|
.job-step-cell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -458,6 +471,14 @@ body {
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-root-tabview {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-root-tabview .p-tabview-panels {
|
||||||
|
padding: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-tabview .p-tabview-panels {
|
.settings-tabview .p-tabview-panels {
|
||||||
padding: 1rem 0 0;
|
padding: 1rem 0 0;
|
||||||
}
|
}
|
||||||
@@ -503,6 +524,131 @@ body {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.script-manager-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-box {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: #fff7ea;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-box h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--rip-brown-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-item {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-item-editing {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-title-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-id-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-title-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-actions .p-button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.38rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-actions .p-button .p-button-icon {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-action-spacer {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-editor-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-editor-fields .p-inputtextarea {
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-test-result {
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-test-result h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-test-result pre {
|
||||||
|
margin: 0;
|
||||||
|
background: #f7ecd7;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.5rem 0.55rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.required {
|
.required {
|
||||||
color: #9d261b;
|
color: #9d261b;
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
@@ -579,6 +725,103 @@ body {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-detail-meta-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-film-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-block {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-block.job-meta-block-film {
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-item {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8rem minmax(0, 1fr);
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-item strong,
|
||||||
|
.job-meta-item span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-block h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-grid.job-meta-grid-compact {
|
||||||
|
margin-bottom: 0;
|
||||||
|
gap: 0.4rem 0.65rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-grid.job-meta-grid-compact strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-col-span-2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status-icon .pi {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status-icon.tone-success {
|
||||||
|
color: #1c8a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status-icon.tone-danger {
|
||||||
|
color: #9c2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status-icon.tone-warning {
|
||||||
|
color: #7a4f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status-icon.tone-info {
|
||||||
|
color: #0d5a86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status-icon.tone-secondary {
|
||||||
|
color: #46556a;
|
||||||
|
}
|
||||||
|
|
||||||
.job-json-grid {
|
.job-json-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -771,6 +1014,53 @@ body {
|
|||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-script-box {
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-box h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-row.editable {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-drag-handle:hover {
|
||||||
|
border-color: var(--rip-border);
|
||||||
|
background: color-mix(in srgb, var(--rip-panel-soft) 75%, #ffffff 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-drag-handle.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
.media-title-list,
|
.media-title-list,
|
||||||
.media-track-list {
|
.media-track-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -919,6 +1209,7 @@ body {
|
|||||||
.media-review-meta,
|
.media-review-meta,
|
||||||
.media-track-grid,
|
.media-track-grid,
|
||||||
.job-meta-grid,
|
.job-meta-grid,
|
||||||
|
.job-film-info-grid,
|
||||||
.table-filters,
|
.table-filters,
|
||||||
.job-head-row,
|
.job-head-row,
|
||||||
.job-json-grid,
|
.job-json-grid,
|
||||||
@@ -940,6 +1231,22 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.script-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-script-row.editable {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.orphan-path-cell {
|
.orphan-path-cell {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -974,6 +1281,19 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.script-title-line {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-action-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-job-row {
|
.dashboard-job-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user