diff --git a/backend/package-lock.json b/backend/package-lock.json
index dcd1d12..74c290a 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
- "version": "0.10.2-2",
+ "version": "0.10.2-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
- "version": "0.10.2-2",
+ "version": "0.10.2-3",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
diff --git a/backend/package.json b/backend/package.json
index 2747b6e..01f25b7 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
- "version": "0.10.2-2",
+ "version": "0.10.2-3",
"private": true,
"type": "commonjs",
"scripts": {
diff --git a/backend/src/db/database.js b/backend/src/db/database.js
index b538630..d81eb05 100644
--- a/backend/src/db/database.js
+++ b/backend/src/db/database.js
@@ -788,7 +788,8 @@ async function removeDeprecatedSettings(db) {
'filename_template_bluray',
'filename_template_dvd',
'output_folder_template_bluray',
- 'output_folder_template_dvd'
+ 'output_folder_template_dvd',
+ 'output_extension_audiobook'
];
for (const key of deprecatedKeys) {
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
@@ -906,11 +907,6 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
- await db.run(
- `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
- VALUES ('output_extension_audiobook', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Audiobook-Datei.', 'mp3', '[{"label":"M4B","value":"m4b"},{"label":"MP3","value":"mp3"},{"label":"FLAC","value":"flac"}]', '{}', 730)`
- );
- await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_audiobook', 'mp3')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
diff --git a/backend/src/services/cronService.js b/backend/src/services/cronService.js
index 35291e6..c871df1 100644
--- a/backend/src/services/cronService.js
+++ b/backend/src/services/cronService.js
@@ -10,6 +10,7 @@ const notificationService = require('./notificationService');
const settingsService = require('./settingsService');
const wsService = require('./websocketService');
const runtimeActivityService = require('./runtimeActivityService');
+const { spawnTrackedProcess } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
// Maximale Zeilen pro Log-Eintrag (Output-Truncation)
@@ -252,33 +253,57 @@ async function runCronJob(job) {
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, {
- 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 }));
+ let stdout = '';
+ let stderr = '';
+ let stdoutTruncated = false;
+ let stderrTruncated = false;
+ const processHandle = spawnTrackedProcess({
+ cmd: prepared.cmd,
+ args: prepared.args,
+ context: { source: 'cron', cronJobId: job.id, scriptId: script.id },
+ onStdoutLine: (line) => {
+ const next = stdout.length <= MAX_OUTPUT_CHARS
+ ? `${stdout}${line}\n`
+ : stdout;
+ stdout = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next;
+ stdoutTruncated = stdoutTruncated || next.length > MAX_OUTPUT_CHARS;
+ runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line });
+ },
+ onStderrLine: (line) => {
+ const next = stderr.length <= MAX_OUTPUT_CHARS
+ ? `${stderr}${line}\n`
+ : stderr;
+ stderr = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next;
+ stderrTruncated = stderrTruncated || next.length > MAX_OUTPUT_CHARS;
+ runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
+ }
});
+ let exitCode = 0;
+ try {
+ const result = await processHandle.promise;
+ exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
+ } catch (error) {
+ exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
+ if (exitCode === null) {
+ throw error;
+ }
+ }
- output = [result.stdout, result.stderr].filter(Boolean).join('\n');
+ output = [stdout, stderr].filter(Boolean).join('\n');
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}`;
+ success = exitCode === 0;
+ if (!success) errorMessage = `Exit-Code ${exitCode}`;
runtimeActivityService.completeActivity(scriptActivityId, {
status: success ? 'success' : 'error',
success,
outcome: success ? 'success' : 'error',
- exitCode: result.code,
+ exitCode,
message: success ? null : errorMessage,
output: output || null,
- stdout: result.stdout || null,
- stderr: result.stderr || null,
+ stdout: stdout || null,
+ stderr: stderr || null,
+ stdoutTruncated,
+ stderrTruncated,
errorMessage: success ? null : (errorMessage || null)
});
} catch (error) {
diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js
index 310b2d7..83594d0 100644
--- a/backend/src/services/pipelineService.js
+++ b/backend/src/services/pipelineService.js
@@ -737,7 +737,7 @@ function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePla
|| audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE
).trim() || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE;
const outputFormat = audiobookService.normalizeOutputFormat(
- encodePlan?.format || settings?.output_extension || 'mp3'
+ encodePlan?.format || 'm4b'
);
const numericJobId = Number(fallbackJobId || job?.id || 0);
const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
@@ -799,6 +799,28 @@ function truncateLine(value, max = 180) {
return `${raw.slice(0, max)}...`;
}
+function appendTailText(currentValue, nextChunk, maxChars = 12000) {
+ const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ if (!chunk) {
+ return {
+ value: currentValue || '',
+ truncated: false
+ };
+ }
+ const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`;
+ const combined = `${String(currentValue || '')}${normalizedChunk}`;
+ if (combined.length <= maxChars) {
+ return {
+ value: combined,
+ truncated: false
+ };
+ }
+ return {
+ value: combined.slice(-maxChars),
+ truncated: true
+ };
+}
+
function extractProgressDetail(source, line) {
const text = truncateLine(line, 220);
if (!text) {
@@ -4854,42 +4876,59 @@ class PipelineService extends EventEmitter {
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', '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();
- });
+ let stdout = '';
+ let stderr = '';
+ let stdoutTruncated = false;
+ let stderrTruncated = false;
+ const processHandle = spawnTrackedProcess({
+ cmd: prepared.cmd,
+ args: prepared.args,
+ context: { source: 'queue', scriptId: script.id },
+ onStdoutLine: (line) => {
+ const next = appendTailText(stdout, line);
+ stdout = next.value;
+ stdoutTruncated = stdoutTruncated || next.truncated;
+ runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
+ },
+ onStderrLine: (line) => {
+ const next = appendTailText(stderr, line);
+ stderr = next.value;
+ stderrTruncated = stderrTruncated || next.truncated;
+ runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
+ }
});
+ let exitCode = 0;
+ let runError = null;
+ try {
+ const result = await processHandle.promise;
+ exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
+ } catch (error) {
+ runError = error;
+ exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
+ if (exitCode === null) {
+ throw error;
+ }
+ }
+
+ logger.info('queue:script:done', { scriptId: script.id, exitCode });
+ const output = [stdout, stderr].filter(Boolean).join('\n').trim();
+ const success = Number(exitCode) === 0;
+ runtimeActivityService.completeActivity(activityId, {
+ status: success ? 'success' : 'error',
+ success,
+ outcome: success ? 'success' : 'error',
+ exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null,
+ message: success ? 'Queue-Skript abgeschlossen' : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})`,
+ output: output || null,
+ stdout: stdout || null,
+ stderr: stderr || null,
+ stdoutTruncated,
+ stderrTruncated,
+ errorMessage: success ? null : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})`
+ });
+ if (runError && !success) {
+ logger.warn('queue:script:exit-nonzero', { scriptId: script.id, exitCode });
+ }
} catch (err) {
runtimeActivityService.completeActivity(activityId, {
status: 'error',
@@ -8366,7 +8405,13 @@ class PipelineService extends EventEmitter {
source: 'PRE_ENCODE_SCRIPT',
cmd: prepared.cmd,
args: prepared.args,
- argsForLog: prepared.argsForLog
+ argsForLog: prepared.argsForLog,
+ onStdoutLine: (line) => {
+ runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
+ },
+ onStderrLine: (line) => {
+ runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
+ }
});
succeeded += 1;
results.push({ scriptId: script.id, scriptName: script.name, status: 'SUCCESS', runInfo });
@@ -8377,7 +8422,11 @@ class PipelineService extends EventEmitter {
outcome: 'success',
exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null,
message: 'Pre-Encode Skript erfolgreich',
- output: runOutput || null
+ output: runOutput || null,
+ stdout: runInfo?.stdoutTail || null,
+ stderr: runInfo?.stderrTail || null,
+ stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
+ stderrTruncated: Boolean(runInfo?.stderrTruncated)
});
await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript erfolgreich: ${script.name}`);
if (progressTracker?.onStepComplete) {
@@ -8395,7 +8444,11 @@ class PipelineService extends EventEmitter {
cancelled,
message: error?.message || 'Pre-Encode Skriptfehler',
errorMessage: error?.message || 'Pre-Encode Skriptfehler',
- output: runOutput || null
+ output: runOutput || null,
+ stdout: runInfo?.stdoutTail || null,
+ stderr: runInfo?.stderrTail || null,
+ stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
+ stderrTruncated: Boolean(runInfo?.stderrTruncated)
});
failed += 1;
aborted = true;
@@ -8527,7 +8580,13 @@ class PipelineService extends EventEmitter {
source: 'POST_ENCODE_SCRIPT',
cmd: prepared.cmd,
args: prepared.args,
- argsForLog: prepared.argsForLog
+ argsForLog: prepared.argsForLog,
+ onStdoutLine: (line) => {
+ runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
+ },
+ onStderrLine: (line) => {
+ runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
+ }
});
succeeded += 1;
@@ -8544,7 +8603,11 @@ class PipelineService extends EventEmitter {
outcome: 'success',
exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null,
message: 'Post-Encode Skript erfolgreich',
- output: runOutput || null
+ output: runOutput || null,
+ stdout: runInfo?.stdoutTail || null,
+ stderr: runInfo?.stderrTail || null,
+ stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
+ stderrTruncated: Boolean(runInfo?.stderrTruncated)
});
await historyService.appendLog(
jobId,
@@ -8566,7 +8629,11 @@ class PipelineService extends EventEmitter {
cancelled,
message: error?.message || 'Post-Encode Skriptfehler',
errorMessage: error?.message || 'Post-Encode Skriptfehler',
- output: runOutput || null
+ output: runOutput || null,
+ stdout: runInfo?.stdoutTail || null,
+ stderr: runInfo?.stderrTail || null,
+ stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
+ stderrTruncated: Boolean(runInfo?.stderrTruncated)
});
failed += 1;
aborted = true;
@@ -10550,7 +10617,9 @@ class PipelineService extends EventEmitter {
collectStdoutLines = true,
collectStderrLines = true,
argsForLog = null,
- silent = false
+ silent = false,
+ onStdoutLine = null,
+ onStderrLine = null
}) {
const normalizedJobId = this.normalizeQueueJobId(jobId) || Number(jobId) || jobId;
const loggableArgs = Array.isArray(argsForLog) ? argsForLog : args;
@@ -10570,6 +10639,10 @@ class PipelineService extends EventEmitter {
exitCode: null,
stdoutLines: 0,
stderrLines: 0,
+ stdoutTail: '',
+ stderrTail: '',
+ stdoutTruncated: false,
+ stderrTruncated: false,
lastProgress: 0,
eta: null,
lastDetail: null,
@@ -10594,6 +10667,10 @@ class PipelineService extends EventEmitter {
exitCode: null,
stdoutLines: 0,
stderrLines: 0,
+ stdoutTail: '',
+ stderrTail: '',
+ stdoutTruncated: false,
+ stderrTruncated: false,
lastProgress: 0,
eta: null,
lastDetail: null,
@@ -10643,6 +10720,16 @@ class PipelineService extends EventEmitter {
collectLines.push(line);
}
void historyService.appendProcessLog(jobId, source, line);
+ const nextStdout = appendTailText(runInfo.stdoutTail, line);
+ runInfo.stdoutTail = nextStdout.value;
+ runInfo.stdoutTruncated = runInfo.stdoutTruncated || nextStdout.truncated;
+ if (typeof onStdoutLine === 'function') {
+ try {
+ onStdoutLine(line);
+ } catch (_error) {
+ // ignore observer failures for live runtime mirroring
+ }
+ }
applyLine(line, false);
},
onStderrLine: (line) => {
@@ -10650,6 +10737,16 @@ class PipelineService extends EventEmitter {
collectLines.push(line);
}
void historyService.appendProcessLog(jobId, `${source}_ERR`, line);
+ const nextStderr = appendTailText(runInfo.stderrTail, line);
+ runInfo.stderrTail = nextStderr.value;
+ runInfo.stderrTruncated = runInfo.stderrTruncated || nextStderr.truncated;
+ if (typeof onStderrLine === 'function') {
+ try {
+ onStderrLine(line);
+ } catch (_error) {
+ // ignore observer failures for live runtime mirroring
+ }
+ }
applyLine(line, true);
}
});
@@ -10900,7 +10997,7 @@ class PipelineService extends EventEmitter {
settings?.output_template || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE
).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
const outputFormat = audiobookService.normalizeOutputFormat(
- requestedFormat || settings?.output_extension || 'mp3'
+ requestedFormat || 'm4b'
);
const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat);
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
@@ -11196,7 +11293,7 @@ class PipelineService extends EventEmitter {
}
const format = audiobookService.normalizeOutputFormat(
- config?.format || encodePlan?.format || 'mp3'
+ config?.format || encodePlan?.format || 'm4b'
);
const formatOptions = audiobookService.normalizeFormatOptions(
format,
diff --git a/backend/src/services/processRunner.js b/backend/src/services/processRunner.js
index bd160e0..28403d9 100644
--- a/backend/src/services/processRunner.js
+++ b/backend/src/services/processRunner.js
@@ -109,5 +109,6 @@ function spawnTrackedProcess({
}
module.exports = {
- spawnTrackedProcess
+ spawnTrackedProcess,
+ streamLines
};
diff --git a/backend/src/services/runtimeActivityService.js b/backend/src/services/runtimeActivityService.js
index bdba99b..8845917 100644
--- a/backend/src/services/runtimeActivityService.js
+++ b/backend/src/services/runtimeActivityService.js
@@ -3,6 +3,7 @@ const wsService = require('./websocketService');
const MAX_RECENT_ACTIVITIES = 120;
const MAX_ACTIVITY_OUTPUT_CHARS = 12000;
const MAX_ACTIVITY_TEXT_CHARS = 2000;
+const OUTPUT_BROADCAST_THROTTLE_MS = 180;
function nowIso() {
return new Date().toISOString();
@@ -28,12 +29,52 @@ function normalizeText(value, { trim = true, maxChars = MAX_ACTIVITY_TEXT_CHARS
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}`;
+ if (trim) {
+ const suffix = ' ...[gekürzt]';
+ text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`;
+ } else {
+ const prefix = '...[gekürzt]\n';
+ text = `${prefix}${text.slice(-Math.max(0, maxChars - prefix.length))}`;
+ }
}
return text;
}
+function normalizeOutputChunk(value) {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ const normalized = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ if (!normalized) {
+ return '';
+ }
+ return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
+}
+
+function appendOutputTail(currentValue, chunk, maxChars = MAX_ACTIVITY_OUTPUT_CHARS) {
+ const normalizedChunk = normalizeOutputChunk(chunk);
+ const currentText = currentValue == null ? '' : String(currentValue);
+ if (!normalizedChunk) {
+ return {
+ value: currentText || null,
+ truncated: false
+ };
+ }
+
+ const combined = `${currentText}${normalizedChunk}`;
+ if (combined.length <= maxChars) {
+ return {
+ value: combined,
+ truncated: false
+ };
+ }
+
+ return {
+ value: combined.slice(-maxChars),
+ truncated: true
+ };
+}
+
function sanitizeActivity(input = {}) {
const source = input && typeof input === 'object' ? input : {};
const normalizedOutcome = normalizeText(source.outcome, { trim: true, maxChars: 40 });
@@ -61,6 +102,7 @@ function sanitizeActivity(input = {}) {
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 }),
+ outputTruncated: Boolean(source.outputTruncated),
stdoutTruncated: Boolean(source.stdoutTruncated),
stderrTruncated: Boolean(source.stderrTruncated),
startedAt: source.startedAt || nowIso(),
@@ -77,6 +119,7 @@ class RuntimeActivityService {
this.active = new Map();
this.recent = [];
this.controls = new Map();
+ this.outputBroadcastTimer = null;
}
buildSnapshot() {
@@ -92,9 +135,23 @@ class RuntimeActivityService {
}
broadcastSnapshot() {
+ if (this.outputBroadcastTimer) {
+ clearTimeout(this.outputBroadcastTimer);
+ this.outputBroadcastTimer = null;
+ }
wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
}
+ scheduleOutputBroadcast() {
+ if (this.outputBroadcastTimer) {
+ return;
+ }
+ this.outputBroadcastTimer = setTimeout(() => {
+ this.outputBroadcastTimer = null;
+ wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
+ }, OUTPUT_BROADCAST_THROTTLE_MS);
+ }
+
startActivity(type, payload = {}) {
const id = this.nextId;
this.nextId += 1;
@@ -134,6 +191,35 @@ class RuntimeActivityService {
return next;
}
+ appendActivityOutput(activityId, patch = {}) {
+ const id = normalizeNumber(activityId);
+ if (!id || !this.active.has(id)) {
+ return null;
+ }
+
+ const current = this.active.get(id);
+ const nextOutput = appendOutputTail(current.output, patch?.output, MAX_ACTIVITY_OUTPUT_CHARS);
+ const nextStdout = appendOutputTail(current.stdout, patch?.stdout, MAX_ACTIVITY_OUTPUT_CHARS);
+ const nextStderr = appendOutputTail(current.stderr, patch?.stderr, MAX_ACTIVITY_OUTPUT_CHARS);
+ const next = sanitizeActivity({
+ ...current,
+ ...patch,
+ id: current.id,
+ type: current.type,
+ status: current.status,
+ startedAt: current.startedAt,
+ output: nextOutput.value,
+ stdout: nextStdout.value,
+ stderr: nextStderr.value,
+ outputTruncated: Boolean(current.outputTruncated || patch?.outputTruncated || nextOutput.truncated),
+ stdoutTruncated: Boolean(current.stdoutTruncated || patch?.stdoutTruncated || nextStdout.truncated),
+ stderrTruncated: Boolean(current.stderrTruncated || patch?.stderrTruncated || nextStderr.truncated)
+ });
+ this.active.set(id, next);
+ this.scheduleOutputBroadcast();
+ return next;
+ }
+
completeActivity(activityId, payload = {}) {
const id = normalizeNumber(activityId);
if (!id || !this.active.has(id)) {
diff --git a/backend/src/services/scriptChainService.js b/backend/src/services/scriptChainService.js
index bf295aa..d999b96 100644
--- a/backend/src/services/scriptChainService.js
+++ b/backend/src/services/scriptChainService.js
@@ -1,7 +1,7 @@
-const { spawn } = require('child_process');
const { getDb } = require('../db/database');
const logger = require('./logger').child('SCRIPT_CHAINS');
const runtimeActivityService = require('./runtimeActivityService');
+const { spawnTrackedProcess } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
const CHAIN_NAME_MAX_LENGTH = 120;
@@ -76,6 +76,28 @@ function terminateChildProcess(child, { immediate = false } = {}) {
}
}
+function appendTailText(currentValue, nextChunk, maxChars = 12000) {
+ const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ if (!chunk) {
+ return {
+ value: currentValue || '',
+ truncated: false
+ };
+ }
+ const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`;
+ const combined = `${String(currentValue || '')}${normalizedChunk}`;
+ if (combined.length <= maxChars) {
+ return {
+ value: combined,
+ truncated: false
+ };
+ }
+ return {
+ value: combined.slice(-maxChars),
+ truncated: true
+ };
+}
+
function validateSteps(rawSteps) {
const steps = Array.isArray(rawSteps) ? rawSteps : [];
const errors = [];
@@ -615,29 +637,58 @@ class ScriptChainService {
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'],
- detached: true
- });
- 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;
+ let stdout = '';
+ let stderr = '';
+ let stdoutTruncated = false;
+ let stderrTruncated = false;
+ const processHandle = spawnTrackedProcess({
+ cmd: prepared.cmd,
+ args: prepared.args,
+ context: { source: context?.source || 'chain', chainId: chain.id, scriptId: script.id },
+ onStart: (child) => {
+ controlState.activeChild = child;
controlState.activeChildTermination = null;
- resolve({ code, signal, stdout, stderr, termination });
- });
+ },
+ onStdoutLine: (line) => {
+ const next = appendTailText(stdout, line);
+ stdout = next.value;
+ stdoutTruncated = stdoutTruncated || next.truncated;
+ runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line });
+ },
+ onStderrLine: (line) => {
+ const next = appendTailText(stderr, line);
+ stderr = next.value;
+ stderrTruncated = stderrTruncated || next.truncated;
+ runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
+ }
});
+ let runError = null;
+ let exitCode = 0;
+ let signal = null;
+ try {
+ const result = await processHandle.promise;
+ exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
+ signal = result?.signal || null;
+ } catch (error) {
+ runError = error;
+ exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
+ signal = error?.signal || null;
+ }
+ const termination = controlState.activeChildTermination;
+ controlState.activeChild = null;
+ controlState.activeChildTermination = null;
+ if (runError && exitCode === null && !termination) {
+ throw runError;
+ }
+ const run = {
+ code: exitCode,
+ signal,
+ stdout,
+ stderr,
+ stdoutTruncated,
+ stderrTruncated,
+ termination
+ };
controlState.currentStepType = null;
if (run.termination === 'skip') {
@@ -648,7 +699,11 @@ class ScriptChainService {
skipped: true,
currentStep: null,
message: 'Schritt übersprungen',
- output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null
+ output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
+ stdout: run.stdout || null,
+ stderr: run.stderr || null,
+ stdoutTruncated: Boolean(run.stdoutTruncated),
+ stderrTruncated: Boolean(run.stderrTruncated)
});
if (typeof appendLog === 'function') {
try {
@@ -678,6 +733,10 @@ class ScriptChainService {
currentStep: null,
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
+ stdout: run.stdout || null,
+ stderr: run.stderr || null,
+ stdoutTruncated: Boolean(run.stdoutTruncated),
+ stderrTruncated: Boolean(run.stderrTruncated),
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
});
if (typeof appendLog === 'function') {
@@ -709,6 +768,8 @@ class ScriptChainService {
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),
+ stdoutTruncated: Boolean(run.stdoutTruncated),
+ stderrTruncated: Boolean(run.stderrTruncated),
errorMessage: success ? null : `Fehler (Exit ${run.code})`
});
logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success });
diff --git a/backend/src/services/scriptService.js b/backend/src/services/scriptService.js
index 91908cd..2e28220 100644
--- a/backend/src/services/scriptService.js
+++ b/backend/src/services/scriptService.js
@@ -6,6 +6,7 @@ const { getDb } = require('../db/database');
const logger = require('./logger').child('SCRIPTS');
const settingsService = require('./settingsService');
const runtimeActivityService = require('./runtimeActivityService');
+const { streamLines } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
const SCRIPT_NAME_MAX_LENGTH = 120;
@@ -206,7 +207,15 @@ function killChildProcessTree(child, signal = 'SIGTERM') {
}
}
-function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) {
+function runProcessCapture({
+ cmd,
+ args,
+ timeoutMs = SCRIPT_TEST_TIMEOUT_MS,
+ cwd = process.cwd(),
+ onChild = null,
+ onStdoutLine = null,
+ onStderrLine = null
+}) {
return new Promise((resolve, reject) => {
const effectiveTimeoutMs = normalizeScriptTestTimeoutMs(timeoutMs, SCRIPT_TEST_TIMEOUT_MS);
const startedAt = Date.now();
@@ -259,6 +268,13 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
child.stdout?.on('data', (chunk) => onData('stdout', chunk));
child.stderr?.on('data', (chunk) => onData('stderr', chunk));
+ if (child.stdout && typeof onStdoutLine === 'function') {
+ streamLines(child.stdout, onStdoutLine);
+ }
+ if (child.stderr && typeof onStderrLine === 'function') {
+ streamLines(child.stderr, onStderrLine);
+ }
+
child.on('error', (error) => {
ended = true;
if (timeout) {
@@ -597,6 +613,12 @@ class ScriptService {
timeoutMs: effectiveTimeoutMs,
onChild: (child) => {
controlState.child = child;
+ },
+ onStdoutLine: (line) => {
+ runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
+ },
+ onStderrLine: (line) => {
+ runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
}
});
const exitCode = Number.isFinite(Number(run.code)) ? Number(run.code) : null;
diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js
index 71756f8..6d4434a 100644
--- a/backend/src/services/settingsService.js
+++ b/backend/src/services/settingsService.js
@@ -97,8 +97,7 @@ const PROFILED_SETTINGS = {
},
output_extension: {
bluray: 'output_extension_bluray',
- dvd: 'output_extension_dvd',
- audiobook: 'output_extension_audiobook'
+ dvd: 'output_extension_dvd'
},
output_template: {
bluray: 'output_template_bluray',
diff --git a/db/schema.sql b/db/schema.sql
index 193e285..1b8f659 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -297,8 +297,10 @@ VALUES ('mediainfo_extra_args_bluray', 'Tools', 'Mediainfo Extra Args', 'string'
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args_bluray', NULL);
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
-VALUES ('makemkv_rip_mode_bluray', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.', 'backup', '[{"label":"MKV","value":"mkv"},{"label":"Backup","value":"backup"}]', '{}', 305);
+VALUES ('makemkv_rip_mode_bluray', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).', 'backup', '[{"label":"Backup","value":"backup"},{"label":"MKV","value":"mkv"}]', '{}', 305);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_bluray', 'backup');
+UPDATE settings_schema SET default_value = 'backup', description = 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).' WHERE key = 'makemkv_rip_mode_bluray';
+UPDATE settings_values SET value = 'backup' WHERE key = 'makemkv_rip_mode_bluray';
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('makemkv_analyze_extra_args_bluray', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (Blu-ray).', NULL, '[]', '{}', 310);
@@ -389,10 +391,6 @@ INSERT OR IGNORE INTO settings_values (key, value)
VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}');
-- Tools – Audiobook
-INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
-VALUES ('output_extension_audiobook', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Audiobook-Datei.', 'mp3', '[{"label":"M4B","value":"m4b"},{"label":"MP3","value":"mp3"},{"label":"FLAC","value":"flac"}]', '{}', 730);
-INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_audiobook', 'mp3');
-
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})');
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 35f725b..f95da3d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
- "version": "0.10.2-2",
+ "version": "0.10.2-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
- "version": "0.10.2-2",
+ "version": "0.10.2-3",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
diff --git a/frontend/package.json b/frontend/package.json
index de7df98..6234e1e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
- "version": "0.10.2-2",
+ "version": "0.10.2-3",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index 38ea2cd..4e961b7 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -9,6 +9,7 @@ import { Dialog } from 'primereact/dialog';
import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext';
import { api } from '../api/client';
+import { useWebSocket } from '../hooks/useWebSocket';
import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
@@ -130,6 +131,7 @@ function normalizeRuntimeActivitiesPayload(rawPayload) {
output: source.output != null ? String(source.output) : null,
stdout: source.stdout != null ? String(source.stdout) : null,
stderr: source.stderr != null ? String(source.stderr) : null,
+ outputTruncated: Boolean(source.outputTruncated),
stdoutTruncated: Boolean(source.stdoutTruncated),
stderrTruncated: Boolean(source.stderrTruncated),
exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
@@ -190,6 +192,53 @@ function hasRuntimeOutputDetails(item) {
);
}
+function hasRuntimeLogContent(item) {
+ if (!item || typeof item !== 'object') {
+ return false;
+ }
+ return Boolean(
+ String(item.output || '').trim()
+ || String(item.stdout || '').trim()
+ || String(item.stderr || '').trim()
+ );
+}
+
+function RuntimeActivityDetails({
+ item,
+ summary,
+ emptyLabel = 'Noch keine Log-Ausgabe vorhanden.'
+}) {
+ const hasLogs = hasRuntimeLogContent(item);
+ const hasOutput = Boolean(String(item?.output || '').trim());
+ const hasStdout = Boolean(String(item?.stdout || '').trim());
+ const hasStderr = Boolean(String(item?.stderr || '').trim());
+
+ return (
+ {summary}
+ {!hasLogs ? {emptyLabel} : null}
+ {hasOutput ? (
+ {item.output}
+ {item.stdout}
+ {item.stderr}
+
{item.output}
- {item.stderr}
- {item.stdout}
-