Fixxes + Skriptketten
This commit is contained in:
@@ -2,6 +2,7 @@ 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 scriptService = require('../services/scriptService');
|
||||||
|
const scriptChainService = require('../services/scriptChainService');
|
||||||
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');
|
||||||
@@ -104,6 +105,59 @@ router.post(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/script-chains',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:settings:script-chains', { reqId: req.reqId });
|
||||||
|
const chains = await scriptChainService.listChains();
|
||||||
|
res.json({ chains });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/script-chains',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const payload = req.body || {};
|
||||||
|
logger.info('post:settings:script-chains:create', { reqId: req.reqId, name: payload?.name });
|
||||||
|
const chain = await scriptChainService.createChain(payload);
|
||||||
|
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'created', id: chain.id });
|
||||||
|
res.status(201).json({ chain });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/script-chains/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const chainId = Number(req.params.id);
|
||||||
|
logger.debug('get:settings:script-chains:one', { reqId: req.reqId, chainId });
|
||||||
|
const chain = await scriptChainService.getChainById(chainId);
|
||||||
|
res.json({ chain });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/script-chains/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const chainId = Number(req.params.id);
|
||||||
|
const payload = req.body || {};
|
||||||
|
logger.info('put:settings:script-chains:update', { reqId: req.reqId, chainId, name: payload?.name });
|
||||||
|
const chain = await scriptChainService.updateChain(chainId, payload);
|
||||||
|
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'updated', id: chain.id });
|
||||||
|
res.json({ chain });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/script-chains/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const chainId = Number(req.params.id);
|
||||||
|
logger.info('delete:settings:script-chains', { reqId: req.reqId, chainId });
|
||||||
|
const removed = await scriptChainService.deleteChain(chainId);
|
||||||
|
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'deleted', id: removed.id });
|
||||||
|
res.json({ removed });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
'/:key',
|
'/:key',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -607,6 +607,22 @@ class HistoryService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRunningEncodeJobs() {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.all(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM jobs
|
||||||
|
WHERE status = 'ENCODING'
|
||||||
|
ORDER BY updated_at ASC, id ASC
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return rows.map((job) => ({
|
||||||
|
...enrichJobRow(job),
|
||||||
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async getJobWithLogs(jobId, options = {}) {
|
async getJobWithLogs(jobId, options = {}) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const job = await db.get('SELECT * FROM jobs WHERE id = ?', [jobId]);
|
const job = await db.get('SELECT * FROM jobs WHERE id = ?', [jobId]);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 scriptService = require('./scriptService');
|
||||||
|
const scriptChainService = require('./scriptChainService');
|
||||||
const wsService = require('./websocketService');
|
const wsService = require('./websocketService');
|
||||||
const diskDetectionService = require('./diskDetectionService');
|
const diskDetectionService = require('./diskDetectionService');
|
||||||
const notificationService = require('./notificationService');
|
const notificationService = require('./notificationService');
|
||||||
@@ -2045,6 +2046,7 @@ class PipelineService extends EventEmitter {
|
|||||||
this.activeProcess = null;
|
this.activeProcess = null;
|
||||||
this.activeProcesses = new Map();
|
this.activeProcesses = new Map();
|
||||||
this.cancelRequestedByJob = new Set();
|
this.cancelRequestedByJob = new Set();
|
||||||
|
this.jobProgress = new Map();
|
||||||
this.lastPersistAt = 0;
|
this.lastPersistAt = 0;
|
||||||
this.lastProgressKey = null;
|
this.lastProgressKey = null;
|
||||||
this.queueEntries = [];
|
this.queueEntries = [];
|
||||||
@@ -2126,8 +2128,13 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot() {
|
getSnapshot() {
|
||||||
|
const jobProgress = {};
|
||||||
|
for (const [id, data] of this.jobProgress) {
|
||||||
|
jobProgress[id] = data;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
|
jobProgress,
|
||||||
queue: this.lastQueueSnapshot
|
queue: this.lastQueueSnapshot
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2173,6 +2180,7 @@ class PipelineService extends EventEmitter {
|
|||||||
async getQueueSnapshot() {
|
async getQueueSnapshot() {
|
||||||
const maxParallelJobs = await this.getMaxParallelJobs();
|
const maxParallelJobs = await this.getMaxParallelJobs();
|
||||||
const runningJobs = await historyService.getRunningJobs();
|
const runningJobs = await historyService.getRunningJobs();
|
||||||
|
const runningEncodeCount = runningJobs.filter((job) => job.status === 'ENCODING').length;
|
||||||
const queuedJobIds = this.queueEntries.map((entry) => Number(entry.jobId)).filter((id) => Number.isFinite(id) && id > 0);
|
const queuedJobIds = this.queueEntries.map((entry) => Number(entry.jobId)).filter((id) => Number.isFinite(id) && id > 0);
|
||||||
const queuedRows = queuedJobIds.length > 0
|
const queuedRows = queuedJobIds.length > 0
|
||||||
? await historyService.getJobsByIds(queuedJobIds)
|
? await historyService.getJobsByIds(queuedJobIds)
|
||||||
@@ -2181,7 +2189,7 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
const queue = {
|
const queue = {
|
||||||
maxParallelJobs,
|
maxParallelJobs,
|
||||||
runningCount: runningJobs.length,
|
runningCount: runningEncodeCount,
|
||||||
runningJobs: runningJobs.map((job) => ({
|
runningJobs: runningJobs.map((job) => ({
|
||||||
jobId: Number(job.id),
|
jobId: Number(job.id),
|
||||||
title: job.title || job.detected_title || `Job #${job.id}`,
|
title: job.title || job.detected_title || `Job #${job.id}`,
|
||||||
@@ -2272,8 +2280,8 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxParallelJobs = await this.getMaxParallelJobs();
|
const maxParallelJobs = await this.getMaxParallelJobs();
|
||||||
const runningJobs = await historyService.getRunningJobs();
|
const runningEncodeJobs = await historyService.getRunningEncodeJobs();
|
||||||
const shouldQueue = this.queueEntries.length > 0 || runningJobs.length >= maxParallelJobs;
|
const shouldQueue = this.queueEntries.length > 0 || runningEncodeJobs.length >= maxParallelJobs;
|
||||||
if (!shouldQueue) {
|
if (!shouldQueue) {
|
||||||
const result = await startNow();
|
const result = await startNow();
|
||||||
await this.emitQueueChanged();
|
await this.emitQueueChanged();
|
||||||
@@ -2345,8 +2353,8 @@ class PipelineService extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
while (this.queueEntries.length > 0) {
|
while (this.queueEntries.length > 0) {
|
||||||
const maxParallelJobs = await this.getMaxParallelJobs();
|
const maxParallelJobs = await this.getMaxParallelJobs();
|
||||||
const runningJobs = await historyService.getRunningJobs();
|
const runningEncodeJobs = await historyService.getRunningEncodeJobs();
|
||||||
if (runningJobs.length >= maxParallelJobs) {
|
if (runningEncodeJobs.length >= maxParallelJobs) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2473,6 +2481,7 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
async setState(state, patch = {}) {
|
async setState(state, patch = {}) {
|
||||||
const previous = this.snapshot.state;
|
const previous = this.snapshot.state;
|
||||||
|
const previousActiveJobId = this.snapshot.activeJobId;
|
||||||
this.snapshot = {
|
this.snapshot = {
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
state,
|
state,
|
||||||
@@ -2482,6 +2491,20 @@ class PipelineService extends EventEmitter {
|
|||||||
statusText: patch.statusText !== undefined ? patch.statusText : this.snapshot.statusText,
|
statusText: patch.statusText !== undefined ? patch.statusText : this.snapshot.statusText,
|
||||||
context: patch.context !== undefined ? patch.context : this.snapshot.context
|
context: patch.context !== undefined ? patch.context : this.snapshot.context
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keep per-job progress map in sync when a job starts or finishes.
|
||||||
|
if (patch.activeJobId != null) {
|
||||||
|
this.jobProgress.set(Number(patch.activeJobId), {
|
||||||
|
state,
|
||||||
|
progress: patch.progress ?? 0,
|
||||||
|
eta: patch.eta ?? null,
|
||||||
|
statusText: patch.statusText ?? null
|
||||||
|
});
|
||||||
|
} else if (patch.activeJobId === null && previousActiveJobId != null) {
|
||||||
|
// Job slot cleared – remove the finished job's live entry so it falls
|
||||||
|
// back to DB data in the frontend.
|
||||||
|
this.jobProgress.delete(Number(previousActiveJobId));
|
||||||
|
}
|
||||||
logger.info('state:changed', {
|
logger.info('state:changed', {
|
||||||
from: previous,
|
from: previous,
|
||||||
to: state,
|
to: state,
|
||||||
@@ -2531,34 +2554,53 @@ class PipelineService extends EventEmitter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProgress(stage, percent, eta, statusText) {
|
async updateProgress(stage, percent, eta, statusText, jobIdOverride = null) {
|
||||||
|
const effectiveJobId = jobIdOverride != null ? Number(jobIdOverride) : this.snapshot.activeJobId;
|
||||||
|
const effectiveProgress = percent ?? this.snapshot.progress;
|
||||||
|
const effectiveEta = eta ?? this.snapshot.eta;
|
||||||
|
const effectiveStatusText = statusText ?? this.snapshot.statusText;
|
||||||
|
|
||||||
|
// Update per-job progress so concurrent jobs don't overwrite each other.
|
||||||
|
if (effectiveJobId != null) {
|
||||||
|
this.jobProgress.set(effectiveJobId, {
|
||||||
|
state: stage,
|
||||||
|
progress: effectiveProgress,
|
||||||
|
eta: effectiveEta,
|
||||||
|
statusText: effectiveStatusText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update the global snapshot fields when this update belongs to the
|
||||||
|
// currently active job (avoids the snapshot jumping between parallel jobs).
|
||||||
|
if (effectiveJobId === this.snapshot.activeJobId || effectiveJobId == null) {
|
||||||
this.snapshot = {
|
this.snapshot = {
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
state: stage,
|
state: stage,
|
||||||
progress: percent ?? this.snapshot.progress,
|
progress: effectiveProgress,
|
||||||
eta: eta ?? this.snapshot.eta,
|
eta: effectiveEta,
|
||||||
statusText: statusText ?? this.snapshot.statusText
|
statusText: effectiveStatusText
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.persistSnapshot(false);
|
await this.persistSnapshot(false);
|
||||||
const rounded = Number((this.snapshot.progress || 0).toFixed(2));
|
}
|
||||||
const key = `${stage}:${rounded}`;
|
|
||||||
|
const rounded = Number((effectiveProgress || 0).toFixed(2));
|
||||||
|
const key = `${effectiveJobId}:${stage}:${rounded}`;
|
||||||
if (key !== this.lastProgressKey) {
|
if (key !== this.lastProgressKey) {
|
||||||
this.lastProgressKey = key;
|
this.lastProgressKey = key;
|
||||||
logger.debug('progress:update', {
|
logger.debug('progress:update', {
|
||||||
stage,
|
stage,
|
||||||
activeJobId: this.snapshot.activeJobId,
|
activeJobId: effectiveJobId,
|
||||||
progress: rounded,
|
progress: rounded,
|
||||||
eta: this.snapshot.eta,
|
eta: effectiveEta,
|
||||||
statusText: this.snapshot.statusText
|
statusText: effectiveStatusText
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
wsService.broadcast('PIPELINE_PROGRESS', {
|
wsService.broadcast('PIPELINE_PROGRESS', {
|
||||||
state: stage,
|
state: stage,
|
||||||
activeJobId: this.snapshot.activeJobId,
|
activeJobId: effectiveJobId,
|
||||||
progress: this.snapshot.progress,
|
progress: effectiveProgress,
|
||||||
eta: this.snapshot.eta,
|
eta: effectiveEta,
|
||||||
statusText: this.snapshot.statusText
|
statusText: effectiveStatusText
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4156,6 +4198,14 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE';
|
const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE';
|
||||||
if (isReadyToEncode) {
|
if (isReadyToEncode) {
|
||||||
|
// Check whether this confirmed job will rip first (pre_rip mode) or encode directly.
|
||||||
|
// Pre-rip jobs bypass the encode queue because the next step is a rip, not an encode.
|
||||||
|
const jobEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json);
|
||||||
|
const jobMode = String(jobEncodePlan?.mode || '').trim().toLowerCase();
|
||||||
|
const willRipFirst = jobMode === 'pre_rip' || Boolean(jobEncodePlan?.preRip);
|
||||||
|
if (willRipFirst) {
|
||||||
|
return this.startPreparedJob(jobId, { ...options, immediate: true });
|
||||||
|
}
|
||||||
return this.enqueueOrStartAction(
|
return this.enqueueOrStartAction(
|
||||||
QUEUE_ACTIONS.START_PREPARED,
|
QUEUE_ACTIONS.START_PREPARED,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -4179,11 +4229,8 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasUsableRawInput) {
|
if (!hasUsableRawInput) {
|
||||||
return this.enqueueOrStartAction(
|
// No raw input yet → will rip from disc. Bypass the encode queue entirely.
|
||||||
QUEUE_ACTIONS.START_PREPARED,
|
return this.startPreparedJob(jobId, { ...options, immediate: true });
|
||||||
jobId,
|
|
||||||
() => this.startPreparedJob(jobId, { ...options, immediate: true })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob });
|
return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob });
|
||||||
@@ -4397,6 +4444,28 @@ class PipelineService extends EventEmitter {
|
|||||||
const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, {
|
const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, {
|
||||||
strict: true
|
strict: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeChainIdList = (raw) => {
|
||||||
|
const list = Array.isArray(raw) ? raw : [];
|
||||||
|
return list.map(Number).filter((id) => Number.isFinite(id) && id > 0).map(Math.trunc);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasExplicitPreScriptSelection = options?.selectedPreEncodeScriptIds !== undefined;
|
||||||
|
const selectedPreEncodeScriptIds = hasExplicitPreScriptSelection
|
||||||
|
? normalizeScriptIdList(options?.selectedPreEncodeScriptIds || [])
|
||||||
|
: normalizeScriptIdList(planForConfirm?.preEncodeScriptIds || encodePlan?.preEncodeScriptIds || []);
|
||||||
|
const selectedPreEncodeScripts = await scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true });
|
||||||
|
|
||||||
|
const hasExplicitPostChainSelection = options?.selectedPostEncodeChainIds !== undefined;
|
||||||
|
const selectedPostEncodeChainIds = hasExplicitPostChainSelection
|
||||||
|
? normalizeChainIdList(options?.selectedPostEncodeChainIds || [])
|
||||||
|
: normalizeChainIdList(planForConfirm?.postEncodeChainIds || encodePlan?.postEncodeChainIds || []);
|
||||||
|
|
||||||
|
const hasExplicitPreChainSelection = options?.selectedPreEncodeChainIds !== undefined;
|
||||||
|
const selectedPreEncodeChainIds = hasExplicitPreChainSelection
|
||||||
|
? normalizeChainIdList(options?.selectedPreEncodeChainIds || [])
|
||||||
|
: normalizeChainIdList(planForConfirm?.preEncodeChainIds || encodePlan?.preEncodeChainIds || []);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -4413,6 +4482,13 @@ class PipelineService extends EventEmitter {
|
|||||||
id: Number(item.id),
|
id: Number(item.id),
|
||||||
name: item.name
|
name: item.name
|
||||||
})),
|
})),
|
||||||
|
preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)),
|
||||||
|
preEncodeScripts: selectedPreEncodeScripts.map((item) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
name: item.name
|
||||||
|
})),
|
||||||
|
postEncodeChainIds: selectedPostEncodeChainIds,
|
||||||
|
preEncodeChainIds: selectedPreEncodeChainIds,
|
||||||
reviewConfirmed: true,
|
reviewConfirmed: true,
|
||||||
reviewConfirmedAt: nowIso()
|
reviewConfirmedAt: nowIso()
|
||||||
};
|
};
|
||||||
@@ -4434,7 +4510,10 @@ 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'}.`
|
||||||
|
+ ` Pre-Encode-Scripte: ${selectedPreEncodeScripts.length > 0 ? selectedPreEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.`
|
||||||
|
+ ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.`
|
||||||
+ ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.`
|
+ ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.`
|
||||||
|
+ ` Post-Encode-Ketten: ${selectedPostEncodeChainIds.length > 0 ? selectedPostEncodeChainIds.join(',') : 'none'}.`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!skipPipelineStateUpdate) {
|
if (!skipPipelineStateUpdate) {
|
||||||
@@ -4817,9 +4896,138 @@ class PipelineService extends EventEmitter {
|
|||||||
return enrichedReview;
|
return enrichedReview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runEncodeChains(jobId, chainIds, context = {}, phase = 'post') {
|
||||||
|
const ids = Array.isArray(chainIds) ? chainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) : [];
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return { configured: 0, succeeded: 0, failed: 0, results: [] };
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const chainId of ids) {
|
||||||
|
await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette startet (ID ${chainId})...`);
|
||||||
|
try {
|
||||||
|
const chainResult = await scriptChainService.executeChain(chainId, {
|
||||||
|
...context,
|
||||||
|
source: phase === 'pre' ? 'pre_encode_chain' : 'post_encode_chain'
|
||||||
|
}, {
|
||||||
|
appendLog: (src, msg) => historyService.appendLog(jobId, src, msg)
|
||||||
|
});
|
||||||
|
if (chainResult.aborted || chainResult.failed > 0) {
|
||||||
|
failed += 1;
|
||||||
|
await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" fehlgeschlagen.`);
|
||||||
|
} else {
|
||||||
|
succeeded += 1;
|
||||||
|
await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" erfolgreich.`);
|
||||||
|
}
|
||||||
|
results.push({ chainId, ...chainResult });
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
results.push({ chainId, success: false, error: error.message });
|
||||||
|
await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette ${chainId} Fehler: ${error.message}`);
|
||||||
|
logger.warn(`encode:${phase}-chain:failed`, { jobId, chainId, error: errorToMeta(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { configured: ids.length, succeeded, failed, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
async runPreEncodeScripts(jobId, encodePlan, context = {}) {
|
||||||
|
const scriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []);
|
||||||
|
const chainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [];
|
||||||
|
if (scriptIds.length === 0 && chainIds.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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
results.push({ scriptId, scriptName: null, status: 'ERROR', error: 'missing' });
|
||||||
|
await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript #${scriptId} nicht gefunden. Kette abgebrochen.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`);
|
||||||
|
let prepared = null;
|
||||||
|
try {
|
||||||
|
prepared = await scriptService.createExecutableScriptFile(script, {
|
||||||
|
source: 'pre_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: 'PRE_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', `Pre-Encode Skript erfolgreich: ${script.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
aborted = true;
|
||||||
|
results.push({ scriptId: script.id, scriptName: script.name, status: 'ERROR', error: error?.message || 'unknown' });
|
||||||
|
await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript fehlgeschlagen: ${script.name} (${error?.message || 'unknown'})`);
|
||||||
|
logger.warn('encode:pre-script:failed', { jobId, scriptId: script.id, error: errorToMeta(error) });
|
||||||
|
break;
|
||||||
|
} finally {
|
||||||
|
if (prepared?.cleanup) {
|
||||||
|
await prepared.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aborted && chainIds.length > 0) {
|
||||||
|
const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'pre');
|
||||||
|
if (chainResult.failed > 0) {
|
||||||
|
aborted = true;
|
||||||
|
failed += chainResult.failed;
|
||||||
|
}
|
||||||
|
succeeded += chainResult.succeeded;
|
||||||
|
results.push(...chainResult.results);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aborted) {
|
||||||
|
const pendingScripts = scriptIds.slice(results.filter((r) => r.scriptId != null).length);
|
||||||
|
for (const pendingId of pendingScripts) {
|
||||||
|
const s = scriptById.get(Number(pendingId));
|
||||||
|
skipped += 1;
|
||||||
|
results.push({ scriptId: Number(pendingId), scriptName: s?.name || null, status: 'SKIPPED_ABORTED' });
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error('Pre-Encode Skripte fehlgeschlagen - Encode wird nicht gestartet.'), { statusCode: 500, preEncodeFailed: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: scriptIds.length + chainIds.length,
|
||||||
|
attempted: scriptIds.length - skipped + chainIds.length,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
skipped,
|
||||||
|
aborted,
|
||||||
|
results
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async runPostEncodeScripts(jobId, encodePlan, context = {}) {
|
async runPostEncodeScripts(jobId, encodePlan, context = {}) {
|
||||||
const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
|
const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
|
||||||
if (scriptIds.length === 0) {
|
const chainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [];
|
||||||
|
if (scriptIds.length === 0 && chainIds.length === 0) {
|
||||||
return {
|
return {
|
||||||
configured: 0,
|
configured: 0,
|
||||||
attempted: 0,
|
attempted: 0,
|
||||||
@@ -4957,9 +5165,24 @@ class PipelineService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!aborted && chainIds.length > 0) {
|
||||||
|
const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'post');
|
||||||
|
if (chainResult.failed > 0) {
|
||||||
|
aborted = true;
|
||||||
|
failed += chainResult.failed;
|
||||||
|
abortReason = `Post-Encode Kette fehlgeschlagen`;
|
||||||
|
void this.notifyPushover('job_error', {
|
||||||
|
title: 'Ripster - Post-Encode Kettenfehler',
|
||||||
|
message: `${context?.jobTitle || `Job #${jobId}`}: Eine Post-Encode Kette ist fehlgeschlagen.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
succeeded += chainResult.succeeded;
|
||||||
|
results.push(...chainResult.results);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
configured: scriptIds.length,
|
configured: scriptIds.length + chainIds.length,
|
||||||
attempted: scriptIds.length - skipped,
|
attempted: scriptIds.length - skipped + chainIds.length,
|
||||||
succeeded,
|
succeeded,
|
||||||
failed,
|
failed,
|
||||||
skipped,
|
skipped,
|
||||||
@@ -5060,6 +5283,29 @@ class PipelineService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preEncodeContext = {
|
||||||
|
mode,
|
||||||
|
jobId,
|
||||||
|
jobTitle: job.title || job.detected_title || null,
|
||||||
|
inputPath,
|
||||||
|
rawPath: job.raw_path || null
|
||||||
|
};
|
||||||
|
const preScriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []);
|
||||||
|
const preChainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [];
|
||||||
|
if (preScriptIds.length > 0 || preChainIds.length > 0) {
|
||||||
|
await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten werden ausgeführt...');
|
||||||
|
try {
|
||||||
|
await this.runPreEncodeScripts(jobId, encodePlan, preEncodeContext);
|
||||||
|
} catch (preError) {
|
||||||
|
if (preError.preEncodeFailed) {
|
||||||
|
await this.failJob(jobId, 'ENCODING', preError);
|
||||||
|
throw preError;
|
||||||
|
}
|
||||||
|
throw preError;
|
||||||
|
}
|
||||||
|
await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten abgeschlossen.');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trackSelection = extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath);
|
const trackSelection = extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath);
|
||||||
let handBrakeTitleId = null;
|
let handBrakeTitleId = null;
|
||||||
@@ -5524,11 +5770,8 @@ class PipelineService extends EventEmitter {
|
|||||||
async retry(jobId, options = {}) {
|
async retry(jobId, options = {}) {
|
||||||
const immediate = Boolean(options?.immediate);
|
const immediate = Boolean(options?.immediate);
|
||||||
if (!immediate) {
|
if (!immediate) {
|
||||||
return this.enqueueOrStartAction(
|
// Retry always starts a rip → bypass the encode queue entirely.
|
||||||
QUEUE_ACTIONS.RETRY,
|
return this.retry(jobId, { ...options, immediate: true });
|
||||||
jobId,
|
|
||||||
() => this.retry(jobId, { ...options, immediate: true })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ensureNotBusy('retry', jobId);
|
this.ensureNotBusy('retry', jobId);
|
||||||
@@ -6000,19 +6243,13 @@ class PipelineService extends EventEmitter {
|
|||||||
runInfo.lastProgress = progress.percent;
|
runInfo.lastProgress = progress.percent;
|
||||||
runInfo.eta = progress.eta || runInfo.eta;
|
runInfo.eta = progress.eta || runInfo.eta;
|
||||||
const statusText = composeStatusText(stage, progress.percent, runInfo.lastDetail);
|
const statusText = composeStatusText(stage, progress.percent, runInfo.lastDetail);
|
||||||
void this.updateProgress(stage, progress.percent, progress.eta, statusText);
|
void this.updateProgress(stage, progress.percent, progress.eta, statusText, normalizedJobId);
|
||||||
} else if (detail) {
|
} else if (detail) {
|
||||||
const statusText = composeStatusText(
|
const jobEntry = this.jobProgress.get(Number(normalizedJobId));
|
||||||
stage,
|
const currentProgress = jobEntry?.progress ?? Number(this.snapshot.progress || 0);
|
||||||
Number(this.snapshot.progress || 0),
|
const currentEta = jobEntry?.eta ?? this.snapshot.eta;
|
||||||
runInfo.lastDetail
|
const statusText = composeStatusText(stage, currentProgress, runInfo.lastDetail);
|
||||||
);
|
void this.updateProgress(stage, currentProgress, currentEta, statusText, normalizedJobId);
|
||||||
void this.updateProgress(
|
|
||||||
stage,
|
|
||||||
Number(this.snapshot.progress || 0),
|
|
||||||
this.snapshot.eta,
|
|
||||||
statusText
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
407
backend/src/services/scriptChainService.js
Normal file
407
backend/src/services/scriptChainService.js
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
|
const logger = require('./logger').child('SCRIPT_CHAINS');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
const CHAIN_NAME_MAX_LENGTH = 120;
|
||||||
|
const STEP_TYPE_SCRIPT = 'script';
|
||||||
|
const STEP_TYPE_WAIT = 'wait';
|
||||||
|
const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]);
|
||||||
|
|
||||||
|
function normalizeChainId(rawValue) {
|
||||||
|
const value = Number(rawValue);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createValidationError(message, details = null) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.statusCode = 400;
|
||||||
|
if (details) {
|
||||||
|
error.details = details;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChainRow(row, steps = []) {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
name: String(row.name || ''),
|
||||||
|
steps: steps.map(mapStepRow),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStepRow(row) {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
position: Number(row.position),
|
||||||
|
stepType: String(row.step_type || ''),
|
||||||
|
scriptId: row.script_id != null ? Number(row.script_id) : null,
|
||||||
|
scriptName: row.script_name != null ? String(row.script_name) : null,
|
||||||
|
waitSeconds: row.wait_seconds != null ? Number(row.wait_seconds) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSteps(rawSteps) {
|
||||||
|
const steps = Array.isArray(rawSteps) ? rawSteps : [];
|
||||||
|
const errors = [];
|
||||||
|
const normalized = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const step = steps[i] && typeof steps[i] === 'object' ? steps[i] : {};
|
||||||
|
const stepType = String(step.stepType || step.step_type || '').trim();
|
||||||
|
|
||||||
|
if (!VALID_STEP_TYPES.has(stepType)) {
|
||||||
|
errors.push({ field: `steps[${i}].stepType`, message: `Ungültiger Schritt-Typ: '${stepType}'. Erlaubt: script, wait.` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepType === STEP_TYPE_SCRIPT) {
|
||||||
|
const scriptId = Number(step.scriptId ?? step.script_id);
|
||||||
|
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||||||
|
errors.push({ field: `steps[${i}].scriptId`, message: 'scriptId fehlt oder ist ungültig.' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.push({ stepType, scriptId: Math.trunc(scriptId), waitSeconds: null });
|
||||||
|
} else if (stepType === STEP_TYPE_WAIT) {
|
||||||
|
const waitSeconds = Number(step.waitSeconds ?? step.wait_seconds);
|
||||||
|
if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) {
|
||||||
|
errors.push({ field: `steps[${i}].waitSeconds`, message: 'waitSeconds muss zwischen 1 und 3600 liegen.' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.push({ stepType, scriptId: null, waitSeconds: Math.round(waitSeconds) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw createValidationError('Ungültige Schritte in der Skriptkette.', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStepsForChain(db, chainId) {
|
||||||
|
return db.all(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.chain_id,
|
||||||
|
s.position,
|
||||||
|
s.step_type,
|
||||||
|
s.script_id,
|
||||||
|
s.wait_seconds,
|
||||||
|
sc.name AS script_name
|
||||||
|
FROM script_chain_steps s
|
||||||
|
LEFT JOIN scripts sc ON sc.id = s.script_id
|
||||||
|
WHERE s.chain_id = ?
|
||||||
|
ORDER BY s.position ASC, s.id ASC
|
||||||
|
`,
|
||||||
|
[chainId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScriptChainService {
|
||||||
|
async listChains() {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.all(
|
||||||
|
`
|
||||||
|
SELECT id, name, created_at, updated_at
|
||||||
|
FROM script_chains
|
||||||
|
ORDER BY LOWER(name) ASC, id ASC
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chainIds = rows.map((row) => Number(row.id));
|
||||||
|
const placeholders = chainIds.map(() => '?').join(', ');
|
||||||
|
const stepRows = await db.all(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.chain_id,
|
||||||
|
s.position,
|
||||||
|
s.step_type,
|
||||||
|
s.script_id,
|
||||||
|
s.wait_seconds,
|
||||||
|
sc.name AS script_name
|
||||||
|
FROM script_chain_steps s
|
||||||
|
LEFT JOIN scripts sc ON sc.id = s.script_id
|
||||||
|
WHERE s.chain_id IN (${placeholders})
|
||||||
|
ORDER BY s.chain_id ASC, s.position ASC, s.id ASC
|
||||||
|
`,
|
||||||
|
chainIds
|
||||||
|
);
|
||||||
|
|
||||||
|
const stepsByChain = new Map();
|
||||||
|
for (const step of stepRows) {
|
||||||
|
const cid = Number(step.chain_id);
|
||||||
|
if (!stepsByChain.has(cid)) {
|
||||||
|
stepsByChain.set(cid, []);
|
||||||
|
}
|
||||||
|
stepsByChain.get(cid).push(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((row) => mapChainRow(row, stepsByChain.get(Number(row.id)) || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChainById(chainId) {
|
||||||
|
const normalizedId = normalizeChainId(chainId);
|
||||||
|
if (!normalizedId) {
|
||||||
|
throw createValidationError('Ungültige chainId.');
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
const row = await db.get(
|
||||||
|
`SELECT id, name, created_at, updated_at FROM script_chains WHERE id = ?`,
|
||||||
|
[normalizedId]
|
||||||
|
);
|
||||||
|
if (!row) {
|
||||||
|
const error = new Error(`Skriptkette #${normalizedId} wurde nicht gefunden.`);
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const steps = await getStepsForChain(db, normalizedId);
|
||||||
|
return mapChainRow(row, steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChainsByIds(rawIds = []) {
|
||||||
|
const ids = Array.isArray(rawIds)
|
||||||
|
? rawIds.map(normalizeChainId).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT id, name, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`,
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
const stepRows = await db.all(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.id, s.chain_id, s.position, s.step_type, s.script_id, s.wait_seconds,
|
||||||
|
sc.name AS script_name
|
||||||
|
FROM script_chain_steps s
|
||||||
|
LEFT JOIN scripts sc ON sc.id = s.script_id
|
||||||
|
WHERE s.chain_id IN (${placeholders})
|
||||||
|
ORDER BY s.chain_id ASC, s.position ASC, s.id ASC
|
||||||
|
`,
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
const stepsByChain = new Map();
|
||||||
|
for (const step of stepRows) {
|
||||||
|
const cid = Number(step.chain_id);
|
||||||
|
if (!stepsByChain.has(cid)) {
|
||||||
|
stepsByChain.set(cid, []);
|
||||||
|
}
|
||||||
|
stepsByChain.get(cid).push(step);
|
||||||
|
}
|
||||||
|
const byId = new Map(rows.map((row) => [
|
||||||
|
Number(row.id),
|
||||||
|
mapChainRow(row, stepsByChain.get(Number(row.id)) || [])
|
||||||
|
]));
|
||||||
|
return ids.map((id) => byId.get(id)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChain(payload = {}) {
|
||||||
|
const body = payload && typeof payload === 'object' ? payload : {};
|
||||||
|
const name = String(body.name || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
||||||
|
}
|
||||||
|
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
||||||
|
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
|
||||||
|
}
|
||||||
|
const steps = validateSteps(body.steps);
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
try {
|
||||||
|
const result = await db.run(
|
||||||
|
`INSERT INTO script_chains (name, created_at, updated_at) VALUES (?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
const chainId = result.lastID;
|
||||||
|
await this._saveSteps(db, chainId, steps);
|
||||||
|
return this.getChainById(chainId);
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||||
|
throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateChain(chainId, payload = {}) {
|
||||||
|
const normalizedId = normalizeChainId(chainId);
|
||||||
|
if (!normalizedId) {
|
||||||
|
throw createValidationError('Ungültige chainId.');
|
||||||
|
}
|
||||||
|
const body = payload && typeof payload === 'object' ? payload : {};
|
||||||
|
const name = String(body.name || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
||||||
|
}
|
||||||
|
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
||||||
|
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
|
||||||
|
}
|
||||||
|
const steps = validateSteps(body.steps);
|
||||||
|
|
||||||
|
await this.getChainById(normalizedId);
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
try {
|
||||||
|
await db.run(
|
||||||
|
`UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
[name, normalizedId]
|
||||||
|
);
|
||||||
|
await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]);
|
||||||
|
await this._saveSteps(db, normalizedId, steps);
|
||||||
|
return this.getChainById(normalizedId);
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
|
||||||
|
throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChain(chainId) {
|
||||||
|
const normalizedId = normalizeChainId(chainId);
|
||||||
|
if (!normalizedId) {
|
||||||
|
throw createValidationError('Ungültige chainId.');
|
||||||
|
}
|
||||||
|
const existing = await this.getChainById(normalizedId);
|
||||||
|
const db = await getDb();
|
||||||
|
await db.run(`DELETE FROM script_chains WHERE id = ?`, [normalizedId]);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _saveSteps(db, chainId, steps) {
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const step = steps[i];
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO script_chain_steps (chain_id, position, step_type, script_id, wait_seconds, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`,
|
||||||
|
[chainId, i + 1, step.stepType, step.scriptId ?? null, step.waitSeconds ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeChain(chainId, context = {}, { appendLog = null } = {}) {
|
||||||
|
const chain = await this.getChainById(chainId);
|
||||||
|
logger.info('chain:execute:start', { chainId, chainName: chain.name, steps: chain.steps.length });
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const step of chain.steps) {
|
||||||
|
if (step.stepType === STEP_TYPE_WAIT) {
|
||||||
|
const seconds = Math.max(1, Number(step.waitSeconds || 1));
|
||||||
|
logger.info('chain:step:wait', { chainId, seconds });
|
||||||
|
if (typeof appendLog === 'function') {
|
||||||
|
await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||||
|
results.push({ stepType: 'wait', waitSeconds: seconds, success: true });
|
||||||
|
} else if (step.stepType === STEP_TYPE_SCRIPT) {
|
||||||
|
if (!step.scriptId) {
|
||||||
|
logger.warn('chain:step:script-missing', { chainId, stepId: step.id });
|
||||||
|
results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptService = require('./scriptService');
|
||||||
|
let script;
|
||||||
|
try {
|
||||||
|
script = await scriptService.getScriptById(step.scriptId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
|
||||||
|
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof appendLog === 'function') {
|
||||||
|
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prepared = null;
|
||||||
|
try {
|
||||||
|
prepared = await scriptService.createExecutableScriptFile(script, {
|
||||||
|
...context,
|
||||||
|
scriptId: script.id,
|
||||||
|
scriptName: script.name,
|
||||||
|
source: context?.source || 'chain'
|
||||||
|
});
|
||||||
|
const run = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(prepared.cmd, prepared.args, {
|
||||||
|
env: process.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||||||
|
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = run.code === 0;
|
||||||
|
logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success });
|
||||||
|
if (typeof appendLog === 'function') {
|
||||||
|
await appendLog(
|
||||||
|
success ? 'SYSTEM' : 'ERROR',
|
||||||
|
`Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code });
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
|
||||||
|
if (typeof appendLog === 'function') {
|
||||||
|
await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`);
|
||||||
|
}
|
||||||
|
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message });
|
||||||
|
break;
|
||||||
|
} finally {
|
||||||
|
if (prepared?.cleanup) {
|
||||||
|
await prepared.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
const failed = results.filter((r) => !r.success && !r.skipped).length;
|
||||||
|
logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed });
|
||||||
|
|
||||||
|
return {
|
||||||
|
chainId,
|
||||||
|
chainName: chain.name,
|
||||||
|
steps: results.length,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
aborted: failed > 0,
|
||||||
|
results
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ScriptChainService();
|
||||||
@@ -699,17 +699,24 @@ function resolveAudioEncoderAction(track, encoderToken, copyMask, fallbackEncode
|
|||||||
|
|
||||||
const normalizedMask = Array.isArray(copyMask) ? copyMask : [];
|
const normalizedMask = Array.isArray(copyMask) ? copyMask : [];
|
||||||
let canCopy = false;
|
let canCopy = false;
|
||||||
|
let effectiveCodec = sourceCodec;
|
||||||
if (explicitCopyCodec) {
|
if (explicitCopyCodec) {
|
||||||
canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec);
|
canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec);
|
||||||
} else if (sourceCodec && normalizedMask.length > 0) {
|
} else if (sourceCodec && normalizedMask.length > 0) {
|
||||||
canCopy = normalizedMask.includes(sourceCodec);
|
canCopy = normalizedMask.includes(sourceCodec);
|
||||||
|
// DTS-HD MA contains an embedded DTS core track. When dtshd is not in
|
||||||
|
// the copy mask but dts is, HandBrake will extract and copy the DTS core.
|
||||||
|
if (!canCopy && sourceCodec === 'dtshd' && normalizedMask.includes('dts')) {
|
||||||
|
canCopy = true;
|
||||||
|
effectiveCodec = 'dts';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canCopy) {
|
if (canCopy) {
|
||||||
return {
|
return {
|
||||||
type: 'copy',
|
type: 'copy',
|
||||||
encoder: normalizedToken,
|
encoder: normalizedToken,
|
||||||
label: `Copy (${sourceCodec || track?.format || 'Quelle'})`
|
label: `Copy (${effectiveCodec || track?.format || 'Quelle'})`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,29 @@ CREATE TABLE scripts (
|
|||||||
|
|
||||||
CREATE INDEX idx_scripts_name ON scripts(name);
|
CREATE INDEX idx_scripts_name ON scripts(name);
|
||||||
|
|
||||||
|
CREATE TABLE script_chains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_script_chains_name ON script_chains(name);
|
||||||
|
|
||||||
|
CREATE TABLE script_chain_steps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chain_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
step_type TEXT NOT NULL,
|
||||||
|
script_id INTEGER,
|
||||||
|
wait_seconds INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (chain_id) REFERENCES script_chains(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_script_chain_steps_chain ON script_chain_steps(chain_id, position);
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ LOCAL_PATH="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}"
|
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}"
|
||||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"
|
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"
|
||||||
DATA_RELATIVE_DIR="backend/data/***"
|
DATA_RELATIVE_DIR="backend/data/***"
|
||||||
|
DATA_DIR="backend/data"
|
||||||
|
|
||||||
if ! command -v sshpass >/dev/null 2>&1; then
|
if ! command -v sshpass >/dev/null 2>&1; then
|
||||||
echo "sshpass ist nicht installiert. Bitte installieren, z. B.: sudo apt-get install -y sshpass"
|
echo "sshpass ist nicht installiert. Bitte installieren, z. B.: sudo apt-get install -y sshpass"
|
||||||
@@ -25,7 +26,7 @@ echo "Pruefe SSH-Verbindung zu ${REMOTE_USER}@${REMOTE_HOST} ..."
|
|||||||
sshpass -p "$SSH_PASSWORD" ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "echo connected" >/dev/null
|
sshpass -p "$SSH_PASSWORD" ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "echo connected" >/dev/null
|
||||||
|
|
||||||
echo "Stelle sicher, dass Remote-Ordner ${REMOTE_PATH} existiert ..."
|
echo "Stelle sicher, dass Remote-Ordner ${REMOTE_PATH} existiert ..."
|
||||||
sshpass -p "$SSH_PASSWORD" ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "set -euo pipefail; mkdir -p '${REMOTE_PATH}'"
|
sshpass -p "$SSH_PASSWORD" ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "set -euo pipefail; mkdir -p '${REMOTE_PATH}' '${REMOTE_PATH}/${DATA_DIR}'"
|
||||||
|
|
||||||
echo "Uebertrage lokalen Ordner ${LOCAL_PATH} nach ${REMOTE_TARGET} ..."
|
echo "Uebertrage lokalen Ordner ${LOCAL_PATH} nach ${REMOTE_TARGET} ..."
|
||||||
echo "backend/data wird weder uebertragen noch auf dem Ziel geloescht: ${DATA_RELATIVE_DIR}"
|
echo "backend/data wird weder uebertragen noch auf dem Ziel geloescht: ${DATA_RELATIVE_DIR}"
|
||||||
@@ -36,4 +37,10 @@ sshpass -p "$SSH_PASSWORD" rsync -az --progress --delete \
|
|||||||
-e "ssh $SSH_OPTS" \
|
-e "ssh $SSH_OPTS" \
|
||||||
"${LOCAL_PATH}/" "${REMOTE_TARGET}/"
|
"${LOCAL_PATH}/" "${REMOTE_TARGET}/"
|
||||||
|
|
||||||
echo "Fertig: ${LOCAL_PATH} wurde nach ${REMOTE_TARGET} uebertragen (backend/data ausgenommen)."
|
echo "Hole ${DATA_DIR} nach dem Deploy vom Zielserver auf den Quellserver ..."
|
||||||
|
mkdir -p "${LOCAL_PATH}/${DATA_DIR}"
|
||||||
|
sshpass -p "$SSH_PASSWORD" rsync -az --progress \
|
||||||
|
-e "ssh $SSH_OPTS" \
|
||||||
|
"${REMOTE_TARGET}/${DATA_DIR}/" "${LOCAL_PATH}/${DATA_DIR}/"
|
||||||
|
|
||||||
|
echo "Fertig: ${LOCAL_PATH} wurde nach ${REMOTE_TARGET} uebertragen und ${DATA_DIR} wurde vom Zielserver auf den Quellserver geholt."
|
||||||
|
|||||||
@@ -31,10 +31,31 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'PIPELINE_PROGRESS') {
|
if (message.type === 'PIPELINE_PROGRESS') {
|
||||||
setPipeline((prev) => ({
|
const payload = message.payload;
|
||||||
...prev,
|
const progressJobId = payload?.activeJobId;
|
||||||
...message.payload
|
setPipeline((prev) => {
|
||||||
}));
|
const next = { ...prev };
|
||||||
|
// Update per-job progress map so concurrent jobs don't overwrite each other.
|
||||||
|
if (progressJobId != null) {
|
||||||
|
next.jobProgress = {
|
||||||
|
...(prev?.jobProgress || {}),
|
||||||
|
[progressJobId]: {
|
||||||
|
state: payload.state,
|
||||||
|
progress: payload.progress,
|
||||||
|
eta: payload.eta,
|
||||||
|
statusText: payload.statusText
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Update global snapshot fields only for the primary active job.
|
||||||
|
if (progressJobId === prev?.activeJobId || progressJobId == null) {
|
||||||
|
next.state = payload.state ?? prev?.state;
|
||||||
|
next.progress = payload.progress ?? prev?.progress;
|
||||||
|
next.eta = payload.eta ?? prev?.eta;
|
||||||
|
next.statusText = payload.statusText ?? prev?.statusText;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'PIPELINE_QUEUE_CHANGED') {
|
if (message.type === 'PIPELINE_QUEUE_CHANGED') {
|
||||||
|
|||||||
@@ -64,6 +64,26 @@ export const api = {
|
|||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
getScriptChains() {
|
||||||
|
return request('/settings/script-chains');
|
||||||
|
},
|
||||||
|
createScriptChain(payload = {}) {
|
||||||
|
return request('/settings/script-chains', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateScriptChain(chainId, payload = {}) {
|
||||||
|
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteScriptChain(chainId) {
|
||||||
|
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
updateSetting(key, value) {
|
updateSetting(key, value) {
|
||||||
return request(`/settings/${encodeURIComponent(key)}`, {
|
return request(`/settings/${encodeURIComponent(key)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -486,14 +486,21 @@ function resolveAudioEncoderPreviewLabel(track, encoderToken, copyMask, fallback
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
let canCopy = false;
|
let canCopy = false;
|
||||||
|
let effectiveCodec = sourceCodec;
|
||||||
if (explicitCopyCodec) {
|
if (explicitCopyCodec) {
|
||||||
canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec);
|
canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec);
|
||||||
} else if (sourceCodec && normalizedCopyMask.length > 0) {
|
} else if (sourceCodec && normalizedCopyMask.length > 0) {
|
||||||
canCopy = normalizedCopyMask.includes(sourceCodec);
|
canCopy = normalizedCopyMask.includes(sourceCodec);
|
||||||
|
// DTS-HD MA contains an embedded DTS core. When dtshd is not in the copy
|
||||||
|
// mask but dts is, HandBrake will extract and copy the DTS core layer.
|
||||||
|
if (!canCopy && sourceCodec === 'dtshd' && normalizedCopyMask.includes('dts')) {
|
||||||
|
canCopy = true;
|
||||||
|
effectiveCodec = 'dts';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canCopy) {
|
if (canCopy) {
|
||||||
return `Copy (${sourceCodec || track?.format || 'Quelle'})`;
|
return `Copy (${effectiveCodec || track?.format || 'Quelle'})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK_PREVIEW).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK_PREVIEW;
|
const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK_PREVIEW).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK_PREVIEW;
|
||||||
@@ -684,7 +691,21 @@ export default function MediaInfoReviewPanel({
|
|||||||
onAddPostEncodeScript = null,
|
onAddPostEncodeScript = null,
|
||||||
onChangePostEncodeScript = null,
|
onChangePostEncodeScript = null,
|
||||||
onRemovePostEncodeScript = null,
|
onRemovePostEncodeScript = null,
|
||||||
onReorderPostEncodeScript = null
|
onReorderPostEncodeScript = null,
|
||||||
|
availablePreScripts = [],
|
||||||
|
selectedPreEncodeScriptIds = [],
|
||||||
|
allowPreScriptSelection = false,
|
||||||
|
onAddPreEncodeScript = null,
|
||||||
|
onChangePreEncodeScript = null,
|
||||||
|
onRemovePreEncodeScript = null,
|
||||||
|
availableChains = [],
|
||||||
|
selectedPreEncodeChainIds = [],
|
||||||
|
selectedPostEncodeChainIds = [],
|
||||||
|
allowChainSelection = false,
|
||||||
|
onAddPreEncodeChain = null,
|
||||||
|
onRemovePreEncodeChain = null,
|
||||||
|
onAddPostEncodeChain = null,
|
||||||
|
onRemovePostEncodeChain = null
|
||||||
}) {
|
}) {
|
||||||
if (!review) {
|
if (!review) {
|
||||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||||
@@ -759,6 +780,136 @@ export default function MediaInfoReviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Pre-Encode Scripts */}
|
||||||
|
{(allowPreScriptSelection || normalizeScriptIdList(selectedPreEncodeScriptIds).length > 0) ? (
|
||||||
|
<div className="post-script-box">
|
||||||
|
<h4>Pre-Encode Scripte (optional)</h4>
|
||||||
|
{(Array.isArray(availablePreScripts) ? availablePreScripts : []).length === 0 ? (
|
||||||
|
<small>Keine Scripte konfiguriert. In den Settings unter "Scripte" anlegen.</small>
|
||||||
|
) : null}
|
||||||
|
{normalizeScriptIdList(selectedPreEncodeScriptIds).length === 0 ? (
|
||||||
|
<small>Keine Pre-Encode Scripte ausgewählt.</small>
|
||||||
|
) : null}
|
||||||
|
{normalizeScriptIdList(selectedPreEncodeScriptIds).map((scriptId, rowIndex) => {
|
||||||
|
const preCatalog = (Array.isArray(availablePreScripts) ? availablePreScripts : [])
|
||||||
|
.map((item) => ({ id: normalizeScriptId(item?.id), name: String(item?.name || '') }))
|
||||||
|
.filter((item) => item.id !== null);
|
||||||
|
const preById = new Map(preCatalog.map((item) => [item.id, item]));
|
||||||
|
const script = preById.get(scriptId) || null;
|
||||||
|
const selectedElsewhere = new Set(
|
||||||
|
normalizeScriptIdList(selectedPreEncodeScriptIds).filter((_, i) => i !== rowIndex).map((id) => String(id))
|
||||||
|
);
|
||||||
|
const options = preCatalog.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
disabled: selectedElsewhere.has(String(item.id))
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<div key={`pre-script-row-${rowIndex}-${scriptId}`} className={`post-script-row${allowPreScriptSelection ? ' editable' : ''}`}>
|
||||||
|
{allowPreScriptSelection ? (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
value={scriptId}
|
||||||
|
options={options}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
optionDisabled="disabled"
|
||||||
|
onChange={(event) => onChangePreEncodeScript?.(rowIndex, event.value)}
|
||||||
|
className="full-width"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onRemovePreEncodeScript?.(rowIndex)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<small>{`${rowIndex + 1}. ${script?.name || `Script #${scriptId}`}`}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allowPreScriptSelection && (Array.isArray(availablePreScripts) ? availablePreScripts : []).length > normalizeScriptIdList(selectedPreEncodeScriptIds).length ? (
|
||||||
|
<Button
|
||||||
|
label="Pre-Script hinzufügen"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => onAddPreEncodeScript?.()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<small>Diese Scripte werden vor dem Encoding ausgeführt. Bei Fehler wird der Encode abgebrochen.</small>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Chain Selections */}
|
||||||
|
{(allowChainSelection || selectedPreEncodeChainIds.length > 0 || selectedPostEncodeChainIds.length > 0) ? (
|
||||||
|
<div className="post-script-box">
|
||||||
|
<h4>Skriptketten (optional)</h4>
|
||||||
|
{(Array.isArray(availableChains) ? availableChains : []).length === 0 ? (
|
||||||
|
<small>Keine Skriptketten konfiguriert. In den Settings unter "Skriptketten" anlegen.</small>
|
||||||
|
) : null}
|
||||||
|
{(Array.isArray(availableChains) ? availableChains : []).length > 0 ? (
|
||||||
|
<div className="chain-selection-groups">
|
||||||
|
<div className="chain-selection-group">
|
||||||
|
<strong>Pre-Encode Ketten</strong>
|
||||||
|
{selectedPreEncodeChainIds.length === 0 ? <small>Keine ausgewählt.</small> : null}
|
||||||
|
{selectedPreEncodeChainIds.map((chainId, index) => {
|
||||||
|
const chain = (Array.isArray(availableChains) ? availableChains : []).find((c) => Number(c.id) === chainId);
|
||||||
|
return (
|
||||||
|
<div key={`pre-chain-${index}-${chainId}`} className="post-script-row editable">
|
||||||
|
<small>{`${index + 1}. ${chain?.name || `Kette #${chainId}`}`}</small>
|
||||||
|
{allowChainSelection ? (
|
||||||
|
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeChain?.(index)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allowChainSelection ? (
|
||||||
|
<Dropdown
|
||||||
|
value={null}
|
||||||
|
options={(Array.isArray(availableChains) ? availableChains : [])
|
||||||
|
.filter((c) => !selectedPreEncodeChainIds.includes(Number(c.id)))
|
||||||
|
.map((c) => ({ label: c.name, value: c.id }))}
|
||||||
|
onChange={(e) => onAddPreEncodeChain?.(e.value)}
|
||||||
|
placeholder="Kette hinzufügen..."
|
||||||
|
className="chain-add-dropdown"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chain-selection-group">
|
||||||
|
<strong>Post-Encode Ketten</strong>
|
||||||
|
{selectedPostEncodeChainIds.length === 0 ? <small>Keine ausgewählt.</small> : null}
|
||||||
|
{selectedPostEncodeChainIds.map((chainId, index) => {
|
||||||
|
const chain = (Array.isArray(availableChains) ? availableChains : []).find((c) => Number(c.id) === chainId);
|
||||||
|
return (
|
||||||
|
<div key={`post-chain-${index}-${chainId}`} className="post-script-row editable">
|
||||||
|
<small>{`${index + 1}. ${chain?.name || `Kette #${chainId}`}`}</small>
|
||||||
|
{allowChainSelection ? (
|
||||||
|
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeChain?.(index)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allowChainSelection ? (
|
||||||
|
<Dropdown
|
||||||
|
value={null}
|
||||||
|
options={(Array.isArray(availableChains) ? availableChains : [])
|
||||||
|
.filter((c) => !selectedPostEncodeChainIds.includes(Number(c.id)))
|
||||||
|
.map((c) => ({ label: c.name, value: c.id }))}
|
||||||
|
onChange={(e) => onAddPostEncodeChain?.(e.value)}
|
||||||
|
placeholder="Kette hinzufügen..."
|
||||||
|
className="chain-add-dropdown"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="post-script-box">
|
<div className="post-script-box">
|
||||||
<h4>Post-Encode Scripte (optional)</h4>
|
<h4>Post-Encode Scripte (optional)</h4>
|
||||||
{scriptCatalog.length === 0 ? (
|
{scriptCatalog.length === 0 ? (
|
||||||
|
|||||||
@@ -224,16 +224,21 @@ export default function PipelineStatusCard({
|
|||||||
const [settingsMap, setSettingsMap] = useState({});
|
const [settingsMap, setSettingsMap] = useState({});
|
||||||
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
||||||
const [scriptCatalog, setScriptCatalog] = useState([]);
|
const [scriptCatalog, setScriptCatalog] = useState([]);
|
||||||
|
const [chainCatalog, setChainCatalog] = useState([]);
|
||||||
const [selectedPostEncodeScriptIds, setSelectedPostEncodeScriptIds] = useState([]);
|
const [selectedPostEncodeScriptIds, setSelectedPostEncodeScriptIds] = useState([]);
|
||||||
|
const [selectedPreEncodeScriptIds, setSelectedPreEncodeScriptIds] = useState([]);
|
||||||
|
const [selectedPostEncodeChainIds, setSelectedPostEncodeChainIds] = useState([]);
|
||||||
|
const [selectedPreEncodeChainIds, setSelectedPreEncodeChainIds] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const [settingsResponse, presetsResponse, scriptsResponse] = await Promise.allSettled([
|
const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse] = await Promise.allSettled([
|
||||||
api.getSettings(),
|
api.getSettings(),
|
||||||
api.getHandBrakePresets(),
|
api.getHandBrakePresets(),
|
||||||
api.getScripts()
|
api.getScripts(),
|
||||||
|
api.getScriptChains()
|
||||||
]);
|
]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const categories = settingsResponse.status === 'fulfilled'
|
const categories = settingsResponse.status === 'fulfilled'
|
||||||
@@ -253,12 +258,17 @@ export default function PipelineStatusCard({
|
|||||||
name: item?.name
|
name: item?.name
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
const chains = chainsResponse.status === 'fulfilled'
|
||||||
|
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
|
||||||
|
: [];
|
||||||
|
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSettingsMap({});
|
setSettingsMap({});
|
||||||
setPresetDisplayMap({});
|
setPresetDisplayMap({});
|
||||||
setScriptCatalog([]);
|
setScriptCatalog([]);
|
||||||
|
setChainCatalog([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -275,6 +285,17 @@ export default function PipelineStatusCard({
|
|||||||
setSelectedPostEncodeScriptIds(
|
setSelectedPostEncodeScriptIds(
|
||||||
normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || [])
|
normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || [])
|
||||||
);
|
);
|
||||||
|
setSelectedPreEncodeScriptIds(
|
||||||
|
normalizeScriptIdList(mediaInfoReview?.preEncodeScriptIds || [])
|
||||||
|
);
|
||||||
|
setSelectedPostEncodeChainIds(
|
||||||
|
(Array.isArray(mediaInfoReview?.postEncodeChainIds) ? mediaInfoReview.postEncodeChainIds : [])
|
||||||
|
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
||||||
|
);
|
||||||
|
setSelectedPreEncodeChainIds(
|
||||||
|
(Array.isArray(mediaInfoReview?.preEncodeChainIds) ? mediaInfoReview.preEncodeChainIds : [])
|
||||||
|
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
||||||
|
);
|
||||||
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -434,10 +455,16 @@ export default function PipelineStatusCard({
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const selectedPostScriptIds = normalizeScriptIdList(selectedPostEncodeScriptIds);
|
const selectedPostScriptIds = normalizeScriptIdList(selectedPostEncodeScriptIds);
|
||||||
|
const selectedPreScriptIds = normalizeScriptIdList(selectedPreEncodeScriptIds);
|
||||||
|
const normalizeChainIdList = (raw) =>
|
||||||
|
(Array.isArray(raw) ? raw : []).map(Number).filter((id) => Number.isFinite(id) && id > 0);
|
||||||
return {
|
return {
|
||||||
encodeTitleId,
|
encodeTitleId,
|
||||||
selectedTrackSelection,
|
selectedTrackSelection,
|
||||||
selectedPostScriptIds
|
selectedPostScriptIds,
|
||||||
|
selectedPreScriptIds,
|
||||||
|
selectedPostChainIds: normalizeChainIdList(selectedPostEncodeChainIds),
|
||||||
|
selectedPreChainIds: normalizeChainIdList(selectedPreEncodeChainIds)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -530,13 +557,19 @@ export default function PipelineStatusCard({
|
|||||||
const {
|
const {
|
||||||
encodeTitleId,
|
encodeTitleId,
|
||||||
selectedTrackSelection,
|
selectedTrackSelection,
|
||||||
selectedPostScriptIds
|
selectedPostScriptIds,
|
||||||
|
selectedPreScriptIds,
|
||||||
|
selectedPostChainIds,
|
||||||
|
selectedPreChainIds
|
||||||
} = buildSelectedTrackSelectionForCurrentTitle();
|
} = buildSelectedTrackSelectionForCurrentTitle();
|
||||||
await onStart(retryJobId, {
|
await onStart(retryJobId, {
|
||||||
ensureConfirmed: true,
|
ensureConfirmed: true,
|
||||||
selectedEncodeTitleId: encodeTitleId,
|
selectedEncodeTitleId: encodeTitleId,
|
||||||
selectedTrackSelection,
|
selectedTrackSelection,
|
||||||
selectedPostEncodeScriptIds: selectedPostScriptIds
|
selectedPostEncodeScriptIds: selectedPostScriptIds,
|
||||||
|
selectedPreEncodeScriptIds: selectedPreScriptIds,
|
||||||
|
selectedPostEncodeChainIds: selectedPostChainIds,
|
||||||
|
selectedPreEncodeChainIds: selectedPreChainIds
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
@@ -809,6 +842,77 @@ export default function PipelineStatusCard({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
availablePreScripts={scriptCatalog}
|
||||||
|
selectedPreEncodeScriptIds={selectedPreEncodeScriptIds}
|
||||||
|
allowPreScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
||||||
|
onAddPreEncodeScript={() => {
|
||||||
|
setSelectedPreEncodeScriptIds((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];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onChangePreEncodeScript={(rowIndex, nextScriptId) => {
|
||||||
|
setSelectedPreEncodeScriptIds((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;
|
||||||
|
}
|
||||||
|
if (normalizedCurrent.some((id, idx) => idx !== rowIndex && String(id) === String(normalizedScriptId))) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
const next = [...normalizedCurrent];
|
||||||
|
next[rowIndex] = normalizedScriptId;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onRemovePreEncodeScript={(rowIndex) => {
|
||||||
|
setSelectedPreEncodeScriptIds((prev) => {
|
||||||
|
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||||
|
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
||||||
|
return normalizedCurrent;
|
||||||
|
}
|
||||||
|
return normalizedCurrent.filter((_, idx) => idx !== rowIndex);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
availableChains={chainCatalog}
|
||||||
|
selectedPreEncodeChainIds={selectedPreEncodeChainIds}
|
||||||
|
selectedPostEncodeChainIds={selectedPostEncodeChainIds}
|
||||||
|
allowChainSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
||||||
|
onAddPreEncodeChain={(chainId) => {
|
||||||
|
setSelectedPreEncodeChainIds((prev) => {
|
||||||
|
const id = Number(chainId);
|
||||||
|
if (!Number.isFinite(id) || id <= 0 || prev.includes(id)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, id];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onRemovePreEncodeChain={(index) => {
|
||||||
|
setSelectedPreEncodeChainIds((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
|
onAddPostEncodeChain={(chainId) => {
|
||||||
|
setSelectedPostEncodeChainIds((prev) => {
|
||||||
|
const id = Number(chainId);
|
||||||
|
if (!Number.isFinite(id) || id <= 0 || prev.includes(id)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, id];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onRemovePostEncodeChain={(index) => {
|
||||||
|
setSelectedPostEncodeChainIds((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -305,12 +305,17 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use live per-job progress from the backend if available (concurrent jobs).
|
||||||
|
const liveJobProgress = currentPipeline?.jobProgress && jobId
|
||||||
|
? (currentPipeline.jobProgress[jobId] || null)
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: jobStatus,
|
state: liveJobProgress?.state || jobStatus,
|
||||||
activeJobId: jobId,
|
activeJobId: jobId,
|
||||||
progress: Number.isFinite(Number(job?.progress)) ? Number(job.progress) : 0,
|
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
|
||||||
eta: job?.eta || null,
|
eta: liveJobProgress?.eta || null,
|
||||||
statusText: job?.status_text || job?.error_message || null,
|
statusText: liveJobProgress?.statusText || job?.error_message || null,
|
||||||
context: computedContext
|
context: computedContext
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -723,6 +728,9 @@ export default function DashboardPage({
|
|||||||
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
||||||
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
||||||
selectedPostEncodeScriptIds: startOptions.selectedPostEncodeScriptIds ?? [],
|
selectedPostEncodeScriptIds: startOptions.selectedPostEncodeScriptIds ?? [],
|
||||||
|
selectedPreEncodeScriptIds: startOptions.selectedPreEncodeScriptIds ?? [],
|
||||||
|
selectedPostEncodeChainIds: startOptions.selectedPostEncodeChainIds ?? [],
|
||||||
|
selectedPreEncodeChainIds: startOptions.selectedPreEncodeChainIds ?? [],
|
||||||
skipPipelineStateUpdate: true
|
skipPipelineStateUpdate: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
const [scriptErrors, setScriptErrors] = useState({});
|
const [scriptErrors, setScriptErrors] = useState({});
|
||||||
const [lastScriptTestResult, setLastScriptTestResult] = useState(null);
|
const [lastScriptTestResult, setLastScriptTestResult] = useState(null);
|
||||||
|
|
||||||
|
// Script chains state
|
||||||
|
const [chains, setChains] = useState([]);
|
||||||
|
const [chainsLoading, setChainsLoading] = useState(false);
|
||||||
|
const [chainSaving, setChainSaving] = useState(false);
|
||||||
|
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
|
||||||
|
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||||||
|
const [chainDragSource, setChainDragSource] = useState(null);
|
||||||
|
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
const loadScripts = async ({ silent = false } = {}) => {
|
const loadScripts = async ({ silent = false } = {}) => {
|
||||||
@@ -132,13 +141,32 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadChains = async ({ silent = false } = {}) => {
|
||||||
|
if (!silent) {
|
||||||
|
setChainsLoading(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await api.getScriptChains();
|
||||||
|
setChains(Array.isArray(response?.chains) ? response.chains : []);
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Skriptketten', detail: error.message });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
setChainsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [settingsResponse, presetsResponse, scriptsResponse] = await Promise.allSettled([
|
const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse] = await Promise.allSettled([
|
||||||
api.getSettings(),
|
api.getSettings(),
|
||||||
api.getHandBrakePresets(),
|
api.getHandBrakePresets(),
|
||||||
api.getScripts()
|
api.getScripts(),
|
||||||
|
api.getScriptChains()
|
||||||
]);
|
]);
|
||||||
if (settingsResponse.status !== 'fulfilled') {
|
if (settingsResponse.status !== 'fulfilled') {
|
||||||
throw settingsResponse.reason;
|
throw settingsResponse.reason;
|
||||||
@@ -174,6 +202,9 @@ export default function SettingsPage() {
|
|||||||
detail: 'Script-Liste konnte nicht geladen werden.'
|
detail: 'Script-Liste konnte nicht geladen werden.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (chainsResponse.status === 'fulfilled') {
|
||||||
|
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
|
||||||
|
}
|
||||||
} 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 {
|
||||||
@@ -438,6 +469,162 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chain editor handlers
|
||||||
|
const openChainEditor = (chain = null) => {
|
||||||
|
if (chain) {
|
||||||
|
setChainEditor({ open: true, id: chain.id, name: chain.name, steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` })) });
|
||||||
|
} else {
|
||||||
|
setChainEditor({ open: true, id: null, name: '', steps: [] });
|
||||||
|
}
|
||||||
|
setChainEditorErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeChainEditor = () => {
|
||||||
|
setChainEditor({ open: false, id: null, name: '', steps: [] });
|
||||||
|
setChainEditorErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChainStep = (stepType, scriptId = null, scriptName = null) => {
|
||||||
|
setChainEditor((prev) => ({
|
||||||
|
...prev,
|
||||||
|
steps: [
|
||||||
|
...prev.steps,
|
||||||
|
{
|
||||||
|
_key: `new-${Date.now()}-${Math.random()}`,
|
||||||
|
stepType,
|
||||||
|
scriptId: stepType === 'script' ? scriptId : null,
|
||||||
|
scriptName: stepType === 'script' ? scriptName : null,
|
||||||
|
waitSeconds: stepType === 'wait' ? 10 : null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeChainStep = (index) => {
|
||||||
|
setChainEditor((prev) => ({ ...prev, steps: prev.steps.filter((_, i) => i !== index) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChainStepWait = (index, seconds) => {
|
||||||
|
setChainEditor((prev) => ({
|
||||||
|
...prev,
|
||||||
|
steps: prev.steps.map((s, i) => i === index ? { ...s, waitSeconds: seconds } : s)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveChainStep = (fromIndex, toIndex) => {
|
||||||
|
if (fromIndex === toIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChainEditor((prev) => {
|
||||||
|
const steps = [...prev.steps];
|
||||||
|
const [moved] = steps.splice(fromIndex, 1);
|
||||||
|
steps.splice(toIndex, 0, moved);
|
||||||
|
return { ...prev, steps };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveChain = async () => {
|
||||||
|
const name = String(chainEditor.name || '').trim();
|
||||||
|
if (!name) {
|
||||||
|
setChainEditorErrors({ name: 'Name darf nicht leer sein.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
steps: chainEditor.steps.map((s) => ({
|
||||||
|
stepType: s.stepType,
|
||||||
|
scriptId: s.stepType === 'script' ? s.scriptId : null,
|
||||||
|
waitSeconds: s.stepType === 'wait' ? Number(s.waitSeconds || 10) : null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
setChainSaving(true);
|
||||||
|
try {
|
||||||
|
if (chainEditor.id) {
|
||||||
|
await api.updateScriptChain(chainEditor.id, payload);
|
||||||
|
toastRef.current?.show({ severity: 'success', summary: 'Skriptkette', detail: 'Kette aktualisiert.' });
|
||||||
|
} else {
|
||||||
|
await api.createScriptChain(payload);
|
||||||
|
toastRef.current?.show({ severity: 'success', summary: 'Skriptkette', detail: 'Kette angelegt.' });
|
||||||
|
}
|
||||||
|
await loadChains({ silent: true });
|
||||||
|
closeChainEditor();
|
||||||
|
} catch (error) {
|
||||||
|
const details = Array.isArray(error?.details) ? error.details : [];
|
||||||
|
if (details.length > 0) {
|
||||||
|
const errs = {};
|
||||||
|
for (const item of details) {
|
||||||
|
if (item?.field) {
|
||||||
|
errs[item.field] = item.message || 'Ungültig';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setChainEditorErrors(errs);
|
||||||
|
}
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Kette speichern fehlgeschlagen', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setChainSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteChain = async (chain) => {
|
||||||
|
const chainId = Number(chain?.id);
|
||||||
|
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`Skriptkette "${chain?.name || chainId}" wirklich löschen?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteScriptChain(chainId);
|
||||||
|
toastRef.current?.show({ severity: 'success', summary: 'Skriptketten', detail: 'Kette gelöscht.' });
|
||||||
|
await loadChains({ silent: true });
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Kette löschen fehlgeschlagen', detail: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chain DnD handlers
|
||||||
|
const handleChainPaletteDragStart = (event, data) => {
|
||||||
|
setChainDragSource({ origin: 'palette', ...data });
|
||||||
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
event.dataTransfer.setData('text/plain', JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChainStepDragStart = (event, index) => {
|
||||||
|
setChainDragSource({ origin: 'step', index });
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', String(index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChainDropzoneDrop = (event, targetIndex) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!chainDragSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chainDragSource.origin === 'palette') {
|
||||||
|
const newStep = {
|
||||||
|
_key: `new-${Date.now()}-${Math.random()}`,
|
||||||
|
stepType: chainDragSource.stepType,
|
||||||
|
scriptId: chainDragSource.stepType === 'script' ? chainDragSource.scriptId : null,
|
||||||
|
scriptName: chainDragSource.stepType === 'script' ? chainDragSource.scriptName : null,
|
||||||
|
waitSeconds: chainDragSource.stepType === 'wait' ? 10 : null
|
||||||
|
};
|
||||||
|
setChainEditor((prev) => {
|
||||||
|
const steps = [...prev.steps];
|
||||||
|
const insertAt = targetIndex != null ? targetIndex : steps.length;
|
||||||
|
steps.splice(insertAt, 0, newStep);
|
||||||
|
return { ...prev, steps };
|
||||||
|
});
|
||||||
|
} else if (chainDragSource.origin === 'step') {
|
||||||
|
moveChainStep(chainDragSource.index, targetIndex != null ? targetIndex : chainEditor.steps.length - 1);
|
||||||
|
}
|
||||||
|
setChainDragSource(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChainDragOver = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = chainDragSource?.origin === 'palette' ? 'copy' : 'move';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
@@ -677,6 +864,240 @@ export default function SettingsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel header="Skriptketten">
|
||||||
|
<div className="script-manager-wrap">
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
label="Neue Kette erstellen"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
severity="success"
|
||||||
|
outlined
|
||||||
|
onClick={() => openChainEditor()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Ketten neu laden"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
onClick={() => loadChains()}
|
||||||
|
loading={chainsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small>
|
||||||
|
Skriptketten kombinieren einzelne Scripte und Systemblöcke (z.B. Warten) zu einer ausführbaren Sequenz.
|
||||||
|
Ketten können an Jobs als Pre- oder Post-Encode-Aktion hinterlegt werden.
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<div className="script-list-box">
|
||||||
|
<h4>Verfügbare Skriptketten</h4>
|
||||||
|
{chainsLoading ? (
|
||||||
|
<p>Lade Skriptketten...</p>
|
||||||
|
) : chains.length === 0 ? (
|
||||||
|
<p>Keine Skriptketten vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="script-list">
|
||||||
|
{chains.map((chain) => (
|
||||||
|
<div key={chain.id} className="script-list-item">
|
||||||
|
<div className="script-list-main">
|
||||||
|
<strong className="script-id-title">{`ID #${chain.id} - ${chain.name}`}</strong>
|
||||||
|
<small>
|
||||||
|
{chain.steps?.length ?? 0} Schritt(e):
|
||||||
|
{' '}
|
||||||
|
{(chain.steps || []).map((s, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 ? ' → ' : ''}
|
||||||
|
{s.stepType === 'wait'
|
||||||
|
? `⏱ ${s.waitSeconds}s`
|
||||||
|
: (s.scriptName || `Script #${s.scriptId}`)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="script-list-actions">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
label="Bearbeiten"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => openChainEditor(chain)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
label="Löschen"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => handleDeleteChain(chain)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chain editor dialog */}
|
||||||
|
<Dialog
|
||||||
|
header={chainEditor.id ? `Skriptkette bearbeiten (#${chainEditor.id})` : 'Neue Skriptkette'}
|
||||||
|
visible={chainEditor.open}
|
||||||
|
onHide={closeChainEditor}
|
||||||
|
style={{ width: 'min(70rem, calc(100vw - 1.5rem))' }}
|
||||||
|
className="script-edit-dialog chain-editor-dialog"
|
||||||
|
dismissableMask={false}
|
||||||
|
draggable={false}
|
||||||
|
>
|
||||||
|
<div className="chain-editor-name-row">
|
||||||
|
<label htmlFor="chain-name">Name der Kette</label>
|
||||||
|
<InputText
|
||||||
|
id="chain-name"
|
||||||
|
value={chainEditor.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setChainEditor((prev) => ({ ...prev, name: e.target.value }));
|
||||||
|
setChainEditorErrors((prev) => ({ ...prev, name: null }));
|
||||||
|
}}
|
||||||
|
placeholder="z.B. Plex-Refresh + Cleanup"
|
||||||
|
/>
|
||||||
|
{chainEditorErrors.name ? <small className="error-text">{chainEditorErrors.name}</small> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chain-editor-body">
|
||||||
|
{/* Palette */}
|
||||||
|
<div className="chain-palette">
|
||||||
|
<h4>Bausteine</h4>
|
||||||
|
<p className="chain-palette-hint">Auf Schritt klicken oder in die Kette ziehen</p>
|
||||||
|
|
||||||
|
<div className="chain-palette-section">
|
||||||
|
<strong>Systemblöcke</strong>
|
||||||
|
<div
|
||||||
|
className="chain-palette-item chain-palette-item--system"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleChainPaletteDragStart(e, { stepType: 'wait' })}
|
||||||
|
onClick={() => addChainStep('wait')}
|
||||||
|
title="Wartezeit zwischen zwei Schritten"
|
||||||
|
>
|
||||||
|
<i className="pi pi-clock" />
|
||||||
|
{' '}Warten (Sekunden)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scripts.length > 0 ? (
|
||||||
|
<div className="chain-palette-section">
|
||||||
|
<strong>Scripte</strong>
|
||||||
|
{scripts.map((script) => (
|
||||||
|
<div
|
||||||
|
key={script.id}
|
||||||
|
className="chain-palette-item chain-palette-item--script"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleChainPaletteDragStart(e, { stepType: 'script', scriptId: script.id, scriptName: script.name })}
|
||||||
|
onClick={() => addChainStep('script', script.id, script.name)}
|
||||||
|
title={`Script #${script.id} hinzufügen`}
|
||||||
|
>
|
||||||
|
<i className="pi pi-code" />
|
||||||
|
{' '}{script.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<small>Keine Scripte verfügbar. Zuerst Scripte anlegen.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chain canvas */}
|
||||||
|
<div className="chain-canvas">
|
||||||
|
<h4>Kette ({chainEditor.steps.length} Schritt{chainEditor.steps.length !== 1 ? 'e' : ''})</h4>
|
||||||
|
|
||||||
|
{chainEditor.steps.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="chain-canvas-empty"
|
||||||
|
onDragOver={handleChainDragOver}
|
||||||
|
onDrop={(e) => handleChainDropzoneDrop(e, 0)}
|
||||||
|
>
|
||||||
|
Bausteine hierhin ziehen oder links anklicken
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="chain-steps-list">
|
||||||
|
{chainEditor.steps.map((step, index) => (
|
||||||
|
<div key={step._key || index} className="chain-step-wrapper">
|
||||||
|
{/* Drop zone before step */}
|
||||||
|
<div
|
||||||
|
className="chain-drop-zone"
|
||||||
|
onDragOver={handleChainDragOver}
|
||||||
|
onDrop={(e) => handleChainDropzoneDrop(e, index)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`chain-step chain-step--${step.stepType}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleChainStepDragStart(e, index)}
|
||||||
|
onDragEnd={() => setChainDragSource(null)}
|
||||||
|
>
|
||||||
|
<div className="chain-step-drag-handle">
|
||||||
|
<i className="pi pi-bars" />
|
||||||
|
</div>
|
||||||
|
<div className="chain-step-content">
|
||||||
|
{step.stepType === 'wait' ? (
|
||||||
|
<div className="chain-step-wait">
|
||||||
|
<i className="pi pi-clock" />
|
||||||
|
<span>Warten:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="3600"
|
||||||
|
value={step.waitSeconds ?? 10}
|
||||||
|
onChange={(e) => updateChainStepWait(index, Number(e.target.value))}
|
||||||
|
className="chain-wait-input"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<span>Sekunden</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="chain-step-script">
|
||||||
|
<i className="pi pi-code" />
|
||||||
|
<span>{step.scriptName || `Script #${step.scriptId}`}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
className="chain-step-remove"
|
||||||
|
onClick={() => removeChainStep(index)}
|
||||||
|
title="Schritt entfernen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Drop zone after last step */}
|
||||||
|
<div
|
||||||
|
className="chain-drop-zone chain-drop-zone--end"
|
||||||
|
onDragOver={handleChainDragOver}
|
||||||
|
onDrop={(e) => handleChainDropzoneDrop(e, chainEditor.steps.length)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions-row" style={{ marginTop: '1rem' }}>
|
||||||
|
<Button
|
||||||
|
label={chainEditor.id ? 'Kette aktualisieren' : 'Kette erstellen'}
|
||||||
|
icon="pi pi-save"
|
||||||
|
onClick={handleSaveChain}
|
||||||
|
loading={chainSaving}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Abbrechen"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={closeChainEditor}
|
||||||
|
disabled={chainSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TabPanel>
|
||||||
</TabView>
|
</TabView>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1704,4 +1704,245 @@ body {
|
|||||||
padding: 0.9rem 1rem 1rem;
|
padding: 0.9rem 1rem 1rem;
|
||||||
max-height: 78vh;
|
max-height: 78vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chain-editor-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette,
|
||||||
|
.chain-canvas {
|
||||||
|
min-width: unset;
|
||||||
|
max-width: unset;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chain Editor ─────────────────────────────────────── */
|
||||||
|
.chain-editor-name-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-editor-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette {
|
||||||
|
min-width: 14rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette h4 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette-hint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette-section strong {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette-item {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette-item:hover {
|
||||||
|
background: var(--rip-gold-200);
|
||||||
|
border-color: var(--rip-gold-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette-item--system {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-palette-item--script {
|
||||||
|
background: var(--rip-cream-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-canvas {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-canvas h4 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-canvas-empty {
|
||||||
|
flex: 1;
|
||||||
|
border: 2px dashed var(--rip-border);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-height: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-steps-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-step-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drop-zone {
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: height 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drop-zone--end {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drop-zone:hover,
|
||||||
|
.chain-drop-zone:focus-within {
|
||||||
|
height: 1.5rem;
|
||||||
|
background: var(--rip-gold-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
margin: 0.1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-step--wait {
|
||||||
|
background: #fffbe6;
|
||||||
|
border-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-step--script {
|
||||||
|
background: var(--rip-cream-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-step-drag-handle {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
cursor: grab;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-step-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-step-wait,
|
||||||
|
.chain-step-script {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-wait-input {
|
||||||
|
width: 4rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-step-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-selection-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-selection-group {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-top: 1px dashed var(--rip-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-selection-group:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-selection-group strong {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-add-dropdown {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.chain-selection-groups {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-selection-group {
|
||||||
|
flex: unset;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user