diff --git a/backend/src/db/defaultSettings.js b/backend/src/db/defaultSettings.js
index 9046e2c..90f6c6b 100644
--- a/backend/src/db/defaultSettings.js
+++ b/backend/src/db/defaultSettings.js
@@ -86,6 +86,30 @@ const defaultSchema = [
validation: { minLength: 1 },
orderIndex: 120
},
+ {
+ key: 'hardware_monitoring_enabled',
+ category: 'Monitoring',
+ label: 'Hardware Monitoring aktiviert',
+ type: 'boolean',
+ required: 1,
+ description: 'Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates).',
+ defaultValue: 'true',
+ options: [],
+ validation: {},
+ orderIndex: 130
+ },
+ {
+ key: 'hardware_monitoring_interval_ms',
+ category: 'Monitoring',
+ label: 'Hardware Monitoring Intervall (ms)',
+ type: 'number',
+ required: 1,
+ description: 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.',
+ defaultValue: '5000',
+ options: [],
+ validation: { min: 1000, max: 60000 },
+ orderIndex: 140
+ },
{
key: 'makemkv_command',
category: 'Tools',
diff --git a/backend/src/index.js b/backend/src/index.js
index bfccfd2..d1b03cd 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -13,6 +13,7 @@ const historyRoutes = require('./routes/historyRoutes');
const wsService = require('./services/websocketService');
const pipelineService = require('./services/pipelineService');
const diskDetectionService = require('./services/diskDetectionService');
+const hardwareMonitorService = require('./services/hardwareMonitorService');
const logger = require('./services/logger').child('BOOT');
const { errorToMeta } = require('./utils/errorMeta');
@@ -38,6 +39,7 @@ async function start() {
const server = http.createServer(app);
wsService.init(server);
+ await hardwareMonitorService.init();
diskDetectionService.on('discInserted', (device) => {
logger.info('disk:inserted:event', { device });
@@ -69,6 +71,7 @@ async function start() {
const shutdown = () => {
logger.warn('backend:shutdown:received');
diskDetectionService.stop();
+ hardwareMonitorService.stop();
server.close(() => {
logger.warn('backend:shutdown:completed');
process.exit(0);
diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js
index 8abfa97..95da306 100644
--- a/backend/src/routes/pipelineRoutes.js
+++ b/backend/src/routes/pipelineRoutes.js
@@ -2,6 +2,7 @@ const express = require('express');
const asyncHandler = require('../middleware/asyncHandler');
const pipelineService = require('../services/pipelineService');
const diskDetectionService = require('../services/diskDetectionService');
+const hardwareMonitorService = require('../services/hardwareMonitorService');
const logger = require('../services/logger').child('PIPELINE_ROUTE');
const router = express.Router();
@@ -10,7 +11,10 @@ router.get(
'/state',
asyncHandler(async (req, res) => {
logger.debug('get:state', { reqId: req.reqId });
- res.json({ pipeline: pipelineService.getSnapshot() });
+ res.json({
+ pipeline: pipelineService.getSnapshot(),
+ hardwareMonitoring: hardwareMonitorService.getSnapshot()
+ });
})
);
diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js
index c7bdf66..9231aba 100644
--- a/backend/src/routes/settingsRoutes.js
+++ b/backend/src/routes/settingsRoutes.js
@@ -5,6 +5,7 @@ const scriptService = require('../services/scriptService');
const notificationService = require('../services/notificationService');
const pipelineService = require('../services/pipelineService');
const wsService = require('../services/websocketService');
+const hardwareMonitorService = require('../services/hardwareMonitorService');
const logger = require('../services/logger').child('SETTINGS_ROUTE');
const router = express.Router();
@@ -140,6 +141,18 @@ router.put(
message: error?.message || 'unknown'
};
}
+ try {
+ await hardwareMonitorService.handleSettingsChanged([key]);
+ } catch (error) {
+ logger.warn('put:setting:hardware-monitor-refresh-failed', {
+ reqId: req.reqId,
+ key,
+ error: {
+ name: error?.name,
+ message: error?.message
+ }
+ });
+ }
wsService.broadcast('SETTINGS_UPDATED', updated);
res.json({ setting: updated, reviewRefresh });
@@ -182,6 +195,17 @@ router.put(
message: error?.message || 'unknown'
};
}
+ try {
+ await hardwareMonitorService.handleSettingsChanged(changes.map((item) => item.key));
+ } catch (error) {
+ logger.warn('put:settings:bulk:hardware-monitor-refresh-failed', {
+ reqId: req.reqId,
+ error: {
+ name: error?.name,
+ message: error?.message
+ }
+ });
+ }
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
res.json({ changes, reviewRefresh });
diff --git a/backend/src/services/hardwareMonitorService.js b/backend/src/services/hardwareMonitorService.js
new file mode 100644
index 0000000..c227706
--- /dev/null
+++ b/backend/src/services/hardwareMonitorService.js
@@ -0,0 +1,953 @@
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFile } = require('child_process');
+const { promisify } = require('util');
+const settingsService = require('./settingsService');
+const wsService = require('./websocketService');
+const logger = require('./logger').child('HWMON');
+const { errorToMeta } = require('../utils/errorMeta');
+
+const execFileAsync = promisify(execFile);
+
+const DEFAULT_INTERVAL_MS = 5000;
+const MIN_INTERVAL_MS = 1000;
+const MAX_INTERVAL_MS = 60000;
+const DF_TIMEOUT_MS = 1800;
+const SENSORS_TIMEOUT_MS = 1800;
+const NVIDIA_SMI_TIMEOUT_MS = 1800;
+const RELEVANT_SETTINGS_KEYS = new Set([
+ 'hardware_monitoring_enabled',
+ 'hardware_monitoring_interval_ms',
+ 'raw_dir',
+ 'movie_dir',
+ 'log_dir'
+]);
+const MONITORED_PATH_DEFINITIONS = [
+ { key: 'raw_dir', label: 'RAW-Verzeichnis' },
+ { key: 'movie_dir', label: 'Movie-Verzeichnis' },
+ { key: 'log_dir', label: 'Log-Verzeichnis' }
+];
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function toBoolean(value) {
+ if (typeof value === 'boolean') {
+ return value;
+ }
+ if (typeof value === 'number') {
+ return value !== 0;
+ }
+ const normalized = String(value || '').trim().toLowerCase();
+ if (!normalized) {
+ return false;
+ }
+ if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
+ return true;
+ }
+ if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
+ return false;
+ }
+ return Boolean(normalized);
+}
+
+function clampIntervalMs(rawValue) {
+ const parsed = Number(rawValue);
+ if (!Number.isFinite(parsed)) {
+ return DEFAULT_INTERVAL_MS;
+ }
+ const clamped = Math.max(MIN_INTERVAL_MS, Math.min(MAX_INTERVAL_MS, Math.trunc(parsed)));
+ return clamped || DEFAULT_INTERVAL_MS;
+}
+
+function roundNumber(rawValue, digits = 1) {
+ const value = Number(rawValue);
+ if (!Number.isFinite(value)) {
+ return null;
+ }
+ const factor = 10 ** digits;
+ return Math.round(value * factor) / factor;
+}
+
+function averageNumberList(values = []) {
+ const list = (Array.isArray(values) ? values : []).filter((value) => Number.isFinite(Number(value)));
+ if (list.length === 0) {
+ return null;
+ }
+ const sum = list.reduce((acc, value) => acc + Number(value), 0);
+ return sum / list.length;
+}
+
+function parseMaybeNumber(rawValue) {
+ if (rawValue === null || rawValue === undefined) {
+ return null;
+ }
+ if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
+ return rawValue;
+ }
+ const normalized = String(rawValue).trim().replace(',', '.');
+ if (!normalized) {
+ return null;
+ }
+ const cleaned = normalized.replace(/[^0-9.+-]/g, '');
+ if (!cleaned) {
+ return null;
+ }
+ const parsed = Number(cleaned);
+ if (!Number.isFinite(parsed)) {
+ return null;
+ }
+ return parsed;
+}
+
+function normalizeTempC(rawValue) {
+ const parsed = parseMaybeNumber(rawValue);
+ if (!Number.isFinite(parsed)) {
+ return null;
+ }
+ let celsius = parsed;
+ if (Math.abs(celsius) > 500) {
+ celsius = celsius / 1000;
+ }
+ if (!Number.isFinite(celsius) || celsius <= -40 || celsius >= 160) {
+ return null;
+ }
+ return roundNumber(celsius, 1);
+}
+
+function isCommandMissingError(error) {
+ return String(error?.code || '').toUpperCase() === 'ENOENT';
+}
+
+function readTextFileSafe(filePath) {
+ try {
+ return fs.readFileSync(filePath, 'utf-8').trim();
+ } catch (_error) {
+ return '';
+ }
+}
+
+function collectTemperatureCandidates(node, pathParts = [], out = []) {
+ if (!node || typeof node !== 'object') {
+ return out;
+ }
+
+ for (const [key, value] of Object.entries(node)) {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ collectTemperatureCandidates(value, [...pathParts, key], out);
+ continue;
+ }
+ if (!/^temp\d+_input$/i.test(String(key || ''))) {
+ continue;
+ }
+ const normalizedTemp = normalizeTempC(value);
+ if (normalizedTemp === null) {
+ continue;
+ }
+ out.push({
+ label: [...pathParts, key].join(' / '),
+ value: normalizedTemp
+ });
+ }
+
+ return out;
+}
+
+function mapTemperatureCandidates(candidates = []) {
+ const perCoreSamples = new Map();
+ const packageSamples = [];
+ const genericSamples = [];
+
+ for (const entry of Array.isArray(candidates) ? candidates : []) {
+ const value = Number(entry?.value);
+ if (!Number.isFinite(value)) {
+ continue;
+ }
+ const label = String(entry?.label || '');
+ const labelLower = label.toLowerCase();
+ const coreMatch = labelLower.match(/\bcore\s*([0-9]+)\b/);
+ if (coreMatch) {
+ const index = Number(coreMatch[1]);
+ if (Number.isFinite(index) && index >= 0) {
+ const list = perCoreSamples.get(index) || [];
+ list.push(value);
+ perCoreSamples.set(index, list);
+ continue;
+ }
+ }
+
+ if (/package id|tdie|tctl|cpu package|physical id/.test(labelLower)) {
+ packageSamples.push(value);
+ continue;
+ }
+
+ genericSamples.push(value);
+ }
+
+ const perCore = Array.from(perCoreSamples.entries())
+ .sort((a, b) => a[0] - b[0])
+ .map(([index, values]) => ({
+ index,
+ temperatureC: roundNumber(averageNumberList(values), 1)
+ }))
+ .filter((item) => item.temperatureC !== null);
+
+ const overallRaw = packageSamples.length > 0
+ ? averageNumberList(packageSamples)
+ : (perCore.length > 0 ? averageNumberList(perCore.map((item) => item.temperatureC)) : averageNumberList(genericSamples));
+ const overallC = roundNumber(overallRaw, 1);
+
+ return {
+ overallC,
+ perCore,
+ available: Boolean(overallC !== null || perCore.length > 0)
+ };
+}
+
+function isLikelyCpuTemperatureLabel(label = '') {
+ const normalized = String(label || '').trim().toLowerCase();
+ if (!normalized) {
+ return false;
+ }
+ return /cpu|core|package|tdie|tctl|physical id|x86_pkg_temp|k10temp|zenpower|cpu-thermal|soc_thermal/.test(normalized);
+}
+
+function preferCpuTemperatureCandidates(candidates = []) {
+ const list = Array.isArray(candidates) ? candidates : [];
+ const cpuLikely = list.filter((item) => isLikelyCpuTemperatureLabel(item?.label));
+ return cpuLikely.length > 0 ? cpuLikely : list;
+}
+
+function parseDfStats(rawOutput) {
+ const lines = String(rawOutput || '')
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+ if (lines.length < 2) {
+ return null;
+ }
+ const dataLine = lines[lines.length - 1];
+ const columns = dataLine.split(/\s+/);
+ if (columns.length < 6) {
+ return null;
+ }
+
+ const totalKb = parseMaybeNumber(columns[1]);
+ const usedKb = parseMaybeNumber(columns[2]);
+ const availableKb = parseMaybeNumber(columns[3]);
+ const usagePercent = parseMaybeNumber(String(columns[4]).replace('%', ''));
+ const mountPoint = columns.slice(5).join(' ');
+
+ if (!Number.isFinite(totalKb) || !Number.isFinite(usedKb) || !Number.isFinite(availableKb)) {
+ return null;
+ }
+
+ return {
+ totalBytes: Math.max(0, Math.round(totalKb * 1024)),
+ usedBytes: Math.max(0, Math.round(usedKb * 1024)),
+ freeBytes: Math.max(0, Math.round(availableKb * 1024)),
+ usagePercent: Number.isFinite(usagePercent)
+ ? roundNumber(usagePercent, 1)
+ : (totalKb > 0 ? roundNumber((usedKb / totalKb) * 100, 1) : null),
+ mountPoint: mountPoint || null
+ };
+}
+
+function parseNvidiaCsvLine(line) {
+ const columns = String(line || '').split(',').map((part) => part.trim());
+ if (columns.length < 10) {
+ return null;
+ }
+
+ const index = parseMaybeNumber(columns[0]);
+ const memoryUsedMiB = parseMaybeNumber(columns[5]);
+ const memoryTotalMiB = parseMaybeNumber(columns[6]);
+ return {
+ index: Number.isFinite(index) ? Math.trunc(index) : null,
+ name: columns[1] || null,
+ utilizationPercent: roundNumber(parseMaybeNumber(columns[2]), 1),
+ memoryUtilizationPercent: roundNumber(parseMaybeNumber(columns[3]), 1),
+ temperatureC: roundNumber(parseMaybeNumber(columns[4]), 1),
+ memoryUsedBytes: Number.isFinite(memoryUsedMiB) ? Math.round(memoryUsedMiB * 1024 * 1024) : null,
+ memoryTotalBytes: Number.isFinite(memoryTotalMiB) ? Math.round(memoryTotalMiB * 1024 * 1024) : null,
+ powerDrawW: roundNumber(parseMaybeNumber(columns[7]), 1),
+ powerLimitW: roundNumber(parseMaybeNumber(columns[8]), 1),
+ fanPercent: roundNumber(parseMaybeNumber(columns[9]), 1)
+ };
+}
+
+class HardwareMonitorService {
+ constructor() {
+ this.enabled = false;
+ this.intervalMs = DEFAULT_INTERVAL_MS;
+ this.monitoredPaths = [];
+ this.running = false;
+ this.timer = null;
+ this.pollInFlight = false;
+ this.lastCpuTimes = null;
+ this.sensorsCommandAvailable = null;
+ this.nvidiaSmiAvailable = null;
+ this.lastSnapshot = {
+ enabled: false,
+ intervalMs: DEFAULT_INTERVAL_MS,
+ updatedAt: null,
+ sample: null,
+ error: null
+ };
+ }
+
+ async init() {
+ await this.reloadFromSettings({
+ forceBroadcast: true,
+ forceImmediatePoll: true
+ });
+ }
+
+ stop() {
+ this.stopPolling();
+ }
+
+ getSnapshot() {
+ return {
+ enabled: Boolean(this.lastSnapshot?.enabled),
+ intervalMs: Number(this.lastSnapshot?.intervalMs || DEFAULT_INTERVAL_MS),
+ updatedAt: this.lastSnapshot?.updatedAt || null,
+ sample: this.lastSnapshot?.sample || null,
+ error: this.lastSnapshot?.error || null
+ };
+ }
+
+ async handleSettingsChanged(changedKeys = []) {
+ const normalizedKeys = (Array.isArray(changedKeys) ? changedKeys : [])
+ .map((key) => String(key || '').trim().toLowerCase())
+ .filter(Boolean);
+
+ if (normalizedKeys.length === 0) {
+ return;
+ }
+
+ const relevant = normalizedKeys.some((key) => RELEVANT_SETTINGS_KEYS.has(key));
+ if (!relevant) {
+ return;
+ }
+
+ await this.reloadFromSettings({
+ forceImmediatePoll: true
+ });
+ }
+
+ async reloadFromSettings(options = {}) {
+ const forceBroadcast = Boolean(options?.forceBroadcast);
+ const forceImmediatePoll = Boolean(options?.forceImmediatePoll);
+ let settingsMap = {};
+ try {
+ settingsMap = await settingsService.getSettingsMap();
+ } catch (error) {
+ logger.warn('settings:load:failed', { error: errorToMeta(error) });
+ return this.getSnapshot();
+ }
+
+ const nextEnabled = toBoolean(settingsMap.hardware_monitoring_enabled);
+ const nextIntervalMs = clampIntervalMs(settingsMap.hardware_monitoring_interval_ms);
+ const nextPaths = this.buildMonitoredPaths(settingsMap);
+ const wasEnabled = this.enabled;
+ const intervalChanged = nextIntervalMs !== this.intervalMs;
+ const pathsChanged = this.pathsSignature(this.monitoredPaths) !== this.pathsSignature(nextPaths);
+
+ this.enabled = nextEnabled;
+ this.intervalMs = nextIntervalMs;
+ this.monitoredPaths = nextPaths;
+ this.lastSnapshot = {
+ ...this.lastSnapshot,
+ enabled: this.enabled,
+ intervalMs: this.intervalMs
+ };
+
+ if (!this.enabled) {
+ this.stopPolling();
+ this.lastSnapshot = {
+ enabled: false,
+ intervalMs: this.intervalMs,
+ updatedAt: nowIso(),
+ sample: null,
+ error: null
+ };
+ this.broadcastUpdate();
+ return this.getSnapshot();
+ }
+
+ if (!this.running) {
+ this.startPolling();
+ } else if (intervalChanged || pathsChanged || forceImmediatePoll || !wasEnabled) {
+ this.scheduleNext(25);
+ }
+
+ if (forceBroadcast || intervalChanged || !wasEnabled) {
+ this.broadcastUpdate();
+ }
+
+ return this.getSnapshot();
+ }
+
+ buildMonitoredPaths(settingsMap = {}) {
+ return MONITORED_PATH_DEFINITIONS.map((definition) => ({
+ ...definition,
+ path: String(settingsMap?.[definition.key] || '').trim()
+ }));
+ }
+
+ pathsSignature(paths = []) {
+ return (Array.isArray(paths) ? paths : [])
+ .map((item) => `${String(item?.key || '')}:${String(item?.path || '')}`)
+ .join('|');
+ }
+
+ startPolling() {
+ if (this.running) {
+ return;
+ }
+ this.running = true;
+ logger.info('start', {
+ intervalMs: this.intervalMs,
+ pathKeys: this.monitoredPaths.map((item) => item.key)
+ });
+ this.scheduleNext(20);
+ }
+
+ stopPolling() {
+ const wasActive = this.running || this.pollInFlight || Boolean(this.timer);
+ this.running = false;
+ this.pollInFlight = false;
+ this.lastCpuTimes = null;
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ if (wasActive) {
+ logger.info('stop');
+ }
+ }
+
+ scheduleNext(delayMs) {
+ if (!this.running) {
+ return;
+ }
+ if (this.timer) {
+ clearTimeout(this.timer);
+ }
+ const delay = Math.max(0, Math.trunc(Number(delayMs) || this.intervalMs));
+ this.timer = setTimeout(() => {
+ this.timer = null;
+ void this.pollOnce();
+ }, delay);
+ }
+
+ async pollOnce() {
+ if (!this.running || !this.enabled) {
+ return;
+ }
+ if (this.pollInFlight) {
+ this.scheduleNext(this.intervalMs);
+ return;
+ }
+ this.pollInFlight = true;
+ try {
+ const sample = await this.collectSample();
+ this.lastSnapshot = {
+ enabled: true,
+ intervalMs: this.intervalMs,
+ updatedAt: nowIso(),
+ sample,
+ error: null
+ };
+ this.broadcastUpdate();
+ } catch (error) {
+ logger.warn('poll:failed', { error: errorToMeta(error) });
+ this.lastSnapshot = {
+ ...this.lastSnapshot,
+ enabled: true,
+ intervalMs: this.intervalMs,
+ updatedAt: nowIso(),
+ error: error?.message || 'Hardware-Monitoring fehlgeschlagen.'
+ };
+ this.broadcastUpdate();
+ } finally {
+ this.pollInFlight = false;
+ if (this.running && this.enabled) {
+ this.scheduleNext(this.intervalMs);
+ }
+ }
+ }
+
+ broadcastUpdate() {
+ wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot());
+ }
+
+ async collectSample() {
+ const memory = this.collectMemoryMetrics();
+ const [cpu, gpu, storage] = await Promise.all([
+ this.collectCpuMetrics(),
+ this.collectGpuMetrics(),
+ this.collectStorageMetrics()
+ ]);
+
+ return {
+ cpu,
+ memory,
+ gpu,
+ storage
+ };
+ }
+
+ collectMemoryMetrics() {
+ const totalBytes = Number(os.totalmem() || 0);
+ const freeBytes = Number(os.freemem() || 0);
+ const usedBytes = Math.max(0, totalBytes - freeBytes);
+ const usagePercent = totalBytes > 0
+ ? roundNumber((usedBytes / totalBytes) * 100, 1)
+ : null;
+ return {
+ totalBytes,
+ usedBytes,
+ freeBytes,
+ usagePercent
+ };
+ }
+
+ getCpuTimes() {
+ const cpus = os.cpus() || [];
+ return cpus.map((cpu) => {
+ const times = cpu?.times || {};
+ const idle = Number(times.idle || 0);
+ const total = Object.values(times).reduce((sum, value) => sum + Number(value || 0), 0);
+ return { idle, total };
+ });
+ }
+
+ calculateCpuUsage(currentTimes = [], previousTimes = []) {
+ const perCore = [];
+ const coreCount = Math.min(currentTimes.length, previousTimes.length);
+ if (coreCount <= 0) {
+ return {
+ overallUsagePercent: null,
+ perCore
+ };
+ }
+
+ let totalDelta = 0;
+ let idleDelta = 0;
+ for (let index = 0; index < coreCount; index += 1) {
+ const prev = previousTimes[index];
+ const cur = currentTimes[index];
+ const deltaTotal = Number(cur?.total || 0) - Number(prev?.total || 0);
+ const deltaIdle = Number(cur?.idle || 0) - Number(prev?.idle || 0);
+ const usage = deltaTotal > 0
+ ? roundNumber(((deltaTotal - deltaIdle) / deltaTotal) * 100, 1)
+ : null;
+ perCore.push({
+ index,
+ usagePercent: usage
+ });
+ if (deltaTotal > 0) {
+ totalDelta += deltaTotal;
+ idleDelta += deltaIdle;
+ }
+ }
+
+ const overallUsagePercent = totalDelta > 0
+ ? roundNumber(((totalDelta - idleDelta) / totalDelta) * 100, 1)
+ : null;
+ return {
+ overallUsagePercent,
+ perCore
+ };
+ }
+
+ async collectCpuMetrics() {
+ const cpus = os.cpus() || [];
+ const currentTimes = this.getCpuTimes();
+ const usage = this.calculateCpuUsage(currentTimes, this.lastCpuTimes || []);
+ this.lastCpuTimes = currentTimes;
+
+ const tempMetrics = await this.collectCpuTemperatures();
+ const tempByCoreIndex = new Map(
+ (tempMetrics.perCore || []).map((item) => [Number(item.index), item.temperatureC])
+ );
+
+ const perCore = usage.perCore.map((entry) => ({
+ index: entry.index,
+ usagePercent: entry.usagePercent,
+ temperatureC: tempByCoreIndex.has(entry.index) ? tempByCoreIndex.get(entry.index) : null
+ }));
+
+ for (const tempEntry of tempMetrics.perCore || []) {
+ const index = Number(tempEntry?.index);
+ if (!Number.isFinite(index) || perCore.some((item) => item.index === index)) {
+ continue;
+ }
+ perCore.push({
+ index,
+ usagePercent: null,
+ temperatureC: tempEntry.temperatureC
+ });
+ }
+ perCore.sort((a, b) => a.index - b.index);
+
+ return {
+ model: cpus[0]?.model || null,
+ logicalCoreCount: cpus.length,
+ loadAverage: os.loadavg().map((value) => roundNumber(value, 2)),
+ overallUsagePercent: usage.overallUsagePercent,
+ overallTemperatureC: tempMetrics.overallC,
+ usageAvailable: usage.overallUsagePercent !== null,
+ temperatureAvailable: Boolean(tempMetrics.available),
+ temperatureSource: tempMetrics.source,
+ perCore
+ };
+ }
+
+ async collectCpuTemperatures() {
+ const sensors = await this.collectTempsViaSensors();
+ if (sensors.available) {
+ return sensors;
+ }
+
+ const hwmon = this.collectTempsViaHwmon();
+ if (hwmon.available) {
+ return hwmon;
+ }
+
+ const thermalZones = this.collectTempsViaThermalZones();
+ if (thermalZones.available) {
+ return thermalZones;
+ }
+
+ return {
+ source: 'none',
+ overallC: null,
+ perCore: [],
+ available: false
+ };
+ }
+
+ async collectTempsViaSensors() {
+ if (this.sensorsCommandAvailable === false) {
+ return {
+ source: 'sensors',
+ overallC: null,
+ perCore: [],
+ available: false
+ };
+ }
+
+ try {
+ const { stdout } = await execFileAsync('sensors', ['-j'], {
+ timeout: SENSORS_TIMEOUT_MS,
+ maxBuffer: 2 * 1024 * 1024
+ });
+ this.sensorsCommandAvailable = true;
+ const parsed = JSON.parse(String(stdout || '{}'));
+ const candidates = collectTemperatureCandidates(parsed);
+ const preferred = preferCpuTemperatureCandidates(candidates);
+ return {
+ source: 'sensors',
+ ...mapTemperatureCandidates(preferred)
+ };
+ } catch (error) {
+ if (isCommandMissingError(error)) {
+ this.sensorsCommandAvailable = false;
+ }
+ logger.debug('cpu-temp:sensors:failed', { error: errorToMeta(error) });
+ return {
+ source: 'sensors',
+ overallC: null,
+ perCore: [],
+ available: false
+ };
+ }
+ }
+
+ collectTempsViaHwmon() {
+ const hwmonRoot = '/sys/class/hwmon';
+ if (!fs.existsSync(hwmonRoot)) {
+ return {
+ source: 'hwmon',
+ overallC: null,
+ perCore: [],
+ available: false
+ };
+ }
+
+ const candidates = [];
+ let dirs = [];
+ try {
+ dirs = fs.readdirSync(hwmonRoot, { withFileTypes: true });
+ } catch (_error) {
+ dirs = [];
+ }
+
+ for (const dir of dirs) {
+ if (!dir.isDirectory()) {
+ continue;
+ }
+ const basePath = path.join(hwmonRoot, dir.name);
+ const sensorName = readTextFileSafe(path.join(basePath, 'name')) || dir.name;
+ let files = [];
+ try {
+ files = fs.readdirSync(basePath);
+ } catch (_error) {
+ files = [];
+ }
+ const tempInputFiles = files.filter((file) => /^temp\d+_input$/i.test(file));
+
+ for (const fileName of tempInputFiles) {
+ const tempValue = normalizeTempC(readTextFileSafe(path.join(basePath, fileName)));
+ if (tempValue === null) {
+ continue;
+ }
+ const labelFile = fileName.replace('_input', '_label');
+ const label = readTextFileSafe(path.join(basePath, labelFile)) || fileName;
+ candidates.push({
+ label: `${sensorName} / ${label}`,
+ value: tempValue
+ });
+ }
+ }
+
+ return {
+ source: 'hwmon',
+ ...mapTemperatureCandidates(preferCpuTemperatureCandidates(candidates))
+ };
+ }
+
+ collectTempsViaThermalZones() {
+ const thermalRoot = '/sys/class/thermal';
+ if (!fs.existsSync(thermalRoot)) {
+ return {
+ source: 'thermal_zone',
+ overallC: null,
+ perCore: [],
+ available: false
+ };
+ }
+
+ let files = [];
+ try {
+ files = fs.readdirSync(thermalRoot, { withFileTypes: true });
+ } catch (_error) {
+ files = [];
+ }
+
+ const candidates = [];
+ for (const dir of files) {
+ if (!dir.isDirectory() || !dir.name.startsWith('thermal_zone')) {
+ continue;
+ }
+ const basePath = path.join(thermalRoot, dir.name);
+ const tempC = normalizeTempC(readTextFileSafe(path.join(basePath, 'temp')));
+ if (tempC === null) {
+ continue;
+ }
+ const zoneType = readTextFileSafe(path.join(basePath, 'type')) || dir.name;
+ candidates.push({
+ label: `${zoneType} / temp`,
+ value: tempC
+ });
+ }
+
+ return {
+ source: 'thermal_zone',
+ ...mapTemperatureCandidates(preferCpuTemperatureCandidates(candidates))
+ };
+ }
+
+ async collectGpuMetrics() {
+ if (this.nvidiaSmiAvailable === false) {
+ return {
+ source: 'nvidia-smi',
+ available: false,
+ devices: [],
+ message: 'nvidia-smi ist nicht verfuegbar.'
+ };
+ }
+
+ try {
+ const { stdout } = await execFileAsync(
+ 'nvidia-smi',
+ [
+ '--query-gpu=index,name,utilization.gpu,utilization.memory,temperature.gpu,memory.used,memory.total,power.draw,power.limit,fan.speed',
+ '--format=csv,noheader,nounits'
+ ],
+ {
+ timeout: NVIDIA_SMI_TIMEOUT_MS,
+ maxBuffer: 1024 * 1024
+ }
+ );
+
+ this.nvidiaSmiAvailable = true;
+ const devices = String(stdout || '')
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => parseNvidiaCsvLine(line))
+ .filter(Boolean);
+
+ if (devices.length === 0) {
+ return {
+ source: 'nvidia-smi',
+ available: false,
+ devices: [],
+ message: 'Keine GPU-Daten ueber nvidia-smi erkannt.'
+ };
+ }
+
+ return {
+ source: 'nvidia-smi',
+ available: true,
+ devices,
+ message: null
+ };
+ } catch (error) {
+ const commandMissing = isCommandMissingError(error);
+ if (commandMissing) {
+ this.nvidiaSmiAvailable = false;
+ }
+ logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) });
+ return {
+ source: 'nvidia-smi',
+ available: false,
+ devices: [],
+ message: commandMissing
+ ? 'nvidia-smi ist nicht verfuegbar.'
+ : (String(error?.stderr || error?.message || 'GPU-Abfrage fehlgeschlagen').trim().slice(0, 220))
+ };
+ }
+ }
+
+ async collectStorageMetrics() {
+ const list = [];
+ for (const entry of this.monitoredPaths) {
+ list.push(await this.collectStorageForPath(entry));
+ }
+ return list;
+ }
+
+ findNearestExistingPath(inputPath) {
+ const normalized = String(inputPath || '').trim();
+ if (!normalized) {
+ return null;
+ }
+ let candidate = path.resolve(normalized);
+ for (let depth = 0; depth < 64; depth += 1) {
+ if (fs.existsSync(candidate)) {
+ return candidate;
+ }
+ const parent = path.dirname(candidate);
+ if (!parent || parent === candidate) {
+ break;
+ }
+ candidate = parent;
+ }
+ if (fs.existsSync(candidate)) {
+ return candidate;
+ }
+ return null;
+ }
+
+ async collectStorageForPath(entry) {
+ const key = String(entry?.key || '');
+ const label = String(entry?.label || key || 'Pfad');
+ const rawPath = String(entry?.path || '').trim();
+ if (!rawPath) {
+ return {
+ key,
+ label,
+ path: null,
+ queryPath: null,
+ exists: false,
+ totalBytes: null,
+ usedBytes: null,
+ freeBytes: null,
+ usagePercent: null,
+ mountPoint: null,
+ note: null,
+ error: 'Pfad ist leer.'
+ };
+ }
+
+ const resolvedPath = path.isAbsolute(rawPath) ? path.normalize(rawPath) : path.resolve(rawPath);
+ const exists = fs.existsSync(resolvedPath);
+ const queryPath = exists ? resolvedPath : this.findNearestExistingPath(resolvedPath);
+
+ if (!queryPath) {
+ return {
+ key,
+ label,
+ path: resolvedPath,
+ queryPath: null,
+ exists: false,
+ totalBytes: null,
+ usedBytes: null,
+ freeBytes: null,
+ usagePercent: null,
+ mountPoint: null,
+ note: null,
+ error: 'Pfad oder Parent existiert nicht.'
+ };
+ }
+
+ try {
+ const { stdout } = await execFileAsync('df', ['-Pk', queryPath], {
+ timeout: DF_TIMEOUT_MS,
+ maxBuffer: 256 * 1024
+ });
+ const parsed = parseDfStats(stdout);
+ if (!parsed) {
+ return {
+ key,
+ label,
+ path: resolvedPath,
+ queryPath,
+ exists,
+ totalBytes: null,
+ usedBytes: null,
+ freeBytes: null,
+ usagePercent: null,
+ mountPoint: null,
+ note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`,
+ error: 'Dateisystemdaten konnten nicht geparst werden.'
+ };
+ }
+
+ return {
+ key,
+ label,
+ path: resolvedPath,
+ queryPath,
+ exists,
+ ...parsed,
+ note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`,
+ error: null
+ };
+ } catch (error) {
+ return {
+ key,
+ label,
+ path: resolvedPath,
+ queryPath,
+ exists,
+ totalBytes: null,
+ usedBytes: null,
+ freeBytes: null,
+ usagePercent: null,
+ mountPoint: null,
+ note: null,
+ error: String(error?.message || 'df Abfrage fehlgeschlagen')
+ };
+ }
+ }
+}
+
+module.exports = new HardwareMonitorService();
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7ad42a0..5c5ddfe 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -9,6 +9,7 @@ import DatabasePage from './pages/DatabasePage';
function App() {
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
+ const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
const [lastDiscEvent, setLastDiscEvent] = useState(null);
const location = useLocation();
const navigate = useNavigate();
@@ -16,6 +17,7 @@ function App() {
const refreshPipeline = async () => {
const response = await api.getPipelineState();
setPipeline(response.pipeline);
+ setHardwareMonitoring(response?.hardwareMonitoring || null);
};
useEffect(() => {
@@ -49,6 +51,10 @@ function App() {
if (message.type === 'DISC_REMOVED') {
setLastDiscEvent(null);
}
+
+ if (message.type === 'HARDWARE_MONITOR_UPDATE') {
+ setHardwareMonitoring(message.payload || null);
+ }
}
});
@@ -88,6 +94,7 @@ function App() {
element={
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index 749f0c6..47eda4e 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -25,6 +25,7 @@ const dashboardStatuses = new Set([
'CANCELLED',
'ERROR'
]);
+
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -33,6 +34,80 @@ function normalizeJobId(value) {
return Math.trunc(parsed);
}
+function formatPercent(value, digits = 1) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) {
+ return 'n/a';
+ }
+ return `${parsed.toFixed(digits)}%`;
+}
+
+function formatTemperature(value) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) {
+ return 'n/a';
+ }
+ return `${parsed.toFixed(1)}°C`;
+}
+
+function formatBytes(value) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return 'n/a';
+ }
+ if (parsed === 0) {
+ return '0 B';
+ }
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
+ let unitIndex = 0;
+ let current = parsed;
+ while (current >= 1024 && unitIndex < units.length - 1) {
+ current /= 1024;
+ unitIndex += 1;
+ }
+ const digits = unitIndex <= 1 ? 0 : 2;
+ return `${current.toFixed(digits)} ${units[unitIndex]}`;
+}
+
+function formatUpdatedAt(value) {
+ if (!value) {
+ return '-';
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return '-';
+ }
+ return date.toLocaleString('de-DE');
+}
+
+function normalizeHardwareMonitoringPayload(rawPayload) {
+ const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
+ return {
+ enabled: Boolean(payload.enabled),
+ intervalMs: Number(payload.intervalMs || 0),
+ updatedAt: payload.updatedAt || null,
+ sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null,
+ error: payload.error ? String(payload.error) : null
+ };
+}
+
+function getStorageUsageTone(usagePercent) {
+ const value = Number(usagePercent);
+ if (!Number.isFinite(value)) {
+ return 'unknown';
+ }
+ if (value >= 95) {
+ return 'critical';
+ }
+ if (value >= 85) {
+ return 'high';
+ }
+ if (value >= 70) {
+ return 'warn';
+ }
+ return 'ok';
+}
+
function normalizeQueue(queue) {
const payload = queue && typeof queue === 'object' ? queue : {};
const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : [];
@@ -240,7 +315,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
};
}
-export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline }) {
+export default function DashboardPage({
+ pipeline,
+ hardwareMonitoring,
+ lastDiscEvent,
+ refreshPipeline
+}) {
const [busy, setBusy] = useState(false);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
@@ -258,11 +338,23 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
const [jobsLoading, setJobsLoading] = useState(false);
const [dashboardJobs, setDashboardJobs] = useState([]);
const [expandedJobId, setExpandedJobId] = useState(undefined);
+ const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
const toastRef = useRef(null);
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
const isProcessing = processingStates.includes(state);
+ const monitoringState = useMemo(
+ () => normalizeHardwareMonitoringPayload(hardwareMonitoring),
+ [hardwareMonitoring]
+ );
+ const monitoringSample = monitoringState.sample;
+ const cpuMetrics = monitoringSample?.cpu || null;
+ const memoryMetrics = monitoringSample?.memory || null;
+ const gpuMetrics = monitoringSample?.gpu || null;
+ const storageMetrics = Array.isArray(monitoringSample?.storage) ? monitoringSample.storage : [];
+ const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
+ const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
const loadDashboardJobs = async () => {
setJobsLoading(true);
@@ -881,6 +973,175 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
+
+
+
+
+
+
+
+ {monitoringState.error ? (
+ {monitoringState.error}
+ ) : null}
+
+ {!monitoringState.enabled ? (
+ Monitoring ist deaktiviert. Aktivierung in den Settings unter Kategorie "Monitoring".
+ ) : !monitoringSample ? (
+ Monitoring ist aktiv. Erste Messwerte werden gesammelt ...
+ ) : (
+
+
+ CPU
+
+
+
+ {formatPercent(cpuMetrics?.overallUsagePercent)}
+
+
+
+ {formatTemperature(cpuMetrics?.overallTemperatureC)}
+
+
+
+
+ {Array.isArray(cpuMetrics?.loadAverage) ? cpuMetrics.loadAverage.join(' / ') : '-'}
+
+ {cpuPerCoreMetrics.length > 0 ? (
+
setCpuCoresExpanded((prev) => !prev)}
+ aria-label={cpuCoresExpanded ? 'CPU-Kerne ausblenden' : 'CPU-Kerne einblenden'}
+ aria-expanded={cpuCoresExpanded}
+ >
+
+
+ ) : null}
+
+
+ {cpuPerCoreMetrics.length === 0 ? (
+ Pro-Core-Daten sind noch nicht verfuegbar.
+ ) : null}
+ {cpuPerCoreMetrics.length > 0 && cpuCoresExpanded ? (
+
+ {cpuPerCoreMetrics.map((core) => (
+
+
C{core.index}
+
+
+ {formatPercent(core.usagePercent)}
+
+
+
+ {formatTemperature(core.temperatureC)}
+
+
+ ))}
+
+ ) : null}
+
+
+
+ RAM
+
+
+
+ {formatPercent(memoryMetrics?.usagePercent)}
+
+
+
+ {formatBytes(memoryMetrics?.usedBytes)}
+
+
+
+ {formatBytes(memoryMetrics?.freeBytes)}
+
+
+
+ {formatBytes(memoryMetrics?.totalBytes)}
+
+
+
+
+
+ GPU
+ {!gpuMetrics?.available ? (
+ {gpuMetrics?.message || 'Keine GPU-Metriken verfuegbar.'}
+ ) : (
+
+ {gpuDevices.map((gpu, index) => (
+
+
+ GPU {gpu?.index ?? index}
+ {gpu?.name ? ` | ${gpu.name}` : ''}
+
+ Load: {formatPercent(gpu?.utilizationPercent)}
+ Mem-Load: {formatPercent(gpu?.memoryUtilizationPercent)}
+ Temp: {formatTemperature(gpu?.temperatureC)}
+ VRAM: {formatBytes(gpu?.memoryUsedBytes)} / {formatBytes(gpu?.memoryTotalBytes)}
+ Power: {Number.isFinite(Number(gpu?.powerDrawW)) ? `${gpu.powerDrawW} W` : 'n/a'} / {Number.isFinite(Number(gpu?.powerLimitW)) ? `${gpu.powerLimitW} W` : 'n/a'}
+
+ ))}
+
+ )}
+
+
+
+ Freier Speicher in Pfaden
+
+ {storageMetrics.map((entry) => {
+ const tone = getStorageUsageTone(entry?.usagePercent);
+ const usagePercent = Number(entry?.usagePercent);
+ const barValue = Number.isFinite(usagePercent)
+ ? Math.max(0, Math.min(100, usagePercent))
+ : 0;
+ return (
+
+
+ {entry?.label || entry?.key || 'Pfad'}
+
+ {entry?.error ? 'Fehler' : formatPercent(entry?.usagePercent)}
+
+
+
+ {entry?.error ? (
+
{entry.error}
+ ) : (
+ <>
+
+
+ Frei: {formatBytes(entry?.freeBytes)}
+ Gesamt: {formatBytes(entry?.totalBytes)}
+
+ >
+ )}
+
+
+ Pfad: {entry?.path || '-'}
+
+ {entry?.queryPath && entry.queryPath !== entry.path ? (
+
+ Parent: {entry.queryPath}
+
+ ) : null}
+ {entry?.note ?
{entry.note} : null}
+
+ );
+ })}
+
+
+
+ )}
+
+
@@ -1052,35 +1313,37 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
) : (
Kein Poster
)}
-
-
-
- {jobTitle}
-
-
- #{jobId}
- {job?.year ? ` | ${job.year}` : ''}
- {job?.imdb_id ? ` | ${job.imdb_id}` : ''}
-
+
+
+
+
+ {jobTitle}
+
+
+ #{jobId}
+ {job?.year ? ` | ${job.year}` : ''}
+ {job?.imdb_id ? ` | ${job.imdb_id}` : ''}
+
+
+
+
+ {isCurrentSession ? : null}
+ {isResumable ? : null}
+ {normalizedStatus === 'READY_TO_ENCODE'
+ ?
+ : null}
+
+
{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}
-
-
- {isCurrentSession ? : null}
- {isResumable ? : null}
- {normalizedStatus === 'READY_TO_ENCODE'
- ?
- : null}
-
-
);
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index 30f3d66..d41e812 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -217,6 +217,266 @@ body {
flex-wrap: wrap;
}
+.hardware-monitor-head {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ margin-bottom: 0.7rem;
+}
+
+.hardware-monitor-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem;
+ align-items: stretch;
+}
+
+.hardware-monitor-block {
+ border: 1px solid var(--rip-border);
+ border-radius: 0.5rem;
+ background: var(--rip-panel-soft);
+ padding: 0.65rem;
+ display: grid;
+ gap: 0.55rem;
+ align-content: start;
+}
+
+.hardware-monitor-block h4 {
+ margin: 0;
+}
+
+.hardware-monitor-meta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.35rem 0.55rem;
+}
+
+.hardware-cpu-summary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ align-items: stretch;
+}
+
+.hardware-cpu-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ border: 1px solid var(--rip-border);
+ border-radius: 999px;
+ background: var(--rip-panel);
+ padding: 0.22rem 0.55rem;
+ font-size: 0.8rem;
+ font-weight: 600;
+ min-height: 2rem;
+ height: 2rem;
+ white-space: nowrap;
+}
+
+.hardware-cpu-chip span {
+ line-height: 1;
+}
+
+.hardware-cpu-chip .pi {
+ font-size: 0.82rem;
+ color: var(--rip-brown-700);
+}
+
+.hardware-cpu-load-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.hardware-cpu-core-toggle-btn {
+ border: 1px solid var(--rip-border);
+ background: var(--rip-panel);
+ color: var(--rip-brown-700);
+ width: 2rem;
+ min-width: 2rem;
+ height: 2rem;
+ border-radius: 999px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ padding: 0;
+}
+
+.hardware-cpu-core-toggle-btn:hover {
+ background: #f1e1c8;
+}
+
+.hardware-cpu-core-toggle-btn .pi {
+ font-size: 0.78rem;
+}
+
+.hardware-monitor-meta > div,
+.hardware-core-item,
+.hardware-gpu-item,
+.hardware-storage-item {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.hardware-core-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.45rem;
+}
+
+.hardware-core-item {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.4rem;
+ padding: 0.42rem 0.5rem;
+ background: var(--rip-panel);
+ display: grid;
+ gap: 0.12rem;
+}
+
+.hardware-core-grid.compact {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.32rem;
+}
+
+.hardware-core-item.compact {
+ grid-template-columns: auto auto auto;
+ align-items: center;
+ justify-content: start;
+ gap: 0.35rem;
+ padding: 0.3rem 0.45rem;
+ text-align: left;
+}
+
+.hardware-core-title {
+ font-size: 0.74rem;
+ font-weight: 700;
+ color: var(--rip-brown-700);
+ min-width: 2.2rem;
+}
+
+.hardware-core-metric {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.28rem;
+ justify-self: start;
+}
+
+.hardware-core-metric .pi {
+ font-size: 0.72rem;
+ color: var(--rip-muted);
+}
+
+.hardware-gpu-list,
+.hardware-storage-list {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.hardware-gpu-item,
+.hardware-storage-item {
+ border: 1px dashed var(--rip-border);
+ border-radius: 0.45rem;
+ padding: 0.45rem 0.55rem;
+ background: var(--rip-panel);
+ display: grid;
+ gap: 0.15rem;
+}
+
+.hardware-storage-item.compact {
+ gap: 0.3rem;
+}
+
+.hardware-storage-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.hardware-storage-head strong {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.hardware-storage-percent {
+ font-size: 0.74rem;
+ font-weight: 700;
+ border-radius: 999px;
+ padding: 0.1rem 0.45rem;
+ border: 1px solid transparent;
+ white-space: nowrap;
+}
+
+.hardware-storage-percent.tone-ok {
+ color: #18643a;
+ background: #d8f1df;
+ border-color: #9ed5ad;
+}
+
+.hardware-storage-percent.tone-warn {
+ color: #7d4f00;
+ background: #f8ebc4;
+ border-color: #d7b46a;
+}
+
+.hardware-storage-percent.tone-high {
+ color: #8e3d00;
+ background: #f5dcc9;
+ border-color: #d9a578;
+}
+
+.hardware-storage-percent.tone-critical {
+ color: #8f1f17;
+ background: #f6d6d5;
+ border-color: #d89a97;
+}
+
+.hardware-storage-percent.tone-unknown {
+ color: #5b4636;
+ background: #eadfcc;
+ border-color: #c8ad88;
+}
+
+.hardware-storage-bar .p-progressbar {
+ height: 0.5rem;
+ background: #eadbc1;
+}
+
+.hardware-storage-bar.tone-ok .p-progressbar-value {
+ background: #2d9c4e;
+}
+
+.hardware-storage-bar.tone-warn .p-progressbar-value {
+ background: #c58f00;
+}
+
+.hardware-storage-bar.tone-high .p-progressbar-value {
+ background: #d6761f;
+}
+
+.hardware-storage-bar.tone-critical .p-progressbar-value {
+ background: #c43c2e;
+}
+
+.hardware-storage-bar.tone-unknown .p-progressbar-value {
+ background: #8a6a53;
+}
+
+.hardware-storage-summary {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.hardware-storage-path {
+ color: var(--rip-muted);
+}
+
.pipeline-queue-meta {
display: flex;
gap: 0.45rem;
@@ -312,7 +572,7 @@ body {
padding: 0.6rem 0.7rem;
background: var(--rip-panel-soft);
display: grid;
- grid-template-columns: 48px minmax(0, 1fr) auto auto;
+ grid-template-columns: 48px minmax(0, 1fr) auto;
gap: 0.7rem;
align-items: center;
text-align: left;
@@ -325,6 +585,14 @@ body {
background: #fbf0df;
}
+.dashboard-job-row-content {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.3rem 0.7rem;
+ align-items: start;
+}
+
.dashboard-job-row-main {
display: grid;
gap: 0.3rem;
@@ -360,6 +628,7 @@ body {
}
.dashboard-job-row-progress {
+ grid-column: 1 / -1;
display: grid;
gap: 0.2rem;
}
@@ -377,7 +646,7 @@ body {
display: flex;
align-items: center;
gap: 0.35rem;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
justify-content: flex-end;
}
@@ -1289,6 +1558,8 @@ body {
.metadata-grid,
.device-meta,
+ .hardware-monitor-grid,
+ .hardware-monitor-meta,
.pipeline-queue-grid,
.media-review-meta,
.media-track-grid,
@@ -1303,11 +1574,10 @@ body {
}
.dashboard-job-row {
- grid-template-columns: 48px minmax(0, 1fr);
+ grid-template-columns: 48px minmax(0, 1fr) auto;
}
.dashboard-job-badges {
- grid-column: 1 / -1;
justify-content: flex-start;
}
@@ -1338,6 +1608,26 @@ body {
.metadata-selection-dialog .p-datatable-wrapper {
max-height: 16rem !important;
}
+
+ .hardware-core-item.compact {
+ grid-template-columns: auto auto auto;
+ align-items: center;
+ justify-content: start;
+ gap: 0.3rem;
+ text-align: left;
+ }
+
+ .hardware-core-grid.compact {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .hardware-core-title {
+ min-width: 2rem;
+ }
+
+ .hardware-core-metric {
+ justify-self: start;
+ }
}
@media (max-width: 640px) {
diff --git a/site/api/pipeline/index.html b/site/api/pipeline/index.html
index 6b34460..ddd20cd 100644
--- a/site/api/pipeline/index.html
+++ b/site/api/pipeline/index.html
@@ -1,61 +1,82 @@
-
Pipeline API - Ripster
Pipeline API Alle Endpunkte zur Steuerung des Ripping-Workflows.
GET /api/pipeline/state Gibt den aktuellen Pipeline-Zustand zurück.
Response:
{
- "state" : "ENCODING" ,
- "jobId" : 42 ,
- "job" : {
- "id" : 42 ,
- "title" : "Inception" ,
- "status" : "ENCODING" ,
- "imdb_id" : "tt1375666" ,
- "omdb_year" : "2010"
- },
- "progress" : 73.5 ,
- "eta" : "00:12:34" ,
- "updatedAt" : "2024-01-15T14:30:00.000Z"
-}
- Pipeline-Zustände:
Wert Beschreibung IDLE Wartet auf Disc DISC_DETECTED Disc erkannt, wartet auf Benutzer METADATA_SELECTION Disc-Scan läuft / Metadaten-Dialog WAITING_FOR_USER_DECISION Mehrere Playlist-Kandidaten – manuelle Auswahl READY_TO_START Bereit zum Starten RIPPING MakeMKV-Ripping läuft MEDIAINFO_CHECK HandBrake-Scan & Encode-Plan-Erstellung READY_TO_ENCODE Wartet auf Encode-Bestätigung ENCODING HandBrake encodiert POST_ENCODE_SCRIPTS Post-Encode-Skripte laufen FINISHED Abgeschlossen ERROR Fehler
Kontext-Felder (state-abhängig):
Beim Zustand WAITING_FOR_USER_DECISION enthält die Response zusätzlich:
{
- "state" : "WAITING_FOR_USER_DECISION" ,
- "context" : {
- "playlistAnalysis" : {
- "evaluatedCandidates" : [ ... ],
- "recommendation" : { "playlistId" : "00800" , "score" : 18 },
- "manualDecisionRequired" : true ,
- "manualDecisionReason" : "multiple_candidates_after_min_length"
- },
- "playlistCandidates" : [ "00800" , "00801" , "00900" ]
- }
-}
- POST /api/pipeline/analyze Startet eine manuelle Disc-Analyse.
Request: Kein Body
Response:
{ "ok" : true , "message" : "Analyse gestartet" }
- Fehlerfälle: - 409 – Pipeline bereits aktiv
POST /api/pipeline/rescan-disc Erzwingt eine erneute Disc-Erkennung.
Response: { "ok": true }
GET /api/pipeline/omdb/search Sucht in der OMDb-API nach einem Filmtitel.
Query-Parameter:
Parameter Typ Beschreibung q string Suchbegriff type string movie oder series (optional)
Beispiel: GET /api/pipeline/omdb/search?q=Inception&type=movie
Response:
{
+ Pipeline API - Ripster
Pipeline API Alle Endpunkte zur Steuerung des Ripster-Workflows.
GET /api/pipeline/state Liefert den aktuellen Pipeline-Snapshot.
Response:
{
+ "pipeline" : {
+ "state" : "READY_TO_ENCODE" ,
+ "activeJobId" : 42 ,
+ "progress" : 0 ,
+ "eta" : null ,
+ "statusText" : "Mediainfo geladen - bitte bestätigen" ,
+ "context" : {
+ "jobId" : 42
+ },
+ "queue" : {
+ "maxParallelJobs" : 1 ,
+ "runningCount" : 0 ,
+ "queuedCount" : 0 ,
+ "runningJobs" : [],
+ "queuedJobs" : []
+ }
+ }
+}
+ Pipeline-Zustände:
Wert Beschreibung IDLE Wartet auf Medium DISC_DETECTED Medium erkannt, wartet auf Analyse-Start METADATA_SELECTION Metadaten-Dialog aktiv WAITING_FOR_USER_DECISION Manuelle Playlist-Auswahl erforderlich READY_TO_START Übergang/Fallback vor Start RIPPING MakeMKV läuft MEDIAINFO_CHECK HandBrake-Scan + Plan-Erstellung READY_TO_ENCODE Review bereit ENCODING HandBrake-Encoding läuft (inkl. Post-Skripte) FINISHED Abgeschlossen CANCELLED Vom Benutzer abgebrochen ERROR Fehler
POST /api/pipeline/analyze Startet die Analyse für die aktuell erkannte Disc.
Request: kein Body
Response:
{
+ "result" : {
+ "jobId" : 42 ,
+ "detectedTitle" : "INCEPTION" ,
+ "omdbCandidates" : []
+ }
+}
+ POST /api/pipeline/rescan-disc Erzwingt eine erneute Laufwerksprüfung.
Response (Beispiel):
{
+ "result" : {
+ "emitted" : "discInserted"
+ }
+}
+ GET /api/pipeline/omdb/search?q= Sucht OMDb-Titel.
Response:
{
"results" : [
- { "imdbId" : "tt1375666" , "title" : "Inception" , "year" : "2010" , "type" : "movie" , "poster" : "https://..." }
- ]
-}
- POST /api/pipeline/select-metadata Bestätigt Metadaten und optionale Playlist-Auswahl.
Request:
{
+ {
+ "imdbId" : "tt1375666" ,
+ "title" : "Inception" ,
+ "year" : "2010" ,
+ "type" : "movie" ,
+ "poster" : "https://..."
+ }
+ ]
+}
+ POST /api/pipeline/select-metadata Setzt Metadaten (und optional Playlist-Entscheidung).
Request:
{
"jobId" : 42 ,
- "omdb" : {
- "imdbId" : "tt1375666" ,
- "title" : "Inception" ,
- "year" : "2010" ,
- "type" : "movie" ,
- "poster" : "https://..."
- },
- "selectedPlaylist" : "00800"
-}
- Playlist-Felder
selectedPlaylist ist optional. Wird es beim ersten Aufruf weggelassen (kein Obfuskierungsverdacht), wird die Empfehlung automatisch übernommen.
Beim zweiten Aufruf aus dem WAITING_FOR_USER_DECISION-Dialog reicht es, nur jobId + selectedPlaylist zu schicken – omdb kann dann weggelassen werden.
Response: { "ok": true }
POST /api/pipeline/start/:jobId Startet den Ripping-Prozess.
URL-Parameter: jobId
Response: { "ok": true, "message": "Ripping gestartet" }
Sonderfall: Falls für den Job bereits eine Raw-Datei vorhanden ist, wird das Ripping übersprungen und direkt der HandBrake-Scan gestartet.
Fehlerfälle: - 404 – Job nicht gefunden - 409 – Job nicht im Status READY_TO_START
POST /api/pipeline/confirm-encode/:jobId Bestätigt die Encode-Konfiguration mit Track-Auswahl und Post-Encode-Skripten.
URL-Parameter: jobId
Request:
{
- "selectedEncodeTitleId" : 1 ,
- "selectedTrackSelection" : {
- "1" : {
- "audioTrackIds" : [ 1 , 2 ],
- "subtitleTrackIds" : [ 1 ]
- }
- },
- "selectedPostEncodeScriptIds" : [ "script-abc123" , "script-def456" ]
-}
- Feld Typ Beschreibung selectedEncodeTitleId number HandBrake-Titel-ID (aus dem Encode-Plan) selectedTrackSelection object Pro Titel: Audio- und Untertitel-Track-IDs selectedPostEncodeScriptIds string[] Skript-IDs in Ausführungsreihenfolge (optional)
Track-IDs
Die Track-IDs entsprechen den id-Feldern aus dem Encode-Plan (encode_plan_json), nicht den rohen HandBrake-Track-Nummern.
Response: { "ok": true, "message": "Encoding gestartet" }
POST /api/pipeline/cancel Bricht den aktiven Pipeline-Prozess ab.
Response: { "ok": true, "message": "Pipeline abgebrochen" }
SIGINT → graceful exit (10 s Timeout) → SIGKILL.
POST /api/pipeline/retry/:jobId Wiederholt einen fehlgeschlagenen Job.
Response: { "ok": true, "message": "Job wird wiederholt" }
Fehlerfälle: - 404 – Job nicht gefunden - 409 – Job nicht im Status ERROR
POST /api/pipeline/resume-ready/:jobId Reaktiviert einen Job im Status READY_TO_ENCODE in die aktive Pipeline (z. B. nach Neustart).
Response: { "ok": true }
POST /api/pipeline/reencode/:jobId Encodiert eine abgeschlossene Raw-MKV erneut – ohne Ripping.
Request:
{
+ "title" : "Inception" ,
+ "year" : 2010 ,
+ "imdbId" : "tt1375666" ,
+ "poster" : "https://..." ,
+ "fromOmdb" : true ,
+ "selectedPlaylist" : "00800"
+}
+ Response: { "job": { ... } }
Startlogik
Nach Metadaten-Bestätigung wird der nächste Schritt automatisch ausgelöst (startPreparedJob). Der Job startet direkt oder wird in die Queue eingereiht.
POST /api/pipeline/start/:jobId Startet einen vorbereiteten Job manuell (z. B. Fallback/Queue-Szenario).
Response (Beispiel):
{
+ "result" : {
+ "started" : true ,
+ "stage" : "RIPPING"
+ }
+}
+ Mögliche stage-Werte sind u. a. RIPPING, MEDIAINFO_CHECK, ENCODING.
POST /api/pipeline/confirm-encode/:jobId Bestätigt Review-Auswahl (Titel/Tracks/Post-Skripte).
Request:
{
"selectedEncodeTitleId" : 1 ,
"selectedTrackSelection" : {
- "1" : { "audioTrackIds" : [ 1 , 2 ], "subtitleTrackIds" : [ 1 ] }
- },
- "selectedPostEncodeScriptIds" : [ "script-abc123" ]
-}
- Gleiche Struktur wie confirm-encode – ermöglicht andere Track-Auswahl und Skripte als beim ersten Encoding.
Response: { "ok": true, "message": "Re-Encoding gestartet" }
Zurück zum Seitenanfang
\ No newline at end of file
+ "1" : {
+ "audioTrackIds" : [ 1 , 2 ],
+ "subtitleTrackIds" : [ 3 ]
+ }
+ },
+ "selectedPostEncodeScriptIds" : [ 2 , 7 ],
+ "skipPipelineStateUpdate" : false
+}
+ Response: { "job": { ... } }
POST /api/pipeline/cancel Bricht laufenden Job ab oder entfernt einen Queue-Eintrag.
Request (optional):
Response (Beispiel):
{
+ "result" : {
+ "cancelled" : true ,
+ "queuedOnly" : false ,
+ "jobId" : 42
+ }
+}
+ POST /api/pipeline/retry/:jobId Startet einen Job aus ERROR/CANCELLED erneut (oder reiht ihn in die Queue ein).
Response: { "result": { ... } }
POST /api/pipeline/resume-ready/:jobId Lädt einen READY_TO_ENCODE-Job nach Neustart wieder in die aktive Session.
Response: { "job": { ... } }
POST /api/pipeline/reencode/:jobId Startet Re-Encode aus bestehendem RAW.
Response: { "result": { ... } }
POST /api/pipeline/restart-review/:jobId Berechnet die Review aus vorhandenem RAW neu.
Response: { "result": { ... } }
POST /api/pipeline/restart-encode/:jobId Startet Encoding mit der zuletzt bestätigten Auswahl neu.
Response: { "result": { ... } }
Queue-Endpunkte GET /api/pipeline/queue Liefert den aktuellen Queue-Status.
Response: { "queue": { ... } }
POST /api/pipeline/queue/reorder Sortiert Queue-Einträge neu.
Request:
{
+ "orderedJobIds" : [ 42 , 43 , 41 ]
+}
+ Response: { "queue": { ... } }
Zurück zum Seitenanfang
\ No newline at end of file
diff --git a/site/api/websocket/index.html b/site/api/websocket/index.html
index 3cc50aa..19ad4d5 100644
--- a/site/api/websocket/index.html
+++ b/site/api/websocket/index.html
@@ -1,108 +1,92 @@
-
WebSocket Events - Ripster
WebSocket Events Ripster verwendet WebSockets für Echtzeit-Updates. Der Endpunkt ist /ws.
Verbindung const ws = new WebSocket ( 'ws://localhost:3001/ws' );
+ WebSocket Events - Ripster
WebSocket Events Ripster sendet Echtzeit-Updates über WebSocket unter /ws.
Verbindung const ws = new WebSocket ( 'ws://localhost:3001/ws' );
ws . onmessage = ( event ) => {
const message = JSON . parse ( event . data );
- console . log ( message . type , message . data );
+ console . log ( message . type , message . payload );
};
- Alle Nachrichten folgen diesem Schema:
Alle Broadcasts haben dieses Schema:
{
"type" : "EVENT_TYPE" ,
- "data" : { ... }
-}
- Event-Typen PIPELINE_STATE_CHANGE Wird gesendet, wenn der Pipeline-Zustand wechselt.
{
- "type" : "PIPELINE_STATE_CHANGE" ,
- "data" : {
- "state" : "ENCODING" ,
- "jobId" : 42 ,
- "job" : {
- "id" : 42 ,
- "title" : "Inception" ,
- "status" : "ENCODING"
- }
- }
-}
- PROGRESS_UPDATE Wird während aktiver Prozesse (Ripping/Encoding) regelmäßig gesendet.
{
- "type" : "PROGRESS_UPDATE" ,
- "data" : {
- "progress" : 73.5 ,
- "eta" : "00:12:34" ,
- "speed" : "45.2 fps" ,
- "phase" : "ENCODING"
- }
-}
- Felder:
Feld Typ Beschreibung progress number Fortschritt 0–100 eta string Geschätzte Restzeit (HH:MM:SS) speed string Encoding-Geschwindigkeit (nur beim Encoding) phase string Aktuelle Phase (RIPPING oder ENCODING)
DISC_DETECTED Wird gesendet, wenn eine Disc erkannt wird.
{
- "type" : "DISC_DETECTED" ,
- "data" : {
- "device" : "/dev/sr0"
- }
-}
- DISC_REMOVED Wird gesendet, wenn eine Disc ausgeworfen wird.
{
- "type" : "DISC_REMOVED" ,
- "data" : {
- "device" : "/dev/sr0"
- }
-}
- JOB_COMPLETE Wird gesendet, wenn ein Job erfolgreich abgeschlossen wurde.
{
- "type" : "JOB_COMPLETE" ,
- "data" : {
- "jobId" : 42 ,
- "title" : "Inception" ,
- "outputPath" : "/mnt/nas/movies/Inception (2010).mkv"
- }
-}
- ERROR Wird gesendet, wenn ein Fehler aufgetreten ist.
{
- "type" : "ERROR" ,
- "data" : {
- "jobId" : 42 ,
- "message" : "HandBrake ist abgestürzt" ,
- "details" : "Exit code: 1\nStderr: ..."
+ "payload" : { },
+ "timestamp" : "2026-03-05T10:00:00.000Z"
+}
+ Event-Typen WS_CONNECTED Wird direkt nach Verbindungsaufbau gesendet.
{
+ "type" : "WS_CONNECTED" ,
+ "payload" : {
+ "connectedAt" : "2026-03-05T10:00:00.000Z"
+ }
+}
+ PIPELINE_STATE_CHANGED Snapshot bei Zustandswechsel.
{
+ "type" : "PIPELINE_STATE_CHANGED" ,
+ "payload" : {
+ "state" : "ENCODING" ,
+ "activeJobId" : 42 ,
+ "progress" : 73.5 ,
+ "eta" : "00:12:34" ,
+ "statusText" : "Encoding mit HandBrake" ,
+ "context" : {},
+ "queue" : {
+ "maxParallelJobs" : 1 ,
+ "runningCount" : 1 ,
+ "queuedCount" : 0
+ }
+ }
+}
+ PIPELINE_PROGRESS Laufende Fortschrittsupdates während aktiver Phasen.
{
+ "type" : "PIPELINE_PROGRESS" ,
+ "payload" : {
+ "state" : "ENCODING" ,
+ "activeJobId" : 42 ,
+ "progress" : 73.5 ,
+ "eta" : "00:12:34" ,
+ "statusText" : "ENCODING 73.50% - task 1 of 1"
+ }
+}
+ PIPELINE_QUEUE_CHANGED Aktualisierung der Job-Queue.
{
+ "type" : "PIPELINE_QUEUE_CHANGED" ,
+ "payload" : {
+ "maxParallelJobs" : 1 ,
+ "runningCount" : 1 ,
+ "queuedCount" : 2 ,
+ "runningJobs" : [],
+ "queuedJobs" : []
+ }
+}
+ DISC_DETECTED Disc erkannt.
{
+ "type" : "DISC_DETECTED" ,
+ "payload" : {
+ "device" : {
+ "path" : "/dev/sr0" ,
+ "discLabel" : "INCEPTION"
+ }
+ }
+}
+ DISC_REMOVED Disc entfernt.
{
+ "type" : "DISC_REMOVED" ,
+ "payload" : {
+ "device" : {
+ "path" : "/dev/sr0"
+ }
}
}
- Wird gesendet, wenn Benutzer-Eingabe für Metadaten benötigt wird.
{
- "type" : "METADATA_REQUIRED" ,
- "data" : {
- "jobId" : 42 ,
- "makemkvData" : { ... },
- "playlistAnalysis" : { ... }
- }
-}
- ENCODE_REVIEW_REQUIRED Wird gesendet, wenn der Benutzer den Encode-Plan bestätigen soll.
{
- "type" : "ENCODE_REVIEW_REQUIRED" ,
- "data" : {
- "jobId" : 42 ,
- "encodePlan" : {
- "audioTracks" : [ ... ],
- "subtitleTracks" : [ ... ]
- }
- }
-}
- Reconnect-Verhalten Der Frontend-Hook useWebSocket.js implementiert automatisches Reconnect:
Verbindung verloren
- ↓
- Warte 1s → Reconnect-Versuch
- ↓ (Fehlschlag)
- Warte 2s → Reconnect-Versuch
- ↓ (Fehlschlag)
- Warte 4s → ...
- ↓
- Max. 30s Wartezeit
- Beispiel: React-Hook import { useEffect , useState } from 'react' ;
-
-function usePipelineState () {
- const [ state , setState ] = useState ({ state : 'IDLE' });
-
- useEffect (() => {
- const ws = new WebSocket ( import . meta . env . VITE_WS_URL + '/ws' );
-
- ws . onmessage = ( event ) => {
- const msg = JSON . parse ( event . data );
-
- if ( msg . type === 'PIPELINE_STATE_CHANGE' ) {
- setState ( msg . data );
- }
- };
-
- return () => ws . close ();
- }, []);
-
- return state ;
-}
+ PIPELINE_ERROR Fehler bei Pipeline-Disc-Events im Backend.
{
+ "type" : "PIPELINE_ERROR" ,
+ "payload" : {
+ "message" : "..."
+ }
+}
+ DISK_DETECTION_ERROR Fehler im Laufwerkserkennungsdienst.
{
+ "type" : "DISK_DETECTION_ERROR" ,
+ "payload" : {
+ "message" : "..."
+ }
+}
+ Reconnect-Verhalten useWebSocket.js versucht bei Verbindungsabbruch automatisch erneut zu verbinden.
fester Retry-Intervall: 1500ms erneuter Versuch bis zum Unmount der Komponente React-Beispiel import { useWebSocket } from './hooks/useWebSocket' ;
+
+useWebSocket ({
+ onMessage : ( msg ) => {
+ if ( msg . type === 'PIPELINE_STATE_CHANGED' ) {
+ setPipeline ( msg . payload );
+ }
+ }
+});
Zurück zum Seitenanfang
\ No newline at end of file
diff --git a/site/architecture/backend/index.html b/site/architecture/backend/index.html
index 2907235..06c1618 100644
--- a/site/architecture/backend/index.html
+++ b/site/architecture/backend/index.html
@@ -1,25 +1,31 @@
- Backend-Services - Ripster
Backend-Services Das Backend ist in Node.js/Express geschrieben und in Services aufgeteilt, die jeweils eine klar abgegrenzte Verantwortlichkeit haben.
pipelineService.js Der Kern von Ripster – orchestriert den gesamten Ripping-Workflow.
Zuständigkeiten Verwaltung des Pipeline-Zustands als State Machine Koordination zwischen allen externen Tools Generierung von Encode-Plänen Fehlerbehandlung und Recovery Haupt-Methoden Methode Beschreibung analyzeDisc() Startet MakeMKV-Analyse der eingelegten Disc selectMetadata(jobId, omdbData, playlist) Setzt Metadaten und Playlist für einen Job startJob(jobId) Startet den Ripping-Prozess confirmEncode(jobId, trackSelection) Bestätigt Encode mit Track-Auswahl cancelPipeline() Bricht aktiven Prozess ab retryJob(jobId) Wiederholt fehlgeschlagenen Job reencodeJob(jobId) Encodiert bestehende Raw-MKV neu
Zustandsübergänge flowchart LR
+ Backend-Services - Ripster
Backend-Services Das Backend ist in Node.js/Express geschrieben und in Services aufgeteilt, die jeweils eine klar abgegrenzte Verantwortlichkeit haben.
pipelineService.js Der Kern von Ripster – orchestriert den gesamten Ripping-Workflow.
Zuständigkeiten Verwaltung des Pipeline-Zustands als State Machine Koordination zwischen allen externen Tools Generierung von Encode-Plänen Fehlerbehandlung und Recovery Haupt-Methoden Methode Beschreibung analyzeDisc() Legt Job an und öffnet Metadaten-Auswahl selectMetadata({...}) Setzt Metadaten/Playlist und triggert Auto-Start startPreparedJob(jobId) Startet vorbereiteten Job (oder Queue) confirmEncodeReview(jobId, options) Bestätigt Review inkl. Track/Skript-Auswahl cancel(jobId) Bricht laufenden Job ab oder entfernt Queue-Eintrag retry(jobId) Startet fehlgeschlagenen/abgebrochenen Job neu reencodeFromRaw(jobId) Encodiert aus vorhandenem RAW neu restartReviewFromRaw(jobId) Berechnet Review aus RAW neu restartEncodeWithLastSettings(jobId) Neustart mit letzter bestätigter Auswahl resumeReadyToEncodeJob(jobId) Lädt READY_TO_ENCODE nach Neustart in die Session
Zustandsübergänge flowchart LR
START(( )) --> IDLE
- IDLE -->|analyzeDisc()| ANALYZING[ANALYZING]
- ANALYZING -->|MakeMKV fertig| META[METADATA\nSELECTION]
+ IDLE -->|analyzeDisc()| META[METADATA\nSELECTION]
META -->|selectMetadata()| RTS[READY_TO\nSTART]
- RTS -->|startJob()| RIP[RIPPING]
+ RTS -->|Auto-Start/Queue| RIP[RIPPING]
+ RTS -->|Auto-Start mit RAW| MIC[MEDIAINFO\nCHECK]
RIP -->|MKV erstellt| MIC[MEDIAINFO\nCHECK]
+ MIC -->|Playlist offen| WUD[WAITING_FOR\nUSER_DECISION]
+ WUD -->|selectMetadata(selectedPlaylist)| MIC
MIC -->|Tracks analysiert| RTE[READY_TO\nENCODE]
- RTE -->|confirmEncode()| ENC[ENCODING]
- ENC -->|HandBrake fertig| FIN([FINISHED])
+ RTE -->|confirmEncodeReview() + startPreparedJob()| ENC[ENCODING]
+ ENC -->|HandBrake + Post-Skripte fertig| FIN([FINISHED])
+ ENC -->|Abbruch| CAN([CANCELLED])
ENC -->|Fehler| ERR([ERROR])
RIP -->|Fehler| ERR
- ERR -->|retryJob() / cancel| IDLE
+ RIP -->|Abbruch| CAN
+ ERR -->|retry() / cancel()| IDLE
+ CAN -->|retry() / analyzeDisc()| IDLE
FIN -->|cancel / neue Disc| IDLE
style FIN fill:#e8f5e9,stroke:#66bb6a,color:#2e7d32
+ style CAN fill:#fff3e0,stroke:#fb8c00,color:#e65100
style ERR fill:#ffebee,stroke:#ef5350,color:#c62828
style ENC fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a
style RIP fill:#e3f2fd,stroke:#42a5f5,color:#1565c0
- style MIC fill:#e3f2fd,stroke:#42a5f5,color:#1565c0 diskDetectionService.js Überwacht das Disc-Laufwerk auf Disc-Einleger- und Auswurf-Ereignisse.
Modi Modus Beschreibung auto Erkennt verfügbare Laufwerke automatisch explicit Überwacht ein bestimmtes Gerät (z.B. /dev/sr0)
Polling Der Service pollt das Laufwerk im konfigurierten Intervall (disc_poll_interval_ms, Standard: 5000ms) und emittiert Events:
// Ereignisse
-emit ( 'disc-detected' , { device : '/dev/sr0' })
-emit ( 'disc-removed' , { device : '/dev/sr0' })
+ style MIC fill:#e3f2fd,stroke:#42a5f5,color:#1565c0 diskDetectionService.js Überwacht das Disc-Laufwerk auf Disc-Einleger- und Auswurf-Ereignisse.
Modi Modus Beschreibung auto Erkennt verfügbare Laufwerke automatisch explicit Überwacht ein bestimmtes Gerät (z.B. /dev/sr0)
Polling Der Service pollt das Laufwerk im konfigurierten Intervall (disc_poll_interval_ms, Standard: 4000ms) und emittiert Events:
// Ereignisse
+emit ( 'discInserted' , { path : '/dev/sr0' })
+emit ( 'discRemoved' , { path : '/dev/sr0' })
processRunner.js Verwaltet externe CLI-Prozesse.
Features Streaming : stdout/stderr werden zeilenweise gelesen Progress-Callbacks : Ermöglicht Echtzeit-Fortschrittsanzeige Graceful Shutdown : SIGINT → Warte-Timeout → SIGKILL Prozess-Registry : Verfolgt aktive Prozesse für sauberes Beenden Nutzung const result = await runProcess (
'HandBrakeCLI' ,
[ '--input' , rawFile , '--output' , outputFile , '--preset' , preset ],
@@ -28,8 +34,9 @@
onStdout : ( line ) => logger . debug ( line )
}
);
- websocketService.js WebSocket-Server für Echtzeit-Client-Kommunikation.
Betrieb Läuft auf Pfad /ws des Express-Servers Hält eine Registry aller verbundenen Clients Ermöglicht Broadcast an alle Clients oder gezieltes Senden API broadcast ({ type : 'PIPELINE_STATE_CHANGE' , data : { state , jobId } });
-broadcast ({ type : 'PROGRESS_UPDATE' , data : { progress , eta } });
+ websocketService.js WebSocket-Server für Echtzeit-Client-Kommunikation.
Betrieb Läuft auf Pfad /ws des Express-Servers Hält eine Registry aller verbundenen Clients Ermöglicht Broadcast an alle Clients oder gezieltes Senden API broadcast ( 'PIPELINE_STATE_CHANGED' , { state , activeJobId });
+broadcast ( 'PIPELINE_PROGRESS' , { state , progress , eta , statusText });
+broadcast ( 'PIPELINE_QUEUE_CHANGED' , queueSnapshot );
omdbService.js Integration mit der OMDb API .
Methoden Methode Beschreibung searchByTitle(title, type) Suche nach Titel (movie/series) fetchById(imdbId) Vollständige Metadaten per IMDb-ID
Zurückgegebene Daten {
"imdbId" : "tt1375666" ,
"title" : "Inception" ,
@@ -39,7 +46,7 @@
"plot" : "..." ,
"director" : "Christopher Nolan"
}
- settingsService.js Verwaltet alle Anwendungseinstellungen.
Features Schema-getriebene Validierung : Jede Einstellung hat Typ, Grenzen und Pflichtfeld-Flag Kategorisierung : Einstellungen sind in Kategorien gruppiert (Paths, Tools, Encoding, ...) Persistenz : Werte in SQLite, Schema ebenfalls in SQLite Defaults : defaultSettings.js definiert Standardwerte Einstellungs-Kategorien Kategorie Einstellungen paths raw_dir, movie_dir, log_dir tools makemkv_command, handbrake_command, mediainfo_command encoding handbrake_preset, handbrake_extra_args, output_extension, filename_template drive drive_mode, drive_device, disc_poll_interval_ms makemkv makemkv_min_length_minutes, makemkv_backup_mode omdb omdb_api_key, omdb_default_type notifications pushover_user_key, pushover_api_token
historyService.js Datenbankoperationen für Job-Historie.
Hauptoperationen Operation Beschreibung listJobs(filters) Jobs nach Status/Titel filtern getJob(id) Job-Details mit Logs abrufen findOrphanRawFolders() Nicht-getrackte Raw-Ordner finden importOrphanRaw(path) Orphan-Ordner als Job importieren assignOmdb(id, omdbData) OMDb-Metadaten nachträglich zuweisen deleteJob(id, deleteFiles) Job und optional Dateien löschen
notificationService.js PushOver-Push-Benachrichtigungen.
settingsService.js Verwaltet alle Anwendungseinstellungen.
Features Schema-getriebene Validierung : Jede Einstellung hat Typ, Grenzen und Pflichtfeld-Flag Kategorisierung : Einstellungen sind in Kategorien gruppiert (Paths, Tools, Encoding, ...) Persistenz : Werte in SQLite, Schema ebenfalls in SQLite Defaults : defaultSettings.js definiert Standardwerte Einstellungs-Kategorien Kategorie Einstellungen Pfade raw_dir, movie_dir, log_dir Laufwerk drive_mode, drive_device, disc_poll_interval_ms, makemkv_source_index Monitoring hardware_monitoring_enabled, hardware_monitoring_interval_ms Tools makemkv_command, handbrake_command, mediainfo_command, pipeline_max_parallel_jobs Metadaten omdb_api_key, omdb_default_type Benachrichtigungen pushover_user_key, pushover_api_token
historyService.js Datenbankoperationen für Job-Historie.
Hauptoperationen Operation Beschreibung listJobs(filters) Jobs nach Status/Titel filtern getJob(id) Job-Details mit Logs abrufen findOrphanRawFolders() Nicht-getrackte Raw-Ordner finden importOrphanRaw(path) Orphan-Ordner als Job importieren assignOmdb(id, omdbData) OMDb-Metadaten nachträglich zuweisen deleteJob(id, deleteFiles) Job und optional Dateien löschen
notificationService.js PushOver-Push-Benachrichtigungen.
await notify ({
title : 'Ripster: Job abgeschlossen' ,
message : 'Inception (2010) wurde erfolgreich encodiert'
});
diff --git a/site/architecture/frontend/index.html b/site/architecture/frontend/index.html
index 0498b7f..4a7fea2 100644
--- a/site/architecture/frontend/index.html
+++ b/site/architecture/frontend/index.html
@@ -1,4 +1,4 @@
- Frontend-Komponenten - Ripster
Frontend-Komponenten Das Frontend ist mit React 18 und PrimeReact gebaut und kommuniziert über REST-API und WebSocket mit dem Backend.
Seiten (Pages) DashboardPage.jsx Die Hauptseite von Ripster – zeigt den aktuellen Pipeline-Status und ermöglicht alle Workflow-Aktionen.
Funktionen: - Anzeige des aktuellen Pipeline-Zustands (IDLE, ANALYZING, RIPPING, ENCODING, ...) - Live-Fortschrittsbalken mit ETA - Trigger für Metadaten-Dialog - Playlist-Entscheidungs-UI (bei Blu-ray Obfuskierung) - Encode-Review mit Track-Auswahl - Job-Steuerung (Start, Abbruch, Retry)
Zugehörige Komponenten: - PipelineStatusCard – Status-Widget - MetadataSelectionDialog – OMDb-Suche und Playlist-Auswahl - MediaInfoReviewPanel – Track-Auswahl vor dem Encoding - DiscDetectedDialog – Benachrichtigung bei Disc-Erkennung
SettingsPage.jsx Konfigurationsoberfläche für alle Ripster-Einstellungen.
Funktionen: - Dynamisch generiertes Formular aus dem Settings-Schema - Echtzeit-Validierungsfeedback - PushOver-Verbindungstest - Automatische Aktualisierung des Encode-Reviews bei relevanten Änderungen
HistoryPage.jsx Job-Historie mit vollständigem Audit-Trail.
Funktionen: - Sortier- und filterbares Job-Verzeichnis - Statusfilter (FINISHED, ERROR, WAITING_FOR_USER_DECISION, ...) - Job-Detail-Dialog mit vollständigen Logs - Re-Encode, Löschen und Metadaten-Zuweisung - Import von Orphan-Raw-Ordnern
Komponenten (Components) Dialog für die Metadaten-Auswahl nach der Disc-Analyse.
┌─────────────────────────────────────┐
+ Frontend-Komponenten - Ripster
Frontend-Komponenten Das Frontend ist mit React 18 und PrimeReact gebaut und kommuniziert über REST-API und WebSocket mit dem Backend.
Seiten (Pages) DashboardPage.jsx Die Hauptseite von Ripster – zeigt den aktuellen Pipeline-Status und ermöglicht alle Workflow-Aktionen.
Funktionen: - Anzeige des aktuellen Pipeline-Zustands (IDLE, DISC_DETECTED, METADATA_SELECTION, RIPPING, MEDIAINFO_CHECK, READY_TO_ENCODE, ENCODING, ...) - Live-Fortschrittsbalken mit ETA - Trigger für Metadaten-Dialog - Playlist-Entscheidungs-UI (bei Blu-ray Obfuskierung) - Encode-Review mit Track-Auswahl - Job-Steuerung (Start, Abbruch, Retry, Queue-Interaktion)
Zugehörige Komponenten: - PipelineStatusCard – Status-Widget - MetadataSelectionDialog – OMDb-Suche und Playlist-Auswahl - MediaInfoReviewPanel – Track-Auswahl vor dem Encoding - Queue- und Job-Karten-UI direkt in DashboardPage
SettingsPage.jsx Konfigurationsoberfläche für alle Ripster-Einstellungen.
Funktionen: - Dynamisch generiertes Formular aus dem Settings-Schema - Echtzeit-Validierungsfeedback - PushOver-Verbindungstest - Automatische Aktualisierung des Encode-Reviews bei relevanten Änderungen
DatabasePage.jsx (/history) Job-Historie und Datenbankansicht mit vollständigem Audit-Trail.
Funktionen: - Sortier- und filterbares Job-Verzeichnis - Statusfilter (FINISHED, ERROR, WAITING_FOR_USER_DECISION, ...) - Job-Detail-Dialog mit vollständigen Logs - Re-Encode, Löschen und Metadaten-Zuweisung - Import von Orphan-Raw-Ordnern
Komponenten (Components) Dialog für die Metadaten-Auswahl nach der Disc-Analyse.
┌─────────────────────────────────────┐
│ Metadaten auswählen │
├─────────────────────────────────────┤
│ Suche: [Inception ] 🔍 │
@@ -25,20 +25,23 @@
│ ☑ Track 1: Deutsch │
│ ☐ Track 2: English │
├─────────────────────────────────────┤
- │ [Encodierung starten] │
+ │ [Encoding starten] │
└─────────────────────────────────────┘
- Wiederverwendbares Formular, das aus dem Settings-Schema generiert wird.
Unterstützte Feldtypen:
Typ UI-Element string Text-Input number Zahlen-Input mit Min/Max boolean Toggle/Checkbox select Dropdown password Passwort-Input
PipelineStatusCard.jsx Status-Anzeige-Widget für die Dashboard-Seite.
JobDetailDialog.jsx Vollständiger Job-Detail-Dialog mit Logs-Viewer.
Hooks useWebSocket.js Zentraler Custom-Hook für die WebSocket-Verbindung.
const { status , lastMessage } = useWebSocket ({
+ Wiederverwendbares Formular, das aus dem Settings-Schema generiert wird.
Unterstützte Feldtypen:
Typ UI-Element string Text-Input number Zahlen-Input mit Min/Max boolean Toggle/Checkbox select Dropdown password Passwort-Input
PipelineStatusCard.jsx Status-Anzeige-Widget für die Dashboard-Seite.
JobDetailDialog.jsx Vollständiger Job-Detail-Dialog mit Logs-Viewer.
Hooks useWebSocket.js Zentraler Custom-Hook für die WebSocket-Verbindung.
useWebSocket ({
onMessage : ( msg ) => {
- if ( msg . type === 'PIPELINE_STATE_CHANGE' ) {
- setPipelineState ( msg . data );
+ if ( msg . type === 'PIPELINE_STATE_CHANGED' ) {
+ setPipelineState ( msg . payload );
}
}
});
- Features: - Automatische Verbindung zu /ws - Reconnect mit exponential backoff - Message-Parsing (JSON) - Status-Tracking (connecting, connected, disconnected)
API-Client (client.js) Zentraler HTTP-Client für alle Backend-Anfragen.
Features: - Automatische Verbindung zu /ws - Reconnect mit festem Intervall (1500ms) - Message-Parsing (JSON)
API-Client (client.js) Zentraler HTTP-Client für alle Backend-Anfragen.
// Beispiel-Aufrufe
const state = await api . getPipelineState ();
const results = await api . searchOmdb ( 'Inception' );
-await api . selectMetadata ( jobId , omdbData , playlist );
-await api . confirmEncode ( jobId , { audioTracks : [ 0 , 1 ], subtitleTracks : [ 0 ] });
+await api . selectMetadata ({ jobId , title , year , imdbId , selectedPlaylist });
+await api . confirmEncodeReview ( jobId , {
+ selectedEncodeTitleId : 1 ,
+ selectedTrackSelection : { 1 : { audioTrackIds : [ 1 ], subtitleTrackIds : [ 3 ] } }
+});
Features: - Zentralisierte Fehlerbehandlung - Automatische JSON-Serialisierung - Basis-URL aus Umgebungsvariable (VITE_API_BASE)
Build & Entwicklung Entwicklungsserver cd frontend
npm run dev
# → http://localhost:5173
diff --git a/site/architecture/index.html b/site/architecture/index.html
index 845e62e..fec2e84 100644
--- a/site/architecture/index.html
+++ b/site/architecture/index.html
@@ -58,7 +58,7 @@
├── Pages
│ ├── DashboardPage.jsx ← Haupt-Interface
│ ├── SettingsPage.jsx
- │ └── HistoryPage.jsx
+ │ └── DatabasePage.jsx ← Historie/DB-Ansicht
├── Components
│ ├── PipelineStatusCard.jsx
│ ├── MetadataSelectionDialog.jsx
diff --git a/site/architecture/overview/index.html b/site/architecture/overview/index.html
index fd35453..d35f007 100644
--- a/site/architecture/overview/index.html
+++ b/site/architecture/overview/index.html
@@ -1,15 +1,15 @@
Übersicht - Ripster
Architektur-Übersicht Kern-Designprinzipien Event-Driven Pipeline Der gesamte Ripping-Workflow ist als State Machine implementiert. Der pipelineService verwaltet den aktuellen Zustand und emittiert Ereignisse bei jedem Zustandswechsel. Der WebSocket-Service überträgt diese Ereignisse sofort an alle verbundenen Clients.
Zustandswechsel → Event → WebSocket → Frontend-Update
Service-Layer-Muster HTTP-Route → Service → Datenbank
Routes delegieren die gesamte Business-Logik an Services. Services sind voneinander unabhängig und können einzeln getestet werden.
Schema-getriebene Einstellungen Die Settings-Konfiguration definiert sowohl die Validierungsregeln als auch die UI-Struktur in einer einzigen Quelle (settings_schema-Tabelle). Die DynamicSettingsForm-Komponente rendert das Formular dynamisch aus dem Schema.
Echtzeit-Kommunikation WebSocket-Protokoll Der WebSocket-Server läuft unter dem Pfad /ws. Nachrichten werden als JSON übertragen:
{
- "type" : "PIPELINE_STATE_CHANGE" ,
- "data" : {
+ "type" : "PIPELINE_STATE_CHANGED" ,
+ "payload" : {
"state" : "ENCODING" ,
- "jobId" : 42 ,
+ "activeJobId" : 42 ,
"progress" : 73.5 ,
"eta" : "00:12:34"
}
}
- Nachrichtentypen:
Typ Beschreibung PIPELINE_STATE_CHANGE Pipeline-Zustand hat gewechselt PROGRESS_UPDATE Fortschritt (% und ETA) DISC_DETECTED Disc wurde erkannt DISC_REMOVED Disc wurde entfernt ERROR Fehler aufgetreten JOB_COMPLETE Job abgeschlossen
Reconnect-Logik Der Frontend-Hook useWebSocket.js implementiert automatisches Reconnect mit exponential backoff bei Verbindungsabbrüchen.
Prozess-Management processRunner.js Externe Tools (MakeMKV, HandBrake, MediaInfo) werden als Child Processes gestartet:
spawn ( command , args , { stdio : [ 'ignore' , 'pipe' , 'pipe' ] })
+ Nachrichtentypen:
Typ Beschreibung PIPELINE_STATE_CHANGED Pipeline-Zustand hat gewechselt PIPELINE_PROGRESS Fortschritt (% und ETA) PIPELINE_QUEUE_CHANGED Queue-Status geändert DISC_DETECTED Disc wurde erkannt DISC_REMOVED Disc wurde entfernt PIPELINE_ERROR Pipeline-Fehler aufgetreten DISK_DETECTION_ERROR Laufwerkserkennung-Fehler
Reconnect-Logik Der Frontend-Hook useWebSocket.js implementiert automatisches Reconnect mit festem Intervall von 1500ms bei Verbindungsabbrüchen.
Prozess-Management processRunner.js Externe Tools (MakeMKV, HandBrake, MediaInfo) werden als Child Processes gestartet:
spawn ( command , args , { stdio : [ 'ignore' , 'pipe' , 'pipe' ] })
stdout/stderr werden zeilenweise gelesen und in Echtzeit verarbeitet Progress-Parsing erfolgt über reguläre Ausdrücke in progressParsers.js Graceful Shutdown : SIGINT → Timeout → SIGKILL Prozess-Tracking : Aktive Prozesse werden registriert für sauberes Beenden Datenpersistenz SQLite-Datenbank Ripster verwendet eine einzige SQLite-Datei für alle persistenten Daten:
Tabellen:
Tabelle Inhalt jobs Alle Rip-Jobs mit Status, Logs, Metadaten pipeline_state Aktueller Pipeline-Zustand (Singleton) settings_schema Schema aller verfügbaren Einstellungen settings_values Benutzer-konfigurierte Werte
Migrations-Strategie Beim Start prüft database.js automatisch, ob das Schema aktuell ist, und führt fehlende Migrationen aus. Korrupte Datenbankdateien werden in ein Quarantäne-Verzeichnis verschoben und eine neue Datenbank erstellt.
Fehlerbehandlung Strukturierte Fehler Alle Fehler werden mit Kontext-Metadaten protokolliert:
logger . error ( 'Encoding fehlgeschlagen' , {
jobId : job . id ,
diff --git a/site/configuration/settings-reference/index.html b/site/configuration/settings-reference/index.html
index e56b6dc..fb0a88e 100644
--- a/site/configuration/settings-reference/index.html
+++ b/site/configuration/settings-reference/index.html
@@ -5,6 +5,6 @@
handbrake_command = /usr/local/bin/HandBrakeCLI
mediainfo_command = /usr/bin/mediainfo
Kategorie: Encoding (encoding) Schlüssel Typ Standard Beschreibung handbrake_preset string H.265 MKV 1080p30 Name des HandBrake-Presets handbrake_extra_args string (leer) Zusätzliche HandBrake CLI-Argumente output_extension string mkv Dateiendung der Ausgabedatei filename_template string {title} ({year}) Template für den Dateinamen
Verfügbare HandBrake-Presets Eine vollständige Liste der verfügbaren Presets:
HandBrakeCLI --preset-list
- Häufig verwendete Presets:
Preset Beschreibung H.265 MKV 1080p30 H.265/HEVC, Full-HD, 30fps H.265 MKV 720p30 H.265/HEVC, HD, 30fps H.264 MKV 1080p30 H.264/AVC, Full-HD, 30fps HQ 1080p30 Surround Hohe Qualität, Full-HD mit Surround
Dateiname-Template-Platzhalter Platzhalter Beispiel {title} Inception {year} 2010 {imdb_id} tt1375666 {type} movie
Kategorie: Laufwerk (drive) Schlüssel Typ Standard Optionen Beschreibung drive_mode select auto auto, explicit Laufwerk-Erkennungsmodus drive_device string /dev/sr0 — Geräte-Pfad (nur bei explicit) disc_poll_interval_ms number 5000 1000–60000 Polling-Intervall in Millisekunden
drive_mode Optionen:
Modus Beschreibung auto Ripster erkennt das Laufwerk automatisch explicit Verwendet das in drive_device konfigurierte Gerät
Kategorie: MakeMKV (makemkv) Schlüssel Typ Standard Min Max Beschreibung makemkv_min_length_minutes number 15 0 999 Mindest-Titellänge in Minuten makemkv_backup_mode boolean false — — Backup-Modus statt MKV-Modus
makemkv_min_length_minutes: Titel kürzer als dieser Wert werden von MakeMKV ignoriert. Verhindert das Rippen von Menü-Schleifen und kurzen Extra-Clips.
makemkv_backup_mode: Im Backup-Modus erstellt MakeMKV eine vollständige Disc-Kopie mit Menüs. Im Standard-Modus werden direkt MKV-Dateien erstellt.
Kategorie: OMDb (omdb) Schlüssel Typ Standard Pflicht Beschreibung omdb_api_key string — ✅ API-Key von omdbapi.com omdb_default_type select movie — Standard-Suchtyp: movie oder series
Kategorie: Benachrichtigungen (notifications) Schlüssel Typ Standard Beschreibung pushover_user_key string — PushOver User-Key pushover_api_token string — PushOver API-Token
Beide Felder müssen konfiguriert sein, um PushOver-Benachrichtigungen zu aktivieren. Die Verbindung kann mit dem Test-Button in den Einstellungen geprüft werden.
Standard-Einstellungen zurücksetzen Über die Datenbank können Einstellungen auf Standardwerte zurückgesetzt werden:
sqlite3 backend/data/ripster.db \
+ Häufig verwendete Presets:
Preset Beschreibung H.265 MKV 1080p30 H.265/HEVC, Full-HD, 30fps H.265 MKV 720p30 H.265/HEVC, HD, 30fps H.264 MKV 1080p30 H.264/AVC, Full-HD, 30fps HQ 1080p30 Surround Hohe Qualität, Full-HD mit Surround
Dateiname-Template-Platzhalter Platzhalter Beispiel {title} Inception {year} 2010 {imdb_id} tt1375666 {type} movie
Kategorie: Laufwerk (drive) Schlüssel Typ Standard Optionen Beschreibung drive_mode select auto auto, explicit Laufwerk-Erkennungsmodus drive_device string /dev/sr0 — Geräte-Pfad (nur bei explicit) disc_poll_interval_ms number 4000 1000–60000 Polling-Intervall in Millisekunden
drive_mode Optionen:
Modus Beschreibung auto Ripster erkennt das Laufwerk automatisch explicit Verwendet das in drive_device konfigurierte Gerät
Kategorie: MakeMKV (makemkv) Schlüssel Typ Standard Min Max Beschreibung makemkv_min_length_minutes number 15 0 999 Mindest-Titellänge in Minuten makemkv_backup_mode boolean false — — Backup-Modus statt MKV-Modus
makemkv_min_length_minutes: Titel kürzer als dieser Wert werden von MakeMKV ignoriert. Verhindert das Rippen von Menü-Schleifen und kurzen Extra-Clips.
makemkv_backup_mode: Im Backup-Modus erstellt MakeMKV eine vollständige Disc-Kopie mit Menüs. Im Standard-Modus werden direkt MKV-Dateien erstellt.
Kategorie: OMDb (omdb) Schlüssel Typ Standard Pflicht Beschreibung omdb_api_key string — ✅ API-Key von omdbapi.com omdb_default_type select movie — Standard-Suchtyp: movie oder series
Kategorie: Benachrichtigungen (notifications) Schlüssel Typ Standard Beschreibung pushover_user_key string — PushOver User-Key pushover_api_token string — PushOver API-Token
Beide Felder müssen konfiguriert sein, um PushOver-Benachrichtigungen zu aktivieren. Die Verbindung kann mit dem Test-Button in den Einstellungen geprüft werden.
Standard-Einstellungen zurücksetzen Über die Datenbank können Einstellungen auf Standardwerte zurückgesetzt werden:
sqlite3 backend/data/ripster.db \
"DELETE FROM settings_values WHERE key = 'handbrake_preset';"
Beim nächsten Laden der Einstellungen wird der Standardwert verwendet.
Zurück zum Seitenanfang
\ No newline at end of file
diff --git a/site/getting-started/configuration/index.html b/site/getting-started/configuration/index.html
index 7cb49b3..153b93f 100644
--- a/site/getting-started/configuration/index.html
+++ b/site/getting-started/configuration/index.html
@@ -4,4 +4,4 @@
OMDb API Einstellung Beschreibung omdb_api_key API-Key von omdbapi.com omdb_default_type Standard-Suchtyp: movie oder series
Einstellung Standard Beschreibung makemkv_command makemkvcon Pfad oder Befehl für MakeMKV handbrake_command HandBrakeCLI Pfad oder Befehl für HandBrake mediainfo_command mediainfo Pfad oder Befehl für MediaInfo
Absolute Pfade
Falls die Tools nicht im PATH sind, verwende absolute Pfade:
/usr/local/bin/HandBrakeCLI
Encoding-Konfiguration Einstellung Standard Beschreibung handbrake_preset H.265 MKV 1080p30 HandBrake-Preset-Name handbrake_extra_args (leer) Zusätzliche HandBrake-Argumente output_extension mkv Dateiendung der Ausgabedatei filename_template {title} ({year}) Template für Dateinamen
Dateiname-Template Das Template unterstützt folgende Platzhalter:
Platzhalter Beschreibung Beispiel {title} Filmtitel Inception {year} Erscheinungsjahr 2010 {imdb_id} IMDb-ID tt1375666 {type} movie oder series movie
Beispiel-Template:
{title} ({year})
→ Inception (2010).mkv
- Laufwerk-Konfiguration Einstellung Standard Beschreibung drive_mode auto auto (automatisch erkennen) oder explicit (festes Gerät) drive_device /dev/sr0 Geräte-Pfad (nur bei explicit) disc_poll_interval_ms 5000 Polling-Intervall in Millisekunden
MakeMKV-Konfiguration Einstellung Standard Beschreibung makemkv_min_length_minutes 15 Mindestlänge für Titel in Minuten makemkv_backup_mode false Backup-Modus statt MKV-Modus
Backup-Modus
Im Backup-Modus erstellt MakeMKV eine vollständige Kopie der Disc (inkl. Menüs). Der Standardmodus erstellt direkt MKV-Dateien.
Benachrichtigungen (PushOver) Einstellung Beschreibung pushover_user_key Dein PushOver User-Key pushover_api_token API-Token deiner PushOver-App
Nach der Eingabe kann die Verbindung mit dem Test-Button geprüft werden.
Vollständige Einstellungsreferenz Eine vollständige Liste aller Einstellungen mit Typen, Validierung und Standardwerten findest du unter:
Einstellungsreferenz
Zurück zum Seitenanfang
\ No newline at end of file
+
Laufwerk-Konfiguration Einstellung Standard Beschreibung drive_mode auto auto (automatisch erkennen) oder explicit (festes Gerät) drive_device /dev/sr0 Geräte-Pfad (nur bei explicit) disc_poll_interval_ms 4000 Polling-Intervall in Millisekunden
MakeMKV-Konfiguration Einstellung Standard Beschreibung makemkv_min_length_minutes 15 Mindestlänge für Titel in Minuten makemkv_backup_mode false Backup-Modus statt MKV-Modus
Backup-Modus
Im Backup-Modus erstellt MakeMKV eine vollständige Kopie der Disc (inkl. Menüs). Der Standardmodus erstellt direkt MKV-Dateien.
Benachrichtigungen (PushOver) Einstellung Beschreibung pushover_user_key Dein PushOver User-Key pushover_api_token API-Token deiner PushOver-App
Nach der Eingabe kann die Verbindung mit dem Test-Button geprüft werden.
Vollständige Einstellungsreferenz Eine vollständige Liste aller Einstellungen mit Typen, Validierung und Standardwerten findest du unter:
Einstellungsreferenz
Zurück zum Seitenanfang
\ No newline at end of file
diff --git a/site/getting-started/quickstart/index.html b/site/getting-started/quickstart/index.html
index 23a0a44..368ecd0 100644
--- a/site/getting-started/quickstart/index.html
+++ b/site/getting-started/quickstart/index.html
@@ -1,38 +1,36 @@
-
Schnellstart - Ripster
Schnellstart – Vollständiger Workflow Nach der Installation und Konfiguration führt diese Seite Schritt für Schritt durch den ersten Rip – mit allen Details aus dem Code.
Übersicht: Pipeline-Zustände 1
DISC_DETECTED
Disc erkannt
2
METADATA_SELECTION
Scan & Metadaten
⚠
WAITING_FOR_USER_DECISION
Playlist wählen(nur bei Obfusk.)
5
MEDIAINFO_CHECK
HandBrake-Scan
6
READY_TO_ENCODE
Track-Review
8
POST_ENCODE_SCRIPTS
Skripte(optional)
Legende: ● Warten | ■ Läuft automatisch | ■ Benutzeraktion | ⚠ Optional | ■ Encodierung | ✓ Fertig
Vollständiges Zustandsdiagramm (inkl. Fehler- & Alternativpfade) flowchart LR
+ Schnellstart - Ripster
Schnellstart – Vollständiger Workflow Nach der Installation und Konfiguration führt diese Seite Schritt für Schritt durch den ersten Rip – mit allen Details aus dem Code.
Übersicht: Pipeline-Ablauf 1
DISC_DETECTED
Disc erkannt
2
METADATA_SELECTION
OMDb & Dialog
⚠
WAITING_FOR_USER_DECISION
Playlist wählen(nur bei Obfusk.)
5
MEDIAINFO_CHECK
HandBrake-Scan
6
READY_TO_ENCODE
Track-Review
8*
POST-ENCODE
Skripte(innerhalb ENCODING)
Legende: ● Warten | ■ Läuft automatisch | ■ Benutzeraktion | ⚠ Optional | ■ Encodierung | ✓ Fertig
Vollständiges Zustandsdiagramm (inkl. Fehler- & Alternativpfade) flowchart LR
START(( )) --> IDLE
IDLE -->|Disc erkannt| DD[DISC_DETECTED]
DD -->|Analyse starten| META[METADATA\nSELECTION]
- META -->|1 Kandidat| RTS[READY_TO\nSTART]
- META -->|mehrere Kandidaten| WUD[WAITING_FOR\nUSER_DECISION]
- WUD -->|Playlist bestätigt| RTS
+ META -->|Metadaten übernommen| RTS[READY_TO\nSTART]
+ META -->|vorhandenes RAW +\nPlaylist offen| WUD[WAITING_FOR\nUSER_DECISION]
+ RTS -->|Auto-Start| RIP[RIPPING]
+ RTS -->|Auto-Start mit RAW| MIC[MEDIAINFO\nCHECK]
- RTS -->|Raw vorhanden| MIC[MEDIAINFO\nCHECK]
- RTS -->|Ripping starten| RIP[RIPPING]
RIP -->|MKV fertig| MIC
RIP -->|Fehler| ERR
+ MIC -->|Playlist offen (Backup)| WUD
+ WUD -->|Playlist bestätigt| MIC
+ WUD -->|Playlist bestätigt,\nnoch kein RAW| RTS
+
MIC --> RTE[READY_TO\nENCODE]
- RTE -->|bestätigt| ENC[ENCODING]
+ RTE -->|Encoding starten| ENC[ENCODING]
- ENC -->|mit Skripten| PES[POST_ENCODE\nSCRIPTS]
- ENC -->|ohne Skripte| FIN([FINISHED])
+ ENC -->|inkl. Post-Skripte| FIN([FINISHED])
ENC -->|Fehler| ERR
- PES -->|Erfolg| FIN
- PES -->|Fehler| ERR
-
ERR([ERROR]) -->|Retry / Cancel| IDLE
style FIN fill:#e8f5e9,stroke:#66bb6a,color:#2e7d32
style ERR fill:#ffebee,stroke:#ef5350,color:#c62828
style WUD fill:#fff8e1,stroke:#ffa726,color:#e65100
- style PES fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a
style ENC fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a Schritt 1 – Ripster starten Öffne http://localhost:5173 im Browser. Das Dashboard zeigt IDLE.
Schritt 2 – Disc einlegen → DISC_DETECTED Lege eine DVD oder Blu-ray ein. Der diskDetectionService pollt das Laufwerk alle disc_poll_interval_ms Millisekunden (Standard: 5 Sekunden).
Was passiert im Code:
diskDetectionService emittiert disc:inserted mit Geräteinformationen pipelineService.onDiscInserted() wird aufgerufen Dashboard zeigt Badge "Neue Disc erkannt" Der "Analyse starten" -Button wird aktiv Manuelle Auslösung
Falls die automatische Erkennung nicht greift:
curl -X POST http://localhost:3001/api/pipeline/analyze
- Klicke auf "Analyse starten" oder warte auf automatischen Start.
Was passiert im Code:
Ein neuer Job-Datensatz wird in der Datenbank angelegt (status: METADATA_SELECTION) Ripster versucht, den Titel automatisch aus dem Disc-Label/Modell zu ermitteln Mit diesem erkannten Titel wird sofort eine OMDb-Suche ausgelöst Der MetadataSelectionDialog öffnet sich im Frontend mit den vorgeladenen Suchergebnissen Erkannter Titel: Der Disc-Label (z. B. INCEPTION) wird als Suchbegriff verwendet. Falls kein Label vorhanden, bleibt das Suchfeld leer.
Der Dialog zeigt vorgeladene OMDb-Suchergebnisse. Du kannst:
4a) OMDb-Suchergebnis wählen ┌─────────────────────────────────────────────────┐
+ Öffne http://localhost:5173 im Browser. Das Dashboard zeigt IDLE.
Schritt 2 – Disc einlegen → DISC_DETECTED Lege eine DVD oder Blu-ray ein. Der diskDetectionService pollt das Laufwerk alle disc_poll_interval_ms Millisekunden (Standard: 4 Sekunden).
Was passiert im Code:
diskDetectionService emittiert discInserted mit Geräteinformationen pipelineService.onDiscInserted() wird aufgerufen Dashboard-Status-Badge zeigt "Medium erkannt" Status-Text zeigt "Neue Disk erkannt" Der "Analyse starten" -Button wird aktiv Manuelle Auslösung
Falls die automatische Erkennung nicht greift:
curl -X POST http://localhost:3001/api/pipeline/analyze
+ Klicke auf "Analyse starten" .
Was passiert im Code:
Ein neuer Job-Datensatz wird in der Datenbank angelegt (status: METADATA_SELECTION) Ripster versucht, den Titel automatisch aus dem Disc-Label/Modell zu ermitteln Mit diesem erkannten Titel wird sofort eine OMDb-Suche ausgelöst Der MetadataSelectionDialog öffnet sich im Frontend mit den vorgeladenen Suchergebnissen Erkannter Titel: Der Disc-Label (z. B. INCEPTION) wird als Suchbegriff verwendet. Falls kein Label vorhanden, bleibt das Suchfeld leer.
Der Dialog zeigt vorgeladene OMDb-Suchergebnisse. Du kannst:
4a) OMDb-Suchergebnis wählen ┌─────────────────────────────────────────────────┐
│ Suche: [Inception ] 🔍 │
├─────────────────────────────────────────────────┤
│ ▶ Inception (2010) · Movie · tt1375666 │
@@ -40,7 +38,7 @@
├─────────────────────────────────────────────────┤
│ [Auswahl übernehmen] │
└─────────────────────────────────────────────────┘
- Suche durch Titel anpassen und Enter drücken Typ-Filter: movie / series umschalten möglich Einen Eintrag anklicken, dann "Auswahl übernehmen" 4b) Manuelle Eingabe (ohne OMDb) Falls kein passendes Ergebnis gefunden wird: - Titel, Jahr und IMDb-ID manuell eingeben - OMDb-Poster wird übersprungen
Was passiert nach Bestätigung:
Ripster ruft pipelineService.selectMetadata() auf und führt sofort eine Playlist-Analyse durch:
MakeMKV wird im Info-Modus gestartet Alle Titel und deren Segment-Reihenfolgen werden analysiert Das Ergebnis entscheidet über den nächsten Zustand (→ Schritt 5) Schritt 5 – Playlist-Situation (zwei Wege) 5a) Keine Obfuskierung → READY_TO_START Der Dialog schließt sich automatisch. Die empfohlene Playlist wird still übernommen. Weiter zu Schritt 6 .
5b) Obfuskierung erkannt → WAITING_FOR_USER_DECISION Der Playlist-Auswahl-Dialog erscheint zusätzlich (nach dem Metadaten-Dialog):
┌───────────────────────────────────────────────────────────────┐
+ Suche durch Titel anpassen und Enter drücken Typ-Filter: movie / series umschalten möglich Einen Eintrag anklicken, dann "Auswahl übernehmen" 4b) Manuelle Eingabe (ohne OMDb) Falls kein passendes Ergebnis gefunden wird: - Titel, Jahr und IMDb-ID manuell eingeben - OMDb-Poster wird übersprungen
Was passiert nach Bestätigung:
Ripster ruft pipelineService.selectMetadata() auf und startet den nächsten Schritt automatisch:
Job wird auf READY_TO_START gesetzt (kurzer Übergangszustand) Falls bereits RAW vorhanden: direkter Sprung zu MEDIAINFO_CHECK Falls kein RAW vorhanden: automatischer Start von RIPPING Wenn bereits andere Jobs laufen, landet der Start stattdessen in der Queue Schritt 5 – Optional: Playlist-Auswahl → WAITING_FOR_USER_DECISION Dieser Zustand erscheint nur bei mehrdeutigen Blu-ray-Playlists (typisch nach RAW-Analyse im Backup-Modus).
Der Playlist-Auswahl-Dialog erscheint zusätzlich (nach dem Metadaten-Dialog):
┌───────────────────────────────────────────────────────────────┐
│ Playlist-Auswahl │
│ Es wurden mehrere Titel mit ähnlicher Laufzeit gefunden. │
│ Bitte wähle die korrekte Playlist: │
@@ -57,14 +55,14 @@
└───────────┴──────────┴────────┴──────────────────────────────┘
847 Playlists insgesamt · 3 relevante Kandidaten (≥ 15 min)
Empfehlung: 00800 (vorausgewählt)
- [Playlist bestätigen]
- Die empfohlene Playlist ist vorausgewählt (Radio-Button) Score und Bewertungslabel helfen bei der Entscheidung Nach Bestätigung: Pipeline wechselt zu READY_TO_START Scoring-Details
Wie die Scores berechnet werden, erklärt die Playlist-Analyse -Seite.
Schritt 6 – Ripping starten → RIPPING Vorher prüft Ripster: Existiert bereits eine Raw-Datei für diesen Job?
Ja, Raw-Datei vorhanden → Direkt zu Schritt 7 (Track-Review), kein erneutes Ripping Nein → MakeMKV-Ripping startet Klicke auf "Starten" im Dashboard.
Was MakeMKV ausführt (MKV-Modus):
makemkvcon mkv disc:0 all /mnt/raw/Inception-2010/ \
+ [Playlist übernehmen]
+ Die empfohlene Playlist ist vorausgewählt (Checkbox) Score und Bewertungslabel helfen bei der Entscheidung Nach "Playlist übernehmen" setzt Ripster automatisch fort: mit vorhandenem RAW in MEDIAINFO_CHECK ohne RAW über READY_TO_START weiter Richtung RIPPING Scoring-Details
Wie die Scores berechnet werden, erklärt die Playlist-Analyse -Seite.
Schritt 6 – Ripping → RIPPING Vorher prüft Ripster: Existiert bereits eine Raw-Datei für diesen Job?
Ja, Raw-Datei vorhanden → Direkt zu Schritt 7 (Track-Review), kein erneutes Ripping Nein → MakeMKV-Ripping startet Im Standardfall startet Ripster diesen Schritt automatisch nach der Metadaten-Auswahl. Der Button "Job starten" ist hauptsächlich für Sonderfälle sichtbar (z. B. Fallback/Queue).
Was MakeMKV ausführt (MKV-Modus):
makemkvcon mkv disc:0 all /mnt/raw/Inception-2010/ \
--minlength= 900 -r
Was MakeMKV ausführt (Backup-Modus):
makemkvcon backup disc:0 /mnt/raw/Inception-2010-backup/ \
--decrypt -r
Live-Fortschritt wird aus der MakeMKV-Ausgabe geparst:
PRGV:2048,0,65536 → Fortschritt wird berechnet und per WebSocket gesendet
PRGT:5011,0,"Sichern..." → Aktueller Task-Name
- Typische Dauer: - DVD: 20–45 Minuten - Blu-ray: 45–120 Minuten
Schritt 7 – Track-Review → READY_TO_ENCODE Nach dem Ripping (oder direkt bei vorhandener Raw-Datei) startet der HandBrake-Scan :
HandBrakeCLI --scan -i <quelle> -t 0
+ Typische Dauer: - DVD: 20–45 Minuten - Blu-ray: 45–120 Minuten
Schritt 7 – Track-Review → READY_TO_ENCODE Nach dem Ripping, nach Playlist-Übernahme oder direkt bei vorhandenem RAW startet der HandBrake-Scan :
HandBrakeCLI --scan -i <quelle> -t 0
Dieser Scan liest alle Tracks aus ohne zu encodieren. Ripster baut daraus den Encode-Plan mit automatischer Vorauswahl:
Status: MEDIAINFO_CHECK – läuft automatisch, kein Benutzereingriff
Danach öffnet sich das Encode-Review-Panel (READY_TO_ENCODE):
┌─────────────────────────────────────────────────────────────────┐
│ Encode-Review │
│ Titel: Disc Title 1 · Laufzeit: 2:28:05 · 28 Kapitel │
@@ -80,9 +78,9 @@
│ ☑ │ Track 1: Deutsch │ Einbr.☐ │Forc.☐│Default☑ │
│ ☐ │ Track 2: English │ Einbr.☐ │Forc.☐│Default☐ │
├──────┴─────────────────────────────┴────────┴──────┴──────────┤
- │ [Encode bestätigen] │
+ │ [Encoding starten] │
└─────────────────────────────────────────────────────────────────┘
- Audio-Track-Aktionen verstehen Symbol/Text Bedeutung Copy (ac3) Track wird verlustfrei direkt übernommen Copy (truehd) TrueHD-Track wird direkt übernommen Transcode (av_aac) Track wird zu AAC umgewandelt Fallback Transcode (av_aac) Copy nicht möglich → automatisch zu AAC Preset-Default (HandBrake) HandBrake-Preset entscheidet Nicht übernommen Track ist nicht ausgewählt
Untertitel-Flags Flag Bedeutung Einbrennen Untertitel werden fest ins Video gebrannt (nur ein Track möglich) Forced Nur erzwungene Untertitel-Einblendungen übernehmen Default Diese Spur wird beim Abspielen automatisch aktiviert
Vorauswahl-Regeln Die Tracks mit ☑ wurden nach der Regel aus den Einstellungen automatisch vorausgewählt (selectedByRule: true). Die Auswahl kann frei geändert werden.
Klicke "Encode bestätigen" um fortzufahren.
Schritt 8 – Encoding → ENCODING HandBrake startet mit dem finalisierten Plan:
Audio-Track-Aktionen verstehen Symbol/Text Bedeutung Copy (ac3) Track wird verlustfrei direkt übernommen Copy (truehd) TrueHD-Track wird direkt übernommen Transcode (av_aac) Track wird zu AAC umgewandelt Fallback Transcode (av_aac) Copy nicht möglich → automatisch zu AAC Preset-Default (HandBrake) HandBrake-Preset entscheidet Nicht übernommen Track ist nicht ausgewählt
Untertitel-Flags Flag Bedeutung Einbrennen Untertitel werden fest ins Video gebrannt (nur ein Track möglich) Forced Nur erzwungene Untertitel-Einblendungen übernehmen Default Diese Spur wird beim Abspielen automatisch aktiviert
Vorauswahl-Regeln Die Tracks mit ☑ wurden nach der Regel aus den Einstellungen automatisch vorausgewählt (selectedByRule: true). Die Auswahl kann frei geändert werden.
Klicke "Encoding starten" (bzw. im Pre-Rip-Modus "Backup + Encoding starten" ), um fortzufahren. Falls die Auswahl noch nicht bestätigt wurde, übernimmt das Frontend die Bestätigung automatisch beim Start.
Schritt 8 – Encoding → ENCODING HandBrake startet mit dem finalisierten Plan:
HandBrakeCLI \
-i /dev/sr0 \
-o "/mnt/movies/Inception (2010).mkv" \
-t 1 \
@@ -94,4 +92,4 @@
Live-Fortschritt wird aus HandBrake-stderr geparst:
Encoding: task 1 of 1, 73.50 % (45.23 fps, avg 44.12 fps, ETA 00h12m34s)
Das Dashboard zeigt: - Fortschrittsbalken (0–100 %) - Aktuelle Encoding-Geschwindigkeit (FPS) - Geschätzte Restzeit (ETA)
Typische Dauer (abhängig von CPU/GPU und Preset): - Schnelles Preset (fast): 0.5× Echtzeit - Standard-Preset: 1–3× Echtzeit - Langsames Preset (slow): 5–10× Echtzeit
Schritt 9 – Fertig! → FINISHED /mnt/nas/movies/
└── Inception (2010).mkv ✓ Encodierung abgeschlossen
- Job-Status in der Datenbank: FINISHED PushOver-Benachrichtigung (falls konfiguriert) Eintrag in der History mit vollständigen Logs Fehlerbehandlung Job im Status ERROR Dashboard : Details-Button → Log-Ausgabe prüfen Retry : Job vom Fehlerzustand neu starten (behält Metadaten) History : Vollständige Logs und Fehlerdetails Häufige Fehlerursachen Fehler Ursache Lösung MakeMKV: Lizenzfehler Abgelaufene Beta-Lizenz Neue Lizenz im MakeMKV-Forum HandBrake: Preset nicht gefunden Preset-Name falsch HandBrakeCLI --preset-list prüfen Keine Disc erkannt Laufwerk-Berechtigungen sudo chmod a+rw /dev/sr0 Falsches Video (zerstückelt) Falsche Playlist Job re-encodieren mit anderer Playlist OMDb: Keine Ergebnisse API-Key fehlt oder Titel nicht gefunden Einstellungen prüfen; manuell eingeben
Kurzübersicht aller Schritte # Status Benutzeraktion Was Ripster tut 1 IDLE Disc einlegen Disc-Polling erkennt Disc 2 DISC_DETECTED "Analyse starten" klicken Job anlegen, OMDb vorsuchen 3 METADATA_SELECTION Film im Dialog auswählen Playlist-Analyse durchführen 4a READY_TO_START — Empfehlung automatisch übernommen 4b WAITING_FOR_USER_DECISION Playlist manuell wählen Auf Bestätigung warten 5 READY_TO_START "Starten" klicken MakeMKV-Ripping starten 6 RIPPING Warten MakeMKV rippt, Fortschritt streamen 7 MEDIAINFO_CHECK Warten HandBrake-Scan, Encode-Plan bauen 8 READY_TO_ENCODE Tracks prüfen + bestätigen Auswahl in Plan übernehmen 9 ENCODING Warten HandBrake encodiert, Fortschritt streamen 10 FINISHED — Datei fertig, Benachrichtigung senden
Zurück zum Seitenanfang
\ No newline at end of file
+ Job-Status in der Datenbank: FINISHED PushOver-Benachrichtigung (falls konfiguriert) Eintrag in der History mit vollständigen Logs Fehlerbehandlung Job im Status ERROR Dashboard : Details-Button → Log-Ausgabe prüfen Retry : Job vom Fehlerzustand neu starten (behält Metadaten) History : Vollständige Logs und Fehlerdetails Häufige Fehlerursachen Fehler Ursache Lösung MakeMKV: Lizenzfehler Abgelaufene Beta-Lizenz Neue Lizenz im MakeMKV-Forum HandBrake: Preset nicht gefunden Preset-Name falsch HandBrakeCLI --preset-list prüfen Keine Disc erkannt Laufwerk-Berechtigungen sudo chmod a+rw /dev/sr0 Falsches Video (zerstückelt) Falsche Playlist Job re-encodieren mit anderer Playlist OMDb: Keine Ergebnisse API-Key fehlt oder Titel nicht gefunden Einstellungen prüfen; manuell eingeben
Kurzübersicht aller Schritte # Status Benutzeraktion Was Ripster tut 1 IDLE Disc einlegen Disc-Polling erkennt Disc 2 DISC_DETECTED "Analyse starten" klicken Job anlegen, OMDb vorsuchen 3 METADATA_SELECTION Film im Dialog auswählen Start automatisch einplanen/auslösen 4 READY_TO_START meist keine Übergangszustand vor Auto-Start 5 RIPPING Warten MakeMKV rippt, Fortschritt streamen 6 MEDIAINFO_CHECK Warten HandBrake-Scan, Encode-Plan bauen 7 WAITING_FOR_USER_DECISION (optional) Playlist manuell wählen Auf Bestätigung warten 8 READY_TO_ENCODE Tracks prüfen + "Encoding starten" Auswahl übernehmen, Start auslösen 9 ENCODING Warten HandBrake encodiert, inkl. Post-Skripte 10 FINISHED — Datei fertig, Benachrichtigung senden
Zurück zum Seitenanfang
\ No newline at end of file
diff --git a/site/index.html b/site/index.html
index 477d919..d28f871 100644
--- a/site/index.html
+++ b/site/index.html
@@ -11,22 +11,19 @@
Erste Schritte
Die vollständige Installationsanleitung mit allen Voraussetzungen findest du unter Erste Schritte .
Pipeline-Überblick flowchart LR
IDLE --> DD[DISC_DETECTED]
DD --> META[METADATA\nSELECTION]
- META -->|1 Kandidat| RTS[READY_TO\nSTART]
- META -->|Obfuskierung| WUD[WAITING_FOR\nUSER_DECISION]
- WUD --> RTS
- RTS --> RIP[RIPPING]
- RTS -->|Raw vorhanden| MIC
+ META --> RTS[READY_TO\nSTART]
+ RTS -->|Auto-Start| RIP[RIPPING]
+ RTS -->|Auto-Start mit RAW| MIC
RIP --> MIC[MEDIAINFO\nCHECK]
+ MIC -->|Playlist offen (Backup)| WUD[WAITING_FOR\nUSER_DECISION]
+ WUD --> MIC
MIC --> RTE[READY_TO\nENCODE]
RTE --> ENC[ENCODING]
- ENC --> PES[POST_ENCODE\nSCRIPTS]
- ENC -->|keine Skripte| FIN([FINISHED])
- PES --> FIN
+ ENC -->|inkl. Post-Skripte| FIN([FINISHED])
ENC --> ERR([ERROR])
RIP --> ERR
style FIN fill:#e8f5e9,stroke:#66bb6a,color:#2e7d32
style ERR fill:#ffebee,stroke:#ef5350,color:#c62828
style WUD fill:#fff8e1,stroke:#ffa726,color:#e65100
- style PES fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a
- style ENC fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a Zurück zum Seitenanfang
\ No newline at end of file
+ style ENC fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a
READY_TO_START ist in der Praxis meist ein kurzer Übergangszustand: der Job wird nach Metadaten-Auswahl automatisch gestartet oder in die Queue eingeplant.
Zurück zum Seitenanfang
\ No newline at end of file
diff --git a/site/pipeline/encoding/index.html b/site/pipeline/encoding/index.html
index 0982aa0..db51523 100644
--- a/site/pipeline/encoding/index.html
+++ b/site/pipeline/encoding/index.html
@@ -116,9 +116,9 @@
│ [✓] │ Track 1: Deutsch │Einbr.[ ]│Forced[ ]│Default[✓]│
│ [ ] │ Track 2: English │Einbr.[ ]│Forced[ ]│Default[ ]│
├──────┴──────────────────────────┴────────┴────────┴────────────┤
- │ [Encode bestätigen] │
+ │ [Encoding starten] │
└─────────────────────────────────────────────────────────────────┘
- Der Benutzer kann: - Audio-Tracks per Checkbox aktivieren/deaktivieren - Untertitel-Flags (Einbrennen, Forced, Default) setzen - Mehrere Titel bei der Titleauswahl wechseln (für Discs mit mehreren Haupttiteln)
Phase 7: Benutzer-Auswahl anwenden (applyManualTrackSelectionToPlan) Nach "Encode bestätigen" wird die Benutzer-Auswahl auf den Plan angewendet:
Der Benutzer kann: - Audio-Tracks per Checkbox aktivieren/deaktivieren - Untertitel-Flags (Einbrennen, Forced, Default) setzen - Mehrere Titel bei der Titleauswahl wechseln (für Discs mit mehreren Haupttiteln)
Phase 7: Benutzer-Auswahl anwenden (applyManualTrackSelectionToPlan) Im Frontend wird die Benutzer-Auswahl beim Klick auf "Encoding starten" (ggf. automatisch) bestätigt und dann auf den Plan angewendet:
Payload : {
"selectedEncodeTitleId" : 1 ,
"selectedTrackSelection" : {
"1" : {
diff --git a/site/pipeline/post-encode-scripts/index.html b/site/pipeline/post-encode-scripts/index.html
index 1fb452b..4864cc2 100644
--- a/site/pipeline/post-encode-scripts/index.html
+++ b/site/pipeline/post-encode-scripts/index.html
@@ -7,7 +7,7 @@
...
↓
FINISHED
- Abbruch bei Fehler
Schlägt ein Skript fehl (Exit-Code ≠ 0), werden alle nachfolgenden Skripte nicht mehr ausgeführt . Der Job wechselt in den Status ERROR.
Skript-Verwaltung Skripte werden über die Einstellungen-Seite angelegt und verwaltet. Sie stehen danach in jedem Encode-Review zur Auswahl.
Skript anlegen Navigiere zu Einstellungen → Skripte und klicke "Neues Skript" :
Feld Beschreibung Name Anzeigename des Skripts (z. B. Zu Plex verschieben) Befehl Shell-Befehl oder Skriptpfad (z. B. /home/michael/scripts/move-to-plex.sh) Beschreibung Optionale Erklärung
Verfügbare Umgebungsvariablen Jedes Skript wird mit folgenden Umgebungsvariablen aufgerufen:
Variable Inhalt Beispiel RIPSTER_OUTPUT_PATH Absoluter Pfad der encodierten Datei /mnt/movies/Inception (2010).mkv RIPSTER_JOB_ID Job-ID in der Datenbank 42 RIPSTER_TITLE Filmtitel Inception RIPSTER_YEAR Erscheinungsjahr 2010 RIPSTER_IMDB_ID IMDb-ID tt1375666 RIPSTER_RAW_PATH Pfad zur Raw-MKV-Datei /mnt/raw/Inception-2010/t00.mkv
Beispiel-Skript: Datei nach Jellyfin verschieben Abbruch bei Fehler
Schlägt ein Skript fehl (Exit-Code ≠ 0), werden alle nachfolgenden Skripte nicht mehr ausgeführt . Der Job bleibt im Abschlusszustand FINISHED; der Fehler wird in Log/Status-Text und im postEncodeScripts-Summary festgehalten.
Skript-Verwaltung Skripte werden über die Einstellungen-Seite angelegt und verwaltet. Sie stehen danach in jedem Encode-Review zur Auswahl.
Skript anlegen Navigiere zu Einstellungen → Skripte und klicke "Neues Skript" :
Feld Beschreibung Name Anzeigename des Skripts (z. B. Zu Plex verschieben) Befehl Shell-Befehl oder Skriptpfad (z. B. /home/michael/scripts/move-to-plex.sh) Beschreibung Optionale Erklärung
Verfügbare Umgebungsvariablen Jedes Skript wird mit folgenden Umgebungsvariablen aufgerufen:
Variable Inhalt Beispiel RIPSTER_OUTPUT_PATH Absoluter Pfad der encodierten Datei /mnt/movies/Inception (2010).mkv RIPSTER_JOB_ID Job-ID in der Datenbank 42 RIPSTER_TITLE Filmtitel Inception RIPSTER_YEAR Erscheinungsjahr 2010 RIPSTER_IMDB_ID IMDb-ID tt1375666 RIPSTER_RAW_PATH Pfad zur Raw-MKV-Datei /mnt/raw/Inception-2010/t00.mkv
Beispiel-Skript: Datei nach Jellyfin verschieben #!/bin/bash
# /home/michael/scripts/move-to-jellyfin.sh
TARGET_DIR = "/mnt/media/movies"
@@ -33,28 +33,23 @@
Der Test-Aufruf befüllt die Umgebungsvariablen mit Platzhalter-Werten.
Ausführungs-Ergebnis Das Ergebnis der Skript-Ausführung wird im Job-Datensatz gespeichert und in der History angezeigt:
{
"postEncodeScripts" : {
"configured" : 2 ,
- "succeeded" : 2 ,
- "failed" : 0 ,
- "skipped" : 0 ,
- "aborted" : false ,
- "results" : [
- {
- "scriptId" : "script-1" ,
- "name" : "Zu Plex verschieben" ,
- "exitCode" : 0 ,
- "stdout" : "Verschoben: Inception nach /mnt/media/movies" ,
- "stderr" : "" ,
- "durationMs" : 342
- },
- {
- "scriptId" : "script-2" ,
- "name" : "Webhook auslösen" ,
- "exitCode" : 0 ,
- "stdout" : "" ,
- "stderr" : "" ,
- "durationMs" : 128
- }
- ]
- }
-}
- Feld Beschreibung configured Anzahl ausgewählter Skripte succeeded Erfolgreich ausgeführt (Exit-Code 0) failed Fehlgeschlagen skipped Nicht ausgeführt (wegen vorherigem Fehler) aborted true, wenn die Kette abgebrochen wurde
API-Referenz Eine vollständige API-Dokumentation der Skript-Endpunkte findest du unter:
Settings API – Skripte
Zurück zum Seitenanfang
\ No newline at end of file
+ "attempted" : 2 ,
+ "succeeded" : 2 ,
+ "failed" : 0 ,
+ "skipped" : 0 ,
+ "aborted" : false ,
+ "results" : [
+ {
+ "scriptId" : 1 ,
+ "scriptName" : "Zu Plex verschieben" ,
+ "status" : "SUCCESS"
+ },
+ {
+ "scriptId" : 2 ,
+ "scriptName" : "Webhook auslösen" ,
+ "status" : "SUCCESS"
+ }
+ ]
+ }
+}
+ Feld Beschreibung configured Anzahl ausgewählter Skripte attempted Anzahl tatsächlich gestarteter Skripte succeeded Erfolgreich ausgeführt (Exit-Code 0) failed Fehlgeschlagen skipped Nicht ausgeführt (wegen vorherigem Fehler) aborted true, wenn die Kette abgebrochen wurde
API-Referenz Eine vollständige API-Dokumentation der Skript-Endpunkte findest du unter:
Settings API – Skripte
Zurück zum Seitenanfang