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 ? ( +
+ Ausgabe:{item?.outputTruncated ? ' (gekürzt)' : ''} +
{item.output}
+
+ ) : null} + {hasStdout ? ( +
+ stdout:{item?.stdoutTruncated ? ' (gekürzt)' : ''} +
{item.stdout}
+
+ ) : null} + {hasStderr ? ( +
+ stderr:{item?.stderrTruncated ? ' (gekürzt)' : ''} +
{item.stderr}
+
+ ) : null} +
+ ); +} + function normalizeHardwareMonitoringPayload(rawPayload) { const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {}; return { @@ -1016,13 +1065,23 @@ export default function DashboardPage({ void load(false); const interval = setInterval(() => { void load(true); - }, 2500); + }, 10000); return () => { cancelled = true; clearInterval(interval); }; }, []); + useWebSocket({ + onMessage: (message) => { + if (message?.type !== 'RUNTIME_ACTIVITY_CHANGED') { + return; + } + setRuntimeActivities(normalizeRuntimeActivitiesPayload(message.payload)); + setRuntimeLoading(false); + } + }); + useEffect(() => { const normalizedExpanded = normalizeJobId(expandedJobId); const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded); @@ -2447,6 +2506,10 @@ export default function DashboardPage({ {item?.currentStep ? Schritt: {item.currentStep} : null} {item?.currentScriptName ? Laufendes Skript: {item.currentScriptName} : null} {item?.message ? {item.message} : null} + Gestartet: {formatUpdatedAt(item?.startedAt)} {canCancel || canNextStep ? (
@@ -2515,27 +2578,10 @@ export default function DashboardPage({ {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)} diff --git a/package-lock.json b/package-lock.json index 7539bc9..70f4fd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.10.2-2", + "version": "0.10.2-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.10.2-2", + "version": "0.10.2-3", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 30b7348..aa7c34d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.10.2-2", + "version": "0.10.2-3", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",