From 39577738545ecfbe2004f044e337ff3df6b55627 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 4 Mar 2026 21:09:04 +0000 Subject: [PATCH] Skript Integration + UI Anpassungen --- backend/src/db/defaultSettings.js | 12 + backend/src/routes/pipelineRoutes.js | 9 +- backend/src/routes/settingsRoutes.js | 78 +++ backend/src/services/pipelineService.js | 417 +++++++++++-- backend/src/services/scriptService.js | 435 +++++++++++++ backend/src/services/settingsService.js | 274 +++++++++ db/schema.sql | 10 + frontend/src/api/client.js | 28 + .../src/components/DynamicSettingsForm.jsx | 21 +- frontend/src/components/JobDetailDialog.jsx | 185 ++++-- .../src/components/MediaInfoReviewPanel.jsx | 147 ++++- .../src/components/PipelineStatusCard.jsx | 170 +++++- frontend/src/pages/DashboardPage.jsx | 13 +- frontend/src/pages/DatabasePage.jsx | 21 + frontend/src/pages/SettingsPage.jsx | 572 ++++++++++++++++-- frontend/src/styles/app.css | 320 ++++++++++ 16 files changed, 2569 insertions(+), 143 deletions(-) create mode 100644 backend/src/services/scriptService.js diff --git a/backend/src/db/defaultSettings.js b/backend/src/db/defaultSettings.js index a79a1cc..97ee4d0 100644 --- a/backend/src/db/defaultSettings.js +++ b/backend/src/db/defaultSettings.js @@ -260,6 +260,18 @@ const defaultSchema = [ validation: { minLength: 1 }, orderIndex: 340 }, + { + key: 'output_folder_template', + category: 'Tools', + label: 'Ordnername Template', + type: 'string', + required: 0, + description: 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template verwenden.', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 345 + }, { key: 'omdb_api_key', category: 'Metadaten', diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 033fbd1..6ece90d 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -94,15 +94,20 @@ router.post( const jobId = Number(req.params.jobId); const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null; const selectedTrackSelection = req.body?.selectedTrackSelection ?? null; + const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds; logger.info('post:confirm-encode', { reqId: req.reqId, jobId, selectedEncodeTitleId, - selectedTrackSelectionProvided: Boolean(selectedTrackSelection) + selectedTrackSelectionProvided: Boolean(selectedTrackSelection), + selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds) + ? selectedPostEncodeScriptIds.length + : 0 }); const job = await pipelineService.confirmEncodeReview(jobId, { selectedEncodeTitleId, - selectedTrackSelection + selectedTrackSelection, + selectedPostEncodeScriptIds }); res.json({ job }); }) diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index 4c3c79e..c7bdf66 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -1,6 +1,7 @@ const express = require('express'); const asyncHandler = require('../middleware/asyncHandler'); const settingsService = require('../services/settingsService'); +const scriptService = require('../services/scriptService'); const notificationService = require('../services/notificationService'); const pipelineService = require('../services/pipelineService'); const wsService = require('../services/websocketService'); @@ -25,6 +26,83 @@ router.get( }) ); +router.get( + '/handbrake-presets', + asyncHandler(async (req, res) => { + logger.debug('get:settings:handbrake-presets', { reqId: req.reqId }); + const presets = await settingsService.getHandBrakePresetOptions(); + res.json(presets); + }) +); + +router.get( + '/scripts', + asyncHandler(async (req, res) => { + logger.debug('get:settings:scripts', { reqId: req.reqId }); + const scripts = await scriptService.listScripts(); + res.json({ scripts }); + }) +); + +router.post( + '/scripts', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('post:settings:scripts:create', { + reqId: req.reqId, + name: String(payload?.name || '').trim() || null, + scriptBodyLength: String(payload?.scriptBody || '').length + }); + const script = await scriptService.createScript(payload); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'created', id: script.id }); + res.status(201).json({ script }); + }) +); + +router.put( + '/scripts/:id', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + const payload = req.body || {}; + logger.info('put:settings:scripts:update', { + reqId: req.reqId, + scriptId, + name: String(payload?.name || '').trim() || null, + scriptBodyLength: String(payload?.scriptBody || '').length + }); + const script = await scriptService.updateScript(scriptId, payload); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'updated', id: script.id }); + res.json({ script }); + }) +); + +router.delete( + '/scripts/:id', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + logger.info('delete:settings:scripts', { + reqId: req.reqId, + scriptId + }); + const removed = await scriptService.deleteScript(scriptId); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'deleted', id: removed.id }); + res.json({ removed }); + }) +); + +router.post( + '/scripts/:id/test', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + logger.info('post:settings:scripts:test', { + reqId: req.reqId, + scriptId + }); + const result = await scriptService.testScript(scriptId); + res.json({ result }); + }) +); + router.put( '/:key', asyncHandler(async (req, res) => { diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 86fed87..7410f32 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -5,6 +5,7 @@ const { getDb } = require('../db/database'); const settingsService = require('./settingsService'); const historyService = require('./historyService'); const omdbService = require('./omdbService'); +const scriptService = require('./scriptService'); const wsService = require('./websocketService'); const diskDetectionService = require('./diskDetectionService'); const notificationService = require('./notificationService'); @@ -42,30 +43,47 @@ function withTimestampBeforeExtension(targetPath, suffix) { return path.join(dir, `${base}_${suffix}${ext}`); } -function buildOutputPathFromJob(settings, job, fallbackJobId = null) { +function resolveOutputTemplateValues(job, fallbackJobId = null) { + return { + title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'), + year: job.year || new Date().getFullYear(), + imdbId: job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb') + }; +} + +function resolveOutputFileName(settings, values) { + const fileTemplate = settings.filename_template || '${title} (${year})'; + return sanitizeFileName(renderTemplate(fileTemplate, values)); +} + +function resolveFinalOutputFolderName(settings, values) { + const folderTemplateRaw = String(settings.output_folder_template || '').trim(); + const fallbackTemplate = settings.filename_template || '${title} (${year})'; + const folderTemplate = folderTemplateRaw || fallbackTemplate; + return sanitizeFileName(renderTemplate(folderTemplate, values)); +} + +function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { const movieDir = settings.movie_dir; - const title = job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'); - const year = job.year || new Date().getFullYear(); - const imdbId = job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb'); - const template = settings.filename_template || '${title} (${year})'; - const folderName = sanitizeFileName( - renderTemplate('${title} (${year})', { - title, - year, - imdbId - }) - ); - const baseName = sanitizeFileName( - renderTemplate(template, { - title, - year, - imdbId - }) - ); - const ext = settings.output_extension || 'mkv'; + const values = resolveOutputTemplateValues(job, fallbackJobId); + const folderName = resolveFinalOutputFolderName(settings, values); + const baseName = resolveOutputFileName(settings, values); + const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; return path.join(movieDir, folderName, `${baseName}.${ext}`); } +function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { + const movieDir = settings.movie_dir; + const values = resolveOutputTemplateValues(job, fallbackJobId); + const baseName = resolveOutputFileName(settings, values); + const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; + const numericJobId = Number(fallbackJobId || job?.id || 0); + const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0 + ? `Incomplete_job-${numericJobId}` + : 'Incomplete_job-unknown'; + return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`); +} + function ensureUniqueOutputPath(outputPath) { if (!fs.existsSync(outputPath)) { return outputPath; @@ -81,6 +99,65 @@ function ensureUniqueOutputPath(outputPath) { return attempt; } +function moveFileWithFallback(sourcePath, targetPath) { + try { + fs.renameSync(sourcePath, targetPath); + } catch (error) { + if (error?.code !== 'EXDEV') { + throw error; + } + fs.copyFileSync(sourcePath, targetPath); + fs.unlinkSync(sourcePath); + } +} + +function removeDirectoryIfEmpty(directoryPath) { + try { + const entries = fs.readdirSync(directoryPath); + if (entries.length === 0) { + fs.rmdirSync(directoryPath); + } + } catch (_error) { + // Best effort cleanup. + } +} + +function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFinalOutputPath) { + const sourcePath = String(incompleteOutputPath || '').trim(); + if (!sourcePath) { + throw new Error('Encode-Finalisierung fehlgeschlagen: temporärer Output-Pfad fehlt.'); + } + if (!fs.existsSync(sourcePath)) { + throw new Error(`Encode-Finalisierung fehlgeschlagen: temporäre Datei fehlt (${sourcePath}).`); + } + + const plannedTargetPath = String(preferredFinalOutputPath || '').trim(); + if (!plannedTargetPath) { + throw new Error('Encode-Finalisierung fehlgeschlagen: finaler Output-Pfad fehlt.'); + } + + const sourceResolved = path.resolve(sourcePath); + const targetPath = ensureUniqueOutputPath(plannedTargetPath); + const targetResolved = path.resolve(targetPath); + const outputPathWithTimestamp = targetPath !== plannedTargetPath; + + if (sourceResolved === targetResolved) { + return { + outputPath: targetPath, + outputPathWithTimestamp + }; + } + + ensureDir(path.dirname(targetPath)); + moveFileWithFallback(sourcePath, targetPath); + removeDirectoryIfEmpty(path.dirname(sourcePath)); + + return { + outputPath: targetPath, + outputPathWithTimestamp + }; +} + function truncateLine(value, max = 180) { const raw = String(value || '').replace(/\s+/g, ' ').trim(); if (raw.length <= max) { @@ -1556,6 +1633,26 @@ function normalizeTrackIdList(rawList) { return output; } +function normalizeScriptIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const value = Number(item); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const normalized = Math.trunc(value); + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null; if (!plan || !Array.isArray(plan.titles)) { @@ -3806,7 +3903,10 @@ class PipelineService extends EventEmitter { logger.info('confirmEncodeReview:requested', { jobId, selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null, - selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection) + selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection), + selectedPostEncodeScriptIdsCount: Array.isArray(options?.selectedPostEncodeScriptIds) + ? options.selectedPostEncodeScriptIds.length + : 0 }); const job = await historyService.getJobById(jobId); @@ -3837,6 +3937,13 @@ class PipelineService extends EventEmitter { options?.selectedTrackSelection || null ); planForConfirm = trackSelectionResult.plan; + const hasExplicitPostScriptSelection = options?.selectedPostEncodeScriptIds !== undefined; + const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection + ? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || []) + : normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []); + const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { + strict: true + }); const confirmedMode = String(planForConfirm?.mode || encodePlan?.mode || 'rip').trim().toLowerCase(); const isPreRipMode = confirmedMode === 'pre_rip' || Boolean(planForConfirm?.preRip); @@ -3848,6 +3955,11 @@ class PipelineService extends EventEmitter { const confirmedPlan = { ...planForConfirm, + postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), + postEncodeScripts: selectedPostEncodeScripts.map((item) => ({ + id: Number(item.id), + name: item.name + })), reviewConfirmed: true, reviewConfirmedAt: nowIso() }; @@ -3869,6 +3981,7 @@ class PipelineService extends EventEmitter { `Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}` + ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.` + ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.` + + ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` ); await this.setState('READY_TO_ENCODE', { @@ -4245,6 +4358,160 @@ class PipelineService extends EventEmitter { return enrichedReview; } + async runPostEncodeScripts(jobId, encodePlan, context = {}) { + const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); + if (scriptIds.length === 0) { + return { + configured: 0, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + results: [] + }; + } + + const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false }); + const scriptById = new Map(scripts.map((item) => [Number(item.id), item])); + const results = []; + let succeeded = 0; + let failed = 0; + let skipped = 0; + let aborted = false; + let abortReason = null; + let failedScriptName = null; + let failedScriptId = null; + const titleForPush = context?.jobTitle || `Job #${jobId}`; + + for (let index = 0; index < scriptIds.length; index += 1) { + const scriptId = scriptIds[index]; + const script = scriptById.get(Number(scriptId)); + if (!script) { + failed += 1; + aborted = true; + failedScriptId = Number(scriptId); + failedScriptName = `Script #${scriptId}`; + abortReason = `Post-Encode Skript #${scriptId} wurde nicht gefunden (${index + 1}/${scriptIds.length}).`; + await historyService.appendLog(jobId, 'SYSTEM', abortReason); + results.push({ + scriptId, + scriptName: null, + status: 'ERROR', + error: 'missing' + }); + break; + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}` + ); + + let prepared = null; + try { + prepared = await scriptService.createExecutableScriptFile(script, { + source: 'post_encode', + mode: context?.mode || null, + jobId, + jobTitle: context?.jobTitle || null, + inputPath: context?.inputPath || null, + outputPath: context?.outputPath || null, + rawPath: context?.rawPath || null + }); + const runInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'POST_ENCODE_SCRIPT', + cmd: prepared.cmd, + args: prepared.args, + argsForLog: prepared.argsForLog + }); + + succeeded += 1; + results.push({ + scriptId: script.id, + scriptName: script.name, + status: 'SUCCESS', + runInfo + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skript erfolgreich: ${script.name}` + ); + } catch (error) { + failed += 1; + aborted = true; + failedScriptId = Number(script.id); + failedScriptName = script.name; + abortReason = error?.message || 'unknown'; + results.push({ + scriptId: script.id, + scriptName: script.name, + status: 'ERROR', + error: abortReason + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skript fehlgeschlagen: ${script.name} (${abortReason})` + ); + logger.warn('encode:post-script:failed', { + jobId, + scriptId: script.id, + scriptName: script.name, + error: errorToMeta(error) + }); + break; + } finally { + if (prepared?.cleanup) { + await prepared.cleanup(); + } + } + } + + if (aborted) { + const executedScriptIds = new Set(results.map((item) => Number(item?.scriptId))); + for (const pendingScriptId of scriptIds) { + const numericId = Number(pendingScriptId); + if (executedScriptIds.has(numericId)) { + continue; + } + const pendingScript = scriptById.get(numericId); + skipped += 1; + results.push({ + scriptId: numericId, + scriptName: pendingScript?.name || null, + status: 'SKIPPED_ABORTED' + }); + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skriptkette abgebrochen nach Fehler in ${failedScriptName || `Script #${failedScriptId || 'unknown'}`}.` + ); + void this.notifyPushover('job_error', { + title: 'Ripster - Post-Encode Skriptfehler', + message: `${titleForPush}: ${failedScriptName || `Script #${failedScriptId || 'unknown'}`} fehlgeschlagen (${abortReason || 'unknown'}). Skriptkette abgebrochen.` + }); + } + + return { + configured: scriptIds.length, + attempted: scriptIds.length - skipped, + succeeded, + failed, + skipped, + aborted, + abortReason, + failedScriptId, + failedScriptName, + results + }; + } + async startEncodingFromPrepared(jobId) { this.ensureNotBusy('startEncodingFromPrepared'); logger.info('encode:start-from-prepared', { jobId }); @@ -4284,10 +4551,9 @@ class PipelineService extends EventEmitter { throw error; } - const preferredOutputPath = buildOutputPathFromJob(settings, job, jobId); - const outputPath = ensureUniqueOutputPath(preferredOutputPath); - const outputPathWithTimestamp = outputPath !== preferredOutputPath; - ensureDir(path.dirname(outputPath)); + const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, job, jobId); + const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, job, jobId); + ensureDir(path.dirname(incompleteOutputPath)); await this.setState('ENCODING', { activeJobId: jobId, @@ -4298,7 +4564,7 @@ class PipelineService extends EventEmitter { jobId, mode, inputPath, - outputPath, + outputPath: incompleteOutputPath, reviewConfirmed: true, mediaInfoReview: encodePlan || null, selectedMetadata: { @@ -4313,27 +4579,25 @@ class PipelineService extends EventEmitter { await historyService.updateJob(jobId, { status: 'ENCODING', last_state: 'ENCODING', - output_path: outputPath, + output_path: incompleteOutputPath, encode_input_path: inputPath }); - if (outputPathWithTimestamp) { - await historyService.appendLog( - jobId, - 'SYSTEM', - `Output existierte bereits. Neuer Output-Pfad mit Timestamp: ${outputPath}` - ); - } + await historyService.appendLog( + jobId, + 'SYSTEM', + `Temporärer Encode-Output: ${incompleteOutputPath} (wird nach erfolgreichem Encode in den finalen Zielordner verschoben).` + ); if (mode === 'reencode') { void this.notifyPushover('reencode_started', { title: 'Ripster - Re-Encode gestartet', - message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}` + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` }); } else { void this.notifyPushover('encoding_started', { title: 'Ripster - Encoding gestartet', - message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}` + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` }); } @@ -4407,7 +4671,7 @@ class PipelineService extends EventEmitter { handBrakeTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId ?? encodePlan?.encodeInputTitleId); } } - const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, outputPath, { + const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, incompleteOutputPath, { trackSelection, titleId: handBrakeTitleId }); @@ -4434,39 +4698,98 @@ class PipelineService extends EventEmitter { args: handBrakeConfig.args, parser: parseHandBrakeProgress }); + const outputFinalization = finalizeOutputPathForCompletedEncode( + incompleteOutputPath, + preferredFinalOutputPath + ); + const finalizedOutputPath = outputFinalization.outputPath; + if (outputFinalization.outputPathWithTimestamp) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Finaler Output existierte bereits. Neuer Zielpfad mit Timestamp: ${finalizedOutputPath}` + ); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `Encode-Output finalisiert: ${finalizedOutputPath}` + ); + let postEncodeScriptsSummary = { + configured: 0, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + results: [] + }; + try { + postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, { + mode, + jobTitle: job.title || job.detected_title || null, + inputPath, + outputPath: finalizedOutputPath, + rawPath: job.raw_path || null + }); + } catch (error) { + logger.warn('encode:post-script:summary-failed', { + jobId, + error: errorToMeta(error) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}` + ); + } + if (postEncodeScriptsSummary.configured > 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}` + ); + } + const handbrakeInfoWithPostScripts = { + ...handbrakeInfo, + postEncodeScripts: postEncodeScriptsSummary + }; await historyService.updateJob(jobId, { - handbrake_info_json: JSON.stringify(handbrakeInfo), + handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts), status: 'FINISHED', last_state: 'FINISHED', end_time: nowIso(), - output_path: outputPath, + output_path: finalizedOutputPath, error_message: null }); - logger.info('encoding:finished', { jobId, mode, outputPath }); + logger.info('encoding:finished', { jobId, mode, outputPath: finalizedOutputPath }); + const finishedStatusTextBase = mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen'; + const finishedStatusText = postEncodeScriptsSummary.failed > 0 + ? `${finishedStatusTextBase} (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)` + : finishedStatusTextBase; await this.setState('FINISHED', { activeJobId: jobId, progress: 100, eta: null, - statusText: mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen', + statusText: finishedStatusText, context: { jobId, mode, - outputPath + outputPath: finalizedOutputPath } }); if (mode === 'reencode') { void this.notifyPushover('reencode_finished', { title: 'Ripster - Re-Encode abgeschlossen', - message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}` + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` }); } else { void this.notifyPushover('job_finished', { title: 'Ripster - Job abgeschlossen', - message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${outputPath}` + message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` }); } @@ -4510,6 +4833,9 @@ class PipelineService extends EventEmitter { const preRipTrackSelectionPayload = hasPreRipConfirmedSelection ? extractManualSelectionPayloadFromPlan(preRipPlanBeforeRip) : null; + const preRipPostEncodeScriptIds = hasPreRipConfirmedSelection + ? normalizeScriptIdList(preRipPlanBeforeRip?.postEncodeScriptIds || []) + : []; const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); const selectedTitleId = playlistDecision.selectedTitleId; const selectedPlaylist = playlistDecision.selectedPlaylist; @@ -4693,7 +5019,8 @@ class PipelineService extends EventEmitter { ); await this.confirmEncodeReview(jobId, { selectedEncodeTitleId: review?.encodeInputTitleId || null, - selectedTrackSelection: preRipTrackSelectionPayload || null + selectedTrackSelection: preRipTrackSelectionPayload || null, + selectedPostEncodeScriptIds: preRipPostEncodeScriptIds }); const autoStartResult = await this.startPreparedJob(jobId); logger.info('rip:auto-encode-started', { diff --git a/backend/src/services/scriptService.js b/backend/src/services/scriptService.js new file mode 100644 index 0000000..69deee1 --- /dev/null +++ b/backend/src/services/scriptService.js @@ -0,0 +1,435 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const { getDb } = require('../db/database'); +const logger = require('./logger').child('SCRIPTS'); +const { errorToMeta } = require('../utils/errorMeta'); + +const SCRIPT_NAME_MAX_LENGTH = 120; +const SCRIPT_BODY_MAX_LENGTH = 200000; +const SCRIPT_TEST_TIMEOUT_MS = 120000; +const SCRIPT_OUTPUT_MAX_CHARS = 150000; + +function normalizeScriptId(rawValue) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return Math.trunc(value); +} + +function normalizeScriptIdList(rawList) { + const list = Array.isArray(rawList) ? rawList : []; + const seen = new Set(); + const output = []; + for (const item of list) { + const normalized = normalizeScriptId(item); + if (!normalized) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function normalizeScriptName(rawValue) { + return String(rawValue || '').trim(); +} + +function normalizeScriptBody(rawValue) { + return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function createValidationError(message, details = null) { + const error = new Error(message); + error.statusCode = 400; + if (details) { + error.details = details; + } + return error; +} + +function validateScriptPayload(payload, { partial = false } = {}) { + const body = payload && typeof payload === 'object' ? payload : {}; + const hasName = Object.prototype.hasOwnProperty.call(body, 'name'); + const hasScriptBody = Object.prototype.hasOwnProperty.call(body, 'scriptBody'); + const normalized = {}; + const errors = []; + + if (!partial || hasName) { + const name = normalizeScriptName(body.name); + if (!name) { + errors.push({ field: 'name', message: 'Name darf nicht leer sein.' }); + } else if (name.length > SCRIPT_NAME_MAX_LENGTH) { + errors.push({ field: 'name', message: `Name darf maximal ${SCRIPT_NAME_MAX_LENGTH} Zeichen enthalten.` }); + } else { + normalized.name = name; + } + } + + if (!partial || hasScriptBody) { + const scriptBody = normalizeScriptBody(body.scriptBody); + if (!scriptBody.trim()) { + errors.push({ field: 'scriptBody', message: 'Skript darf nicht leer sein.' }); + } else if (scriptBody.length > SCRIPT_BODY_MAX_LENGTH) { + errors.push({ field: 'scriptBody', message: `Skript darf maximal ${SCRIPT_BODY_MAX_LENGTH} Zeichen enthalten.` }); + } else { + normalized.scriptBody = scriptBody; + } + } + + if (errors.length > 0) { + throw createValidationError('Skript ist ungültig.', errors); + } + + return normalized; +} + +function mapScriptRow(row) { + if (!row) { + return null; + } + return { + id: Number(row.id), + name: String(row.name || ''), + scriptBody: String(row.script_body || ''), + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function quoteForBashSingle(value) { + return `'${String(value || '').replace(/'/g, `'\"'\"'`)}'`; +} + +function buildScriptEnvironment(context = {}) { + const now = new Date().toISOString(); + const entries = { + RIPSTER_SCRIPT_RUN_AT: now, + RIPSTER_JOB_ID: context?.jobId ?? '', + RIPSTER_JOB_TITLE: context?.jobTitle ?? '', + RIPSTER_MODE: context?.mode ?? '', + RIPSTER_INPUT_PATH: context?.inputPath ?? '', + RIPSTER_OUTPUT_PATH: context?.outputPath ?? '', + RIPSTER_RAW_PATH: context?.rawPath ?? '', + RIPSTER_SCRIPT_ID: context?.scriptId ?? '', + RIPSTER_SCRIPT_NAME: context?.scriptName ?? '', + RIPSTER_SCRIPT_SOURCE: context?.source ?? '' + }; + + const output = {}; + for (const [key, value] of Object.entries(entries)) { + output[key] = String(value ?? ''); + } + return output; +} + +function buildScriptWrapper(scriptBody, context = {}) { + const envVars = buildScriptEnvironment(context); + const exportLines = Object.entries(envVars) + .map(([key, value]) => `export ${key}=${quoteForBashSingle(value)}`) + .join('\n'); + // Wait for potential background jobs started by the script before returning. + return `${exportLines}\n\n${String(scriptBody || '')}\n\nwait\n`; +} + +function appendWithCap(current, chunk, maxChars) { + const value = String(chunk || ''); + if (!value) { + return { value: current, truncated: false }; + } + const currentText = String(current || ''); + if (currentText.length >= maxChars) { + return { value: currentText, truncated: true }; + } + const available = maxChars - currentText.length; + if (value.length <= available) { + return { value: `${currentText}${value}`, truncated: false }; + } + return { + value: `${currentText}${value.slice(0, available)}`, + truncated: true + }; +} + +function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd() }) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const child = spawn(cmd, args, { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + let stdoutTruncated = false; + let stderrTruncated = false; + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, 2000); + }, Math.max(1000, Number(timeoutMs || SCRIPT_TEST_TIMEOUT_MS))); + + const onData = (streamName, chunk) => { + if (streamName === 'stdout') { + const next = appendWithCap(stdout, chunk, SCRIPT_OUTPUT_MAX_CHARS); + stdout = next.value; + stdoutTruncated = stdoutTruncated || next.truncated; + } else { + const next = appendWithCap(stderr, chunk, SCRIPT_OUTPUT_MAX_CHARS); + stderr = next.value; + stderrTruncated = stderrTruncated || next.truncated; + } + }; + + child.stdout?.on('data', (chunk) => onData('stdout', chunk)); + child.stderr?.on('data', (chunk) => onData('stderr', chunk)); + + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + + child.on('close', (code, signal) => { + clearTimeout(timeout); + const endedAt = Date.now(); + resolve({ + code: Number.isFinite(Number(code)) ? Number(code) : null, + signal: signal || null, + durationMs: Math.max(0, endedAt - startedAt), + timedOut, + stdout, + stderr, + stdoutTruncated, + stderrTruncated + }); + }); + }); +} + +class ScriptService { + async listScripts() { + const db = await getDb(); + const rows = await db.all( + ` + SELECT id, name, script_body, created_at, updated_at + FROM scripts + ORDER BY LOWER(name) ASC, id ASC + ` + ); + return rows.map(mapScriptRow); + } + + async getScriptById(scriptId) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const db = await getDb(); + const row = await db.get( + ` + SELECT id, name, script_body, created_at, updated_at + FROM scripts + WHERE id = ? + `, + [normalizedId] + ); + if (!row) { + const error = new Error(`Skript #${normalizedId} wurde nicht gefunden.`); + error.statusCode = 404; + throw error; + } + return mapScriptRow(row); + } + + async createScript(payload = {}) { + const normalized = validateScriptPayload(payload, { partial: false }); + const db = await getDb(); + try { + const result = await db.run( + ` + INSERT INTO scripts (name, script_body, created_at, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, + [normalized.name, normalized.scriptBody] + ); + return this.getScriptById(result.lastID); + } catch (error) { + if (String(error?.message || '').includes('UNIQUE constraint failed')) { + throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [ + { field: 'name', message: 'Name muss eindeutig sein.' } + ]); + } + throw error; + } + } + + async updateScript(scriptId, payload = {}) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const normalized = validateScriptPayload(payload, { partial: false }); + const db = await getDb(); + await this.getScriptById(normalizedId); + try { + await db.run( + ` + UPDATE scripts + SET + name = ?, + script_body = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [normalized.name, normalized.scriptBody, normalizedId] + ); + } catch (error) { + if (String(error?.message || '').includes('UNIQUE constraint failed')) { + throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [ + { field: 'name', message: 'Name muss eindeutig sein.' } + ]); + } + throw error; + } + return this.getScriptById(normalizedId); + } + + async deleteScript(scriptId) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const db = await getDb(); + const existing = await this.getScriptById(normalizedId); + await db.run('DELETE FROM scripts WHERE id = ?', [normalizedId]); + return existing; + } + + async getScriptsByIds(rawIds = []) { + const ids = normalizeScriptIdList(rawIds); + if (ids.length === 0) { + return []; + } + const db = await getDb(); + const placeholders = ids.map(() => '?').join(', '); + const rows = await db.all( + ` + SELECT id, name, script_body, created_at, updated_at + FROM scripts + WHERE id IN (${placeholders}) + `, + ids + ); + const byId = new Map(rows.map((row) => [Number(row.id), mapScriptRow(row)])); + return ids.map((id) => byId.get(id)).filter(Boolean); + } + + async resolveScriptsByIds(rawIds = [], options = {}) { + const ids = normalizeScriptIdList(rawIds); + if (ids.length === 0) { + return []; + } + const strict = options?.strict !== false; + const scripts = await this.getScriptsByIds(ids); + if (!strict) { + return scripts; + } + const foundIds = new Set(scripts.map((item) => Number(item.id))); + const missing = ids.filter((id) => !foundIds.has(Number(id))); + if (missing.length > 0) { + throw createValidationError(`Skript(e) nicht gefunden: ${missing.join(', ')}`, [ + { field: 'selectedPostEncodeScriptIds', message: `Nicht gefunden: ${missing.join(', ')}` } + ]); + } + return scripts; + } + + async createExecutableScriptFile(script, context = {}) { + const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`; + const scriptBody = normalizeScriptBody(script?.scriptBody); + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-')); + const scriptPath = path.join(tempDir, 'script.sh'); + const wrapped = buildScriptWrapper(scriptBody, { + ...context, + scriptId: script?.id ?? context?.scriptId ?? '', + scriptName: name, + source: context?.source || 'post_encode' + }); + + await fs.promises.writeFile(scriptPath, wrapped, { + encoding: 'utf-8', + mode: 0o700 + }); + + const cleanup = async () => { + try { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } catch (error) { + logger.warn('script:temp-cleanup-failed', { + scriptId: script?.id ?? null, + scriptName: name, + tempDir, + error: errorToMeta(error) + }); + } + }; + + return { + tempDir, + scriptPath, + cmd: '/usr/bin/env', + args: ['bash', scriptPath], + argsForLog: ['bash', ``], + cleanup + }; + } + + async testScript(scriptId, options = {}) { + const script = await this.getScriptById(scriptId); + const timeoutMs = Number(options?.timeoutMs); + const prepared = await this.createExecutableScriptFile(script, { + source: 'settings_test', + mode: 'test' + }); + + try { + const run = await runProcessCapture({ + cmd: prepared.cmd, + args: prepared.args, + timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : SCRIPT_TEST_TIMEOUT_MS + }); + const success = run.code === 0 && !run.timedOut; + return { + scriptId: script.id, + scriptName: script.name, + success, + exitCode: run.code, + signal: run.signal, + timedOut: run.timedOut, + durationMs: run.durationMs, + stdout: run.stdout, + stderr: run.stderr, + stdoutTruncated: run.stdoutTruncated, + stderrTruncated: run.stderrTruncated + }; + } finally { + await prepared.cleanup(); + } + } +} + +module.exports = new ScriptService(); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index b9c4bb3..8fdf03d 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -14,6 +14,7 @@ const { splitArgs } = require('../utils/commandLine'); 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 SENSITIVE_SETTING_KEYS = new Set([ 'makemkv_registration_key', 'omdb_api_key', @@ -132,6 +133,200 @@ function buildFallbackPresetProfile(presetName, message = null) { }; } +function stripAnsiEscapeCodes(value) { + return String(value || '').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); +} + +function uniqueOrderedValues(values) { + const unique = []; + const seen = new Set(); + for (const value of values || []) { + const normalized = String(value || '').trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + unique.push(normalized); + } + return unique; +} + +function uniquePresetEntries(entries) { + const unique = []; + const seenNames = new Set(); + for (const entry of entries || []) { + const name = String(entry?.name || '').trim(); + if (!name || seenNames.has(name)) { + continue; + } + seenNames.add(name); + const categoryRaw = entry?.category; + const category = categoryRaw === null || categoryRaw === undefined + ? null + : String(categoryRaw).trim() || null; + unique.push({ name, category }); + } + return unique; +} + +function normalizePresetListLines(rawOutput) { + const lines = String(rawOutput || '').split(/\r?\n/); + const normalized = []; + + for (const line of lines) { + const sanitized = stripAnsiEscapeCodes(line || '').replace(/\r/g, ''); + if (!sanitized.trim()) { + continue; + } + if (/^\s*\[[^\]]+\]/.test(sanitized)) { + continue; + } + if ( + /^\s*(Cannot load|Compile-time|qsv:|HandBrake \d|Opening |No title found|libhb:|hb_init:|thread |bdj\.c:|stream:|scan:|bd:|libdvdnav:|libdvdread:)/i + .test(sanitized) + ) { + continue; + } + if (/^\s*HandBrake has exited\.?\s*$/i.test(sanitized)) { + continue; + } + const leadingWhitespace = (sanitized.match(/^[\t ]*/) || [''])[0]; + const indentation = leadingWhitespace.replace(/\t/g, ' ').length; + const text = sanitized.trim(); + normalized.push({ indentation, text }); + } + + return normalized; +} + +function parsePlusTreePresetEntries(lines) { + const plusEntries = []; + for (const line of lines || []) { + const match = String(line?.text || '').match(/^\+\s+(.+?)\s*$/); + if (!match) { + continue; + } + plusEntries.push({ + indentation: Number(line?.indentation || 0), + name: String(match[1] || '').trim() + }); + } + + if (plusEntries.length === 0) { + return []; + } + + const leafEntries = []; + for (let index = 0; index < plusEntries.length; index += 1) { + const current = plusEntries[index]; + const next = plusEntries[index + 1]; + const hasChildren = Boolean(next) && next.indentation > current.indentation; + if (!hasChildren) { + let category = null; + for (let parentIndex = index - 1; parentIndex >= 0; parentIndex -= 1) { + const candidate = plusEntries[parentIndex]; + if (candidate.indentation < current.indentation) { + category = candidate.name || null; + break; + } + } + leafEntries.push({ + name: current.name, + category + }); + } + } + + return uniquePresetEntries(leafEntries); +} + +function parseSlashTreePresetEntries(lines) { + const list = Array.isArray(lines) ? lines : []; + const presetEntries = []; + let currentCategoryIndent = null; + let currentCategoryName = null; + let currentPresetIndent = null; + + for (const line of list) { + const indentation = Number(line?.indentation || 0); + const text = String(line?.text || '').trim(); + if (!text) { + continue; + } + + if (text.endsWith('/')) { + currentCategoryIndent = indentation; + currentCategoryName = String(text.slice(0, -1) || '').trim() || null; + currentPresetIndent = null; + continue; + } + + if (currentCategoryIndent === null) { + continue; + } + + if (indentation <= currentCategoryIndent) { + currentCategoryIndent = null; + currentCategoryName = null; + currentPresetIndent = null; + continue; + } + + if (currentPresetIndent === null) { + currentPresetIndent = indentation; + } + + if (indentation === currentPresetIndent) { + presetEntries.push({ + name: text, + category: currentCategoryName + }); + } + } + + return uniquePresetEntries(presetEntries); +} + +function parseHandBrakePresetEntriesFromListOutput(rawOutput) { + const lines = normalizePresetListLines(rawOutput); + const plusTreeEntries = parsePlusTreePresetEntries(lines); + if (plusTreeEntries.length > 0) { + return plusTreeEntries; + } + return parseSlashTreePresetEntries(lines); +} + +function mapPresetEntriesToOptions(entries) { + const list = Array.isArray(entries) ? entries : []; + const options = []; + const seenCategories = new Set(); + const INDENT = '\u00A0\u00A0\u00A0'; + + for (const entry of list) { + const name = String(entry?.name || '').trim(); + if (!name) { + continue; + } + const category = entry?.category ? String(entry.category).trim() : ''; + if (category && !seenCategories.has(category)) { + seenCategories.add(category); + options.push({ + label: `${category}/`, + value: `__group__${category.toLowerCase().replace(/\s+/g, '_')}`, + disabled: true, + category + }); + } + options.push({ + label: category ? `${INDENT}${name}` : name, + value: name, + category: category || null + }); + } + + return options; +} + class SettingsService { async getSchemaRows() { const db = await getDb(); @@ -705,6 +900,85 @@ class SettingsService { return `disc:${map.makemkv_source_index ?? 0}`; } + + async getHandBrakePresetOptions() { + const map = await this.getSettingsMap(); + const configuredPreset = String(map.handbrake_preset || '').trim(); + const fallbackOptions = configuredPreset + ? [{ label: configuredPreset, value: configuredPreset }] + : []; + const rawCommand = String(map.handbrake_command || 'HandBrakeCLI').trim(); + const commandTokens = splitArgs(rawCommand); + const cmd = commandTokens[0] || 'HandBrakeCLI'; + const baseArgs = commandTokens.slice(1); + const args = [...baseArgs, '-z']; + + try { + const result = spawnSync(cmd, args, { + encoding: 'utf-8', + timeout: HANDBRAKE_PRESET_LIST_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024 + }); + + if (result.error) { + return { + source: 'fallback', + message: `Preset-Liste konnte nicht geladen werden: ${result.error.message}`, + options: fallbackOptions + }; + } + + if (result.status !== 0) { + const stderr = String(result.stderr || '').trim(); + const stdout = String(result.stdout || '').trim(); + const detail = (stderr || stdout || `exit=${result.status}`).slice(0, 280); + return { + source: 'fallback', + message: `Preset-Liste konnte nicht geladen werden (${detail})`, + options: fallbackOptions + }; + } + + const combinedOutput = `${String(result.stdout || '')}\n${String(result.stderr || '')}`; + const entries = parseHandBrakePresetEntriesFromListOutput(combinedOutput); + const options = mapPresetEntriesToOptions(entries); + if (options.length === 0) { + return { + source: 'fallback', + message: 'Preset-Liste konnte aus HandBrakeCLI -z nicht geparst werden.', + options: fallbackOptions + }; + } + if (!configuredPreset) { + return { + source: 'handbrake-cli', + message: null, + options + }; + } + + const hasConfiguredPreset = options.some((option) => option.value === configuredPreset); + if (hasConfiguredPreset) { + return { + source: 'handbrake-cli', + message: null, + options + }; + } + + return { + source: 'handbrake-cli', + message: `Aktuell gesetztes Preset "${configuredPreset}" wurde in HandBrakeCLI -z nicht gefunden.`, + options: [{ label: configuredPreset, value: configuredPreset }, ...options] + }; + } catch (error) { + return { + source: 'fallback', + message: `Preset-Liste konnte nicht geladen werden: ${error.message}`, + options: fallbackOptions + }; + } + } } module.exports = new SettingsService(); diff --git a/db/schema.sql b/db/schema.sql index 3579b44..b9fe622 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -52,6 +52,16 @@ CREATE TABLE jobs ( CREATE INDEX idx_jobs_status ON jobs(status); CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC); +CREATE TABLE scripts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + script_body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_scripts_name ON scripts(name); + CREATE TABLE pipeline_state ( id INTEGER PRIMARY KEY CHECK (id = 1), state TEXT NOT NULL, diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 24a74db..baf5a94 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -36,6 +36,34 @@ export const api = { getSettings() { return request('/settings'); }, + getHandBrakePresets() { + return request('/settings/handbrake-presets'); + }, + getScripts() { + return request('/settings/scripts'); + }, + createScript(payload = {}) { + return request('/settings/scripts', { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + }, + updateScript(scriptId, payload = {}) { + return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, { + method: 'PUT', + body: JSON.stringify(payload || {}) + }); + }, + deleteScript(scriptId) { + return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, { + method: 'DELETE' + }); + }, + testScript(scriptId) { + return request(`/settings/scripts/${encodeURIComponent(scriptId)}/test`, { + method: 'POST' + }); + }, updateSetting(key, value) { return request(`/settings/${encodeURIComponent(key)}`, { method: 'PUT', diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index fbfb0eb..b2a171f 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -38,8 +38,8 @@ function buildToolSections(settings) { { id: 'output', title: 'Output', - description: 'Container-Format und Dateinamen-Template.', - match: (key) => key === 'output_extension' || key === 'filename_template' + description: 'Container-Format sowie Datei- und Ordnernamen-Template.', + match: (key) => key === 'output_extension' || key === 'filename_template' || key === 'output_folder_template' } ]; @@ -95,6 +95,10 @@ function buildSectionsForCategory(categoryName, settings) { ]; } +function isHandBrakePresetSetting(setting) { + return String(setting?.key || '').trim().toLowerCase() === 'handbrake_preset'; +} + export default function DynamicSettingsForm({ categories, values, @@ -194,11 +198,24 @@ export default function DynamicSettingsForm({ options={setting.options} optionLabel="label" optionValue="value" + optionDisabled="disabled" onChange={(event) => onChange?.(setting.key, event.value)} /> ) : null} {setting.description || ''} + {isHandBrakePresetSetting(setting) ? ( + + Preset-Erklärung:{' '} + + HandBrake Official Presets + + + ) : null} {error ? ( {error} ) : ( diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 7405bfc..c4d0979 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -1,7 +1,8 @@ import { Dialog } from 'primereact/dialog'; -import { Tag } from 'primereact/tag'; import { Button } from 'primereact/button'; import MediaInfoReviewPanel from './MediaInfoReviewPanel'; +import blurayIndicatorIcon from '../assets/media-bluray.svg'; +import discIndicatorIcon from '../assets/media-disc.svg'; function JsonView({ title, value }) { return ( @@ -12,6 +13,67 @@ function JsonView({ title, value }) { ); } +function resolveMediaType(job) { + const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase(); + return raw === 'bluray' ? 'bluray' : 'disc'; +} + +function statusBadgeMeta(status) { + const normalized = String(status || '').trim().toUpperCase(); + if (normalized === 'FINISHED') { + return { label: normalized, icon: 'pi-check-circle', tone: 'success' }; + } + if (normalized === 'ERROR') { + return { label: normalized, icon: 'pi-times-circle', tone: 'danger' }; + } + if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') { + return { label: normalized, icon: 'pi-play-circle', tone: 'info' }; + } + if (normalized === 'WAITING_FOR_USER_DECISION') { + return { label: normalized, icon: 'pi-exclamation-circle', tone: 'warning' }; + } + if (normalized === 'METADATA_SELECTION') { + return { label: normalized, icon: 'pi-list', tone: 'warning' }; + } + if (normalized === 'ANALYZING') { + return { label: normalized, icon: 'pi-search', tone: 'warning' }; + } + if (normalized === 'RIPPING') { + return { label: normalized, icon: 'pi-download', tone: 'warning' }; + } + if (normalized === 'MEDIAINFO_CHECK') { + return { label: normalized, icon: 'pi-sliders-h', tone: 'warning' }; + } + if (normalized === 'ENCODING') { + return { label: normalized, icon: 'pi-cog', tone: 'warning' }; + } + return { label: normalized || '-', icon: 'pi-info-circle', tone: 'secondary' }; +} + +function omdbField(value) { + const raw = String(value || '').trim(); + return raw || '-'; +} + +function omdbRottenTomatoesScore(omdbInfo) { + const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : []; + const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === 'rotten tomatoes'); + return omdbField(entry?.Value); +} + +function BoolState({ value }) { + const isTrue = Boolean(value); + return isTrue ? ( + +