From 7979b353aa430eee6c31984092c0d7de446d5275 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 11 Mar 2026 11:56:17 +0000 Subject: [PATCH] final dev --- backend/src/index.js | 2 + backend/src/routes/historyRoutes.js | 28 +- backend/src/routes/runtimeRoutes.js | 56 ++ backend/src/services/cronService.js | 87 +- backend/src/services/historyService.js | 94 +- backend/src/services/pipelineService.js | 831 ++++++++++++++++-- .../src/services/runtimeActivityService.js | 248 ++++++ backend/src/services/scriptChainService.js | 546 ++++++++++-- backend/src/services/scriptService.js | 100 ++- backend/src/services/settingsService.js | 302 ++++++- frontend/src/api/client.js | 368 ++++++-- frontend/src/components/CronJobsTab.jsx | 70 +- frontend/src/components/JobDetailDialog.jsx | 189 +++- .../src/components/MediaInfoReviewPanel.jsx | 68 +- .../src/components/PipelineStatusCard.jsx | 87 +- frontend/src/pages/DashboardPage.jsx | 613 ++++++++++++- frontend/src/pages/SettingsPage.jsx | 201 +++-- frontend/src/styles/app.css | 201 ++++- 18 files changed, 3651 insertions(+), 440 deletions(-) create mode 100644 backend/src/routes/runtimeRoutes.js create mode 100644 backend/src/services/runtimeActivityService.js diff --git a/backend/src/index.js b/backend/src/index.js index 84cdc55..6ed3427 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -11,6 +11,7 @@ const settingsRoutes = require('./routes/settingsRoutes'); const pipelineRoutes = require('./routes/pipelineRoutes'); const historyRoutes = require('./routes/historyRoutes'); const cronRoutes = require('./routes/cronRoutes'); +const runtimeRoutes = require('./routes/runtimeRoutes'); const wsService = require('./services/websocketService'); const pipelineService = require('./services/pipelineService'); const cronService = require('./services/cronService'); @@ -38,6 +39,7 @@ async function start() { app.use('/api/pipeline', pipelineRoutes); app.use('/api/history', historyRoutes); app.use('/api/crons', cronRoutes); + app.use('/api/runtime', runtimeRoutes); app.use(errorHandler); diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index 2abe40c..4c4c78f 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -9,15 +9,30 @@ const router = express.Router(); router.get( '/', asyncHandler(async (req, res) => { + const parsedLimit = Number(req.query.limit); + const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.trunc(parsedLimit) + : null; + const statuses = String(req.query.statuses || '') + .split(',') + .map((value) => String(value || '').trim()) + .filter(Boolean); + const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase()); logger.info('get:jobs', { reqId: req.reqId, status: req.query.status, - search: req.query.search + statuses: statuses.length > 0 ? statuses : null, + search: req.query.search, + limit, + lite }); const jobs = await historyService.getJobs({ status: req.query.status, - search: req.query.search + statuses, + search: req.query.search, + limit, + includeFsChecks: !lite }); res.json({ jobs }); @@ -122,10 +137,12 @@ router.get( const includeLiveLog = ['1', 'true', 'yes'].includes(String(req.query.includeLiveLog || '').toLowerCase()); const includeLogs = ['1', 'true', 'yes'].includes(String(req.query.includeLogs || '').toLowerCase()); const includeAllLogs = ['1', 'true', 'yes'].includes(String(req.query.includeAllLogs || '').toLowerCase()); + const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase()); const parsedTail = Number(req.query.logTailLines); const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0 ? Math.trunc(parsedTail) : null; + const includeFsChecks = !(lite || includeLiveLog); logger.info('get:job-detail', { reqId: req.reqId, @@ -133,13 +150,16 @@ router.get( includeLiveLog, includeLogs, includeAllLogs, - logTailLines + logTailLines, + lite, + includeFsChecks }); const job = await historyService.getJobWithLogs(id, { includeLiveLog, includeLogs, includeAllLogs, - logTailLines + logTailLines, + includeFsChecks }); if (!job) { const error = new Error('Job nicht gefunden.'); diff --git a/backend/src/routes/runtimeRoutes.js b/backend/src/routes/runtimeRoutes.js new file mode 100644 index 0000000..6824b5a --- /dev/null +++ b/backend/src/routes/runtimeRoutes.js @@ -0,0 +1,56 @@ +const express = require('express'); +const asyncHandler = require('../middleware/asyncHandler'); +const runtimeActivityService = require('../services/runtimeActivityService'); +const logger = require('../services/logger').child('RUNTIME_ROUTE'); + +const router = express.Router(); + +router.get( + '/activities', + asyncHandler(async (req, res) => { + logger.debug('get:runtime:activities', { reqId: req.reqId }); + const snapshot = runtimeActivityService.getSnapshot(); + res.json(snapshot); + }) +); + +router.post( + '/activities/:id/cancel', + asyncHandler(async (req, res) => { + const activityId = Number(req.params.id); + const reason = String(req.body?.reason || '').trim() || null; + logger.info('post:runtime:activities:cancel', { reqId: req.reqId, activityId, reason }); + const action = await runtimeActivityService.requestCancel(activityId, { reason }); + if (!action?.ok) { + const error = new Error(action?.message || 'Abbrechen fehlgeschlagen.'); + error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409; + throw error; + } + res.json({ + ok: true, + action: action.result || null, + snapshot: runtimeActivityService.getSnapshot() + }); + }) +); + +router.post( + '/activities/:id/next-step', + asyncHandler(async (req, res) => { + const activityId = Number(req.params.id); + logger.info('post:runtime:activities:next-step', { reqId: req.reqId, activityId }); + const action = await runtimeActivityService.requestNextStep(activityId, {}); + if (!action?.ok) { + const error = new Error(action?.message || 'Nächster Schritt fehlgeschlagen.'); + error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409; + throw error; + } + res.json({ + ok: true, + action: action.result || null, + snapshot: runtimeActivityService.getSnapshot() + }); + }) +); + +module.exports = router; diff --git a/backend/src/services/cronService.js b/backend/src/services/cronService.js index fcb58a6..35291e6 100644 --- a/backend/src/services/cronService.js +++ b/backend/src/services/cronService.js @@ -9,6 +9,7 @@ const logger = require('./logger').child('CRON'); const notificationService = require('./notificationService'); const settingsService = require('./settingsService'); const wsService = require('./websocketService'); +const runtimeActivityService = require('./runtimeActivityService'); const { errorToMeta } = require('../utils/errorMeta'); // Maximale Zeilen pro Log-Eintrag (Output-Truncation) @@ -203,6 +204,12 @@ async function fetchAllJobsWithSource(db) { async function runCronJob(job) { const db = await getDb(); const startedAt = new Date().toISOString(); + const cronActivityId = runtimeActivityService.startActivity('cron', { + name: job?.name || `Cron #${job?.id || '?'}`, + source: 'cron', + cronJobId: job?.id || null, + currentStep: 'Starte Cronjob' + }); logger.info('cron:run:start', { cronJobId: job.id, name: job.name, sourceType: job.sourceType, sourceId: job.sourceId }); @@ -228,9 +235,23 @@ async function runCronJob(job) { if (job.sourceType === 'script') { const scriptService = require('./scriptService'); const script = await scriptService.getScriptById(job.sourceId); - const prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id }); - + runtimeActivityService.updateActivity(cronActivityId, { + currentStepType: 'script', + currentStep: `Skript: ${script.name}`, + currentScriptName: script.name, + scriptId: script.id + }); + const scriptActivityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'cron', + scriptId: script.id, + cronJobId: job.id, + parentActivityId: cronActivityId, + currentStep: `Cronjob: ${job.name}` + }); + let prepared = null; try { + prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id }); const result = await new Promise((resolve, reject) => { const { spawn } = require('child_process'); const child = spawn(prepared.cmd, prepared.args, { @@ -249,15 +270,58 @@ async function runCronJob(job) { if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]'; success = result.code === 0; if (!success) errorMessage = `Exit-Code ${result.code}`; + runtimeActivityService.completeActivity(scriptActivityId, { + status: success ? 'success' : 'error', + success, + outcome: success ? 'success' : 'error', + exitCode: result.code, + message: success ? null : errorMessage, + output: output || null, + stdout: result.stdout || null, + stderr: result.stderr || null, + errorMessage: success ? null : (errorMessage || null) + }); + } catch (error) { + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'error', + message: error?.message || 'Skriptfehler', + errorMessage: error?.message || 'Skriptfehler' + }); + throw error; } finally { - await prepared.cleanup(); + if (prepared?.cleanup) { + await prepared.cleanup(); + } } } else if (job.sourceType === 'chain') { const scriptChainService = require('./scriptChainService'); const logLines = []; + runtimeActivityService.updateActivity(cronActivityId, { + currentStepType: 'chain', + currentStep: `Kette: ${job.sourceName || `#${job.sourceId}`}`, + currentScriptName: null, + chainId: job.sourceId + }); const result = await scriptChainService.executeChain( job.sourceId, - { source: 'cron', cronJobId: job.id }, + { + source: 'cron', + cronJobId: job.id, + runtimeParentActivityId: cronActivityId, + onRuntimeStep: (payload = {}) => { + const currentScriptName = payload?.stepType === 'script' + ? (payload?.scriptName || payload?.currentScriptName || null) + : null; + runtimeActivityService.updateActivity(cronActivityId, { + currentStepType: payload?.stepType || 'chain', + currentStep: payload?.currentStep || null, + currentScriptName, + scriptId: payload?.scriptId || null + }); + } + }, { appendLog: async (_source, line) => { logLines.push(line); @@ -267,7 +331,9 @@ async function runCronJob(job) { output = logLines.join('\n'); if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]'; - success = Array.isArray(result) ? result.every((r) => r.success !== false) : Boolean(result); + success = result && typeof result === 'object' + ? !(Boolean(result.aborted) || Number(result.failed || 0) > 0) + : Boolean(result); if (!success) errorMessage = 'Kette enthielt fehlgeschlagene Schritte.'; } else { throw new Error(`Unbekannter source_type: ${job.sourceType}`); @@ -307,6 +373,17 @@ async function runCronJob(job) { ); logger.info('cron:run:done', { cronJobId: job.id, status, durationMs: new Date(finishedAt) - new Date(startedAt) }); + runtimeActivityService.completeActivity(cronActivityId, { + status, + success, + outcome: success ? 'success' : 'error', + finishedAt, + currentStep: null, + currentScriptName: null, + message: success ? 'Cronjob abgeschlossen' : (errorMessage || 'Cronjob fehlgeschlagen'), + output: output || null, + errorMessage: success ? null : (errorMessage || null) + }); wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: status, lastRunAt: finishedAt, nextRunAt }); diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 357db06..045fd37 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -47,13 +47,35 @@ function inspectDirectory(dirPath) { }; } - const entries = fs.readdirSync(dirPath); + // Fast path: only determine whether directory is empty, avoid loading all entries. + let firstEntry = null; + let openError = null; + try { + const dir = fs.opendirSync(dirPath); + try { + firstEntry = dir.readSync(); + } finally { + dir.closeSync(); + } + } catch (error) { + openError = error; + } + if (openError) { + const entries = fs.readdirSync(dirPath); + return { + path: dirPath, + exists: true, + isDirectory: true, + isEmpty: entries.length === 0, + entryCount: entries.length + }; + } return { path: dirPath, exists: true, isDirectory: true, - isEmpty: entries.length === 0, - entryCount: entries.length + isEmpty: !firstEntry, + entryCount: firstEntry ? null : 0 }; } catch (error) { return { @@ -378,14 +400,40 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = }; } -function enrichJobRow(job, settings = null) { +function buildUnknownDirectoryStatus(dirPath = null) { + return { + path: dirPath || null, + exists: null, + isDirectory: null, + isEmpty: null, + entryCount: null + }; +} + +function buildUnknownFileStatus(filePath = null) { + return { + path: filePath || null, + exists: null, + isFile: null, + sizeBytes: null + }; +} + +function enrichJobRow(job, settings = null, options = {}) { + const includeFsChecks = options?.includeFsChecks !== false; const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null); const omdbInfo = parseJsonSafe(job.omdb_json, null); const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); - const rawStatus = inspectDirectory(resolvedPaths.effectiveRawPath); - const outputStatus = inspectOutputFile(resolvedPaths.effectiveOutputPath); + const rawStatus = includeFsChecks + ? inspectDirectory(resolvedPaths.effectiveRawPath) + : buildUnknownDirectoryStatus(resolvedPaths.effectiveRawPath); + const outputStatus = includeFsChecks + ? inspectOutputFile(resolvedPaths.effectiveOutputPath) + : buildUnknownFileStatus(resolvedPaths.effectiveOutputPath); const movieDirPath = resolvedPaths.effectiveOutputPath ? path.dirname(resolvedPaths.effectiveOutputPath) : null; - const movieDirStatus = inspectDirectory(movieDirPath); + const movieDirStatus = includeFsChecks + ? inspectDirectory(movieDirPath) + : buildUnknownDirectoryStatus(movieDirPath); const makemkvInfo = resolvedPaths.makemkvInfo; const mediainfoInfo = resolvedPaths.mediainfoInfo; const encodePlan = resolvedPaths.encodePlan; @@ -750,8 +798,25 @@ class HistoryService { const db = await getDb(); const where = []; const values = []; + const includeFsChecks = filters?.includeFsChecks !== false; + const rawStatuses = Array.isArray(filters?.statuses) + ? filters.statuses + : (typeof filters?.statuses === 'string' + ? String(filters.statuses).split(',') + : []); + const normalizedStatuses = rawStatuses + .map((value) => String(value || '').trim().toUpperCase()) + .filter(Boolean); + const limitRaw = Number(filters?.limit); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(Math.trunc(limitRaw), 500) + : 500; - if (filters.status) { + if (normalizedStatuses.length > 0) { + const placeholders = normalizedStatuses.map(() => '?').join(', '); + where.push(`status IN (${placeholders})`); + values.push(...normalizedStatuses); + } else if (filters.status) { where.push('status = ?'); values.push(filters.status); } @@ -770,7 +835,7 @@ class HistoryService { FROM jobs j ${whereClause} ORDER BY j.created_at DESC - LIMIT 500 + LIMIT ${limit} `, values ), @@ -778,8 +843,8 @@ class HistoryService { ]); return jobs.map((job) => ({ - ...enrichJobRow(job, settings), - log_count: hasProcessLogFile(job.id) ? 1 : 0 + ...enrichJobRow(job, settings, { includeFsChecks }), + log_count: includeFsChecks ? (hasProcessLogFile(job.id) ? 1 : 0) : 0 })); } @@ -852,6 +917,7 @@ class HistoryService { async getJobWithLogs(jobId, options = {}) { const db = await getDb(); + const includeFsChecks = options?.includeFsChecks !== false; const [job, settings] = await Promise.all([ db.get('SELECT * FROM jobs WHERE id = ?', [jobId]), settingsService.getSettingsMap() @@ -868,12 +934,12 @@ class HistoryService { const includeLogs = Boolean(options.includeLogs); const includeAllLogs = Boolean(options.includeAllLogs); const shouldLoadLogs = includeLiveLog || includeLogs; - const hasProcessLog = hasProcessLogFile(jobId); + const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false; const baseLogCount = hasProcessLog ? 1 : 0; if (!shouldLoadLogs) { return { - ...enrichJobRow(job, settings), + ...enrichJobRow(job, settings, { includeFsChecks }), log_count: baseLogCount, logs: [], log: '', @@ -892,7 +958,7 @@ class HistoryService { }); return { - ...enrichJobRow(job, settings), + ...enrichJobRow(job, settings, { includeFsChecks }), log_count: processLog.exists ? processLog.total : 0, logs: [], log: processLog.lines.join('\n'), diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 5b6adf9..5657bdf 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -7,6 +7,7 @@ const historyService = require('./historyService'); const omdbService = require('./omdbService'); const scriptService = require('./scriptService'); const scriptChainService = require('./scriptChainService'); +const runtimeActivityService = require('./runtimeActivityService'); const wsService = require('./websocketService'); const diskDetectionService = require('./diskDetectionService'); const notificationService = require('./notificationService'); @@ -3061,6 +3062,304 @@ function extractManualSelectionPayloadFromPlan(encodePlan) { }; } +function normalizeChainIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = Math.trunc(value); + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function normalizeUserPresetForPlan(rawPreset) { + if (!rawPreset || typeof rawPreset !== 'object') { + return null; + } + const rawId = Number(rawPreset.id); + const presetId = Number.isFinite(rawId) && rawId > 0 ? Math.trunc(rawId) : null; + const name = String(rawPreset.name || '').trim(); + const handbrakePreset = String(rawPreset.handbrakePreset || '').trim(); + const extraArgs = String(rawPreset.extraArgs || '').trim(); + if (!presetId && !name && !handbrakePreset && !extraArgs) { + return null; + } + return { + id: presetId, + name: name || (presetId ? `Preset #${presetId}` : 'User-Preset'), + handbrakePreset: handbrakePreset || null, + extraArgs: extraArgs || null + }; +} + +function buildScriptDescriptorList(scriptIds, sourceScripts = []) { + const normalizedIds = normalizeScriptIdList(scriptIds); + if (normalizedIds.length === 0) { + return []; + } + const source = Array.isArray(sourceScripts) ? sourceScripts : []; + const namesById = new Map( + source + .map((item) => { + const id = Number(item?.id ?? item?.scriptId); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + const name = String(item?.name || '').trim(); + if (!normalizedId || !name) { + return null; + } + return [normalizedId, name]; + }) + .filter(Boolean) + ); + return normalizedIds.map((id) => ({ + id, + name: namesById.get(id) || `Skript #${id}` + })); +} + +function findSelectedTitleInPlan(encodePlan) { + if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) { + return null; + } + const preferredTitleId = normalizeReviewTitleId(encodePlan.encodeInputTitleId); + if (preferredTitleId) { + const byId = encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === preferredTitleId) || null; + if (byId) { + return byId; + } + } + return encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) || null; +} + +function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) { + const reviewTitles = Array.isArray(reviewPlan?.titles) ? reviewPlan.titles : []; + if (reviewTitles.length === 0) { + return null; + } + + const previousSelectedTitle = findSelectedTitleInPlan(previousPlan); + if (!previousSelectedTitle) { + return null; + } + + const previousPlaylistId = normalizePlaylistId( + previousSelectedTitle?.playlistId + || previousPlan?.selectedPlaylistId + || null + ); + if (previousPlaylistId) { + const byPlaylist = reviewTitles.find((title) => normalizePlaylistId(title?.playlistId) === previousPlaylistId) || null; + const id = normalizeReviewTitleId(byPlaylist?.id); + if (id) { + return id; + } + } + + const previousMakemkvTitleId = normalizeNonNegativeInteger( + previousSelectedTitle?.makemkvTitleId + ?? previousPlan?.selectedMakemkvTitleId + ?? null + ); + if (previousMakemkvTitleId !== null) { + const byMakemkvTitleId = reviewTitles.find((title) => ( + normalizeNonNegativeInteger(title?.makemkvTitleId) === previousMakemkvTitleId + )) || null; + const id = normalizeReviewTitleId(byMakemkvTitleId?.id); + if (id) { + return id; + } + } + + const previousFileName = path.basename( + String(previousSelectedTitle?.filePath || previousSelectedTitle?.fileName || '').trim() + ).toLowerCase(); + if (previousFileName) { + const byFileName = reviewTitles.find((title) => { + const candidate = path.basename( + String(title?.filePath || title?.fileName || '').trim() + ).toLowerCase(); + return candidate && candidate === previousFileName; + }) || null; + const id = normalizeReviewTitleId(byFileName?.id); + if (id) { + return id; + } + } + + const previousTitleId = normalizeReviewTitleId(previousPlan?.encodeInputTitleId); + if (!previousTitleId) { + return null; + } + const fallback = reviewTitles.find((title) => normalizeReviewTitleId(title?.id) === previousTitleId) || null; + return normalizeReviewTitleId(fallback?.id); +} + +function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, { excludeBurned = false } = {}) { + const tracks = Array.isArray(targetTracks) ? targetTracks : []; + const allowedTracks = excludeBurned + ? tracks.filter((track) => !isBurnedSubtitleTrack(track)) + : tracks; + const requested = normalizeTrackIdList(sourceTrackIds); + if (requested.length === 0 || allowedTracks.length === 0) { + return []; + } + + const mapped = []; + const seen = new Set(); + for (const sourceTrackId of requested) { + const match = allowedTracks.find((track) => { + const sourceId = normalizeTrackIdList([track?.sourceTrackId])[0] || null; + const reviewId = normalizeTrackIdList([track?.id])[0] || null; + return sourceId === sourceTrackId || reviewId === sourceTrackId; + }) || null; + const targetId = normalizeTrackIdList([match?.id])[0] || null; + if (targetId === null) { + continue; + } + const key = String(targetId); + if (seen.has(key)) { + continue; + } + seen.add(key); + mapped.push(targetId); + } + return mapped; +} + +function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = null) { + const hasReviewTitles = reviewPlan && Array.isArray(reviewPlan?.titles) && reviewPlan.titles.length > 0; + const hasPreviousTitles = previousPlan && Array.isArray(previousPlan?.titles) && previousPlan.titles.length > 0; + if (!hasReviewTitles || !hasPreviousTitles) { + return { + plan: reviewPlan, + applied: false, + selectedEncodeTitleId: normalizeReviewTitleId(reviewPlan?.encodeInputTitleId), + preEncodeScriptCount: 0, + postEncodeScriptCount: 0, + preEncodeChainCount: 0, + postEncodeChainCount: 0, + userPresetApplied: false + }; + } + + let nextPlan = reviewPlan; + const prefillTitleId = resolvePrefillEncodeTitleId(nextPlan, previousPlan); + let selectedTitleApplied = false; + if (prefillTitleId) { + try { + const remapped = applyEncodeTitleSelectionToPlan(nextPlan, prefillTitleId); + nextPlan = remapped.plan; + selectedTitleApplied = true; + } catch (_error) { + // Keep calculated review defaults when title from previous run is no longer available. + } + } + + const previousSelectedTitle = findSelectedTitleInPlan(previousPlan); + const nextSelectedTitle = findSelectedTitleInPlan(nextPlan); + let trackSelectionApplied = false; + if (previousSelectedTitle && nextSelectedTitle) { + const previousAudioSourceIds = normalizeTrackIdList( + (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)) + .map((track) => track?.sourceTrackId ?? track?.id) + ); + const previousSubtitleSourceIds = normalizeTrackIdList( + (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)) + .map((track) => track?.sourceTrackId ?? track?.id) + ); + + const mappedAudioTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( + nextSelectedTitle?.audioTracks, + previousAudioSourceIds + ); + const mappedSubtitleTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( + nextSelectedTitle?.subtitleTracks, + previousSubtitleSourceIds, + { excludeBurned: true } + ); + const fallbackAudioTrackIds = normalizeTrackIdList( + (Array.isArray(nextSelectedTitle?.audioTracks) ? nextSelectedTitle.audioTracks : []) + .filter((track) => Boolean(track?.selectedByRule)) + .map((track) => track?.id) + ); + const fallbackSubtitleTrackIds = normalizeTrackIdList( + (Array.isArray(nextSelectedTitle?.subtitleTracks) ? nextSelectedTitle.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track)) + .map((track) => track?.id) + ); + const effectiveAudioTrackIds = previousAudioSourceIds.length > 0 && mappedAudioTrackIds.length === 0 + ? fallbackAudioTrackIds + : mappedAudioTrackIds; + const effectiveSubtitleTrackIds = previousSubtitleSourceIds.length > 0 && mappedSubtitleTrackIds.length === 0 + ? fallbackSubtitleTrackIds + : mappedSubtitleTrackIds; + + const targetTitleId = normalizeReviewTitleId(nextSelectedTitle?.id || nextPlan?.encodeInputTitleId); + if (targetTitleId) { + const trackSelectionResult = applyManualTrackSelectionToPlan(nextPlan, { + [targetTitleId]: { + audioTrackIds: effectiveAudioTrackIds, + subtitleTrackIds: effectiveSubtitleTrackIds + } + }); + nextPlan = trackSelectionResult.plan; + trackSelectionApplied = Boolean(trackSelectionResult.selectionApplied); + } + } + + const preEncodeScriptIds = normalizeScriptIdList(previousPlan?.preEncodeScriptIds || []); + const postEncodeScriptIds = normalizeScriptIdList(previousPlan?.postEncodeScriptIds || []); + const preEncodeChainIds = normalizeChainIdList(previousPlan?.preEncodeChainIds || []); + const postEncodeChainIds = normalizeChainIdList(previousPlan?.postEncodeChainIds || []); + const userPreset = normalizeUserPresetForPlan(previousPlan?.userPreset || null); + + nextPlan = { + ...nextPlan, + preEncodeScriptIds, + postEncodeScriptIds, + preEncodeScripts: buildScriptDescriptorList(preEncodeScriptIds, previousPlan?.preEncodeScripts || []), + postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []), + preEncodeChainIds, + postEncodeChainIds, + userPreset, + reviewConfirmed: false, + reviewConfirmedAt: null, + prefilledFromPreviousRun: true, + prefilledFromPreviousRunAt: nowIso() + }; + + const applied = selectedTitleApplied + || trackSelectionApplied + || preEncodeScriptIds.length > 0 + || postEncodeScriptIds.length > 0 + || preEncodeChainIds.length > 0 + || postEncodeChainIds.length > 0 + || Boolean(userPreset); + + return { + plan: nextPlan, + applied, + selectedEncodeTitleId: normalizeReviewTitleId(nextPlan?.encodeInputTitleId), + preEncodeScriptCount: preEncodeScriptIds.length, + postEncodeScriptCount: postEncodeScriptIds.length, + preEncodeChainCount: preEncodeChainIds.length, + postEncodeChainCount: postEncodeChainIds.length, + userPresetApplied: Boolean(userPreset) + }; +} + class PipelineService extends EventEmitter { constructor() { super(); @@ -3415,6 +3714,201 @@ class PipelineService extends EventEmitter { return this.queueEntries.findIndex((entry) => Number(entry?.jobId) === Number(jobId)); } + normalizeQueueChainIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = Math.trunc(value); + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; + } + + extractQueueJobPlan(row) { + const source = row && typeof row === 'object' ? row : null; + if (!source) { + return null; + } + if (source.encodePlan && typeof source.encodePlan === 'object') { + return source.encodePlan; + } + if (source.encode_plan_json) { + try { + const parsed = JSON.parse(source.encode_plan_json); + if (parsed && typeof parsed === 'object') { + return parsed; + } + } catch (_) { + // ignore parse errors for queue decorations + } + } + return null; + } + + async buildQueueJobScriptMeta(rows = []) { + const list = Array.isArray(rows) ? rows : []; + const byJobId = new Map(); + const allScriptIds = new Set(); + const allChainIds = new Set(); + const scriptNameHints = new Map(); + const chainNameHints = new Map(); + + const addScriptHints = (items) => { + for (const item of (Array.isArray(items) ? items : [])) { + if (!item || typeof item !== 'object') { + continue; + } + const id = normalizeScriptIdList([item.id ?? item.scriptId])[0] || null; + const name = String(item.name || item.scriptName || '').trim(); + if (!id) { + continue; + } + allScriptIds.add(id); + if (name) { + scriptNameHints.set(id, name); + } + } + }; + + const addChainHints = (items) => { + for (const item of (Array.isArray(items) ? items : [])) { + if (!item || typeof item !== 'object') { + continue; + } + const id = this.normalizeQueueChainIdList([item.id ?? item.chainId])[0] || null; + const name = String(item.name || item.chainName || '').trim(); + if (!id) { + continue; + } + allChainIds.add(id); + if (name) { + chainNameHints.set(id, name); + } + } + }; + + for (const row of list) { + const jobId = this.normalizeQueueJobId(row?.id); + if (!jobId) { + continue; + } + const plan = this.extractQueueJobPlan(row); + if (!plan) { + continue; + } + + const preScriptIds = normalizeScriptIdList([ + ...normalizeScriptIdList(plan?.preEncodeScriptIds || []), + ...normalizeScriptIdList((Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts : []).map((item) => item?.id ?? item?.scriptId)) + ]); + const postScriptIds = normalizeScriptIdList([ + ...normalizeScriptIdList(plan?.postEncodeScriptIds || []), + ...normalizeScriptIdList((Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts : []).map((item) => item?.id ?? item?.scriptId)) + ]); + const preChainIds = this.normalizeQueueChainIdList([ + ...this.normalizeQueueChainIdList(plan?.preEncodeChainIds || []), + ...this.normalizeQueueChainIdList((Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains : []).map((item) => item?.id ?? item?.chainId)) + ]); + const postChainIds = this.normalizeQueueChainIdList([ + ...this.normalizeQueueChainIdList(plan?.postEncodeChainIds || []), + ...this.normalizeQueueChainIdList((Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains : []).map((item) => item?.id ?? item?.chainId)) + ]); + + addScriptHints(plan?.preEncodeScripts); + addScriptHints(plan?.postEncodeScripts); + addChainHints(plan?.preEncodeChains); + addChainHints(plan?.postEncodeChains); + + for (const id of preScriptIds) allScriptIds.add(id); + for (const id of postScriptIds) allScriptIds.add(id); + for (const id of preChainIds) allChainIds.add(id); + for (const id of postChainIds) allChainIds.add(id); + + byJobId.set(jobId, { + preScriptIds, + postScriptIds, + preChainIds, + postChainIds + }); + } + + if (byJobId.size === 0) { + return new Map(); + } + + const scriptNameById = new Map(); + const chainNameById = new Map(); + for (const [id, name] of scriptNameHints.entries()) { + scriptNameById.set(id, name); + } + for (const [id, name] of chainNameHints.entries()) { + chainNameById.set(id, name); + } + + if (allScriptIds.size > 0) { + const scriptService = require('./scriptService'); + try { + const scripts = await scriptService.resolveScriptsByIds(Array.from(allScriptIds), { strict: false }); + for (const script of scripts) { + const id = Number(script?.id); + const name = String(script?.name || '').trim(); + if (Number.isFinite(id) && id > 0 && name) { + scriptNameById.set(id, name); + } + } + } catch (error) { + logger.warn('queue:script-summary:resolve-failed', { error: errorToMeta(error) }); + } + } + + if (allChainIds.size > 0) { + const scriptChainService = require('./scriptChainService'); + try { + const chains = await scriptChainService.getChainsByIds(Array.from(allChainIds)); + for (const chain of chains) { + const id = Number(chain?.id); + const name = String(chain?.name || '').trim(); + if (Number.isFinite(id) && id > 0 && name) { + chainNameById.set(id, name); + } + } + } catch (error) { + logger.warn('queue:chain-summary:resolve-failed', { error: errorToMeta(error) }); + } + } + + const output = new Map(); + for (const [jobId, data] of byJobId.entries()) { + const preScripts = data.preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`); + const postScripts = data.postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`); + const preChains = data.preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`); + const postChains = data.postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`); + const hasScripts = preScripts.length > 0 || postScripts.length > 0; + const hasChains = preChains.length > 0 || postChains.length > 0; + output.set(jobId, { + hasScripts, + hasChains, + summary: { + preScripts, + postScripts, + preChains, + postChains + } + }); + } + return output; + } + async getQueueSnapshot() { const maxParallelJobs = await this.getMaxParallelJobs(); const runningJobs = await historyService.getRunningJobs(); @@ -3427,6 +3921,13 @@ class PipelineService extends EventEmitter { ? await historyService.getJobsByIds(queuedJobIds) : []; const queuedById = new Map(queuedRows.map((row) => [Number(row.id), row])); + const scriptMetaByJobId = await this.buildQueueJobScriptMeta( + Array.from( + new Map( + [...runningJobs, ...queuedRows].map((row) => [Number(row?.id), row]) + ).values() + ) + ); const queue = { maxParallelJobs, @@ -3435,7 +3936,10 @@ class PipelineService extends EventEmitter { jobId: Number(job.id), title: job.title || job.detected_title || `Job #${job.id}`, status: job.status, - lastState: job.last_state || null + lastState: job.last_state || null, + hasScripts: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasScripts), + hasChains: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasChains), + scriptSummary: scriptMetaByJobId.get(Number(job.id))?.summary || null })), queuedJobs: this.queueEntries.map((entry, index) => { const entryType = entry.type || 'job'; @@ -3458,21 +3962,7 @@ class PipelineService extends EventEmitter { // type === 'job' const row = queuedById.get(Number(entry.jobId)); - let hasScripts = false; - let hasChains = false; - if (row?.encode_plan_json) { - try { - const plan = JSON.parse(row.encode_plan_json); - hasScripts = Boolean( - (Array.isArray(plan?.preEncodeScriptIds) && plan.preEncodeScriptIds.length > 0) - || (Array.isArray(plan?.postEncodeScriptIds) && plan.postEncodeScriptIds.length > 0) - ); - hasChains = Boolean( - (Array.isArray(plan?.preEncodeChainIds) && plan.preEncodeChainIds.length > 0) - || (Array.isArray(plan?.postEncodeChainIds) && plan.postEncodeChainIds.length > 0) - ); - } catch (_) { /* ignore */ } - } + const scriptMeta = scriptMetaByJobId.get(Number(entry.jobId)) || null; return { ...base, jobId: Number(entry.jobId), @@ -3481,8 +3971,9 @@ class PipelineService extends EventEmitter { title: row?.title || row?.detected_title || `Job #${entry.jobId}`, status: row?.status || null, lastState: row?.last_state || null, - hasScripts, - hasChains + hasScripts: Boolean(scriptMeta?.hasScripts), + hasChains: Boolean(scriptMeta?.hasChains), + scriptSummary: scriptMeta?.summary || null }; }), queuedCount: this.queueEntries.length, @@ -3684,19 +4175,59 @@ class PipelineService extends EventEmitter { logger.warn('queue:script:not-found', { scriptId: entry.scriptId }); return; } + const activityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'queue', + scriptId: script.id, + currentStep: 'Queue-Ausfuehrung' + }); let prepared = null; try { prepared = await scriptService.createExecutableScriptFile(script, { source: 'queue', scriptId: script.id, scriptName: script.name }); const { spawn } = require('child_process'); await new Promise((resolve, reject) => { - const child = spawn(prepared.cmd, prepared.args, { env: process.env, stdio: 'ignore' }); + 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); + if (stdout.length > 12000) { + stdout = `${stdout.slice(0, 12000)}\n...[truncated]`; + } + }); + child.stderr?.on('data', (chunk) => { + stderr += String(chunk); + if (stderr.length > 12000) { + stderr = `${stderr.slice(0, 12000)}\n...[truncated]`; + } + }); child.on('error', reject); child.on('close', (code) => { logger.info('queue:script:done', { scriptId: script.id, exitCode: code }); + const output = [stdout, stderr].filter(Boolean).join('\n').trim(); + const success = Number(code) === 0; + runtimeActivityService.completeActivity(activityId, { + status: success ? 'success' : 'error', + success, + outcome: success ? 'success' : 'error', + exitCode: Number.isFinite(Number(code)) ? Number(code) : null, + message: success ? 'Queue-Skript abgeschlossen' : `Queue-Skript fehlgeschlagen (Exit ${code})`, + output: output || null, + stdout: stdout || null, + stderr: stderr || null, + errorMessage: success ? null : `Queue-Skript fehlgeschlagen (Exit ${code})` + }); resolve(); }); }); } catch (err) { + runtimeActivityService.completeActivity(activityId, { + status: 'error', + success: false, + outcome: 'error', + message: err?.message || 'Queue-Skript Fehler', + errorMessage: err?.message || 'Queue-Skript Fehler' + }); logger.error('queue:script:error', { scriptId: entry.scriptId, error: errorToMeta(err) }); } finally { if (prepared?.cleanup) await prepared.cleanup(); @@ -5465,6 +5996,14 @@ class PipelineService extends EventEmitter { ] }; + const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( + review, + options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null + ); + review = reviewPrefillResult.plan; + if (!Array.isArray(review.titles) || review.titles.length === 0) { const error = new Error('Titel-/Spurprüfung aus RAW lieferte keine Titel.'); error.statusCode = 400; @@ -5495,6 +6034,16 @@ class PipelineService extends EventEmitter { 'SYSTEM', `Titel-/Spurprüfung aus RAW abgeschlossen (MakeMKV Titel #${selectedTitleForReview}): ${review.titles.length} Titel, Vorauswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.` ); + if (reviewPrefillResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, ` + + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, ` + + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, ` + + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` + ); + } if (playlistDecisionRequired) { const playlistFiles = playlistCandidates.map((item) => item.playlistFile).filter(Boolean); const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId); @@ -6139,11 +6688,6 @@ class PipelineService extends EventEmitter { 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 || []) @@ -6169,19 +6713,24 @@ class PipelineService extends EventEmitter { throw error; } - // Resolve user preset if provided - const rawUserPresetId = options?.selectedUserPresetId ?? null; - const userPresetId = rawUserPresetId !== null && rawUserPresetId !== undefined - ? Number(rawUserPresetId) - : null; + // Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan. + const hasExplicitUserPresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId'); let resolvedUserPreset = null; - if (Number.isFinite(userPresetId) && userPresetId > 0) { - resolvedUserPreset = await userPresetService.getPresetById(userPresetId); - if (!resolvedUserPreset) { - const error = new Error(`User-Preset ${userPresetId} nicht gefunden.`); - error.statusCode = 404; - throw error; + if (hasExplicitUserPresetSelection) { + const rawUserPresetId = options?.selectedUserPresetId; + const userPresetId = rawUserPresetId !== null && rawUserPresetId !== undefined && String(rawUserPresetId).trim() !== '' + ? Number(rawUserPresetId) + : null; + if (Number.isFinite(userPresetId) && userPresetId > 0) { + resolvedUserPreset = await userPresetService.getPresetById(userPresetId); + if (!resolvedUserPreset) { + const error = new Error(`User-Preset ${userPresetId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } } + } else { + resolvedUserPreset = normalizeUserPresetForPlan(encodePlan?.userPreset || null); } const confirmedPlan = { @@ -6200,15 +6749,11 @@ class PipelineService extends EventEmitter { preEncodeChainIds: selectedPreEncodeChainIds, reviewConfirmed: true, reviewConfirmedAt: nowIso(), - userPreset: resolvedUserPreset - ? { - id: resolvedUserPreset.id, - name: resolvedUserPreset.name, - handbrakePreset: resolvedUserPreset.handbrakePreset, - extraArgs: resolvedUserPreset.extraArgs - } - : null + userPreset: normalizeUserPresetForPlan(resolvedUserPreset) }; + const readyMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: confirmedPlan + }); const inputPath = isPreRipMode ? null : (job.encode_input_path || confirmedPlan.encodeInputPath || this.snapshot.context?.inputPath || null); @@ -6231,7 +6776,9 @@ class PipelineService extends EventEmitter { + ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.` + ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` + ` Post-Encode-Ketten: ${selectedPostEncodeChainIds.length > 0 ? selectedPostEncodeChainIds.join(',') : 'none'}.` - + (resolvedUserPreset ? ` User-Preset: "${resolvedUserPreset.name}" (ID ${resolvedUserPreset.id}).` : '') + + (resolvedUserPreset + ? ` User-Preset: "${resolvedUserPreset.name}"${resolvedUserPreset.id ? ` (ID ${resolvedUserPreset.id})` : ''}.` + : '') ); if (!skipPipelineStateUpdate) { @@ -6251,6 +6798,7 @@ class PipelineService extends EventEmitter { jobId, inputPath, hasEncodableTitle, + mediaProfile: readyMediaProfile, mediaInfoReview: confirmedPlan, reviewConfirmed: true } @@ -6666,7 +7214,7 @@ class PipelineService extends EventEmitter { selectedMakemkvTitleId: preferredEncodeTitleId }); - const enrichedReview = { + let enrichedReview = { ...review, mode: options.mode || 'rip', mediaProfile, @@ -6676,6 +7224,13 @@ class PipelineService extends EventEmitter { processedFiles: mediaFiles.length, totalFiles: mediaFiles.length }; + const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( + enrichedReview, + options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null + ); + enrichedReview = reviewPrefillResult.plan; const hasEncodableTitle = Boolean(enrichedReview.encodeInputPath); const titleSelectionRequired = Boolean(enrichedReview.titleSelectionRequired); if (!hasEncodableTitle && !titleSelectionRequired) { @@ -6703,6 +7258,16 @@ class PipelineService extends EventEmitter { 'SYSTEM', `Mediainfo-Prüfung abgeschlossen: ${enrichedReview.titles.length} Titel, Input=${enrichedReview.encodeInputPath || (titleSelectionRequired ? 'Titelauswahl erforderlich' : 'kein passender Titel')}` ); + if (reviewPrefillResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, ` + + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, ` + + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, ` + + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` + ); + } if (this.isPrimaryJob(jobId)) { await this.setState('READY_TO_ENCODE', { @@ -6755,6 +7320,7 @@ class PipelineService extends EventEmitter { try { const chainResult = await scriptChainService.executeChain(chainId, { ...context, + jobId, source: phase === 'pre' ? 'pre_encode_chain' : 'post_encode_chain' }, { appendLog: (src, msg) => historyService.appendLog(jobId, src, msg) @@ -6823,6 +7389,13 @@ class PipelineService extends EventEmitter { break; } await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`); + const activityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'pre_encode', + scriptId: script.id, + jobId, + currentStep: `Pre-Encode ${index + 1}/${scriptIds.length}` + }); let prepared = null; try { prepared = await scriptService.createExecutableScriptFile(script, { @@ -6844,11 +7417,33 @@ class PipelineService extends EventEmitter { }); succeeded += 1; results.push({ scriptId: script.id, scriptName: script.name, status: 'SUCCESS', runInfo }); + const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; + runtimeActivityService.completeActivity(activityId, { + status: 'success', + success: true, + outcome: 'success', + exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null, + message: 'Pre-Encode Skript erfolgreich', + output: runOutput || null + }); await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript erfolgreich: ${script.name}`); if (progressTracker?.onStepComplete) { await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, true); } } catch (error) { + const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null; + const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; + const runStatus = String(runInfo?.status || '').trim().toUpperCase(); + const cancelled = runStatus === 'CANCELLED'; + runtimeActivityService.completeActivity(activityId, { + status: 'error', + success: false, + outcome: cancelled ? 'cancelled' : 'error', + cancelled, + message: error?.message || 'Pre-Encode Skriptfehler', + errorMessage: error?.message || 'Pre-Encode Skriptfehler', + output: runOutput || null + }); failed += 1; aborted = true; results.push({ scriptId: script.id, scriptName: script.name, status: 'ERROR', error: error?.message || 'unknown' }); @@ -6953,6 +7548,13 @@ class PipelineService extends EventEmitter { 'SYSTEM', `Post-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}` ); + const activityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'post_encode', + scriptId: script.id, + jobId, + currentStep: `Post-Encode ${index + 1}/${scriptIds.length}` + }); let prepared = null; try { @@ -6981,6 +7583,15 @@ class PipelineService extends EventEmitter { status: 'SUCCESS', runInfo }); + const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; + runtimeActivityService.completeActivity(activityId, { + status: 'success', + success: true, + outcome: 'success', + exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null, + message: 'Post-Encode Skript erfolgreich', + output: runOutput || null + }); await historyService.appendLog( jobId, 'SYSTEM', @@ -6990,6 +7601,19 @@ class PipelineService extends EventEmitter { await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, true); } } catch (error) { + const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null; + const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; + const runStatus = String(runInfo?.status || '').trim().toUpperCase(); + const cancelled = runStatus === 'CANCELLED'; + runtimeActivityService.completeActivity(activityId, { + status: 'error', + success: false, + outcome: cancelled ? 'cancelled' : 'error', + cancelled, + message: error?.message || 'Post-Encode Skriptfehler', + errorMessage: error?.message || 'Post-Encode Skriptfehler', + output: runOutput || null + }); failed += 1; aborted = true; failedScriptId = Number(script.id); @@ -7942,6 +8566,9 @@ class PipelineService extends EventEmitter { imdbId: job.imdb_id || null, poster: job.poster_url || null }; + const readyMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan + }); await this.setState('READY_TO_ENCODE', { activeJobId: jobId, @@ -7965,6 +8592,7 @@ class PipelineService extends EventEmitter { hasEncodableTitle, reviewConfirmed, mode, + mediaProfile: readyMediaProfile, sourceJobId: encodePlan?.sourceJobId || null, selectedMetadata, mediaInfoReview: encodePlan @@ -7983,16 +8611,15 @@ class PipelineService extends EventEmitter { async restartEncodeWithLastSettings(jobId, options = {}) { const immediate = Boolean(options?.immediate); if (!immediate) { - return this.enqueueOrStartAction( - QUEUE_ACTIONS.RESTART_ENCODE, - jobId, - () => this.restartEncodeWithLastSettings(jobId, { ...options, immediate: true }) - ); + // Restart-Encode now prepares an editable READY_TO_ENCODE state first. + // No queue slot is needed because encoding is not started automatically here. + return this.restartEncodeWithLastSettings(jobId, { ...options, immediate: true }); } this.ensureNotBusy('restartEncodeWithLastSettings', jobId); logger.info('restartEncodeWithLastSettings:requested', { jobId }); this.cancelRequestedByJob.delete(Number(jobId)); + const triggerReason = String(options?.triggerReason || 'manual').trim().toLowerCase(); const job = await historyService.getJobById(jobId); if (!job) { @@ -8058,26 +8685,86 @@ class PipelineService extends EventEmitter { } } + const restartPlan = { + ...encodePlan, + reviewConfirmed: false, + reviewConfirmedAt: null, + prefilledFromPreviousRun: true, + prefilledFromPreviousRunAt: nowIso() + }; + const selectedMetadata = { + title: job.title || job.detected_title || null, + year: job.year || null, + imdbId: job.imdb_id || null, + poster: job.poster_url || null + }; + const readyMediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan: restartPlan + }); + const inputPath = isPreRipMode + ? null + : (job.encode_input_path || restartPlan.encodeInputPath || null); + const hasEncodableTitle = isPreRipMode + ? Boolean(restartPlan?.encodeInputTitleId) + : Boolean(inputPath); + await historyService.updateJob(jobId, { status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, end_time: null, output_path: null, - handbrake_info_json: null + handbrake_info_json: null, + encode_plan_json: JSON.stringify(restartPlan), + encode_input_path: inputPath, + encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'USER_ACTION', - previousOutputPath - ? `Encode-Neustart angefordert. Letzte bestätigte Auswahl wird verwendet. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${restartDeleteIncompleteOutput ? 'on' : 'off'}` - : 'Encode-Neustart angefordert. Letzte bestätigte Auswahl wird verwendet.' + triggerReason === 'cancelled_encode' + ? ( + previousOutputPath + ? `Encode wurde abgebrochen. Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${restartDeleteIncompleteOutput ? 'on' : 'off'}` + : 'Encode wurde abgebrochen. Letzte bestätigte Auswahl wurde geladen und kann angepasst werden.' + ) + : ( + previousOutputPath + ? `Encode-Neustart angefordert. Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${restartDeleteIncompleteOutput ? 'on' : 'off'}` + : 'Encode-Neustart angefordert. Letzte bestätigte Auswahl wurde geladen und kann angepasst werden.' + ) ); - const result = await this.startPreparedJob(jobId, { immediate: true }); + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: hasEncodableTitle + ? (isPreRipMode + ? 'Vorherige Spurauswahl geladen - anpassen und Backup/Rip + Encode starten' + : 'Vorherige Encode-Auswahl geladen - anpassen und Encoding starten') + : (isPreRipMode + ? 'Vorherige Spurauswahl geladen - kein passender Titel gewählt' + : 'Vorherige Encode-Auswahl geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'), + context: { + ...(this.snapshot.context || {}), + jobId, + inputPath, + hasEncodableTitle, + reviewConfirmed: false, + mode, + mediaProfile: readyMediaProfile, + sourceJobId: restartPlan?.sourceJobId || null, + selectedMetadata, + mediaInfoReview: restartPlan + } + }); + return { restarted: true, - ...result + started: false, + stage: 'READY_TO_ENCODE', + reviewConfirmed: false }; } @@ -8144,6 +8831,7 @@ class PipelineService extends EventEmitter { await historyService.resetProcessLog(jobId); const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection); + const previousEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); const nextMakemkvInfoJson = mkInfo && typeof mkInfo === 'object' ? JSON.stringify({ @@ -8201,7 +8889,8 @@ class PipelineService extends EventEmitter { mode: options?.mode || 'reencode', sourceJobId: jobId, forcePlaylistReselection, - forceFreshAnalyze: true + forceFreshAnalyze: true, + previousEncodePlan }).catch((error) => { logger.error('restartReviewFromRaw:background-failed', { jobId, error: errorToMeta(error) }); this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => { @@ -8472,6 +9161,7 @@ class PipelineService extends EventEmitter { const message = error?.message || String(error); const isCancelled = /abgebrochen|cancelled/i.test(message) || String(error?.runInfo?.status || '').trim().toUpperCase() === 'CANCELLED'; + const normalizedStage = String(stage || '').trim().toUpperCase(); const job = await historyService.getJobById(jobId); const title = job?.title || job?.detected_title || `Job #${jobId}`; const finalState = isCancelled ? 'CANCELLED' : 'ERROR'; @@ -8500,6 +9190,33 @@ class PipelineService extends EventEmitter { hasRawPath = false; } + if (isCancelled && normalizedStage === 'ENCODING' && hasConfirmedPlan) { + try { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Abbruch in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.` + ); + await this.restartEncodeWithLastSettings(jobId, { + immediate: true, + triggerReason: 'cancelled_encode' + }); + this.cancelRequestedByJob.delete(Number(jobId)); + return; + } catch (recoveryError) { + logger.error('job:cancelled-encode:auto-recover-failed', { + jobId, + stage, + error: errorToMeta(recoveryError) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Auto-Recovery nach Encode-Abbruch fehlgeschlagen: ${recoveryError?.message || 'unknown'}` + ); + } + } + await historyService.updateJob(jobId, { status: finalState, last_state: finalState, diff --git a/backend/src/services/runtimeActivityService.js b/backend/src/services/runtimeActivityService.js new file mode 100644 index 0000000..6e274b0 --- /dev/null +++ b/backend/src/services/runtimeActivityService.js @@ -0,0 +1,248 @@ +const wsService = require('./websocketService'); + +const MAX_RECENT_ACTIVITIES = 120; +const MAX_ACTIVITY_OUTPUT_CHARS = 12000; +const MAX_ACTIVITY_TEXT_CHARS = 2000; + +function nowIso() { + return new Date().toISOString(); +} + +function normalizeNumber(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeText(value, { trim = true, maxChars = MAX_ACTIVITY_TEXT_CHARS } = {}) { + if (value === null || value === undefined) { + return null; + } + let text = String(value); + if (trim) { + text = text.trim(); + } + if (!text) { + return null; + } + if (text.length > maxChars) { + const suffix = trim ? ' ...[gekürzt]' : '\n...[gekürzt]'; + text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`; + } + return text; +} + +function sanitizeActivity(input = {}) { + const source = input && typeof input === 'object' ? input : {}; + const normalizedOutcome = normalizeText(source.outcome, { trim: true, maxChars: 40 }); + return { + id: normalizeNumber(source.id), + type: String(source.type || '').trim().toLowerCase() || 'task', + name: String(source.name || '').trim() || null, + status: String(source.status || '').trim().toLowerCase() || 'running', + source: String(source.source || '').trim() || null, + message: String(source.message || '').trim() || null, + currentStep: String(source.currentStep || '').trim() || null, + currentStepType: String(source.currentStepType || '').trim() || null, + currentScriptName: String(source.currentScriptName || '').trim() || null, + stepIndex: normalizeNumber(source.stepIndex), + stepTotal: normalizeNumber(source.stepTotal), + parentActivityId: normalizeNumber(source.parentActivityId), + jobId: normalizeNumber(source.jobId), + cronJobId: normalizeNumber(source.cronJobId), + chainId: normalizeNumber(source.chainId), + scriptId: normalizeNumber(source.scriptId), + canCancel: Boolean(source.canCancel), + canNextStep: Boolean(source.canNextStep), + outcome: normalizedOutcome ? String(normalizedOutcome).toLowerCase() : null, + errorMessage: normalizeText(source.errorMessage, { trim: true, maxChars: MAX_ACTIVITY_TEXT_CHARS }), + output: normalizeText(source.output, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }), + stdout: normalizeText(source.stdout, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }), + stderr: normalizeText(source.stderr, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }), + stdoutTruncated: Boolean(source.stdoutTruncated), + stderrTruncated: Boolean(source.stderrTruncated), + startedAt: source.startedAt || nowIso(), + finishedAt: source.finishedAt || null, + durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null, + exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null, + success: source.success === null || source.success === undefined ? null : Boolean(source.success) + }; +} + +class RuntimeActivityService { + constructor() { + this.nextId = 1; + this.active = new Map(); + this.recent = []; + this.controls = new Map(); + } + + buildSnapshot() { + const active = Array.from(this.active.values()) + .sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || ''))); + const recent = [...this.recent] + .sort((a, b) => String(b.finishedAt || b.startedAt || '').localeCompare(String(a.finishedAt || a.startedAt || ''))); + return { + active, + recent, + updatedAt: nowIso() + }; + } + + broadcastSnapshot() { + wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot()); + } + + startActivity(type, payload = {}) { + const id = this.nextId; + this.nextId += 1; + const activity = sanitizeActivity({ + ...payload, + id, + type, + status: 'running', + outcome: 'running', + startedAt: payload?.startedAt || nowIso(), + finishedAt: null, + durationMs: null, + canCancel: Boolean(payload?.canCancel), + canNextStep: Boolean(payload?.canNextStep) + }); + this.active.set(id, activity); + this.broadcastSnapshot(); + return id; + } + + updateActivity(activityId, patch = {}) { + const id = normalizeNumber(activityId); + if (!id || !this.active.has(id)) { + return null; + } + const current = this.active.get(id); + const next = sanitizeActivity({ + ...current, + ...patch, + id: current.id, + type: current.type, + status: current.status === 'running' ? (patch?.status || current.status) : current.status, + startedAt: current.startedAt + }); + this.active.set(id, next); + this.broadcastSnapshot(); + return next; + } + + completeActivity(activityId, payload = {}) { + const id = normalizeNumber(activityId); + if (!id || !this.active.has(id)) { + return null; + } + const current = this.active.get(id); + const finishedAt = payload?.finishedAt || nowIso(); + const startedAtDate = new Date(current.startedAt); + const finishedAtDate = new Date(finishedAt); + const durationMs = Number.isFinite(startedAtDate.getTime()) && Number.isFinite(finishedAtDate.getTime()) + ? Math.max(0, finishedAtDate.getTime() - startedAtDate.getTime()) + : null; + const status = String(payload?.status || '').trim().toLowerCase() || (payload?.success === false ? 'error' : 'success'); + let outcome = String(payload?.outcome || '').trim().toLowerCase(); + if (!outcome) { + if (Boolean(payload?.cancelled)) { + outcome = 'cancelled'; + } else if (Boolean(payload?.skipped)) { + outcome = 'skipped'; + } else { + outcome = status === 'success' ? 'success' : 'error'; + } + } + const finalized = sanitizeActivity({ + ...current, + ...payload, + id: current.id, + type: current.type, + status, + outcome, + canCancel: false, + canNextStep: false, + finishedAt, + durationMs + }); + this.active.delete(id); + this.controls.delete(id); + this.recent.unshift(finalized); + if (this.recent.length > MAX_RECENT_ACTIVITIES) { + this.recent = this.recent.slice(0, MAX_RECENT_ACTIVITIES); + } + this.broadcastSnapshot(); + return finalized; + } + + getSnapshot() { + return this.buildSnapshot(); + } + + setControls(activityId, handlers = {}) { + const id = normalizeNumber(activityId); + if (!id || !this.active.has(id)) { + return null; + } + const safeHandlers = { + cancel: typeof handlers?.cancel === 'function' ? handlers.cancel : null, + nextStep: typeof handlers?.nextStep === 'function' ? handlers.nextStep : null + }; + this.controls.set(id, safeHandlers); + return this.updateActivity(id, { + canCancel: Boolean(safeHandlers.cancel), + canNextStep: Boolean(safeHandlers.nextStep) + }); + } + + async invokeControl(activityId, control, payload = {}) { + const id = normalizeNumber(activityId); + if (!id || !this.active.has(id)) { + return { + ok: false, + code: 'NOT_FOUND', + message: 'Aktivität nicht gefunden oder bereits abgeschlossen.' + }; + } + const handlers = this.controls.get(id) || {}; + const key = control === 'nextStep' ? 'nextStep' : 'cancel'; + const fn = handlers[key]; + if (typeof fn !== 'function') { + return { + ok: false, + code: 'UNSUPPORTED', + message: key === 'nextStep' + ? 'Nächster-Schritt ist für diese Aktivität nicht verfügbar.' + : 'Abbrechen ist für diese Aktivität nicht verfügbar.' + }; + } + try { + const result = await fn(payload); + return { + ok: true, + code: 'OK', + result: result && typeof result === 'object' ? result : null + }; + } catch (error) { + return { + ok: false, + code: 'FAILED', + message: error?.message || 'Aktion fehlgeschlagen.' + }; + } + } + + async requestCancel(activityId, payload = {}) { + return this.invokeControl(activityId, 'cancel', payload); + } + + async requestNextStep(activityId, payload = {}) { + return this.invokeControl(activityId, 'nextStep', payload); + } +} + +module.exports = new RuntimeActivityService(); diff --git a/backend/src/services/scriptChainService.js b/backend/src/services/scriptChainService.js index 6f4dba1..5bb09a4 100644 --- a/backend/src/services/scriptChainService.js +++ b/backend/src/services/scriptChainService.js @@ -1,6 +1,7 @@ const { spawn } = require('child_process'); const { getDb } = require('../db/database'); const logger = require('./logger').child('SCRIPT_CHAINS'); +const runtimeActivityService = require('./runtimeActivityService'); const { errorToMeta } = require('../utils/errorMeta'); const CHAIN_NAME_MAX_LENGTH = 120; @@ -53,6 +54,29 @@ function mapStepRow(row) { }; } +function terminateChildProcess(child) { + if (!child) { + return; + } + try { + child.kill('SIGTERM'); + } catch (_error) { + return; + } + const forceKillTimer = setTimeout(() => { + try { + if (!child.killed) { + child.kill('SIGKILL'); + } + } catch (_error) { + // ignore + } + }, 2000); + if (typeof forceKillTimer.unref === 'function') { + forceKillTimer.unref(); + } +} + function validateSteps(rawSteps) { const steps = Array.isArray(rawSteps) ? rawSteps : []; const errors = []; @@ -382,102 +406,460 @@ class ScriptChainService { async executeChain(chainId, context = {}, { appendLog = null } = {}) { const chain = await this.getChainById(chainId); logger.info('chain:execute:start', { chainId, chainName: chain.name, steps: chain.steps.length }); + const totalSteps = chain.steps.length; + const activityId = runtimeActivityService.startActivity('chain', { + name: chain.name, + source: context?.source || 'chain', + chainId: chain.id, + jobId: context?.jobId || null, + cronJobId: context?.cronJobId || null, + parentActivityId: context?.runtimeParentActivityId || null, + currentStep: totalSteps > 0 ? `Schritt 1/${totalSteps}` : 'Keine Schritte' + }); + const controlState = { + cancelRequested: false, + cancelReason: null, + currentStepType: null, + activeWaitResolve: null, + activeChild: null, + activeChildTermination: null + }; + const emitRuntimeStep = (payload = {}) => { + if (typeof context?.onRuntimeStep !== 'function') { + return; + } + try { + context.onRuntimeStep({ + chainId: chain.id, + chainName: chain.name, + ...payload + }); + } catch (_error) { + // ignore runtime callback errors + } + }; + const requestCancel = async (payload = {}) => { + if (controlState.cancelRequested) { + return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' }; + } + controlState.cancelRequested = true; + controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen'; + runtimeActivityService.updateActivity(activityId, { + message: 'Abbruch angefordert', + currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`); + } catch (_error) { + // ignore appendLog failures for control actions + } + } + if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { + controlState.activeWaitResolve('cancel'); + } else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { + controlState.activeChildTermination = 'cancel'; + terminateChildProcess(controlState.activeChild); + } + return { accepted: true, message: 'Abbruch angefordert.' }; + }; + const requestNextStep = async () => { + if (controlState.cancelRequested) { + return { accepted: false, message: 'Kette wird bereits abgebrochen.' }; + } + if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { + controlState.activeWaitResolve('skip'); + runtimeActivityService.updateActivity(activityId, { + message: 'Nächster Schritt angefordert (Wait übersprungen)' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Wait-Schritt manuell übersprungen.`); + } catch (_error) { + // ignore appendLog failures for control actions + } + } + return { accepted: true, message: 'Wait-Schritt übersprungen.' }; + } + if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { + controlState.activeChildTermination = 'skip'; + terminateChildProcess(controlState.activeChild); + runtimeActivityService.updateActivity(activityId, { + message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript-Schritt manuell übersprungen.`); + } catch (_error) { + // ignore appendLog failures for control actions + } + } + return { accepted: true, message: 'Skript-Schritt wird übersprungen.' }; + } + return { accepted: false, message: 'Kein aktiver Schritt zum Überspringen.' }; + }; + runtimeActivityService.setControls(activityId, { + cancel: requestCancel, + nextStep: requestNextStep + }); const results = []; - - for (const step of chain.steps) { - if (step.stepType === STEP_TYPE_WAIT) { - const seconds = Math.max(1, Number(step.waitSeconds || 1)); - logger.info('chain:step:wait', { chainId, seconds }); - if (typeof appendLog === 'function') { - await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`); + let completionPayload = null; + let abortedByUser = false; + try { + for (let index = 0; index < chain.steps.length; index += 1) { + if (controlState.cancelRequested) { + abortedByUser = true; + break; } - await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); - results.push({ stepType: 'wait', waitSeconds: seconds, success: true }); - } else if (step.stepType === STEP_TYPE_SCRIPT) { - if (!step.scriptId) { - logger.warn('chain:step:script-missing', { chainId, stepId: step.id }); - results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' }); - continue; - } - - const scriptService = require('./scriptService'); - let script; - try { - script = await scriptService.getScriptById(step.scriptId); - } catch (error) { - logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); - results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' }); - continue; - } - - if (typeof appendLog === 'function') { - await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`); - } - - let prepared = null; - try { - prepared = await scriptService.createExecutableScriptFile(script, { - ...context, - scriptId: script.id, - scriptName: script.name, - source: context?.source || 'chain' + const step = chain.steps[index]; + const stepIndex = index + 1; + if (step.stepType === STEP_TYPE_WAIT) { + const seconds = Math.max(1, Number(step.waitSeconds || 1)); + const waitLabel = `Warte ${seconds} Sekunde(n)`; + controlState.currentStepType = STEP_TYPE_WAIT; + runtimeActivityService.updateActivity(activityId, { + currentStepType: 'wait', + currentStep: waitLabel, + currentScriptName: null, + stepIndex, + stepTotal: totalSteps }); - const run = await new Promise((resolve, reject) => { - const child = spawn(prepared.cmd, prepared.args, { - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'] - }); - let stdout = ''; - let stderr = ''; - child.stdout?.on('data', (chunk) => { stdout += String(chunk); }); - child.stderr?.on('data', (chunk) => { stderr += String(chunk); }); - child.on('error', reject); - child.on('close', (code) => resolve({ code, stdout, stderr })); + emitRuntimeStep({ + stepType: 'wait', + stepIndex, + stepTotal: totalSteps, + currentStep: waitLabel }); - - const success = run.code === 0; - logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success }); + logger.info('chain:step:wait', { chainId, seconds }); if (typeof appendLog === 'function') { - await appendLog( - success ? 'SYSTEM' : 'ERROR', - `Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}` - ); + await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`); } - results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' }); - - if (!success) { - logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code }); + const waitOutcome = await new Promise((resolve) => { + const timer = setTimeout(() => { + controlState.activeWaitResolve = null; + resolve('done'); + }, seconds * 1000); + controlState.activeWaitResolve = (mode = 'done') => { + clearTimeout(timer); + controlState.activeWaitResolve = null; + resolve(mode); + }; + }); + controlState.currentStepType = null; + if (waitOutcome === 'skip') { + results.push({ stepType: 'wait', waitSeconds: seconds, success: true, skipped: true, reason: 'skipped_by_user' }); + continue; + } + if (waitOutcome === 'cancel' || controlState.cancelRequested) { + abortedByUser = true; + results.push({ stepType: 'wait', waitSeconds: seconds, success: false, aborted: true, reason: 'cancelled_by_user' }); break; } - } catch (error) { - logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); - if (typeof appendLog === 'function') { - await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`); + results.push({ stepType: 'wait', waitSeconds: seconds, success: true }); + } else if (step.stepType === STEP_TYPE_SCRIPT) { + if (!step.scriptId) { + logger.warn('chain:step:script-missing', { chainId, stepId: step.id }); + results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' }); + continue; } - results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message }); - break; - } finally { - if (prepared?.cleanup) { - await prepared.cleanup(); + + const scriptService = require('./scriptService'); + let script; + try { + script = await scriptService.getScriptById(step.scriptId); + } catch (error) { + logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); + results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' }); + continue; + } + + controlState.currentStepType = STEP_TYPE_SCRIPT; + runtimeActivityService.updateActivity(activityId, { + currentStepType: 'script', + currentStep: `Skript: ${script.name}`, + currentScriptName: script.name, + stepIndex, + stepTotal: totalSteps, + scriptId: script.id + }); + emitRuntimeStep({ + stepType: 'script', + stepIndex, + stepTotal: totalSteps, + scriptId: script.id, + scriptName: script.name, + currentScriptName: script.name, + currentStep: `Skript: ${script.name}` + }); + + if (typeof appendLog === 'function') { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`); + } + + const scriptActivityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: context?.source || 'chain', + scriptId: script.id, + chainId: chain.id, + jobId: context?.jobId || null, + cronJobId: context?.cronJobId || null, + parentActivityId: activityId, + currentStep: `Kette: ${chain.name}` + }); + + let prepared = null; + try { + prepared = await scriptService.createExecutableScriptFile(script, { + ...context, + scriptId: script.id, + scriptName: script.name, + source: context?.source || 'chain' + }); + const run = await new Promise((resolve, reject) => { + const child = spawn(prepared.cmd, prepared.args, { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + controlState.activeChild = child; + controlState.activeChildTermination = null; + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { stdout += String(chunk); }); + child.stderr?.on('data', (chunk) => { stderr += String(chunk); }); + child.on('error', (error) => { + controlState.activeChild = null; + reject(error); + }); + child.on('close', (code, signal) => { + const termination = controlState.activeChildTermination; + controlState.activeChild = null; + controlState.activeChildTermination = null; + resolve({ code, signal, stdout, stderr, termination }); + }); + }); + controlState.currentStepType = null; + + if (run.termination === 'skip') { + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'success', + success: true, + outcome: 'skipped', + skipped: true, + currentStep: null, + message: 'Schritt übersprungen', + output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" übersprungen.`); + } catch (_error) { + // ignore appendLog failures on skip path + } + } + results.push({ + stepType: 'script', + scriptId: script.id, + scriptName: script.name, + success: true, + skipped: true, + reason: 'skipped_by_user' + }); + continue; + } + + if (run.termination === 'cancel' || controlState.cancelRequested) { + abortedByUser = true; + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'cancelled', + cancelled: true, + currentStep: null, + message: controlState.cancelReason || 'Von Benutzer abgebrochen', + output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, + errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`); + } catch (_error) { + // ignore appendLog failures on cancel path + } + } + results.push({ + stepType: 'script', + scriptId: script.id, + scriptName: script.name, + success: false, + aborted: true, + reason: 'cancelled_by_user' + }); + break; + } + + const success = run.code === 0; + runtimeActivityService.completeActivity(scriptActivityId, { + status: success ? 'success' : 'error', + success, + outcome: success ? 'success' : 'error', + exitCode: run.code, + currentStep: null, + message: success ? null : `Fehler (Exit ${run.code})`, + output: success ? null : [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null, + stderr: success ? null : (run.stderr || null), + stdout: success ? null : (run.stdout || null), + errorMessage: success ? null : `Fehler (Exit ${run.code})` + }); + logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success }); + if (typeof appendLog === 'function') { + await appendLog( + success ? 'SYSTEM' : 'ERROR', + `Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}` + ); + } + results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' }); + + if (!success) { + logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code }); + break; + } + } catch (error) { + controlState.currentStepType = null; + if (controlState.cancelRequested) { + abortedByUser = true; + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'cancelled', + cancelled: true, + message: controlState.cancelReason || 'Von Benutzer abgebrochen', + errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' + }); + if (typeof appendLog === 'function') { + try { + await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`); + } catch (_error) { + // ignore appendLog failures on cancel path + } + } + results.push({ + stepType: 'script', + scriptId: script.id, + scriptName: script.name, + success: false, + aborted: true, + reason: 'cancelled_by_user' + }); + break; + } + runtimeActivityService.completeActivity(scriptActivityId, { + status: 'error', + success: false, + outcome: 'error', + message: error?.message || 'unknown', + errorMessage: error?.message || 'unknown' + }); + logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) }); + if (typeof appendLog === 'function') { + await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`); + } + results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message }); + break; + } finally { + controlState.activeChild = null; + controlState.activeChildTermination = null; + if (prepared?.cleanup) { + await prepared.cleanup(); + } } } } + + const succeeded = results.filter((r) => r.success).length; + const skipped = results.filter((r) => r.skipped).length; + const failed = results.filter((r) => !r.success && !r.skipped && !r.aborted).length; + logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed, skipped, abortedByUser }); + if (abortedByUser) { + completionPayload = { + status: 'error', + success: false, + outcome: 'cancelled', + cancelled: true, + currentStep: null, + currentScriptName: null, + message: controlState.cancelReason || 'Von Benutzer abgebrochen', + errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen' + }; + emitRuntimeStep({ + finished: true, + success: false, + aborted: true, + failed, + succeeded + }); + return { + chainId, + chainName: chain.name, + steps: results.length, + succeeded, + failed, + skipped, + aborted: true, + abortedByUser: true, + results + }; + } + completionPayload = { + status: failed > 0 ? 'error' : 'success', + success: failed === 0, + outcome: failed > 0 ? 'error' : (skipped > 0 ? 'skipped' : 'success'), + skipped: skipped > 0, + currentStep: null, + currentScriptName: null, + message: failed > 0 + ? `${failed} Schritt(e) fehlgeschlagen` + : (skipped > 0 + ? `${succeeded} Schritt(e) erfolgreich, ${skipped} übersprungen` + : `${succeeded} Schritt(e) erfolgreich`) + }; + emitRuntimeStep({ + finished: true, + success: failed === 0, + failed, + succeeded + }); + + return { + chainId, + chainName: chain.name, + steps: results.length, + succeeded, + failed, + skipped, + aborted: failed > 0, + results + }; + } catch (error) { + completionPayload = { + status: 'error', + success: false, + outcome: 'error', + message: error?.message || 'unknown', + errorMessage: error?.message || 'unknown', + currentStep: null + }; + throw error; + } finally { + runtimeActivityService.completeActivity(activityId, completionPayload || { + status: 'error', + success: false, + outcome: 'error', + message: 'Kette unerwartet beendet', + errorMessage: 'Kette unerwartet beendet', + currentStep: null + }); } - - const succeeded = results.filter((r) => r.success).length; - const failed = results.filter((r) => !r.success && !r.skipped).length; - logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed }); - - return { - chainId, - chainName: chain.name, - steps: results.length, - succeeded, - failed, - aborted: failed > 0, - results - }; } } diff --git a/backend/src/services/scriptService.js b/backend/src/services/scriptService.js index d41d7e6..4904d68 100644 --- a/backend/src/services/scriptService.js +++ b/backend/src/services/scriptService.js @@ -4,6 +4,7 @@ const path = require('path'); const { spawn } = require('child_process'); const { getDb } = require('../db/database'); const logger = require('./logger').child('SCRIPTS'); +const runtimeActivityService = require('./runtimeActivityService'); const { errorToMeta } = require('../utils/errorMeta'); const SCRIPT_NAME_MAX_LENGTH = 120; @@ -159,7 +160,7 @@ function appendWithCap(current, chunk, maxChars) { }; } -function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd() }) { +function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) { return new Promise((resolve, reject) => { const startedAt = Date.now(); const child = spawn(cmd, args, { @@ -167,6 +168,13 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); + if (typeof onChild === 'function') { + try { + onChild(child); + } catch (_error) { + // ignore observer errors + } + } let stdout = ''; let stderr = ''; @@ -473,18 +481,89 @@ class ScriptService { async testScript(scriptId, options = {}) { const script = await this.getScriptById(scriptId); const timeoutMs = Number(options?.timeoutMs); + const effectiveTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS; const prepared = await this.createExecutableScriptFile(script, { source: 'settings_test', mode: 'test' }); + const activityId = runtimeActivityService.startActivity('script', { + name: script.name, + source: 'settings_test', + scriptId: script.id, + currentStep: 'Skript-Test läuft' + }); + const controlState = { + cancelRequested: false, + cancelReason: null, + child: null + }; + runtimeActivityService.setControls(activityId, { + cancel: async (payload = {}) => { + if (controlState.cancelRequested) { + return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' }; + } + controlState.cancelRequested = true; + controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen'; + runtimeActivityService.updateActivity(activityId, { + message: 'Abbruch angefordert' + }); + if (controlState.child) { + try { + controlState.child.kill('SIGTERM'); + } catch (_error) { + // ignore + } + const forceKillTimer = setTimeout(() => { + try { + if (controlState.child && !controlState.child.killed) { + controlState.child.kill('SIGKILL'); + } + } catch (_error) { + // ignore + } + }, 2000); + if (typeof forceKillTimer.unref === 'function') { + forceKillTimer.unref(); + } + } + return { accepted: true, message: 'Abbruch angefordert.' }; + } + }); try { const run = await runProcessCapture({ cmd: prepared.cmd, args: prepared.args, - timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS + timeoutMs: effectiveTimeoutMs, + onChild: (child) => { + controlState.child = child; + } + }); + const cancelledByUser = controlState.cancelRequested; + const success = !cancelledByUser && run.code === 0 && !run.timedOut; + runtimeActivityService.completeActivity(activityId, { + status: success ? 'success' : 'error', + success, + outcome: cancelledByUser ? 'cancelled' : (success ? 'success' : 'error'), + cancelled: cancelledByUser, + exitCode: Number.isFinite(Number(run.code)) ? Number(run.code) : null, + stdout: run.stdout || null, + stderr: run.stderr || null, + stdoutTruncated: Boolean(run.stdoutTruncated), + stderrTruncated: Boolean(run.stderrTruncated), + errorMessage: !success + ? (cancelledByUser + ? (controlState.cancelReason || 'Von Benutzer abgebrochen') + : (run.timedOut + ? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s` + : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`)) + : null, + message: cancelledByUser + ? (controlState.cancelReason || 'Von Benutzer abgebrochen') + : (run.timedOut + ? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s` + : (success ? 'Skript-Test abgeschlossen' : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`)) }); - const success = run.code === 0 && !run.timedOut; return { scriptId: script.id, scriptName: script.name, @@ -498,7 +577,22 @@ class ScriptService { stdoutTruncated: run.stdoutTruncated, stderrTruncated: run.stderrTruncated }; + } catch (error) { + runtimeActivityService.completeActivity(activityId, { + status: 'error', + success: false, + outcome: controlState.cancelRequested ? 'cancelled' : 'error', + cancelled: Boolean(controlState.cancelRequested), + errorMessage: controlState.cancelRequested + ? (controlState.cancelReason || 'Von Benutzer abgebrochen') + : (error?.message || 'Skript-Test Fehler'), + message: controlState.cancelRequested + ? (controlState.cancelReason || 'Von Benutzer abgebrochen') + : (error?.message || 'Skript-Test Fehler') + }); + throw error; } finally { + controlState.child = null; await prepared.cleanup(); } } diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 503abcc..91b9869 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -1,7 +1,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { spawnSync } = require('child_process'); +const { spawn, spawnSync } = require('child_process'); const { getDb } = require('../db/database'); const logger = require('./logger').child('SETTINGS'); const { @@ -15,6 +15,14 @@ const { setLogRootDir } = require('./logPathService'); const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac']; const HANDBRAKE_PRESET_LIST_TIMEOUT_MS = 30000; +const SETTINGS_CACHE_TTL_MS = 15000; +const HANDBRAKE_PRESET_CACHE_TTL_MS = 5 * 60 * 1000; +const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([ + 'handbrake_command', + 'handbrake_preset', + 'handbrake_preset_bluray', + 'handbrake_preset_dvd' +]); const SENSITIVE_SETTING_KEYS = new Set([ 'makemkv_registration_key', 'omdb_api_key', @@ -230,6 +238,92 @@ function uniqueOrderedValues(values) { return unique; } +function normalizeSettingKey(value) { + return String(value || '').trim().toLowerCase(); +} + +function runCommandCapture(cmd, args = [], options = {}) { + const timeoutMs = Math.max(0, Number(options.timeout || 0)); + const maxBuffer = Math.max(1024, Number(options.maxBuffer || 8 * 1024 * 1024)); + const argv = Array.isArray(args) ? args : []; + + return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + let timer = null; + let stdout = ''; + let stderr = ''; + let totalBytes = 0; + + const finish = (handler, payload) => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + handler(payload); + }; + + const child = spawn(cmd, argv, { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + const appendChunk = (chunk, target) => { + if (settled) { + return; + } + const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8'); + totalBytes += Buffer.byteLength(text, 'utf-8'); + if (totalBytes > maxBuffer) { + try { + child.kill('SIGKILL'); + } catch (_error) { + // ignore kill errors + } + finish(reject, new Error(`Command output exceeded ${maxBuffer} bytes.`)); + return; + } + if (target === 'stdout') { + stdout += text; + } else { + stderr += text; + } + }; + + child.on('error', (error) => finish(reject, error)); + child.on('close', (status, signal) => { + finish(resolve, { + status, + signal, + timedOut, + stdout, + stderr + }); + }); + + if (child.stdout) { + child.stdout.on('data', (chunk) => appendChunk(chunk, 'stdout')); + } + if (child.stderr) { + child.stderr.on('data', (chunk) => appendChunk(chunk, 'stderr')); + } + + if (timeoutMs > 0) { + timer = setTimeout(() => { + timedOut = true; + try { + child.kill('SIGKILL'); + } catch (_error) { + // ignore kill errors + } + }, timeoutMs); + } + }); +} + function uniquePresetEntries(entries) { const unique = []; const seenNames = new Set(); @@ -466,20 +560,112 @@ function mapPresetEntriesToOptions(entries) { } class SettingsService { + constructor() { + this.settingsSnapshotCache = { + expiresAt: 0, + snapshot: null, + inFlight: null + }; + this.handBrakePresetCache = { + expiresAt: 0, + cacheKey: null, + payload: null, + inFlight: null + }; + } + + buildSettingsSnapshot(flat = []) { + const list = Array.isArray(flat) ? flat : []; + const map = {}; + const byCategory = new Map(); + + for (const item of list) { + map[item.key] = item.value; + if (!byCategory.has(item.category)) { + byCategory.set(item.category, []); + } + byCategory.get(item.category).push(item); + } + + return { + flat: list, + map, + categorized: Array.from(byCategory.entries()).map(([category, settings]) => ({ + category, + settings + })) + }; + } + + invalidateHandBrakePresetCache() { + this.handBrakePresetCache = { + expiresAt: 0, + cacheKey: null, + payload: null, + inFlight: null + }; + } + + invalidateSettingsCache(changedKeys = []) { + this.settingsSnapshotCache = { + expiresAt: 0, + snapshot: null, + inFlight: null + }; + const normalizedKeys = Array.isArray(changedKeys) + ? changedKeys.map((key) => normalizeSettingKey(key)).filter(Boolean) + : []; + const shouldInvalidatePresets = normalizedKeys.some((key) => HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS.has(key)); + if (shouldInvalidatePresets) { + this.invalidateHandBrakePresetCache(); + } + } + + buildHandBrakePresetCacheKey(map = {}) { + const source = map && typeof map === 'object' ? map : {}; + return JSON.stringify({ + cmd: String(source.handbrake_command || 'HandBrakeCLI').trim(), + bluray: String(source.handbrake_preset_bluray || '').trim(), + dvd: String(source.handbrake_preset_dvd || '').trim(), + fallback: String(source.handbrake_preset || '').trim() + }); + } + + async getSettingsSnapshot(options = {}) { + const forceRefresh = Boolean(options?.forceRefresh); + const now = Date.now(); + + if (!forceRefresh && this.settingsSnapshotCache.snapshot && this.settingsSnapshotCache.expiresAt > now) { + return this.settingsSnapshotCache.snapshot; + } + if (!forceRefresh && this.settingsSnapshotCache.inFlight) { + return this.settingsSnapshotCache.inFlight; + } + + let loadPromise = null; + loadPromise = (async () => { + const flat = await this.fetchFlatSettingsFromDb(); + const snapshot = this.buildSettingsSnapshot(flat); + this.settingsSnapshotCache.snapshot = snapshot; + this.settingsSnapshotCache.expiresAt = Date.now() + SETTINGS_CACHE_TTL_MS; + return snapshot; + })().finally(() => { + if (this.settingsSnapshotCache.inFlight === loadPromise) { + this.settingsSnapshotCache.inFlight = null; + } + }); + this.settingsSnapshotCache.inFlight = loadPromise; + return loadPromise; + } + async getSchemaRows() { const db = await getDb(); return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC'); } - async getSettingsMap() { - const rows = await this.getFlatSettings(); - const map = {}; - - for (const row of rows) { - map[row.key] = row.value; - } - - return map; + async getSettingsMap(options = {}) { + const snapshot = await this.getSettingsSnapshot(options); + return { ...(snapshot?.map || {}) }; } normalizeMediaProfile(value) { @@ -530,7 +716,7 @@ class SettingsService { return this.resolveEffectiveToolSettings(map, mediaProfile); } - async getFlatSettings() { + async fetchFlatSettingsFromDb() { const db = await getDb(); const rows = await db.all( ` @@ -567,21 +753,14 @@ class SettingsService { })); } - async getCategorizedSettings() { - const flat = await this.getFlatSettings(); - const byCategory = new Map(); + async getFlatSettings(options = {}) { + const snapshot = await this.getSettingsSnapshot(options); + return Array.isArray(snapshot?.flat) ? [...snapshot.flat] : []; + } - for (const item of flat) { - if (!byCategory.has(item.category)) { - byCategory.set(item.category, []); - } - byCategory.get(item.category).push(item); - } - - return Array.from(byCategory.entries()).map(([category, settings]) => ({ - category, - settings - })); + async getCategorizedSettings(options = {}) { + const snapshot = await this.getSettingsSnapshot(options); + return Array.isArray(snapshot?.categorized) ? [...snapshot.categorized] : []; } async setSettingValue(key, rawValue) { @@ -619,6 +798,7 @@ class SettingsService { if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) { applyRuntimeLogDirSetting(result.normalized); } + this.invalidateSettingsCache([key]); return { key, @@ -702,6 +882,7 @@ class SettingsService { applyRuntimeLogDirSetting(logDirChange.value); } + this.invalidateSettingsCache(normalizedEntries.map((item) => item.key)); logger.info('settings:bulk-updated', { count: normalizedEntries.length }); return normalizedEntries.map((item) => ({ key: item.key, @@ -1141,8 +1322,7 @@ class SettingsService { return `disc:${map.makemkv_source_index ?? 0}`; } - async getHandBrakePresetOptions() { - const map = await this.getSettingsMap(); + async loadHandBrakePresetOptionsFromCli(map = {}) { const configuredPresets = uniqueOrderedValues([ map.handbrake_preset_bluray, map.handbrake_preset_dvd, @@ -1156,21 +1336,20 @@ class SettingsService { const args = [...baseArgs, '-z']; try { - const result = spawnSync(cmd, args, { - encoding: 'utf-8', + const result = await runCommandCapture(cmd, args, { timeout: HANDBRAKE_PRESET_LIST_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }); - if (result.error) { + if (result.timedOut) { return { source: 'fallback', - message: `Preset-Liste konnte nicht geladen werden: ${result.error.message}`, + message: 'Preset-Liste konnte nicht geladen werden (Timeout).', options: fallbackOptions }; } - if (result.status !== 0) { + if (Number(result.status) !== 0) { const stderr = String(result.stderr || '').trim(); const stdout = String(result.stdout || '').trim(); const detail = (stderr || stdout || `exit=${result.status}`).slice(0, 280); @@ -1226,6 +1405,65 @@ class SettingsService { }; } } + + async refreshHandBrakePresetCache(map = null, cacheKey = null) { + const resolvedMap = map && typeof map === 'object' + ? map + : await this.getSettingsMap(); + const resolvedCacheKey = String(cacheKey || this.buildHandBrakePresetCacheKey(resolvedMap)); + this.handBrakePresetCache.cacheKey = resolvedCacheKey; + + let loadPromise = null; + loadPromise = this.loadHandBrakePresetOptionsFromCli(resolvedMap) + .then((payload) => { + this.handBrakePresetCache.payload = payload; + this.handBrakePresetCache.cacheKey = resolvedCacheKey; + this.handBrakePresetCache.expiresAt = Date.now() + HANDBRAKE_PRESET_CACHE_TTL_MS; + return payload; + }) + .finally(() => { + if (this.handBrakePresetCache.inFlight === loadPromise) { + this.handBrakePresetCache.inFlight = null; + } + }); + this.handBrakePresetCache.inFlight = loadPromise; + return loadPromise; + } + + async getHandBrakePresetOptions(options = {}) { + const forceRefresh = Boolean(options?.forceRefresh); + const map = options?.settingsMap && typeof options.settingsMap === 'object' + ? options.settingsMap + : await this.getSettingsMap(); + const cacheKey = this.buildHandBrakePresetCacheKey(map); + const now = Date.now(); + + if ( + !forceRefresh + && this.handBrakePresetCache.payload + && this.handBrakePresetCache.cacheKey === cacheKey + && this.handBrakePresetCache.expiresAt > now + ) { + return this.handBrakePresetCache.payload; + } + + if ( + !forceRefresh + && this.handBrakePresetCache.payload + && this.handBrakePresetCache.cacheKey === cacheKey + ) { + if (!this.handBrakePresetCache.inFlight) { + void this.refreshHandBrakePresetCache(map, cacheKey); + } + return this.handBrakePresetCache.payload; + } + + if (this.handBrakePresetCache.inFlight && this.handBrakePresetCache.cacheKey === cacheKey && !forceRefresh) { + return this.handBrakePresetCache.inFlight; + } + + return this.refreshHandBrakePresetCache(map, cacheKey); + } } module.exports = new SettingsService(); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 36a83da..4868a74 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,4 +1,81 @@ const API_BASE = import.meta.env.VITE_API_BASE || '/api'; +const GET_RESPONSE_CACHE = new Map(); + +function invalidateCachedGet(prefixes = []) { + const list = Array.isArray(prefixes) ? prefixes.filter(Boolean) : []; + if (list.length === 0) { + GET_RESPONSE_CACHE.clear(); + return; + } + for (const key of GET_RESPONSE_CACHE.keys()) { + if (list.some((prefix) => key.startsWith(prefix))) { + GET_RESPONSE_CACHE.delete(key); + } + } +} + +function refreshCachedGet(path, ttlMs) { + const cacheKey = String(path || ''); + const nextEntry = GET_RESPONSE_CACHE.get(cacheKey) || { + value: undefined, + expiresAt: 0, + promise: null + }; + const nextPromise = request(path) + .then((payload) => { + GET_RESPONSE_CACHE.set(cacheKey, { + value: payload, + expiresAt: Date.now() + Math.max(1000, Number(ttlMs || 0)), + promise: null + }); + return payload; + }) + .catch((error) => { + const current = GET_RESPONSE_CACHE.get(cacheKey); + if (current && current.promise === nextPromise) { + GET_RESPONSE_CACHE.set(cacheKey, { + value: current.value, + expiresAt: current.expiresAt || 0, + promise: null + }); + } + throw error; + }); + GET_RESPONSE_CACHE.set(cacheKey, { + value: nextEntry.value, + expiresAt: nextEntry.expiresAt || 0, + promise: nextPromise + }); + return nextPromise; +} + +async function requestCachedGet(path, options = {}) { + const ttlMs = Math.max(1000, Number(options?.ttlMs || 0)); + const forceRefresh = Boolean(options?.forceRefresh); + const cacheKey = String(path || ''); + const current = GET_RESPONSE_CACHE.get(cacheKey); + const now = Date.now(); + + if (!forceRefresh && current && current.value !== undefined) { + if (current.expiresAt > now) { + return current.value; + } + if (!current.promise) { + void refreshCachedGet(path, ttlMs); + } + return current.value; + } + + if (!forceRefresh && current?.promise) { + return current.promise; + } + + return refreshCachedGet(path, ttlMs); +} + +function afterMutationInvalidate(prefixes = []) { + invalidateCachedGet(prefixes); +} async function request(path, options = {}) { const response = await fetch(`${API_BASE}${path}`, { @@ -33,89 +110,121 @@ async function request(path, options = {}) { } export const api = { - getSettings() { - return request('/settings'); + getSettings(options = {}) { + return requestCachedGet('/settings', { + ttlMs: 5 * 60 * 1000, + forceRefresh: options.forceRefresh + }); }, - getHandBrakePresets() { - return request('/settings/handbrake-presets'); + getHandBrakePresets(options = {}) { + return requestCachedGet('/settings/handbrake-presets', { + ttlMs: 10 * 60 * 1000, + forceRefresh: options.forceRefresh + }); }, - getScripts() { - return request('/settings/scripts'); + getScripts(options = {}) { + return requestCachedGet('/settings/scripts', { + ttlMs: 2 * 60 * 1000, + forceRefresh: options.forceRefresh + }); }, - createScript(payload = {}) { - return request('/settings/scripts', { + async createScript(payload = {}) { + const result = await request('/settings/scripts', { method: 'POST', body: JSON.stringify(payload || {}) }); + afterMutationInvalidate(['/settings/scripts']); + return result; }, - reorderScripts(orderedScriptIds = []) { - return request('/settings/scripts/reorder', { + async reorderScripts(orderedScriptIds = []) { + const result = await request('/settings/scripts/reorder', { method: 'POST', body: JSON.stringify({ orderedScriptIds: Array.isArray(orderedScriptIds) ? orderedScriptIds : [] }) }); + afterMutationInvalidate(['/settings/scripts']); + return result; }, - updateScript(scriptId, payload = {}) { - return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, { + async updateScript(scriptId, payload = {}) { + const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, { method: 'PUT', body: JSON.stringify(payload || {}) }); + afterMutationInvalidate(['/settings/scripts']); + return result; }, - deleteScript(scriptId) { - return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, { + async deleteScript(scriptId) { + const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, { method: 'DELETE' }); + afterMutationInvalidate(['/settings/scripts']); + return result; }, testScript(scriptId) { return request(`/settings/scripts/${encodeURIComponent(scriptId)}/test`, { method: 'POST' }); }, - getScriptChains() { - return request('/settings/script-chains'); + getScriptChains(options = {}) { + return requestCachedGet('/settings/script-chains', { + ttlMs: 2 * 60 * 1000, + forceRefresh: options.forceRefresh + }); }, - createScriptChain(payload = {}) { - return request('/settings/script-chains', { + async createScriptChain(payload = {}) { + const result = await request('/settings/script-chains', { method: 'POST', body: JSON.stringify(payload) }); + afterMutationInvalidate(['/settings/script-chains']); + return result; }, - reorderScriptChains(orderedChainIds = []) { - return request('/settings/script-chains/reorder', { + async reorderScriptChains(orderedChainIds = []) { + const result = await request('/settings/script-chains/reorder', { method: 'POST', body: JSON.stringify({ orderedChainIds: Array.isArray(orderedChainIds) ? orderedChainIds : [] }) }); + afterMutationInvalidate(['/settings/script-chains']); + return result; }, - updateScriptChain(chainId, payload = {}) { - return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, { + async updateScriptChain(chainId, payload = {}) { + const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, { method: 'PUT', body: JSON.stringify(payload) }); + afterMutationInvalidate(['/settings/script-chains']); + return result; }, - deleteScriptChain(chainId) { - return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, { + async deleteScriptChain(chainId) { + const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, { method: 'DELETE' }); + afterMutationInvalidate(['/settings/script-chains']); + return result; }, testScriptChain(chainId) { return request(`/settings/script-chains/${encodeURIComponent(chainId)}/test`, { method: 'POST' }); }, - updateSetting(key, value) { - return request(`/settings/${encodeURIComponent(key)}`, { + async updateSetting(key, value) { + const result = await request(`/settings/${encodeURIComponent(key)}`, { method: 'PUT', body: JSON.stringify({ value }) }); + afterMutationInvalidate(['/settings', '/settings/handbrake-presets']); + return result; }, - updateSettingsBulk(settings) { - return request('/settings', { + async updateSettingsBulk(settings) { + const result = await request('/settings', { method: 'PUT', body: JSON.stringify({ settings }) }); + afterMutationInvalidate(['/settings', '/settings/handbrake-presets']); + return result; }, testPushover(payload = {}) { return request('/settings/pushover/test', { @@ -126,91 +235,143 @@ export const api = { getPipelineState() { return request('/pipeline/state'); }, - analyzeDisc() { - return request('/pipeline/analyze', { - method: 'POST' - }); + getRuntimeActivities() { + return request('/runtime/activities'); }, - rescanDisc() { - return request('/pipeline/rescan-disc', { - method: 'POST' - }); - }, - searchOmdb(q) { - return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); - }, - selectMetadata(payload) { - return request('/pipeline/select-metadata', { - method: 'POST', - body: JSON.stringify(payload) - }); - }, - startJob(jobId) { - return request(`/pipeline/start/${jobId}`, { - method: 'POST' - }); - }, - confirmEncodeReview(jobId, payload = {}) { - return request(`/pipeline/confirm-encode/${jobId}`, { + cancelRuntimeActivity(activityId, payload = {}) { + return request(`/runtime/activities/${encodeURIComponent(activityId)}/cancel`, { method: 'POST', body: JSON.stringify(payload || {}) }); }, - cancelPipeline(jobId = null) { - return request('/pipeline/cancel', { + requestRuntimeNextStep(activityId, payload = {}) { + return request(`/runtime/activities/${encodeURIComponent(activityId)}/next-step`, { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + }, + async analyzeDisc() { + const result = await request('/pipeline/analyze', { + method: 'POST' + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async rescanDisc() { + const result = await request('/pipeline/rescan-disc', { + method: 'POST' + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + searchOmdb(q) { + return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); + }, + async selectMetadata(payload) { + const result = await request('/pipeline/select-metadata', { + method: 'POST', + body: JSON.stringify(payload) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async startJob(jobId) { + const result = await request(`/pipeline/start/${jobId}`, { + method: 'POST' + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async confirmEncodeReview(jobId, payload = {}) { + const result = await request(`/pipeline/confirm-encode/${jobId}`, { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + async cancelPipeline(jobId = null) { + const result = await request('/pipeline/cancel', { method: 'POST', body: JSON.stringify({ jobId }) }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; }, - retryJob(jobId) { - return request(`/pipeline/retry/${jobId}`, { + async retryJob(jobId) { + const result = await request(`/pipeline/retry/${jobId}`, { method: 'POST' }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; }, - resumeReadyJob(jobId) { - return request(`/pipeline/resume-ready/${jobId}`, { + async resumeReadyJob(jobId) { + const result = await request(`/pipeline/resume-ready/${jobId}`, { method: 'POST' }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; }, - reencodeJob(jobId) { - return request(`/pipeline/reencode/${jobId}`, { + async reencodeJob(jobId) { + const result = await request(`/pipeline/reencode/${jobId}`, { method: 'POST' }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; }, - restartReviewFromRaw(jobId) { - return request(`/pipeline/restart-review/${jobId}`, { + async restartReviewFromRaw(jobId) { + const result = await request(`/pipeline/restart-review/${jobId}`, { method: 'POST' }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; }, - restartEncodeWithLastSettings(jobId) { - return request(`/pipeline/restart-encode/${jobId}`, { + async restartEncodeWithLastSettings(jobId) { + const result = await request(`/pipeline/restart-encode/${jobId}`, { method: 'POST' }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; }, getPipelineQueue() { return request('/pipeline/queue'); }, - reorderPipelineQueue(orderedEntryIds = []) { - return request('/pipeline/queue/reorder', { + async reorderPipelineQueue(orderedEntryIds = []) { + const result = await request('/pipeline/queue/reorder', { method: 'POST', body: JSON.stringify({ orderedEntryIds: Array.isArray(orderedEntryIds) ? orderedEntryIds : [] }) }); + afterMutationInvalidate(['/pipeline/queue']); + return result; }, - addQueueEntry(payload = {}) { - return request('/pipeline/queue/entry', { + async addQueueEntry(payload = {}) { + const result = await request('/pipeline/queue/entry', { method: 'POST', body: JSON.stringify(payload) }); + afterMutationInvalidate(['/pipeline/queue']); + return result; }, - removeQueueEntry(entryId) { - return request(`/pipeline/queue/entry/${encodeURIComponent(entryId)}`, { + async removeQueueEntry(entryId) { + const result = await request(`/pipeline/queue/entry/${encodeURIComponent(entryId)}`, { method: 'DELETE' }); + afterMutationInvalidate(['/pipeline/queue']); + return result; }, getJobs(params = {}) { const query = new URLSearchParams(); if (params.status) query.set('status', params.status); + if (Array.isArray(params.statuses) && params.statuses.length > 0) { + query.set('statuses', params.statuses.join(',')); + } if (params.search) query.set('search', params.search); + if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) { + query.set('limit', String(Math.trunc(Number(params.limit)))); + } + if (params.lite) { + query.set('lite', '1'); + } const suffix = query.toString() ? `?${query.toString()}` : ''; return request(`/history${suffix}`); }, @@ -224,32 +385,43 @@ export const api = { getOrphanRawFolders() { return request('/history/orphan-raw'); }, - importOrphanRawFolder(rawPath) { - return request('/history/orphan-raw/import', { + async importOrphanRawFolder(rawPath) { + const result = await request('/history/orphan-raw/import', { method: 'POST', body: JSON.stringify({ rawPath }) }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; }, - assignJobOmdb(jobId, payload = {}) { - return request(`/history/${jobId}/omdb/assign`, { + async assignJobOmdb(jobId, payload = {}) { + const result = await request(`/history/${jobId}/omdb/assign`, { method: 'POST', body: JSON.stringify(payload || {}) }); + afterMutationInvalidate(['/history']); + return result; }, - deleteJobFiles(jobId, target = 'both') { - return request(`/history/${jobId}/delete-files`, { + async deleteJobFiles(jobId, target = 'both') { + const result = await request(`/history/${jobId}/delete-files`, { method: 'POST', body: JSON.stringify({ target }) }); + afterMutationInvalidate(['/history']); + return result; }, - deleteJobEntry(jobId, target = 'none') { - return request(`/history/${jobId}/delete`, { + async deleteJobEntry(jobId, target = 'none') { + const result = await request(`/history/${jobId}/delete`, { method: 'POST', body: JSON.stringify({ target }) }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; }, getJob(jobId, options = {}) { const query = new URLSearchParams(); + const includeLiveLog = Boolean(options.includeLiveLog); + const includeLogs = Boolean(options.includeLogs); + const includeAllLogs = Boolean(options.includeAllLogs); if (options.includeLiveLog) { query.set('includeLiveLog', '1'); } @@ -262,31 +434,51 @@ export const api = { if (Number.isFinite(Number(options.logTailLines)) && Number(options.logTailLines) > 0) { query.set('logTailLines', String(Math.trunc(Number(options.logTailLines)))); } + if (options.lite) { + query.set('lite', '1'); + } const suffix = query.toString() ? `?${query.toString()}` : ''; - return request(`/history/${jobId}${suffix}`); + const path = `/history/${jobId}${suffix}`; + const canUseCache = !includeLiveLog && !includeLogs && !includeAllLogs; + if (!canUseCache) { + return request(path); + } + return requestCachedGet(path, { + ttlMs: 8000, + forceRefresh: options.forceRefresh + }); }, // ── User Presets ─────────────────────────────────────────────────────────── - getUserPresets(mediaType = null) { + getUserPresets(mediaType = null, options = {}) { const suffix = mediaType ? `?media_type=${encodeURIComponent(mediaType)}` : ''; - return request(`/settings/user-presets${suffix}`); + return requestCachedGet(`/settings/user-presets${suffix}`, { + ttlMs: 2 * 60 * 1000, + forceRefresh: options.forceRefresh + }); }, - createUserPreset(payload = {}) { - return request('/settings/user-presets', { + async createUserPreset(payload = {}) { + const result = await request('/settings/user-presets', { method: 'POST', body: JSON.stringify(payload) }); + afterMutationInvalidate(['/settings/user-presets']); + return result; }, - updateUserPreset(id, payload = {}) { - return request(`/settings/user-presets/${encodeURIComponent(id)}`, { + async updateUserPreset(id, payload = {}) { + const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(payload) }); + afterMutationInvalidate(['/settings/user-presets']); + return result; }, - deleteUserPreset(id) { - return request(`/settings/user-presets/${encodeURIComponent(id)}`, { + async deleteUserPreset(id) { + const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, { method: 'DELETE' }); + afterMutationInvalidate(['/settings/user-presets']); + return result; }, // ── Cron Jobs ────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/CronJobsTab.jsx b/frontend/src/components/CronJobsTab.jsx index 1eacbf6..c53ff2d 100644 --- a/frontend/src/components/CronJobsTab.jsx +++ b/frontend/src/components/CronJobsTab.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from 'primereact/button'; import { Dialog } from 'primereact/dialog'; import { InputText } from 'primereact/inputtext'; @@ -32,6 +32,23 @@ function StatusBadge({ status }) { return {info.label}; } +function normalizeActiveCronRuns(rawPayload) { + const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {}; + const active = Array.isArray(payload.active) ? payload.active : []; + return active + .map((item) => (item && typeof item === 'object' ? item : null)) + .filter(Boolean) + .filter((item) => String(item.type || '').trim().toLowerCase() === 'cron') + .map((item) => ({ + id: Number(item.id), + cronJobId: Number(item.cronJobId || 0), + currentStep: String(item.currentStep || '').trim() || null, + currentScriptName: String(item.currentScriptName || '').trim() || null, + startedAt: item.startedAt || null + })) + .filter((item) => Number.isFinite(item.cronJobId) && item.cronJobId > 0); +} + const EMPTY_FORM = { name: '', cronExpression: '', @@ -50,6 +67,7 @@ export default function CronJobsTab({ onWsMessage }) { const [loading, setLoading] = useState(false); const [scripts, setScripts] = useState([]); const [chains, setChains] = useState([]); + const [activeCronRuns, setActiveCronRuns] = useState([]); // Editor-Dialog const [editorOpen, setEditorOpen] = useState(false); @@ -76,14 +94,18 @@ export default function CronJobsTab({ onWsMessage }) { const loadAll = useCallback(async () => { setLoading(true); try { - const [cronResp, scriptsResp, chainsResp] = await Promise.allSettled([ + const [cronResp, scriptsResp, chainsResp, runtimeResp] = await Promise.allSettled([ api.getCronJobs(), api.getScripts(), - api.getScriptChains() + api.getScriptChains(), + api.getRuntimeActivities() ]); if (cronResp.status === 'fulfilled') setJobs(cronResp.value?.jobs || []); if (scriptsResp.status === 'fulfilled') setScripts(scriptsResp.value?.scripts || []); if (chainsResp.status === 'fulfilled') setChains(chainsResp.value?.chains || []); + if (runtimeResp.status === 'fulfilled') { + setActiveCronRuns(normalizeActiveCronRuns(runtimeResp.value)); + } } finally { setLoading(false); } @@ -93,6 +115,36 @@ export default function CronJobsTab({ onWsMessage }) { loadAll(); }, [loadAll]); + useEffect(() => { + let cancelled = false; + const refreshRuntime = async () => { + try { + const response = await api.getRuntimeActivities(); + if (!cancelled) { + setActiveCronRuns(normalizeActiveCronRuns(response)); + } + } catch (_error) { + // ignore polling errors + } + }; + const interval = setInterval(refreshRuntime, 2500); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, []); + + const activeCronRunByJobId = useMemo(() => { + const map = new Map(); + for (const item of activeCronRuns) { + if (!item?.cronJobId) { + continue; + } + map.set(item.cronJobId, item); + } + return map; + }, [activeCronRuns]); + // WebSocket: Cronjob-Updates empfangen useEffect(() => { if (!onWsMessage) return; @@ -279,6 +331,7 @@ export default function CronJobsTab({ onWsMessage }) {
{jobs.map((job) => { const isBusy = busyId === job.id; + const activeCronRun = activeCronRunByJobId.get(Number(job.id)) || null; return (
@@ -305,6 +358,17 @@ export default function CronJobsTab({ onWsMessage }) { Nächster Lauf: {formatDateTime(job.nextRunAt)} + {activeCronRun ? ( + + Aktuell: + + + {activeCronRun.currentScriptName + ? `Skript: ${activeCronRun.currentScriptName}` + : (activeCronRun.currentStep || 'Ausführung läuft')} + + + ) : null}
diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index b12e94a..6d9f476 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -54,6 +54,127 @@ function ScriptSummarySection({ title, summary }) { ); } +function normalizeIdList(values) { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of list) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + continue; + } + const id = Math.trunc(parsed); + const key = String(id); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(id); + } + return output; +} + +function shellQuote(value) { + const raw = String(value ?? ''); + if (raw.length === 0) { + return "''"; + } + if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) { + return raw; + } + return `'${raw.replace(/'/g, `'"'"'`)}'`; +} + +function buildExecutedHandBrakeCommand(handbrakeInfo) { + const cmd = String(handbrakeInfo?.cmd || '').trim(); + const args = Array.isArray(handbrakeInfo?.args) ? handbrakeInfo.args : []; + if (!cmd) { + return null; + } + return `${cmd} ${args.map((arg) => shellQuote(arg)).join(' ')}`.trim(); +} + +function buildConfiguredScriptAndChainSelection(job) { + const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {}; + const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {}; + const scriptNameById = new Map(); + const chainNameById = new Map(); + + const addScriptHint = (idValue, nameValue) => { + const id = normalizeIdList([idValue])[0] || null; + const name = String(nameValue || '').trim(); + if (!id || !name || scriptNameById.has(id)) { + return; + } + scriptNameById.set(id, name); + }; + + const addChainHint = (idValue, nameValue) => { + const id = normalizeIdList([idValue])[0] || null; + const name = String(nameValue || '').trim(); + if (!id || !name || chainNameById.has(id)) { + return; + } + chainNameById.set(id, name); + }; + + const addScriptHintsFromList = (list) => { + for (const item of (Array.isArray(list) ? list : [])) { + addScriptHint(item?.id ?? item?.scriptId, item?.name ?? item?.scriptName); + } + }; + + const addChainHintsFromList = (list) => { + for (const item of (Array.isArray(list) ? list : [])) { + addChainHint(item?.id ?? item?.chainId, item?.name ?? item?.chainName); + } + }; + + addScriptHintsFromList(plan?.preEncodeScripts); + addScriptHintsFromList(plan?.postEncodeScripts); + addChainHintsFromList(plan?.preEncodeChains); + addChainHintsFromList(plan?.postEncodeChains); + + const scriptSummaries = [handbrakeInfo?.preEncodeScripts, handbrakeInfo?.postEncodeScripts]; + for (const summary of scriptSummaries) { + const results = Array.isArray(summary?.results) ? summary.results : []; + for (const result of results) { + addScriptHint(result?.scriptId, result?.scriptName); + addChainHint(result?.chainId, result?.chainName); + } + } + + const preScriptIds = normalizeIdList([ + ...(Array.isArray(plan?.preEncodeScriptIds) ? plan.preEncodeScriptIds : []), + ...(Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts.map((item) => item?.id ?? item?.scriptId) : []) + ]); + const postScriptIds = normalizeIdList([ + ...(Array.isArray(plan?.postEncodeScriptIds) ? plan.postEncodeScriptIds : []), + ...(Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts.map((item) => item?.id ?? item?.scriptId) : []) + ]); + const preChainIds = normalizeIdList([ + ...(Array.isArray(plan?.preEncodeChainIds) ? plan.preEncodeChainIds : []), + ...(Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains.map((item) => item?.id ?? item?.chainId) : []) + ]); + const postChainIds = normalizeIdList([ + ...(Array.isArray(plan?.postEncodeChainIds) ? plan.postEncodeChainIds : []), + ...(Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains.map((item) => item?.id ?? item?.chainId) : []) + ]); + + return { + preScriptIds, + postScriptIds, + preChainIds, + postChainIds, + preScripts: preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`), + postScripts: postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`), + preChains: preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`), + postChains: postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`), + scriptCatalog: Array.from(scriptNameById.entries()).map(([id, name]) => ({ id, name })), + chainCatalog: Array.from(chainNameById.entries()).map(([id, name]) => ({ id, name })) + }; +} + function resolveMediaType(job) { const candidates = [ job?.mediaType, @@ -205,6 +326,25 @@ export default function JobDetailDialog({ : (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium'); const statusMeta = statusBadgeMeta(job?.status, queueLocked); const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {}; + const configuredSelection = buildConfiguredScriptAndChainSelection(job); + const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0 + || configuredSelection.postScriptIds.length > 0 + || configuredSelection.preChainIds.length > 0 + || configuredSelection.postChainIds.length > 0; + const reviewPreEncodeItems = [ + ...configuredSelection.preScriptIds.map((id) => ({ type: 'script', id })), + ...configuredSelection.preChainIds.map((id) => ({ type: 'chain', id })) + ]; + const reviewPostEncodeItems = [ + ...configuredSelection.postScriptIds.map((id) => ({ type: 'script', id })), + ...configuredSelection.postChainIds.map((id) => ({ type: 'chain', id })) + ]; + const encodePlanUserPreset = job?.encodePlan?.userPreset && typeof job.encodePlan.userPreset === 'object' + ? job.encodePlan.userPreset + : null; + const encodePlanUserPresetId = Number(encodePlanUserPreset?.id); + const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : []; + const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo); return ( + {hasConfiguredSelection || encodePlanUserPreset ? ( +
+

Hinterlegte Encode-Auswahl

+
+
+ Pre-Encode Skripte: {configuredSelection.preScripts.length > 0 ? configuredSelection.preScripts.join(' | ') : '-'} +
+
+ Pre-Encode Ketten: {configuredSelection.preChains.length > 0 ? configuredSelection.preChains.join(' | ') : '-'} +
+
+ Post-Encode Skripte: {configuredSelection.postScripts.length > 0 ? configuredSelection.postScripts.join(' | ') : '-'} +
+
+ Post-Encode Ketten: {configuredSelection.postChains.length > 0 ? configuredSelection.postChains.join(' | ') : '-'} +
+
+ User-Preset:{' '} + {encodePlanUserPreset + ? `${encodePlanUserPreset.name || '-'} | Preset=${encodePlanUserPreset.handbrakePreset || '-'} | ExtraArgs=${encodePlanUserPreset.extraArgs || '-'}` + : '-'} +
+
+
+ ) : null} + + {executedHandBrakeCommand ? ( +
+

Ausgeführter Encode-Befehl

+
+ HandBrakeCLI (tatsächlich gestartet): +
{executedHandBrakeCommand}
+
+
+ ) : null} + {(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (

Skripte

@@ -356,7 +532,18 @@ export default function JobDetailDialog({ {job.encodePlan ? ( <>

Mediainfo-Prüfung (Auswertung)

- + 0 + ? Math.trunc(encodePlanUserPresetId) + : null} + /> ) : null} diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index c9ecc69..25147e7 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -684,6 +684,14 @@ function normalizeScriptId(value) { return Math.trunc(parsed); } +function normalizeChainId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + function normalizeScriptIdList(values) { const list = Array.isArray(values) ? values : []; const seen = new Set(); @@ -762,8 +770,8 @@ export default function MediaInfoReviewPanel({ .filter((item) => item.id !== null && item.name.length > 0); const scriptById = new Map(scriptCatalog.map((item) => [item.id, item])); const chainCatalog = (Array.isArray(availableChains) ? availableChains : []) - .map((item) => ({ id: Number(item?.id), name: String(item?.name || '').trim() })) - .filter((item) => Number.isFinite(item.id) && item.id > 0 && item.name.length > 0); + .map((item) => ({ id: normalizeChainId(item?.id), name: String(item?.name || '').trim() })) + .filter((item) => item.id !== null && item.name.length > 0); const chainById = new Map(chainCatalog.map((item) => [item.id, item])); const makeHandleDrop = (items, onReorder) => (event, targetIndex) => { @@ -884,13 +892,29 @@ export default function MediaInfoReviewPanel({ ? (scriptObj?.name || `Skript #${item.id}`) : (chainObj?.name || `Kette #${item.id}`); const usedScriptIds = new Set( - preEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id))) + preEncodeItems + .filter((it, i) => it.type === 'script' && i !== rowIndex) + .map((it) => normalizeScriptId(it.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const usedChainIds = new Set( + preEncodeItems + .filter((it, i) => it.type === 'chain' && i !== rowIndex) + .map((it) => normalizeChainId(it.id)) + .filter((id) => id !== null) + .map((id) => String(id)) ); const scriptOptions = scriptCatalog.map((s) => ({ label: s.name, value: s.id, disabled: usedScriptIds.has(String(s.id)) })); + const chainOptions = chainCatalog.map((c) => ({ + label: c.name, + value: c.id, + disabled: usedChainIds.has(String(c.id)) + })); return (
) : ( - {name} + onChangePreEncodeItem?.(rowIndex, 'chain', event.value)} + className="full-width" + /> )} + ) : null} +
+ {detailsExpanded ? : null} +
+ ); + }) )}
@@ -1316,6 +1690,9 @@ export default function DashboardPage({ const entryId = Number(item?.entryId); const isNonJob = item.type && item.type !== 'job'; const isDragging = Number(draggingQueueEntryId) === entryId; + const hasScriptSummary = !isNonJob && hasQueueScriptSummary(item); + const detailKey = buildQueuedQueueScriptKey(entryId); + const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey); return (
)}
- + ) : null} +
+ {detailsExpanded ? : null}
+ ) : null} + + ); + })} + + )} + + +
+

Zuletzt abgeschlossen

+ {runtimeRecentItems.length === 0 ? ( + Keine abgeschlossenen Einträge vorhanden. + ) : ( +
+ {runtimeRecentItems.map((item, index) => { + const outcomeMeta = runtimeOutcomeMeta(item?.outcome, item?.status); + return ( +
+
+ {item?.name || '-'} +
+ + +
+
+ + Quelle: {item?.source || '-'} + {item?.jobId ? ` | Job #${item.jobId}` : ''} + {item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''} + + {Number.isFinite(Number(item?.exitCode)) ? Exit-Code: {item.exitCode} : null} + {item?.message ? {item.message} : null} + {item?.errorMessage ? {item.errorMessage} : null} + {hasRuntimeOutputDetails(item) ? ( +
+ Details anzeigen + {item?.output ? ( +
+ Ausgabe: +
{item.output}
+
+ ) : null} + {item?.stderr ? ( +
+ stderr:{item?.stderrTruncated ? ' (gekürzt)' : ''} +
{item.stderr}
+
+ ) : null} + {item?.stdout ? ( +
+ stdout:{item?.stdoutTruncated ? ' (gekürzt)' : ''} +
{item.stdout}
+
+ ) : null} +
+ ) : null} + + Ende: {formatUpdatedAt(item?.finishedAt || item?.startedAt)} + {item?.durationMs != null ? ` | Dauer: ${formatDurationMs(item.durationMs)}` : ''} + +
+ ); + })} +
+ )} +
+ + )} + + {jobsLoading ? (

Jobs werden geladen ...

diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index f822757..de51047 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -6,6 +6,7 @@ import { Dialog } from 'primereact/dialog'; import { TabView, TabPanel } from 'primereact/tabview'; import { InputText } from 'primereact/inputtext'; import { InputTextarea } from 'primereact/inputtextarea'; +import { Dropdown } from 'primereact/dropdown'; import { api } from '../api/client'; import DynamicSettingsForm from '../components/DynamicSettingsForm'; import CronJobsTab from '../components/CronJobsTab'; @@ -51,6 +52,58 @@ function reorderListById(items, sourceId, targetIndex) { return { changed: true, next }; } +function buildHandBrakePresetSelectOptions(sourceOptions, extraValues = []) { + const rawOptions = Array.isArray(sourceOptions) ? sourceOptions : []; + const rawExtraValues = Array.isArray(extraValues) ? extraValues : []; + const normalizedOptions = []; + const seenValues = new Set(); + const seenGroupLabels = new Set(); + + const addGroupOption = (option) => { + const rawLabel = String(option?.label || '').trim(); + if (!rawLabel || seenGroupLabels.has(rawLabel)) { + return; + } + seenGroupLabels.add(rawLabel); + normalizedOptions.push({ + ...option, + label: rawLabel, + value: String(option?.value || `__group__${rawLabel.toLowerCase().replace(/\s+/g, '_')}`), + disabled: true + }); + }; + + const addSelectableOption = (optionValue, optionLabel = optionValue, option = null) => { + const value = String(optionValue || '').trim(); + if (seenValues.has(value)) { + return; + } + seenValues.add(value); + normalizedOptions.push({ + ...(option && typeof option === 'object' ? option : {}), + label: String(optionLabel ?? value), + value, + disabled: false + }); + }; + + normalizedOptions.push({ label: '(kein Preset – nur CLI-Parameter)', value: '', disabled: false }); + seenValues.add(''); + + for (const option of rawOptions) { + if (option?.disabled) { + addGroupOption(option); + continue; + } + addSelectableOption(option?.value, option?.label, option); + } + for (const value of rawExtraValues) { + addSelectableOption(value); + } + + return normalizedOptions; +} + function injectHandBrakePresetOptions(categories, presetPayload) { const list = Array.isArray(categories) ? categories : []; const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : []; @@ -62,50 +115,10 @@ function injectHandBrakePresetOptions(categories, presetPayload) { if (!presetSettingKeys.has(String(setting?.key || '').trim().toLowerCase())) { return setting; } - - const normalizedOptions = []; - const seenValues = new Set(); - const seenGroupLabels = new Set(); - const addGroupOption = (option) => { - const rawLabel = String(option?.label || '').trim(); - if (!rawLabel || seenGroupLabels.has(rawLabel)) { - return; - } - seenGroupLabels.add(rawLabel); - normalizedOptions.push({ - ...option, - label: rawLabel, - value: String(option?.value || `__group__${rawLabel.toLowerCase().replace(/\s+/g, '_')}`), - disabled: true - }); - }; - const addSelectableOption = (optionValue, optionLabel = optionValue, option = null) => { - const value = String(optionValue || '').trim(); - if (!value || seenValues.has(value)) { - return; - } - seenValues.add(value); - normalizedOptions.push({ - ...(option && typeof option === 'object' ? option : {}), - label: String(optionLabel ?? value), - value, - disabled: false - }); - }; - - // "(kein Preset)" immer als erste Option — ermöglicht reinen CLI-Betrieb - normalizedOptions.push({ label: '(kein Preset – nur CLI-Parameter)', value: '', disabled: false }); - seenValues.add(''); - - for (const option of sourceOptions) { - if (option?.disabled) { - addGroupOption(option); - continue; - } - addSelectableOption(option?.value, option?.label, option); - } - addSelectableOption(setting?.value); - addSelectableOption(setting?.defaultValue); + const normalizedOptions = buildHandBrakePresetSelectOptions(sourceOptions, [ + setting?.value, + setting?.defaultValue + ]); if (normalizedOptions.length <= 1) { return setting; @@ -170,9 +183,18 @@ export default function SettingsPage() { description: '' }); const [userPresetErrors, setUserPresetErrors] = useState({}); + const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]); const toastRef = useRef(null); + const userPresetHandBrakeOptions = useMemo( + () => buildHandBrakePresetSelectOptions( + handBrakePresetSourceOptions, + [userPresetEditor.handbrakePreset] + ), + [handBrakePresetSourceOptions, userPresetEditor.handbrakePreset] + ); + const loadScripts = async ({ silent = false } = {}) => { if (!silent) { setScriptsLoading(true); @@ -298,37 +320,18 @@ export default function SettingsPage() { const load = async () => { setLoading(true); try { - const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse] = await Promise.allSettled([ - api.getSettings(), - api.getHandBrakePresets(), - api.getScripts(), - api.getScriptChains() - ]); - if (settingsResponse.status !== 'fulfilled') { - throw settingsResponse.reason; - } - let nextCategories = settingsResponse.value?.categories || []; - const presetPayload = presetsResponse.status === 'fulfilled' ? presetsResponse.value : null; - nextCategories = injectHandBrakePresetOptions(nextCategories, presetPayload); - if (presetsResponse.status === 'fulfilled' && presetsResponse.value?.message) { - toastRef.current?.show({ - severity: presetsResponse.value?.source === 'fallback' ? 'warn' : 'info', - summary: 'HandBrake Presets', - detail: presetsResponse.value.message - }); - } - if (presetsResponse.status === 'rejected') { - toastRef.current?.show({ - severity: 'warn', - summary: 'HandBrake Presets', - detail: 'Preset-Liste konnte nicht geladen werden. Aktueller Wert bleibt auswählbar.' - }); - } + const settingsResponse = await api.getSettings(); + let nextCategories = settingsResponse?.categories || []; const values = buildValuesMap(nextCategories); setCategories(nextCategories); setInitialValues(values); setDraftValues(values); setErrors({}); + + const presetsPromise = api.getHandBrakePresets(); + const scriptsPromise = api.getScripts(); + const chainsPromise = api.getScriptChains(); + const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]); if (scriptsResponse.status === 'fulfilled') { setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []); } else { @@ -341,6 +344,27 @@ export default function SettingsPage() { if (chainsResponse.status === 'fulfilled') { setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []); } + + presetsPromise + .then((presetPayload) => { + setHandBrakePresetSourceOptions(Array.isArray(presetPayload?.options) ? presetPayload.options : []); + setCategories((prevCategories) => injectHandBrakePresetOptions(prevCategories, presetPayload)); + if (presetPayload?.message) { + toastRef.current?.show({ + severity: presetPayload?.source === 'fallback' ? 'warn' : 'info', + summary: 'HandBrake Presets', + detail: presetPayload.message + }); + } + }) + .catch(() => { + setHandBrakePresetSourceOptions([]); + toastRef.current?.show({ + severity: 'warn', + summary: 'HandBrake Presets', + detail: 'Preset-Liste konnte nicht geladen werden. Aktueller Wert bleibt auswählbar.' + }); + }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message }); } finally { @@ -1511,44 +1535,36 @@ export default function SettingsPage() { ) : userPresets.length === 0 ? (

Keine Presets vorhanden. Lege ein neues Preset an.

) : ( -
+
{userPresets.map((preset) => (
- #{preset.id} – {preset.name} - + #{preset.id} - {preset.name} + {preset.mediaType === 'bluray' ? 'Blu-ray' : preset.mediaType === 'dvd' ? 'DVD' : preset.mediaType === 'other' ? 'Sonstiges' : 'Universell'}
- {preset.description && {preset.description}} -
- {preset.handbrakePreset - ? Preset: {preset.handbrakePreset} - : (kein Preset-Name)} - {preset.extraArgs && ( - Args: {preset.extraArgs} - )} -
+ + {preset.description || '-'} +
-
+
@@ -1594,11 +1610,16 @@ export default function SettingsPage() {
- setUserPresetEditor((prev) => ({ ...prev, handbrakePreset: e.target.value }))} - placeholder="z.B. H.264 MKV 1080p30 (leer = kein Preset)" + options={userPresetHandBrakeOptions} + optionLabel="label" + optionValue="value" + optionDisabled="disabled" + onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, handbrakePreset: String(e.value || '') }))} + placeholder="Preset auswählen" + showClear style={{ width: '100%' }} />
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 0d30659..d5e8127 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -637,6 +637,12 @@ body { border-style: solid; } +.pipeline-queue-item.queue-job-item { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.45rem; +} + .pipeline-queue-item.queued { grid-template-columns: auto auto minmax(0, 1fr) auto; align-items: center; @@ -662,6 +668,63 @@ body { vertical-align: middle; } +.pipeline-queue-item-actions { + display: inline-flex; + align-items: center; + gap: 0.15rem; +} + +.queue-job-expand-btn { + width: 1.6rem; + height: 1.6rem; + border: 1px solid var(--rip-border); + border-radius: 0.35rem; + background: transparent; + color: var(--rip-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.queue-job-expand-btn:hover { + border-color: var(--rip-brown-600); + color: var(--rip-brown-700); + background: var(--rip-surface); +} + +.queue-job-script-details { + margin-top: 0.2rem; + border: 1px dashed var(--rip-border); + border-radius: 0.4rem; + background: var(--rip-panel-soft); + padding: 0.4rem 0.5rem; + display: grid; + gap: 0.3rem; +} + +.queue-job-script-group { + display: grid; + gap: 0.1rem; +} + +.queue-job-script-group strong { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + color: var(--rip-muted); +} + +.queue-job-script-group small { + font-size: 0.8rem; + color: var(--rip-ink); + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + .pipeline-queue-entry-wrap { display: flex; flex-direction: column; @@ -1258,6 +1321,10 @@ body { gap: 0.45rem; } +.script-list-actions--two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .script-list-actions .p-button { width: 100%; justify-content: center; @@ -1273,6 +1340,22 @@ body { display: block; } +.preset-media-type-tag { + font-size: 0.8rem; + opacity: 0.7; + white-space: nowrap; +} + +.preset-description-line { + display: block; + margin-top: 0.2rem; + opacity: 0.8; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .script-editor-fields { display: grid; gap: 0.4rem; @@ -1672,6 +1755,19 @@ body { grid-column: 1 / -1; } +.job-configured-selection-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.35rem 0.65rem; + font-size: 0.84rem; +} + +.job-configured-selection-grid > div { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + .job-status-icon { display: inline-flex; align-items: center; @@ -1917,7 +2013,14 @@ body { } .post-script-row.editable { - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: auto auto minmax(0, 1fr) auto; +} + +.post-script-type-icon { + color: var(--rip-muted); + font-size: 0.95rem; + width: 1rem; + text-align: center; } .post-script-drag-handle { @@ -2094,6 +2197,7 @@ body { .media-review-meta, .media-track-grid, .job-meta-grid, + .job-configured-selection-grid, .job-film-info-grid, .table-filters, .history-dv-toolbar, @@ -2133,7 +2237,7 @@ body { } .post-script-row.editable { - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: auto auto minmax(0, 1fr) auto; } .orphan-path-cell { @@ -2785,3 +2889,96 @@ body { white-space: pre-wrap; word-break: break-all; } + +.runtime-activity-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.runtime-activity-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.runtime-activity-col { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.runtime-activity-list { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.runtime-activity-item { + border: 1px solid var(--surface-border, #d8d3c6); + border-radius: 8px; + padding: 0.6rem 0.75rem; + background: var(--surface-card, #f8f6ef); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.runtime-activity-item.done { + opacity: 0.94; +} + +.runtime-activity-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.runtime-activity-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.runtime-activity-actions { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.25rem; +} + +.runtime-activity-details { + margin-top: 0.2rem; +} + +.runtime-activity-details summary { + cursor: pointer; + font-size: 0.82rem; + color: var(--rip-muted, #666); +} + +.runtime-activity-details-block { + margin-top: 0.4rem; +} + +.runtime-activity-details-block pre { + margin: 0.25rem 0 0; + padding: 0.55rem; + border-radius: 6px; + border: 1px solid var(--surface-border, #d8d3c6); + background: #1f1f1f; + color: #e8e8e8; + max-height: 180px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.78rem; +} + +@media (max-width: 980px) { + .runtime-activity-grid { + grid-template-columns: 1fr; + } +}