From 7204dbb65b9f92a5833441043431f208b2fbb7a5 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Sun, 8 Mar 2026 21:52:21 +0000 Subject: [PATCH] some pload --- .claude/settings.json | 3 +- backend/src/db/defaultSettings.js | 72 ++ backend/src/index.js | 5 + backend/src/routes/cronRoutes.js | 101 +++ backend/src/routes/settingsRoutes.js | 38 + backend/src/services/cronService.js | 560 ++++++++++++ backend/src/services/diskDetectionService.js | 136 ++- backend/src/services/historyService.js | 262 ++++-- backend/src/services/pipelineService.js | 866 ++++++++++++++++--- backend/src/services/scriptChainService.js | 91 +- backend/src/services/scriptService.js | 86 +- backend/src/services/settingsService.js | 68 +- db/schema.sql | 35 + frontend/src/App.jsx | 3 +- frontend/src/api/client.js | 60 ++ frontend/src/components/CronJobsTab.jsx | 554 ++++++++++++ frontend/src/components/JobDetailDialog.jsx | 32 +- frontend/src/pages/DashboardPage.jsx | 27 +- frontend/src/pages/DatabasePage.jsx | 32 +- frontend/src/pages/HistoryPage.jsx | 547 ++++++------ frontend/src/pages/SettingsPage.jsx | 433 ++++++++-- frontend/src/styles/app.css | 444 ++++++++-- install-dev.sh | 605 +++++++++++++ install.sh | 590 +++++++++++++ 24 files changed, 4947 insertions(+), 703 deletions(-) create mode 100644 backend/src/routes/cronRoutes.js create mode 100644 backend/src/services/cronService.js create mode 100644 frontend/src/components/CronJobsTab.jsx create mode 100755 install-dev.sh create mode 100755 install.sh diff --git a/.claude/settings.json b/.claude/settings.json index 16a7161..8579b49 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,7 +7,8 @@ "Bash(pip install -q -r requirements-docs.txt)", "Bash(mkdocs build --strict)", "Read(//mnt/external/media/**)", - "WebFetch(domain:www.makemkv.com)" + "WebFetch(domain:www.makemkv.com)", + "Bash(node --check backend/src/services/pipelineService.js)" ] } } diff --git a/backend/src/db/defaultSettings.js b/backend/src/db/defaultSettings.js index 1257cef..99c2eaa 100644 --- a/backend/src/db/defaultSettings.js +++ b/backend/src/db/defaultSettings.js @@ -62,6 +62,42 @@ const defaultSchema = [ validation: { minLength: 1 }, orderIndex: 100 }, + { + key: 'raw_dir_bluray', + category: 'Pfade', + label: 'Raw Ausgabeordner (Blu-ray)', + type: 'path', + required: 0, + description: 'Optionaler RAW-Zielpfad nur für Blu-ray. Leer = Fallback auf "Raw Ausgabeordner".', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 101 + }, + { + key: 'raw_dir_dvd', + category: 'Pfade', + label: 'Raw Ausgabeordner (DVD)', + type: 'path', + required: 0, + description: 'Optionaler RAW-Zielpfad nur für DVD. Leer = Fallback auf "Raw Ausgabeordner".', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 102 + }, + { + key: 'raw_dir_other', + category: 'Pfade', + label: 'Raw Ausgabeordner (Sonstiges)', + type: 'path', + required: 0, + description: 'Optionaler RAW-Zielpfad nur für Sonstiges. Leer = Fallback auf "Raw Ausgabeordner".', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 103 + }, { key: 'movie_dir', category: 'Pfade', @@ -74,6 +110,42 @@ const defaultSchema = [ validation: { minLength: 1 }, orderIndex: 110 }, + { + key: 'movie_dir_bluray', + category: 'Pfade', + label: 'Film Ausgabeordner (Blu-ray)', + type: 'path', + required: 0, + description: 'Optionaler Encode-Zielpfad nur für Blu-ray. Leer = Fallback auf "Film Ausgabeordner".', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 111 + }, + { + key: 'movie_dir_dvd', + category: 'Pfade', + label: 'Film Ausgabeordner (DVD)', + type: 'path', + required: 0, + description: 'Optionaler Encode-Zielpfad nur für DVD. Leer = Fallback auf "Film Ausgabeordner".', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 112 + }, + { + key: 'movie_dir_other', + category: 'Pfade', + label: 'Film Ausgabeordner (Sonstiges)', + type: 'path', + required: 0, + description: 'Optionaler Encode-Zielpfad nur für Sonstiges. Leer = Fallback auf "Film Ausgabeordner".', + defaultValue: '', + options: [], + validation: {}, + orderIndex: 113 + }, { key: 'log_dir', category: 'Pfade', diff --git a/backend/src/index.js b/backend/src/index.js index d1b03cd..84cdc55 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -10,8 +10,10 @@ const requestLogger = require('./middleware/requestLogger'); const settingsRoutes = require('./routes/settingsRoutes'); const pipelineRoutes = require('./routes/pipelineRoutes'); const historyRoutes = require('./routes/historyRoutes'); +const cronRoutes = require('./routes/cronRoutes'); const wsService = require('./services/websocketService'); const pipelineService = require('./services/pipelineService'); +const cronService = require('./services/cronService'); const diskDetectionService = require('./services/diskDetectionService'); const hardwareMonitorService = require('./services/hardwareMonitorService'); const logger = require('./services/logger').child('BOOT'); @@ -21,6 +23,7 @@ async function start() { logger.info('backend:start:init'); await initDatabase(); await pipelineService.init(); + await cronService.init(); const app = express(); app.use(cors({ origin: corsOrigin })); @@ -34,6 +37,7 @@ async function start() { app.use('/api/settings', settingsRoutes); app.use('/api/pipeline', pipelineRoutes); app.use('/api/history', historyRoutes); + app.use('/api/crons', cronRoutes); app.use(errorHandler); @@ -72,6 +76,7 @@ async function start() { logger.warn('backend:shutdown:received'); diskDetectionService.stop(); hardwareMonitorService.stop(); + cronService.stop(); server.close(() => { logger.warn('backend:shutdown:completed'); process.exit(0); diff --git a/backend/src/routes/cronRoutes.js b/backend/src/routes/cronRoutes.js new file mode 100644 index 0000000..3b9ab59 --- /dev/null +++ b/backend/src/routes/cronRoutes.js @@ -0,0 +1,101 @@ +const express = require('express'); +const asyncHandler = require('../middleware/asyncHandler'); +const cronService = require('../services/cronService'); +const wsService = require('../services/websocketService'); +const logger = require('../services/logger').child('CRON_ROUTE'); + +const router = express.Router(); + +// GET /api/crons – alle Cronjobs auflisten +router.get( + '/', + asyncHandler(async (req, res) => { + logger.debug('get:crons', { reqId: req.reqId }); + const jobs = await cronService.listJobs(); + res.json({ jobs }); + }) +); + +// POST /api/crons/validate-expression – Cron-Ausdruck validieren +router.post( + '/validate-expression', + asyncHandler(async (req, res) => { + const expr = String(req.body?.cronExpression || '').trim(); + const validation = cronService.validateExpression(expr); + const nextRunAt = validation.valid ? cronService.getNextRunTime(expr) : null; + res.json({ ...validation, nextRunAt }); + }) +); + +// POST /api/crons – neuen Cronjob anlegen +router.post( + '/', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('post:crons:create', { reqId: req.reqId, name: payload?.name }); + const job = await cronService.createJob(payload); + wsService.broadcast('CRON_JOBS_UPDATED', { action: 'created', id: job.id }); + res.status(201).json({ job }); + }) +); + +// GET /api/crons/:id – einzelnen Cronjob abrufen +router.get( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + logger.debug('get:crons:one', { reqId: req.reqId, cronJobId: id }); + const job = await cronService.getJobById(id); + res.json({ job }); + }) +); + +// PUT /api/crons/:id – Cronjob aktualisieren +router.put( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const payload = req.body || {}; + logger.info('put:crons:update', { reqId: req.reqId, cronJobId: id }); + const job = await cronService.updateJob(id, payload); + wsService.broadcast('CRON_JOBS_UPDATED', { action: 'updated', id: job.id }); + res.json({ job }); + }) +); + +// DELETE /api/crons/:id – Cronjob löschen +router.delete( + '/:id', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + logger.info('delete:crons', { reqId: req.reqId, cronJobId: id }); + const removed = await cronService.deleteJob(id); + wsService.broadcast('CRON_JOBS_UPDATED', { action: 'deleted', id: removed.id }); + res.json({ removed }); + }) +); + +// GET /api/crons/:id/logs – Ausführungs-Logs eines Cronjobs +router.get( + '/:id/logs', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const limit = Math.min(Number(req.query?.limit) || 20, 100); + logger.debug('get:crons:logs', { reqId: req.reqId, cronJobId: id, limit }); + const logs = await cronService.getJobLogs(id, limit); + res.json({ logs }); + }) +); + +// POST /api/crons/:id/run – Cronjob manuell auslösen +router.post( + '/:id/run', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + logger.info('post:crons:run', { reqId: req.reqId, cronJobId: id }); + const result = await cronService.triggerJobManually(id); + res.json(result); + }) +); + +module.exports = router; diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index be5a48c..f1eff68 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -61,6 +61,20 @@ router.post( }) ); +router.post( + '/scripts/reorder', + asyncHandler(async (req, res) => { + const orderedScriptIds = Array.isArray(req.body?.orderedScriptIds) ? req.body.orderedScriptIds : []; + logger.info('post:settings:scripts:reorder', { + reqId: req.reqId, + count: orderedScriptIds.length + }); + const scripts = await scriptService.reorderScripts(orderedScriptIds); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'reordered', count: scripts.length }); + res.json({ scripts }); + }) +); + router.put( '/scripts/:id', asyncHandler(async (req, res) => { @@ -105,6 +119,16 @@ router.post( }) ); +router.post( + '/script-chains/:id/test', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + logger.info('post:settings:script-chains:test', { reqId: req.reqId, chainId }); + const result = await scriptChainService.executeChain(chainId, { source: 'settings_test', mode: 'test' }); + res.json({ result }); + }) +); + router.get( '/script-chains', asyncHandler(async (req, res) => { @@ -125,6 +149,20 @@ router.post( }) ); +router.post( + '/script-chains/reorder', + asyncHandler(async (req, res) => { + const orderedChainIds = Array.isArray(req.body?.orderedChainIds) ? req.body.orderedChainIds : []; + logger.info('post:settings:script-chains:reorder', { + reqId: req.reqId, + count: orderedChainIds.length + }); + const chains = await scriptChainService.reorderChains(orderedChainIds); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'reordered', count: chains.length }); + res.json({ chains }); + }) +); + router.get( '/script-chains/:id', asyncHandler(async (req, res) => { diff --git a/backend/src/services/cronService.js b/backend/src/services/cronService.js new file mode 100644 index 0000000..fcb58a6 --- /dev/null +++ b/backend/src/services/cronService.js @@ -0,0 +1,560 @@ +/** + * cronService.js + * Verwaltet und führt Cronjobs aus (Skripte oder Skriptketten). + * Kein externer Package nötig – eigener Cron-Expression-Parser. + */ + +const { getDb } = require('../db/database'); +const logger = require('./logger').child('CRON'); +const notificationService = require('./notificationService'); +const settingsService = require('./settingsService'); +const wsService = require('./websocketService'); +const { errorToMeta } = require('../utils/errorMeta'); + +// Maximale Zeilen pro Log-Eintrag (Output-Truncation) +const MAX_OUTPUT_CHARS = 100000; +// Maximale Log-Einträge pro Cron-Job (ältere werden gelöscht) +const MAX_LOGS_PER_JOB = 50; + +// ─── Cron-Expression-Parser ──────────────────────────────────────────────── + +// Parst ein einzelnes Cron-Feld (z.B. "* /5", "1,3,5", "1-5", "*") und gibt +// alle erlaubten Werte als Set zurück. +function parseCronField(field, min, max) { + const values = new Set(); + + for (const part of field.split(',')) { + const trimmed = part.trim(); + if (trimmed === '*') { + for (let i = min; i <= max; i++) values.add(i); + } else if (trimmed.startsWith('*/')) { + const step = parseInt(trimmed.slice(2), 10); + if (!Number.isFinite(step) || step < 1) throw new Error(`Ungültiges Step: ${trimmed}`); + for (let i = min; i <= max; i += step) values.add(i); + } else if (trimmed.includes('-')) { + const [startStr, endStr] = trimmed.split('-'); + const start = parseInt(startStr, 10); + const end = parseInt(endStr, 10); + if (!Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Ungültiger Bereich: ${trimmed}`); + for (let i = Math.max(min, start); i <= Math.min(max, end); i++) values.add(i); + } else { + const num = parseInt(trimmed, 10); + if (!Number.isFinite(num) || num < min || num > max) throw new Error(`Ungültiger Wert: ${trimmed}`); + values.add(num); + } + } + + return values; +} + +/** + * Validiert eine Cron-Expression (5 Felder: minute hour day month weekday). + * Gibt { valid: true } oder { valid: false, error: string } zurück. + */ +function validateCronExpression(expr) { + try { + const parts = String(expr || '').trim().split(/\s+/); + if (parts.length !== 5) { + return { valid: false, error: 'Cron-Ausdruck muss genau 5 Felder haben (Minute Stunde Tag Monat Wochentag).' }; + } + parseCronField(parts[0], 0, 59); // minute + parseCronField(parts[1], 0, 23); // hour + parseCronField(parts[2], 1, 31); // day of month + parseCronField(parts[3], 1, 12); // month + parseCronField(parts[4], 0, 7); // weekday (0 und 7 = Sonntag) + return { valid: true }; + } catch (error) { + return { valid: false, error: error.message }; + } +} + +/** + * Berechnet den nächsten Ausführungszeitpunkt nach einem Datum. + * Gibt ein Date-Objekt zurück oder null bei Fehler. + */ +function getNextRunTime(expr, fromDate = new Date()) { + try { + const parts = String(expr || '').trim().split(/\s+/); + if (parts.length !== 5) return null; + + const minutes = parseCronField(parts[0], 0, 59); + const hours = parseCronField(parts[1], 0, 23); + const days = parseCronField(parts[2], 1, 31); + const months = parseCronField(parts[3], 1, 12); + const weekdays = parseCronField(parts[4], 0, 7); + + // Normalisiere Wochentag: 7 → 0 (beide = Sonntag) + if (weekdays.has(7)) weekdays.add(0); + + // Suche ab der nächsten Minute + const candidate = new Date(fromDate); + candidate.setSeconds(0, 0); + candidate.setMinutes(candidate.getMinutes() + 1); + + // Maximal 2 Jahre in die Zukunft suchen + const limit = new Date(fromDate); + limit.setFullYear(limit.getFullYear() + 2); + + while (candidate < limit) { + const month = candidate.getMonth() + 1; // 1-12 + const day = candidate.getDate(); + const hour = candidate.getHours(); + const minute = candidate.getMinutes(); + const weekday = candidate.getDay(); // 0 = Sonntag + + if (!months.has(month)) { + candidate.setMonth(candidate.getMonth() + 1, 1); + candidate.setHours(0, 0, 0, 0); + continue; + } + if (!days.has(day) || !weekdays.has(weekday)) { + candidate.setDate(candidate.getDate() + 1); + candidate.setHours(0, 0, 0, 0); + continue; + } + if (!hours.has(hour)) { + candidate.setHours(candidate.getHours() + 1, 0, 0, 0); + continue; + } + if (!minutes.has(minute)) { + candidate.setMinutes(candidate.getMinutes() + 1, 0, 0); + continue; + } + + return candidate; + } + + return null; + } catch (_error) { + return null; + } +} + +// ─── DB-Helpers ──────────────────────────────────────────────────────────── + +function mapJobRow(row) { + if (!row) return null; + return { + id: Number(row.id), + name: String(row.name || ''), + cronExpression: String(row.cron_expression || ''), + sourceType: String(row.source_type || ''), + sourceId: Number(row.source_id), + sourceName: row.source_name != null ? String(row.source_name) : null, + enabled: Boolean(row.enabled), + pushoverEnabled: Boolean(row.pushover_enabled), + lastRunAt: row.last_run_at || null, + lastRunStatus: row.last_run_status || null, + nextRunAt: row.next_run_at || null, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function mapLogRow(row) { + if (!row) return null; + return { + id: Number(row.id), + cronJobId: Number(row.cron_job_id), + startedAt: row.started_at, + finishedAt: row.finished_at || null, + status: String(row.status || ''), + output: row.output || null, + errorMessage: row.error_message || null + }; +} + +async function fetchJobWithSource(db, id) { + return db.get( + ` + SELECT + c.*, + CASE c.source_type + WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id) + WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id) + ELSE NULL + END AS source_name + FROM cron_jobs c + WHERE c.id = ? + LIMIT 1 + `, + [id] + ); +} + +async function fetchAllJobsWithSource(db) { + return db.all( + ` + SELECT + c.*, + CASE c.source_type + WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id) + WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id) + ELSE NULL + END AS source_name + FROM cron_jobs c + ORDER BY c.id ASC + ` + ); +} + +// ─── Ausführungslogik ────────────────────────────────────────────────────── + +async function runCronJob(job) { + const db = await getDb(); + const startedAt = new Date().toISOString(); + + logger.info('cron:run:start', { cronJobId: job.id, name: job.name, sourceType: job.sourceType, sourceId: job.sourceId }); + + // Log-Eintrag anlegen (status = 'running') + const insertResult = await db.run( + `INSERT INTO cron_run_logs (cron_job_id, started_at, status) VALUES (?, ?, 'running')`, + [job.id, startedAt] + ); + const logId = insertResult.lastID; + + // Job als laufend markieren + await db.run( + `UPDATE cron_jobs SET last_run_at = ?, last_run_status = 'running', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [startedAt, job.id] + ); + wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: 'running', lastRunAt: startedAt }); + + let output = ''; + let errorMessage = null; + let success = false; + + try { + if (job.sourceType === 'script') { + const scriptService = require('./scriptService'); + const script = await scriptService.getScriptById(job.sourceId); + const prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id }); + + try { + const result = await new Promise((resolve, reject) => { + const { spawn } = require('child_process'); + const child = spawn(prepared.cmd, prepared.args, { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { stdout += String(chunk); }); + child.stderr?.on('data', (chunk) => { stderr += String(chunk); }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); + + output = [result.stdout, result.stderr].filter(Boolean).join('\n'); + if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]'; + success = result.code === 0; + if (!success) errorMessage = `Exit-Code ${result.code}`; + } finally { + await prepared.cleanup(); + } + } else if (job.sourceType === 'chain') { + const scriptChainService = require('./scriptChainService'); + const logLines = []; + const result = await scriptChainService.executeChain( + job.sourceId, + { source: 'cron', cronJobId: job.id }, + { + appendLog: async (_source, line) => { + logLines.push(line); + } + } + ); + + output = logLines.join('\n'); + if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]'; + success = Array.isArray(result) ? result.every((r) => r.success !== false) : Boolean(result); + if (!success) errorMessage = 'Kette enthielt fehlgeschlagene Schritte.'; + } else { + throw new Error(`Unbekannter source_type: ${job.sourceType}`); + } + } catch (error) { + success = false; + errorMessage = error.message || String(error); + logger.error('cron:run:error', { cronJobId: job.id, error: errorToMeta(error) }); + } + + const finishedAt = new Date().toISOString(); + const status = success ? 'success' : 'error'; + const nextRunAt = getNextRunTime(job.cronExpression)?.toISOString() || null; + + // Log-Eintrag abschließen + await db.run( + `UPDATE cron_run_logs SET finished_at = ?, status = ?, output = ?, error_message = ? WHERE id = ?`, + [finishedAt, status, output || null, errorMessage, logId] + ); + + // Job-Status aktualisieren + await db.run( + `UPDATE cron_jobs SET last_run_status = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [status, nextRunAt, job.id] + ); + + // Alte Logs trimmen + await db.run( + ` + DELETE FROM cron_run_logs + WHERE cron_job_id = ? + AND id NOT IN ( + SELECT id FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ? + ) + `, + [job.id, job.id, MAX_LOGS_PER_JOB] + ); + + logger.info('cron:run:done', { cronJobId: job.id, status, durationMs: new Date(finishedAt) - new Date(startedAt) }); + + wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: status, lastRunAt: finishedAt, nextRunAt }); + + // Pushover-Benachrichtigung (nur wenn am Cron aktiviert UND global aktiviert) + if (job.pushoverEnabled) { + try { + const settings = await settingsService.getSettingsMap(); + const eventKey = success ? 'cron_success' : 'cron_error'; + const title = `Ripster Cron: ${job.name}`; + const message = success + ? `Cronjob "${job.name}" erfolgreich ausgeführt.` + : `Cronjob "${job.name}" fehlgeschlagen: ${errorMessage || 'Unbekannter Fehler'}`; + + await notificationService.notifyWithSettings(settings, eventKey, { title, message }); + } catch (notifyError) { + logger.warn('cron:run:notify-failed', { cronJobId: job.id, error: errorToMeta(notifyError) }); + } + } + + return { success, status, output, errorMessage, finishedAt, nextRunAt }; +} + +// ─── Scheduler ───────────────────────────────────────────────────────────── + +class CronService { + constructor() { + this._timer = null; + this._running = new Set(); // IDs aktuell laufender Jobs + } + + async init() { + logger.info('cron:scheduler:init'); + // Beim Start next_run_at für alle enabled Jobs neu berechnen + await this._recalcNextRuns(); + this._scheduleNextTick(); + } + + stop() { + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + logger.info('cron:scheduler:stopped'); + } + + _scheduleNextTick() { + // Auf den Beginn der nächsten vollen Minute warten + const now = new Date(); + const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 500; + this._timer = setTimeout(() => this._tick(), msUntilNextMinute); + } + + async _tick() { + try { + await this._checkAndRunDueJobs(); + } catch (error) { + logger.error('cron:scheduler:tick-error', { error: errorToMeta(error) }); + } + this._scheduleNextTick(); + } + + async _recalcNextRuns() { + const db = await getDb(); + const jobs = await db.all(`SELECT id, cron_expression FROM cron_jobs WHERE enabled = 1`); + for (const job of jobs) { + const nextRunAt = getNextRunTime(job.cron_expression)?.toISOString() || null; + await db.run(`UPDATE cron_jobs SET next_run_at = ? WHERE id = ?`, [nextRunAt, job.id]); + } + } + + async _checkAndRunDueJobs() { + const db = await getDb(); + const now = new Date(); + const nowIso = now.toISOString(); + + // Jobs, deren next_run_at <= jetzt ist und die nicht gerade laufen + const dueJobs = await db.all( + `SELECT * FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?`, + [nowIso] + ); + + for (const jobRow of dueJobs) { + const id = Number(jobRow.id); + if (this._running.has(id)) { + logger.warn('cron:scheduler:skip-still-running', { cronJobId: id }); + continue; + } + + const job = mapJobRow(jobRow); + this._running.add(id); + + // Asynchron ausführen, damit der Scheduler nicht blockiert + runCronJob(job) + .catch((error) => { + logger.error('cron:run:unhandled-error', { cronJobId: id, error: errorToMeta(error) }); + }) + .finally(() => { + this._running.delete(id); + }); + } + } + + // ─── Public API ────────────────────────────────────────────────────────── + + async listJobs() { + const db = await getDb(); + const rows = await fetchAllJobsWithSource(db); + return rows.map(mapJobRow); + } + + async getJobById(id) { + const db = await getDb(); + const row = await fetchJobWithSource(db, id); + if (!row) { + const error = new Error(`Cronjob #${id} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + return mapJobRow(row); + } + + async createJob(payload) { + const { name, cronExpression, sourceType, sourceId, enabled = true, pushoverEnabled = true } = payload || {}; + + const trimmedName = String(name || '').trim(); + const trimmedExpr = String(cronExpression || '').trim(); + + if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 }); + if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 }); + + const validation = validateCronExpression(trimmedExpr); + if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 }); + + if (!['script', 'chain'].includes(sourceType)) { + throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 }); + } + + const normalizedSourceId = Number(sourceId); + if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) { + throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 }); + } + + const nextRunAt = getNextRunTime(trimmedExpr)?.toISOString() || null; + const db = await getDb(); + + const result = await db.run( + ` + INSERT INTO cron_jobs (name, cron_expression, source_type, source_id, enabled, pushover_enabled, next_run_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, + [trimmedName, trimmedExpr, sourceType, normalizedSourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt] + ); + + logger.info('cron:create', { cronJobId: result.lastID, name: trimmedName, cronExpression: trimmedExpr }); + return this.getJobById(result.lastID); + } + + async updateJob(id, payload) { + const db = await getDb(); + const existing = await this.getJobById(id); + + const trimmedName = Object.prototype.hasOwnProperty.call(payload, 'name') + ? String(payload.name || '').trim() + : existing.name; + const trimmedExpr = Object.prototype.hasOwnProperty.call(payload, 'cronExpression') + ? String(payload.cronExpression || '').trim() + : existing.cronExpression; + + if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 }); + if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 }); + + const validation = validateCronExpression(trimmedExpr); + if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 }); + + const sourceType = Object.prototype.hasOwnProperty.call(payload, 'sourceType') ? payload.sourceType : existing.sourceType; + const sourceId = Object.prototype.hasOwnProperty.call(payload, 'sourceId') ? Number(payload.sourceId) : existing.sourceId; + const enabled = Object.prototype.hasOwnProperty.call(payload, 'enabled') ? Boolean(payload.enabled) : existing.enabled; + const pushoverEnabled = Object.prototype.hasOwnProperty.call(payload, 'pushoverEnabled') ? Boolean(payload.pushoverEnabled) : existing.pushoverEnabled; + + if (!['script', 'chain'].includes(sourceType)) { + throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 }); + } + if (!Number.isFinite(sourceId) || sourceId <= 0) { + throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 }); + } + + const nextRunAt = enabled ? (getNextRunTime(trimmedExpr)?.toISOString() || null) : null; + + await db.run( + ` + UPDATE cron_jobs + SET name = ?, cron_expression = ?, source_type = ?, source_id = ?, + enabled = ?, pushover_enabled = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [trimmedName, trimmedExpr, sourceType, sourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt, id] + ); + + logger.info('cron:update', { cronJobId: id }); + return this.getJobById(id); + } + + async deleteJob(id) { + const db = await getDb(); + const job = await this.getJobById(id); + await db.run(`DELETE FROM cron_jobs WHERE id = ?`, [id]); + logger.info('cron:delete', { cronJobId: id }); + return job; + } + + async getJobLogs(id, limit = 20) { + await this.getJobById(id); // Existenz prüfen + const db = await getDb(); + const rows = await db.all( + `SELECT * FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?`, + [id, Math.min(Number(limit) || 20, 100)] + ); + return rows.map(mapLogRow); + } + + async triggerJobManually(id) { + const job = await this.getJobById(id); + if (this._running.has(id)) { + throw Object.assign(new Error('Cronjob läuft bereits.'), { statusCode: 409 }); + } + this._running.add(id); + logger.info('cron:manual-trigger', { cronJobId: id }); + + // Asynchron starten + runCronJob(job) + .catch((error) => { + logger.error('cron:manual-trigger:error', { cronJobId: id, error: errorToMeta(error) }); + }) + .finally(() => { + this._running.delete(id); + }); + + return { triggered: true, cronJobId: id }; + } + + validateExpression(expr) { + return validateCronExpression(expr); + } + + getNextRunTime(expr) { + const next = getNextRunTime(expr); + return next ? next.toISOString() : null; + } +} + +module.exports = new CronService(); diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index eb24e49..ad83820 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -28,10 +28,28 @@ function normalizeMediaProfile(rawValue) { if (!value) { return null; } - if (value === 'bluray' || value === 'blu-ray' || value === 'bd' || value === 'bdmv') { + if ( + value === 'bluray' + || value === 'blu-ray' + || value === 'blu_ray' + || value === 'bd' + || value === 'bdmv' + || value === 'bdrom' + || value === 'bd-rom' + || value === 'bd-r' + || value === 'bd-re' + ) { return 'bluray'; } - if (value === 'dvd') { + if ( + value === 'dvd' + || value === 'dvdvideo' + || value === 'dvd-video' + || value === 'dvdrom' + || value === 'dvd-rom' + || value === 'video_ts' + || value === 'iso9660' + ) { return 'dvd'; } if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') { @@ -40,6 +58,10 @@ function normalizeMediaProfile(rawValue) { return null; } +function isSpecificMediaProfile(value) { + return value === 'bluray' || value === 'dvd'; +} + function inferMediaProfileFromTextParts(parts) { const markerText = (parts || []) .map((value) => String(value || '').trim().toLowerCase()) @@ -49,15 +71,55 @@ function inferMediaProfileFromTextParts(parts) { if (!markerText) { return null; } - if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(markerText)) { + if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re/.test(markerText)) { return 'bluray'; } - if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(markerText)) { + if (/(^|[\s_-])video_ts($|[\s_-])|dvd|iso9660/.test(markerText)) { return 'dvd'; } return null; } +function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { + const fstype = String(rawFsType || '').trim().toLowerCase(); + const model = String(rawModel || '').trim().toLowerCase(); + const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model); + const hasDvdModelMarker = /dvd/.test(model); + const hasCdOnlyModelMarker = /(^|[\s_-])cd([\s_-]|$)|cd-?rom/.test(model) && !hasBlurayModelMarker && !hasDvdModelMarker; + + if (!fstype) { + if (hasBlurayModelMarker) { + return 'bluray'; + } + if (hasDvdModelMarker) { + return 'dvd'; + } + return null; + } + + if (fstype.includes('udf')) { + if (hasBlurayModelMarker) { + return 'bluray'; + } + if (hasDvdModelMarker) { + return 'dvd'; + } + return 'dvd'; + } + + if (fstype.includes('iso9660') || fstype.includes('cdfs')) { + if (hasBlurayModelMarker) { + return 'bluray'; + } + if (hasCdOnlyModelMarker) { + return 'other'; + } + return 'dvd'; + } + + return null; +} + class DiskDetectionService extends EventEmitter { constructor() { super(); @@ -290,8 +352,8 @@ class DiskDetectionService extends EventEmitter { return null; } - const hasMedia = await this.checkMediaPresent(devicePath); - if (!hasMedia) { + const mediaState = await this.checkMediaPresent(devicePath); + if (!mediaState.hasMedia) { logger.debug('detect:explicit:no-media', { devicePath }); return null; } @@ -299,12 +361,13 @@ class DiskDetectionService extends EventEmitter { const details = await this.getBlockDeviceInfo(); const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {}; + const detectedFsType = String(match.fstype || mediaState.type || '').trim() || null; const mediaProfile = await this.inferMediaProfile(devicePath, { discLabel, label: match.label, model: match.model, - fstype: match.fstype, + fstype: detectedFsType, mountpoint: match.mountpoint }); @@ -316,7 +379,7 @@ class DiskDetectionService extends EventEmitter { label: match.label || null, discLabel: discLabel || null, mountpoint: match.mountpoint || null, - fstype: match.fstype || null, + fstype: detectedFsType, mediaProfile: mediaProfile || null, index: this.guessDiscIndex(match.name || devicePath) }; @@ -342,17 +405,18 @@ class DiskDetectionService extends EventEmitter { continue; } - const hasMedia = await this.checkMediaPresent(path); - if (!hasMedia) { + const mediaState = await this.checkMediaPresent(path); + if (!mediaState.hasMedia) { continue; } const discLabel = await this.getDiscLabel(path); + const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null; const mediaProfile = await this.inferMediaProfile(path, { discLabel, label: item.label, model: item.model, - fstype: item.fstype, + fstype: detectedFsType, mountpoint: item.mountpoint }); @@ -364,7 +428,7 @@ class DiskDetectionService extends EventEmitter { label: item.label || null, discLabel: discLabel || null, mountpoint: item.mountpoint || null, - fstype: item.fstype || null, + fstype: detectedFsType, mediaProfile: mediaProfile || null, index: this.guessDiscIndex(item.name) }; @@ -404,12 +468,19 @@ class DiskDetectionService extends EventEmitter { async checkMediaPresent(devicePath) { try { const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]); - const has = stdout.trim().length > 0; - logger.debug('blkid:result', { devicePath, hasMedia: has, type: stdout.trim() }); - return has; + const type = String(stdout || '').trim().toLowerCase(); + const has = type.length > 0; + logger.debug('blkid:result', { devicePath, hasMedia: has, type }); + return { + hasMedia: has, + type: type || null + }; } catch (error) { logger.debug('blkid:no-media-or-fail', { devicePath, error: errorToMeta(error) }); - return false; + return { + hasMedia: false, + type: null + }; } } @@ -427,19 +498,29 @@ class DiskDetectionService extends EventEmitter { async inferMediaProfile(devicePath, hints = {}) { const explicit = normalizeMediaProfile(hints?.mediaProfile); - if (explicit) { + if (isSpecificMediaProfile(explicit)) { return explicit; } const hinted = inferMediaProfileFromTextParts([ hints?.discLabel, hints?.label, - hints?.fstype + hints?.fstype, + hints?.model ]); if (hinted) { return hinted; } + const hintFstype = String(hints?.fstype || '').trim().toLowerCase(); + const byFsTypeHint = inferMediaProfileFromFsTypeAndModel(hints?.fstype, hints?.model); + // UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model + // marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the + // early return and fall through to the blkid check which uses the UDF version number. + if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) { + return byFsTypeHint; + } + const mountpoint = String(hints?.mountpoint || '').trim(); if (mountpoint) { try { @@ -477,19 +558,24 @@ class DiskDetectionService extends EventEmitter { const byBlkidMarker = inferMediaProfileFromTextParts([ payload.LABEL, payload.TYPE, - payload.VERSION + payload.VERSION, + payload.APPLICATION_ID, + hints?.model ]); if (byBlkidMarker) { return byBlkidMarker; } const type = String(payload.TYPE || '').trim().toLowerCase(); - if (type === 'udf') { - const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.')); - if (Number.isFinite(version)) { - return version >= 2 ? 'bluray' : 'dvd'; + const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model); + if (byBlkidFsType) { + if (type.includes('udf')) { + const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.')); + if (Number.isFinite(version)) { + return version >= 2 ? 'bluray' : 'dvd'; + } } - return 'dvd'; + return byBlkidFsType; } } catch (error) { logger.debug('infer-media-profile:blkid-failed', { @@ -498,7 +584,7 @@ class DiskDetectionService extends EventEmitter { }); } - return null; + return explicit === 'other' ? 'other' : null; } guessDiscIndex(name) { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 3bc0b0f..a103599 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -20,6 +20,7 @@ function parseJsonSafe(raw, fallback = null) { const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024; const processLogStreams = new Map(); +const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'other']; function inspectDirectory(dirPath) { if (!dirPath) { @@ -181,10 +182,28 @@ function normalizeMediaTypeValue(value) { if (!raw) { return null; } - if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') { + if ( + raw === 'bluray' + || raw === 'blu-ray' + || raw === 'blu_ray' + || raw === 'bd' + || raw === 'bdmv' + || raw === 'bdrom' + || raw === 'bd-rom' + || raw === 'bd-r' + || raw === 'bd-re' + ) { return 'bluray'; } - if (raw === 'dvd') { + if ( + raw === 'dvd' + || raw === 'dvdvideo' + || raw === 'dvd-video' + || raw === 'dvdrom' + || raw === 'dvd-rom' + || raw === 'video_ts' + || raw === 'iso9660' + ) { return 'dvd'; } if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') { @@ -308,40 +327,83 @@ function resolveEffectiveOutputPath(storedPath, movieDir) { return path.join(String(movieDir).trim(), folderName, fileName); } -function enrichJobRow(job, settings = null) { - const rawDir = String(settings?.raw_dir || '').trim(); - const movieDir = String(settings?.movie_dir || '').trim(); +function getConfiguredMediaPathList(settings = {}, baseKey) { + const source = settings && typeof settings === 'object' ? settings : {}; + const candidates = [source[baseKey], ...PROFILE_PATH_SUFFIXES.map((suffix) => source[`${baseKey}_${suffix}`])]; + const unique = []; + const seen = new Set(); - const effectiveRawPath = rawDir && job.raw_path + for (const candidate of candidates) { + const rawPath = String(candidate || '').trim(); + if (!rawPath) { + continue; + } + const normalized = normalizeComparablePath(rawPath); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + unique.push(normalized); + } + + return unique; +} + +function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = {}) { + const mkInfo = parsed?.makemkvInfo || parseJsonSafe(job?.makemkv_info_json, null); + const miInfo = parsed?.mediainfoInfo || parseJsonSafe(job?.mediainfo_info_json, null); + const plan = parsed?.encodePlan || parseJsonSafe(job?.encode_plan_json, null); + const mediaType = inferMediaType(job, mkInfo, miInfo, plan); + const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType); + const rawDir = String(effectiveSettings?.raw_dir || '').trim(); + const movieDir = String(effectiveSettings?.movie_dir || '').trim(); + const effectiveRawPath = rawDir && job?.raw_path ? resolveEffectiveRawPath(job.raw_path, rawDir) - : (job.raw_path || null); - const effectiveOutputPath = movieDir && job.output_path + : (job?.raw_path || null); + const effectiveOutputPath = movieDir && job?.output_path ? resolveEffectiveOutputPath(job.output_path, movieDir) - : (job.output_path || null); + : (job?.output_path || null); - const rawStatus = inspectDirectory(effectiveRawPath); - const outputStatus = inspectOutputFile(effectiveOutputPath); - const movieDirPath = effectiveOutputPath ? path.dirname(effectiveOutputPath) : null; - const movieDirStatus = inspectDirectory(movieDirPath); - const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null); + return { + mediaType, + rawDir, + movieDir, + effectiveRawPath, + effectiveOutputPath, + makemkvInfo: mkInfo, + mediainfoInfo: miInfo, + encodePlan: plan + }; +} + +function enrichJobRow(job, settings = null) { const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null); - const mediainfoInfo = parseJsonSafe(job.mediainfo_info_json, null); const omdbInfo = parseJsonSafe(job.omdb_json, null); - const encodePlan = parseJsonSafe(job.encode_plan_json, null); - const mediaType = inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan); - const backupSuccess = String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const rawStatus = inspectDirectory(resolvedPaths.effectiveRawPath); + const outputStatus = inspectOutputFile(resolvedPaths.effectiveOutputPath); + const movieDirPath = resolvedPaths.effectiveOutputPath ? path.dirname(resolvedPaths.effectiveOutputPath) : null; + const movieDirStatus = inspectDirectory(movieDirPath); + const makemkvInfo = resolvedPaths.makemkvInfo; + const mediainfoInfo = resolvedPaths.mediainfoInfo; + const encodePlan = resolvedPaths.encodePlan; + const mediaType = resolvedPaths.mediaType; + const ripSuccessful = Number(job?.rip_successful || 0) === 1 + || String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + const backupSuccess = ripSuccessful; const encodeSuccess = String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; return { ...job, - raw_path: effectiveRawPath, - output_path: effectiveOutputPath, + raw_path: resolvedPaths.effectiveRawPath, + output_path: resolvedPaths.effectiveOutputPath, makemkvInfo, handbrakeInfo, mediainfoInfo, omdbInfo, encodePlan, mediaType, + ripSuccessful, backupSuccess, encodeSuccess, rawStatus, @@ -370,9 +432,10 @@ function normalizeComparablePath(inputPath) { function parseRawFolderMetadata(folderName) { const rawName = String(folderName || '').trim(); - const folderJobIdMatch = rawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); + const normalizedRawName = rawName.replace(/^Incomplete_/i, '').trim(); + const folderJobIdMatch = normalizedRawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); const folderJobId = folderJobIdMatch ? Number(folderJobIdMatch[1]) : null; - let working = rawName.replace(/\s*-\s*RAW\s*-\s*job-\d+\s*$/i, '').trim(); + let working = normalizedRawName.replace(/\s*-\s*RAW\s*-\s*job-\d+\s*$/i, '').trim(); const imdbMatch = working.match(/\[(tt\d{6,12})\]/i); const imdbId = imdbMatch ? String(imdbMatch[1] || '').toLowerCase() : null; @@ -499,6 +562,16 @@ class HistoryService { }); } + async updateRawPathByOldPath(oldRawPath, newRawPath) { + const db = await getDb(); + const result = await db.run( + 'UPDATE jobs SET raw_path = ?, updated_at = CURRENT_TIMESTAMP WHERE raw_path = ?', + [newRawPath, oldRawPath] + ); + logger.info('job:raw-path-bulk-updated', { oldRawPath, newRawPath, changes: result.changes }); + return result.changes; + } + appendLog(jobId, source, message) { this.appendProcessLog(jobId, source, message); } @@ -820,25 +893,17 @@ class HistoryService { async getOrphanRawFolders() { const settings = await settingsService.getSettingsMap(); - const rawDir = String(settings.raw_dir || '').trim(); - if (!rawDir) { - const error = new Error('raw_dir ist nicht konfiguriert.'); + const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir'); + if (rawDirs.length === 0) { + const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,other}).'); error.statusCode = 400; throw error; } - const rawDirInfo = inspectDirectory(rawDir); - if (!rawDirInfo.exists || !rawDirInfo.isDirectory) { - return { - rawDir, - rows: [] - }; - } - const db = await getDb(); const linkedRows = await db.all( ` - SELECT id, raw_path, status + SELECT id, raw_path, status, makemkv_info_json, mediainfo_info_json, encode_plan_json, encode_input_path, media_type FROM jobs WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' ` @@ -846,63 +911,77 @@ class HistoryService { const linkedPathMap = new Map(); for (const row of linkedRows) { - const normalized = normalizeComparablePath(row.raw_path); - if (!normalized) { - continue; + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, row); + const linkedCandidates = [ + normalizeComparablePath(row.raw_path), + normalizeComparablePath(resolvedPaths.effectiveRawPath) + ].filter(Boolean); + + for (const linkedPath of linkedCandidates) { + if (!linkedPathMap.has(linkedPath)) { + linkedPathMap.set(linkedPath, []); + } + linkedPathMap.get(linkedPath).push({ + id: row.id, + status: row.status + }); } - if (!linkedPathMap.has(normalized)) { - linkedPathMap.set(normalized, []); - } - linkedPathMap.get(normalized).push({ - id: row.id, - status: row.status - }); } - const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true }); const orphanRows = []; + const seenOrphanPaths = new Set(); - for (const entry of dirEntries) { - if (!entry.isDirectory()) { + for (const rawDir of rawDirs) { + const rawDirInfo = inspectDirectory(rawDir); + if (!rawDirInfo.exists || !rawDirInfo.isDirectory) { continue; } + const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true }); - const rawPath = path.join(rawDir, entry.name); - const normalizedPath = normalizeComparablePath(rawPath); - if (linkedPathMap.has(normalizedPath)) { - continue; + for (const entry of dirEntries) { + if (!entry.isDirectory()) { + continue; + } + + const rawPath = path.join(rawDir, entry.name); + const normalizedPath = normalizeComparablePath(rawPath); + if (!normalizedPath || linkedPathMap.has(normalizedPath) || seenOrphanPaths.has(normalizedPath)) { + continue; + } + + const dirInfo = inspectDirectory(rawPath); + if (!dirInfo.exists || !dirInfo.isDirectory || dirInfo.isEmpty) { + continue; + } + + const stat = fs.statSync(rawPath); + const metadata = parseRawFolderMetadata(entry.name); + orphanRows.push({ + rawPath, + folderName: entry.name, + title: metadata.title, + year: metadata.year, + imdbId: metadata.imdbId, + folderJobId: metadata.folderJobId, + entryCount: Number(dirInfo.entryCount || 0), + hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')), + lastModifiedAt: stat.mtime.toISOString() + }); + seenOrphanPaths.add(normalizedPath); } - - const dirInfo = inspectDirectory(rawPath); - if (!dirInfo.exists || !dirInfo.isDirectory || dirInfo.isEmpty) { - continue; - } - - const stat = fs.statSync(rawPath); - const metadata = parseRawFolderMetadata(entry.name); - orphanRows.push({ - rawPath, - folderName: entry.name, - title: metadata.title, - year: metadata.year, - imdbId: metadata.imdbId, - folderJobId: metadata.folderJobId, - entryCount: Number(dirInfo.entryCount || 0), - hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')), - lastModifiedAt: stat.mtime.toISOString() - }); } orphanRows.sort((a, b) => String(b.lastModifiedAt).localeCompare(String(a.lastModifiedAt))); return { - rawDir, + rawDir: rawDirs[0] || null, + rawDirs, rows: orphanRows }; } async importOrphanRawFolder(rawPath) { const settings = await settingsService.getSettingsMap(); - const rawDir = String(settings.raw_dir || '').trim(); + const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir'); const requestedRawPath = String(rawPath || '').trim(); if (!requestedRawPath) { @@ -911,14 +990,15 @@ class HistoryService { throw error; } - if (!rawDir) { - const error = new Error('raw_dir ist nicht konfiguriert.'); + if (rawDirs.length === 0) { + const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,other}).'); error.statusCode = 400; throw error; } - if (!isPathInside(rawDir, requestedRawPath)) { - const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${requestedRawPath}`); + const insideConfiguredRawDir = rawDirs.some((candidate) => isPathInside(candidate, requestedRawPath)); + if (!insideConfiguredRawDir) { + const error = new Error(`RAW-Pfad liegt außerhalb der konfigurierten RAW-Verzeichnisse: ${requestedRawPath}`); error.statusCode = 400; throw error; } @@ -1004,6 +1084,7 @@ class HistoryService { poster_url: omdbById?.poster || null, omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : null, selected_from_omdb: omdbById ? 1 : 0, + rip_successful: 1, raw_path: finalRawPath, output_path: null, handbrake_info_json: null, @@ -1125,12 +1206,11 @@ class HistoryService { } const settings = await settingsService.getSettingsMap(); - const effectiveRawPath = settings.raw_dir && job.raw_path - ? resolveEffectiveRawPath(job.raw_path, settings.raw_dir) - : job.raw_path; - const effectiveOutputPath = settings.movie_dir && job.output_path - ? resolveEffectiveOutputPath(job.output_path, settings.movie_dir) - : job.output_path; + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const effectiveRawPath = resolvedPaths.effectiveRawPath; + const effectiveOutputPath = resolvedPaths.effectiveOutputPath; + const effectiveRawDir = resolvedPaths.rawDir; + const effectiveMovieDir = resolvedPaths.movieDir; const summary = { target, raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null }, @@ -1141,8 +1221,12 @@ class HistoryService { summary.raw.attempted = true; if (!effectiveRawPath) { summary.raw.reason = 'Kein raw_path im Job gesetzt.'; - } else if (!isPathInside(settings.raw_dir, effectiveRawPath)) { - const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${effectiveRawPath}`); + } else if (!effectiveRawDir) { + const error = new Error(`Kein gültiger RAW-Basispfad für Job ${jobId} (${resolvedPaths.mediaType || 'unknown'}).`); + error.statusCode = 400; + throw error; + } else if (!isPathInside(effectiveRawDir, effectiveRawPath)) { + const error = new Error(`RAW-Pfad liegt außerhalb des effektiven RAW-Basispfads: ${effectiveRawPath}`); error.statusCode = 400; throw error; } else if (!fs.existsSync(effectiveRawPath)) { @@ -1159,15 +1243,19 @@ class HistoryService { summary.movie.attempted = true; if (!effectiveOutputPath) { summary.movie.reason = 'Kein output_path im Job gesetzt.'; - } else if (!isPathInside(settings.movie_dir, effectiveOutputPath)) { - const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${effectiveOutputPath}`); + } else if (!effectiveMovieDir) { + const error = new Error(`Kein gültiger Movie-Basispfad für Job ${jobId} (${resolvedPaths.mediaType || 'unknown'}).`); + error.statusCode = 400; + throw error; + } else if (!isPathInside(effectiveMovieDir, effectiveOutputPath)) { + const error = new Error(`Movie-Pfad liegt außerhalb des effektiven Movie-Basispfads: ${effectiveOutputPath}`); error.statusCode = 400; throw error; } else if (!fs.existsSync(effectiveOutputPath)) { summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.'; } else { const outputPath = normalizeComparablePath(effectiveOutputPath); - const movieRoot = normalizeComparablePath(settings.movie_dir); + const movieRoot = normalizeComparablePath(effectiveMovieDir); const stat = fs.lstatSync(outputPath); if (stat.isDirectory()) { const keepRoot = outputPath === movieRoot; diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index ab9dbdd..5c86c44 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -57,6 +57,8 @@ const QUEUE_ACTION_LABELS = { const PRE_ENCODE_PROGRESS_RESERVE = 10; const POST_ENCODE_PROGRESS_RESERVE = 10; const POST_ENCODE_FINISH_BUFFER = 1; +const MIN_EXTENSIONLESS_DISC_IMAGE_BYTES = 256 * 1024 * 1024; +const RAW_INCOMPLETE_PREFIX = 'Incomplete_'; function nowIso() { return new Date().toISOString(); @@ -67,10 +69,28 @@ function normalizeMediaProfile(value) { if (!raw) { return null; } - if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') { + if ( + raw === 'bluray' + || raw === 'blu-ray' + || raw === 'blu_ray' + || raw === 'bd' + || raw === 'bdmv' + || raw === 'bdrom' + || raw === 'bd-rom' + || raw === 'bd-r' + || raw === 'bd-re' + ) { return 'bluray'; } - if (raw === 'dvd') { + if ( + raw === 'dvd' + || raw === 'dvdvideo' + || raw === 'dvd-video' + || raw === 'dvdrom' + || raw === 'dvd-rom' + || raw === 'video_ts' + || raw === 'iso9660' + ) { return 'dvd'; } if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') { @@ -79,11 +99,125 @@ function normalizeMediaProfile(value) { return null; } +function isSpecificMediaProfile(value) { + return value === 'bluray' || value === 'dvd'; +} + +function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { + const fstype = String(rawFsType || '').trim().toLowerCase(); + const model = String(rawModel || '').trim().toLowerCase(); + const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model); + const hasDvdModelMarker = /dvd/.test(model); + const hasCdOnlyModelMarker = /(^|[\s_-])cd([\s_-]|$)|cd-?rom/.test(model) && !hasBlurayModelMarker && !hasDvdModelMarker; + + if (!fstype) { + if (hasBlurayModelMarker) { + return 'bluray'; + } + if (hasDvdModelMarker) { + return 'dvd'; + } + return null; + } + + if (fstype.includes('udf')) { + if (hasBlurayModelMarker) { + return 'bluray'; + } + if (hasDvdModelMarker) { + return 'dvd'; + } + return 'dvd'; + } + + if (fstype.includes('iso9660') || fstype.includes('cdfs')) { + if (hasBlurayModelMarker) { + return 'bluray'; + } + if (hasCdOnlyModelMarker) { + return 'other'; + } + return 'dvd'; + } + + return null; +} + +function isLikelyExtensionlessDvdImageFile(filePath, knownSize = null) { + if (path.extname(String(filePath || '')).toLowerCase() !== '') { + return false; + } + + let size = Number(knownSize); + if (!Number.isFinite(size) || size < 0) { + try { + size = Number(fs.statSync(filePath).size || 0); + } catch (_error) { + return false; + } + } + + return size >= MIN_EXTENSIONLESS_DISC_IMAGE_BYTES; +} + +function listTopLevelExtensionlessDvdImages(dirPath) { + const sourceDir = String(dirPath || '').trim(); + if (!sourceDir) { + return []; + } + + let entries; + try { + entries = fs.readdirSync(sourceDir, { withFileTypes: true }); + } catch (_error) { + return []; + } + + const results = []; + for (const entry of entries) { + if (!entry.isFile()) { + continue; + } + + const absPath = path.join(sourceDir, entry.name); + let stat; + try { + stat = fs.statSync(absPath); + } catch (_error) { + continue; + } + + if (!isLikelyExtensionlessDvdImageFile(absPath, stat.size)) { + continue; + } + + results.push({ + path: absPath, + size: Number(stat.size || 0) + }); + } + + results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path)); + return results; +} + function inferMediaProfileFromRawPath(rawPath) { const source = String(rawPath || '').trim(); if (!source) { return null; } + try { + const sourceStat = fs.statSync(source); + if (sourceStat.isFile()) { + if (isLikelyExtensionlessDvdImageFile(source, sourceStat.size)) { + return 'dvd'; + } + return null; + } + } catch (_error) { + // ignore fs errors + } + const bdmvPath = path.join(source, 'BDMV'); const bdmvStreamPath = path.join(bdmvPath, 'STREAM'); try { @@ -103,16 +237,8 @@ function inferMediaProfileFromRawPath(rawPath) { // ignore fs errors } - try { - const entries = fs.readdirSync(source, { withFileTypes: true }); - const hasIso = entries.some( - (e) => e.isFile() && path.extname(e.name).toLowerCase() === '.iso' - ); - if (hasIso) { - return 'dvd'; - } - } catch (_error) { - // ignore fs errors + if (listTopLevelExtensionlessDvdImages(source).length > 0) { + return 'dvd'; } return null; @@ -136,7 +262,8 @@ function inferMediaProfileFromDeviceInfo(deviceInfo = null) { const markerText = [ device.discLabel, device.label, - device.fstype + device.fstype, + device.model ] .map((value) => String(value || '').trim().toLowerCase()) .filter(Boolean) @@ -149,6 +276,11 @@ function inferMediaProfileFromDeviceInfo(deviceInfo = null) { return 'dvd'; } + const byFsTypeAndModel = inferMediaProfileFromFsTypeAndModel(device.fstype, device.model); + if (byFsTypeAndModel) { + return byFsTypeAndModel; + } + const mountpoint = String(device.mountpoint || '').trim(); if (mountpoint) { try { @@ -698,6 +830,47 @@ function parseMediainfoJsonOutput(rawOutput) { return parsedObjects[parsedObjects.length - 1] || null; } +function getMediaInfoTrackList(mediaInfoJson) { + if (Array.isArray(mediaInfoJson?.media?.track)) { + return mediaInfoJson.media.track; + } + if (Array.isArray(mediaInfoJson?.Media?.track)) { + return mediaInfoJson.Media.track; + } + return []; +} + +function countMediaInfoTrackTypes(mediaInfoJson) { + const tracks = getMediaInfoTrackList(mediaInfoJson); + let audioCount = 0; + let subtitleCount = 0; + for (const track of tracks) { + const type = String(track?.['@type'] || '').trim().toLowerCase(); + if (type === 'audio') { + audioCount += 1; + continue; + } + if (type === 'text' || type === 'subtitle') { + subtitleCount += 1; + } + } + return { + audioCount, + subtitleCount + }; +} + +function shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, inputPath) { + if (normalizeMediaProfile(mediaProfile) !== 'dvd') { + return false; + } + if (path.extname(String(inputPath || '')).toLowerCase() !== '') { + return false; + } + const counts = countMediaInfoTrackTypes(parsedMediaInfo); + return counts.audioCount === 0 && counts.subtitleCount === 0; +} + function parseHmsDurationToSeconds(raw) { const value = String(raw || '').trim(); if (!value) { @@ -1487,9 +1660,15 @@ function findExistingRawDirectory(rawBaseDir, metadataBase) { return null; } - const prefix = sanitizeFileName(`${metadataBase} - RAW - job-`); + const normalizedBase = sanitizeFileName(metadataBase); + const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedIncompletePrefix = RAW_INCOMPLETE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const folderPattern = new RegExp( + `^(?:${escapedIncompletePrefix})?${escapedBase}(?:\\s\\[tt\\d{6,12}\\])?\\s-\\sRAW\\s-\\sjob-\\d+\\s*$`, + 'i' + ); const candidates = entries - .filter((entry) => entry.isDirectory() && entry.name.startsWith(prefix)) + .filter((entry) => entry.isDirectory() && folderPattern.test(entry.name)) .map((entry) => { const absPath = path.join(rawBaseDir, entry.name); try { @@ -1510,6 +1689,59 @@ function findExistingRawDirectory(rawBaseDir, metadataBase) { return candidates.length > 0 ? candidates[0].path : null; } +function buildRawMetadataBase(jobLike = {}, fallbackJobId = null) { + const normalizedJobId = Number(fallbackJobId || jobLike?.id || 0); + const fallbackTitle = Number.isFinite(normalizedJobId) && normalizedJobId > 0 + ? `job-${Math.trunc(normalizedJobId)}` + : 'job-unknown'; + const rawYear = Number(jobLike?.year ?? jobLike?.fallbackYear ?? null); + const yearValue = Number.isFinite(rawYear) && rawYear > 0 + ? Math.trunc(rawYear) + : new Date().getFullYear(); + return sanitizeFileName( + renderTemplate('${title} (${year})', { + title: jobLike?.title || jobLike?.detected_title || jobLike?.detectedTitle || fallbackTitle, + year: yearValue + }) + ); +} + +function buildRawDirName(metadataBase, jobId, options = {}) { + const incomplete = options?.incomplete !== undefined ? Boolean(options.incomplete) : true; + const baseName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`); + return incomplete ? sanitizeFileName(`${RAW_INCOMPLETE_PREFIX}${baseName}`) : baseName; +} + +function buildCompletedRawPath(rawPath) { + const sourcePath = String(rawPath || '').trim(); + if (!sourcePath) { + return null; + } + const folderName = path.basename(sourcePath); + if (!new RegExp(`^${RAW_INCOMPLETE_PREFIX}`, 'i').test(folderName)) { + return sourcePath; + } + const completedFolderName = folderName.replace(new RegExp(`^${RAW_INCOMPLETE_PREFIX}`, 'i'), ''); + if (!completedFolderName) { + return sourcePath; + } + return path.join(path.dirname(sourcePath), completedFolderName); +} + +function normalizeComparablePath(inputPath) { + const source = String(inputPath || '').trim(); + if (!source) { + return ''; + } + return path.resolve(source).replace(/[\\/]+$/, ''); +} + +function isJobFinished(jobLike = null) { + const status = String(jobLike?.status || '').trim().toUpperCase(); + const lastState = String(jobLike?.last_state || '').trim().toUpperCase(); + return status === 'FINISHED' || lastState === 'FINISHED'; +} + function toPlaylistFile(playlistId) { const normalized = normalizePlaylistId(playlistId); return normalized ? `${normalized}.mpls` : null; @@ -2226,42 +2458,51 @@ function buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId = null return set; } -function ensureDvdBackupIso(rawDir) { - let entries; - try { - entries = fs.readdirSync(rawDir, { withFileTypes: true }); - } catch (_error) { - return null; - } - for (const entry of entries) { - if (!entry.isFile()) { - continue; - } - const ext = path.extname(entry.name).toLowerCase(); - if (ext === '.iso') { - return path.join(rawDir, entry.name); - } - } - for (const entry of entries) { - if (!entry.isFile()) { - continue; - } - const ext = path.extname(entry.name).toLowerCase(); - const oldPath = path.join(rawDir, entry.name); - const newName = ext ? entry.name.slice(0, -ext.length) + '.iso' : entry.name + '.iso'; - const newPath = path.join(rawDir, newName); - try { - fs.renameSync(oldPath, newPath); - return newPath; - } catch (_error) { - return null; - } - } - return null; -} function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedPlaylistId = null } = {}) { - const primary = findMediaFiles(rawPath, ['.mkv', '.mp4', '.iso']); + const sourcePath = String(rawPath || '').trim(); + if (!sourcePath) { + return { + mediaFiles: [], + source: 'none' + }; + } + + try { + const sourceStat = fs.statSync(sourcePath); + if (sourceStat.isFile()) { + const ext = path.extname(sourcePath).toLowerCase(); + if ( + ext === '.mkv' + || ext === '.mp4' + || isLikelyExtensionlessDvdImageFile(sourcePath, sourceStat.size) + ) { + return { + mediaFiles: [{ path: sourcePath, size: Number(sourceStat.size || 0) }], + source: ext === '' ? 'single_extensionless' : 'single_file' + }; + } + return { + mediaFiles: [], + source: 'none' + }; + } + } catch (_error) { + return { + mediaFiles: [], + source: 'none' + }; + } + + const topLevelExtensionlessImages = listTopLevelExtensionlessDvdImages(sourcePath); + if (topLevelExtensionlessImages.length > 0) { + return { + mediaFiles: topLevelExtensionlessImages, + source: 'dvd_image' + }; + } + + const primary = findMediaFiles(sourcePath, ['.mkv', '.mp4']); if (primary.length > 0) { return { mediaFiles: primary, @@ -2269,13 +2510,12 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP }; } - const streamDir = path.join(rawPath, 'BDMV', 'STREAM'); - const backupRoot = fs.existsSync(streamDir) ? streamDir : rawPath; + const streamDir = path.join(sourcePath, 'BDMV', 'STREAM'); + const backupRoot = fs.existsSync(streamDir) ? streamDir : sourcePath; let backupFiles = findMediaFiles(backupRoot, ['.m2ts']); if (backupFiles.length === 0) { - const videoTsDir = path.join(rawPath, 'VIDEO_TS'); - if (fs.existsSync(videoTsDir)) { - const vobFiles = findMediaFiles(videoTsDir, ['.vob']); + const vobFiles = findMediaFiles(sourcePath, ['.vob']); + if (vobFiles.length > 0) { return { mediaFiles: vobFiles, source: 'dvd' @@ -2366,8 +2606,189 @@ class PipelineService extends EventEmitter { }; } + isRipSuccessful(job = null) { + if (Number(job?.rip_successful || 0) === 1) { + return true; + } + if (isJobFinished(job)) { + return true; + } + const mkInfo = this.safeParseJson(job?.makemkv_info_json); + return String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + } + + resolveCurrentRawPath(rawBaseDir, storedRawPath) { + const stored = String(storedRawPath || '').trim(); + if (!stored) { + return null; + } + const candidates = [stored]; + if (rawBaseDir) { + const byFolder = path.join(rawBaseDir, path.basename(stored)); + if (!candidates.includes(byFolder)) { + candidates.push(byFolder); + } + } + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + return candidate; + } + } catch (_error) { + // ignore fs errors + } + } + return null; + } + + async migrateRawFolderNamingOnStartup(db) { + const settings = await settingsService.getSettingsMap(); + const rawBaseDir = String(settings?.raw_dir || '').trim(); + if (!rawBaseDir || !fs.existsSync(rawBaseDir)) { + return; + } + + const rows = await db.all(` + SELECT id, title, year, detected_title, raw_path, status, last_state, rip_successful, makemkv_info_json + FROM jobs + WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' + `); + if (!Array.isArray(rows) || rows.length === 0) { + return; + } + + let renamedCount = 0; + let pathUpdateCount = 0; + let ripFlagUpdateCount = 0; + let conflictCount = 0; + let missingCount = 0; + const discoveredByJobId = new Map(); + + try { + const dirEntries = fs.readdirSync(rawBaseDir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (!entry.isDirectory()) { + continue; + } + const match = String(entry.name || '').match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); + if (!match) { + continue; + } + const mappedJobId = Number(match[1]); + if (!Number.isFinite(mappedJobId) || mappedJobId <= 0) { + continue; + } + const candidatePath = path.join(rawBaseDir, entry.name); + let mtimeMs = 0; + try { + mtimeMs = Number(fs.statSync(candidatePath).mtimeMs || 0); + } catch (_error) { + // ignore fs errors and keep zero mtime + } + const current = discoveredByJobId.get(mappedJobId); + if (!current || mtimeMs > current.mtimeMs) { + discoveredByJobId.set(mappedJobId, { + path: candidatePath, + mtimeMs + }); + } + } + } catch (scanError) { + logger.warn('startup:raw-dir-migrate:scan-failed', { + rawBaseDir, + error: errorToMeta(scanError) + }); + } + + for (const row of rows) { + const jobId = Number(row?.id); + if (!Number.isFinite(jobId) || jobId <= 0) { + continue; + } + + const ripSuccessful = this.isRipSuccessful(row); + if (ripSuccessful && Number(row?.rip_successful || 0) !== 1) { + await historyService.updateJob(jobId, { rip_successful: 1 }); + ripFlagUpdateCount += 1; + } + + const currentRawPath = this.resolveCurrentRawPath(rawBaseDir, row.raw_path) + || discoveredByJobId.get(jobId)?.path + || null; + if (!currentRawPath) { + missingCount += 1; + continue; + } + + const currentFolderName = path.basename(currentRawPath).replace(/^Incomplete_/i, '').trim(); + const folderYearMatch = currentFolderName.match(/\((19|20)\d{2}\)/); + const fallbackYear = folderYearMatch + ? Number(String(folderYearMatch[0]).replace(/[()]/g, '')) + : null; + const metadataBase = buildRawMetadataBase({ + title: row.title || row.detected_title || null, + year: row.year || null, + fallbackYear + }, jobId); + const shouldBeIncomplete = !isJobFinished(row); + const desiredRawPath = path.join( + rawBaseDir, + buildRawDirName(metadataBase, jobId, { incomplete: shouldBeIncomplete }) + ); + + let finalRawPath = currentRawPath; + if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(desiredRawPath)) { + if (fs.existsSync(desiredRawPath)) { + conflictCount += 1; + logger.warn('startup:raw-dir-migrate:target-exists', { + jobId, + currentRawPath, + desiredRawPath + }); + } else { + try { + fs.renameSync(currentRawPath, desiredRawPath); + finalRawPath = desiredRawPath; + renamedCount += 1; + } catch (renameError) { + logger.warn('startup:raw-dir-migrate:rename-failed', { + jobId, + currentRawPath, + desiredRawPath, + error: errorToMeta(renameError) + }); + continue; + } + } + } + + if (normalizeComparablePath(row.raw_path) !== normalizeComparablePath(finalRawPath)) { + await historyService.updateRawPathByOldPath(row.raw_path, finalRawPath); + pathUpdateCount += 1; + } + } + + if (renamedCount > 0 || pathUpdateCount > 0 || ripFlagUpdateCount > 0 || conflictCount > 0 || missingCount > 0) { + logger.info('startup:raw-dir-migrate:done', { + renamedCount, + pathUpdateCount, + ripFlagUpdateCount, + conflictCount, + missingCount, + rawBaseDir + }); + } + } + async init() { const db = await getDb(); + try { + await this.migrateRawFolderNamingOnStartup(db); + } catch (migrationError) { + logger.warn('init:raw-dir-migrate-failed', { + error: errorToMeta(migrationError) + }); + } const row = await db.get('SELECT * FROM pipeline_state WHERE id = 1'); if (row) { @@ -3086,9 +3507,13 @@ class PipelineService extends EventEmitter { const rawDevice = deviceInfo && typeof deviceInfo === 'object' ? deviceInfo : {}; - const resolvedMediaProfile = normalizeMediaProfile(rawDevice.mediaProfile) - || inferMediaProfileFromDeviceInfo(rawDevice) - || 'other'; + const explicitProfile = normalizeMediaProfile(rawDevice.mediaProfile); + const inferredProfile = inferMediaProfileFromDeviceInfo(rawDevice); + const resolvedMediaProfile = isSpecificMediaProfile(explicitProfile) + ? explicitProfile + : (isSpecificMediaProfile(inferredProfile) + ? inferredProfile + : (explicitProfile || inferredProfile || 'other')); const resolvedDevice = { ...rawDevice, mediaProfile: resolvedMediaProfile @@ -3209,7 +3634,18 @@ class PipelineService extends EventEmitter { } resolveMediaProfileForJob(job, options = {}) { - const explicitProfile = normalizeMediaProfile(options?.mediaProfile); + const pickSpecificProfile = (value) => { + const normalized = normalizeMediaProfile(value); + if (!normalized) { + return null; + } + if (isSpecificMediaProfile(normalized)) { + return normalized; + } + return null; + }; + + const explicitProfile = pickSpecificProfile(options?.mediaProfile); if (explicitProfile) { return explicitProfile; } @@ -3217,7 +3653,7 @@ class PipelineService extends EventEmitter { const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object' ? options.encodePlan : null; - const profileFromPlan = normalizeMediaProfile(encodePlan?.mediaProfile); + const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile); if (profileFromPlan) { return profileFromPlan; } @@ -3226,7 +3662,7 @@ class PipelineService extends EventEmitter { ? options.makemkvInfo : this.safeParseJson(job?.makemkv_info_json); const analyzeContext = mkInfo?.analyzeContext || {}; - const profileFromAnalyze = normalizeMediaProfile( + const profileFromAnalyze = pickSpecificProfile( analyzeContext.mediaProfile || mkInfo?.mediaProfile ); if (profileFromAnalyze) { @@ -3235,7 +3671,7 @@ class PipelineService extends EventEmitter { const currentContextProfile = ( Number(this.snapshot.context?.jobId) === Number(job?.id) - ? normalizeMediaProfile(this.snapshot.context?.mediaProfile) + ? pickSpecificProfile(this.snapshot.context?.mediaProfile) : null ); if (currentContextProfile) { @@ -3248,7 +3684,7 @@ class PipelineService extends EventEmitter { || this.snapshot.context?.device || null ); - if (deviceProfile) { + if (isSpecificMediaProfile(deviceProfile)) { return deviceProfile; } @@ -3468,9 +3904,13 @@ class PipelineService extends EventEmitter { || device.model || 'Unknown Disc' ).trim(); - const mediaProfile = normalizeMediaProfile(device?.mediaProfile) - || inferMediaProfileFromDeviceInfo(device) - || 'other'; + const explicitProfile = normalizeMediaProfile(device?.mediaProfile); + const inferredProfile = inferMediaProfileFromDeviceInfo(device); + const mediaProfile = isSpecificMediaProfile(explicitProfile) + ? explicitProfile + : (isSpecificMediaProfile(inferredProfile) + ? inferredProfile + : (explicitProfile || inferredProfile || 'other')); const deviceWithProfile = { ...device, mediaProfile @@ -4624,15 +5064,26 @@ class PipelineService extends EventEmitter { ? 'backup' : 'mkv'; const isBackupMode = ripMode === 'backup'; - const metadataBase = sanitizeFileName( - renderTemplate('${title} (${year}) [${imdbId}]', { - title: selectedMetadata.title || job.detected_title || `job-${jobId}`, - year: selectedMetadata.year || new Date().getFullYear(), - imdbId: selectedMetadata.imdbId || `job-${jobId}` - }) - ); + const metadataBase = buildRawMetadataBase({ + title: selectedMetadata.title || job.detected_title || null, + year: selectedMetadata.year || null + }, jobId); const existingRawPath = findExistingRawDirectory(settings.raw_dir, metadataBase); - const updatedRawPath = existingRawPath || null; + let updatedRawPath = existingRawPath || null; + if (existingRawPath) { + const renamedDirName = buildRawDirName(metadataBase, jobId, { incomplete: true }); + const renamedRawPath = path.join(settings.raw_dir, renamedDirName); + if (existingRawPath !== renamedRawPath && !fs.existsSync(renamedRawPath)) { + try { + fs.renameSync(existingRawPath, renamedRawPath); + updatedRawPath = renamedRawPath; + await historyService.updateRawPathByOldPath(existingRawPath, renamedRawPath); + logger.info('metadata:raw-dir-renamed', { from: existingRawPath, to: renamedRawPath, jobId }); + } catch (renameError) { + logger.warn('metadata:raw-dir-rename-failed', { existingRawPath, renamedRawPath, error: errorToMeta(renameError) }); + } + } + } const basePlaylistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylist); const playlistDecision = isBackupMode ? { @@ -5181,8 +5632,11 @@ class PipelineService extends EventEmitter { } const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); - if (mkInfo && mkInfo.status && mkInfo.status !== 'SUCCESS') { - const error = new Error(`Re-Encode nicht möglich: RAW-Rip ist nicht abgeschlossen (MakeMKV Status ${mkInfo.status}).`); + const ripSuccessful = this.isRipSuccessful(sourceJob); + if (!ripSuccessful) { + const error = new Error( + `Re-Encode nicht möglich: RAW-Rip ist nicht abgeschlossen (MakeMKV Status ${mkInfo?.status || 'unknown'}).` + ); error.statusCode = 400; throw error; } @@ -5288,6 +5742,54 @@ class PipelineService extends EventEmitter { }; } + async runDvdTrackFallbackForFile(jobId, inputPath, options = {}) { + const lines = []; + const scanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { + mediaProfile: options?.mediaProfile || null, + settingsMap: options?.settingsMap || null + }); + logger.info('mediainfo:track-fallback:handbrake-scan:command', { + jobId, + inputPath, + cmd: scanConfig.cmd, + args: scanConfig.args + }); + + const runInfo = await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN_DVD_TRACK_FALLBACK', + cmd: scanConfig.cmd, + args: scanConfig.args, + collectLines: lines, + collectStderrLines: false + }); + + const parsedScan = parseMediainfoJsonOutput(lines.join('\n')); + if (!parsedScan) { + return { + runInfo, + parsedMediaInfo: null, + titleInfo: null + }; + } + + const titleInfo = parseHandBrakeSelectedTitleInfo(parsedScan); + if (!titleInfo) { + return { + runInfo, + parsedMediaInfo: null, + titleInfo: null + }; + } + + return { + runInfo, + parsedMediaInfo: buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo), + titleInfo + }; + } + async runMediainfoReviewForJob(jobId, rawPath, options = {}) { this.ensureNotBusy('runMediainfoReviewForJob', jobId); logger.info('mediainfo:review:start', { jobId, rawPath, options }); @@ -5420,10 +5922,49 @@ class PipelineService extends EventEmitter { mediaProfile, settingsMap: settings }); - mediaInfoByPath[file.path] = result.parsed; + let parsedMediaInfo = result.parsed; + let fallbackRunInfo = null; + if (shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, file.path)) { + try { + const fallback = await this.runDvdTrackFallbackForFile(jobId, file.path, { + mediaProfile, + settingsMap: settings + }); + if (fallback?.parsedMediaInfo) { + parsedMediaInfo = fallback.parsedMediaInfo; + fallbackRunInfo = fallback.runInfo || null; + const audioCount = Array.isArray(fallback?.titleInfo?.audioTracks) + ? fallback.titleInfo.audioTracks.length + : 0; + const subtitleCount = Array.isArray(fallback?.titleInfo?.subtitleTracks) + ? fallback.titleInfo.subtitleTracks.length + : 0; + await historyService.appendLog( + jobId, + 'SYSTEM', + `DVD Track-Fallback aktiv (${path.basename(file.path)}): Audio=${audioCount}, Subtitle=${subtitleCount}.` + ); + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + `DVD Track-Fallback ohne Ergebnis (${path.basename(file.path)}).` + ); + } + } catch (error) { + logger.warn('mediainfo:track-fallback:failed', { + jobId, + inputPath: file.path, + error: errorToMeta(error) + }); + } + } + + mediaInfoByPath[file.path] = parsedMediaInfo; mediaInfoRuns.push({ filePath: file.path, - runInfo: result.runInfo + runInfo: result.runInfo, + fallbackRunInfo }); const partialReview = buildReviewSnapshot(i + 1); @@ -6175,6 +6716,49 @@ class PipelineService extends EventEmitter { `Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}` ); } + let finalizedRawPath = job.raw_path || null; + if (job.raw_path) { + const currentRawPath = String(job.raw_path || '').trim(); + const completedRawPath = buildCompletedRawPath(currentRawPath); + if (completedRawPath && completedRawPath !== currentRawPath) { + if (fs.existsSync(completedRawPath)) { + logger.warn('encoding:raw-dir-finalize:target-exists', { + jobId, + sourceRawPath: currentRawPath, + targetRawPath: completedRawPath + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nicht als abgeschlossen markiert werden (Ziel existiert bereits): ${completedRawPath}` + ); + } else { + try { + fs.renameSync(currentRawPath, completedRawPath); + await historyService.updateRawPathByOldPath(currentRawPath, completedRawPath); + finalizedRawPath = completedRawPath; + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner als abgeschlossen markiert: ${currentRawPath} -> ${completedRawPath}` + ); + } catch (rawRenameError) { + logger.warn('encoding:raw-dir-finalize:rename-failed', { + jobId, + sourceRawPath: currentRawPath, + targetRawPath: completedRawPath, + error: errorToMeta(rawRenameError) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nicht als abgeschlossen markiert werden: ${rawRenameError.message}` + ); + } + } + } + } + const handbrakeInfoWithPostScripts = { ...handbrakeInfo, preEncodeScripts: preEncodeScriptsSummary, @@ -6186,6 +6770,8 @@ class PipelineService extends EventEmitter { status: 'FINISHED', last_state: 'FINISHED', end_time: nowIso(), + raw_path: finalizedRawPath, + rip_successful: 1, output_path: finalizedOutputPath, error_message: null }); @@ -6310,14 +6896,11 @@ class PipelineService extends EventEmitter { ensureDir(rawBaseDir); - const metadataBase = sanitizeFileName( - renderTemplate('${title} (${year}) [${imdbId}]', { - title: job.title || job.detected_title || `job-${jobId}`, - year: job.year || new Date().getFullYear(), - imdbId: job.imdb_id || `job-${jobId}` - }) - ); - const rawDirName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`); + const metadataBase = buildRawMetadataBase({ + title: job.title || job.detected_title || null, + year: job.year || null + }, jobId); + const rawDirName = buildRawDirName(metadataBase, jobId, { incomplete: true }); const rawJobDir = path.join(rawBaseDir, rawDirName); ensureDir(rawJobDir); logger.info('rip:raw-dir-created', { jobId, rawJobDir }); @@ -6364,6 +6947,10 @@ class PipelineService extends EventEmitter { message: `${job.title || job.detected_title || `Job #${jobId}`} (${device.path || 'disc'})` }); + const backupOutputBase = ripMode === 'backup' && mediaProfile === 'dvd' + ? sanitizeFileName(job.title || job.detected_title || `disc-${jobId}`) + : null; + await historyService.updateJob(jobId, { status: 'RIPPING', last_state: 'RIPPING', @@ -6383,14 +6970,11 @@ class PipelineService extends EventEmitter { try { await this.ensureMakeMKVRegistration(jobId, 'RIPPING'); - const isoOutputBase = ripMode === 'backup' && mediaProfile === 'dvd' - ? sanitizeFileName(job.title || job.detected_title || `disc-${jobId}`) - : null; const ripConfig = await settingsService.buildMakeMKVRipConfig(rawJobDir, device, { selectedTitleId: effectiveSelectedTitleId, mediaProfile, settingsMap: settings, - isoOutputBase + backupOutputBase }); logger.info('rip:command', { jobId, @@ -6452,24 +7036,54 @@ class PipelineService extends EventEmitter { }); } } - if (ripMode === 'backup' && mediaProfile === 'dvd') { - const isoPath = ensureDvdBackupIso(rawJobDir); - if (isoPath) { - await historyService.appendLog(jobId, 'SYSTEM', `DVD-Backup ISO: ${path.basename(isoPath)}`); - } else { - logger.warn('rip:dvd-backup:no-iso', { jobId, rawJobDir }); - } - } const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); await historyService.updateJob(jobId, { makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...makemkvInfo, analyzeContext: mkInfoBeforeRip?.analyzeContext || null - }, mediaProfile)) + }, mediaProfile)), + rip_successful: 1 }); - const review = await this.runReviewForRawJob(jobId, rawJobDir, { + // Rename Incomplete_ prefix away now that the rip is complete and successful. + let activeRawJobDir = rawJobDir; + const completedRawJobDir = buildCompletedRawPath(rawJobDir); + if (completedRawJobDir && completedRawJobDir !== rawJobDir) { + if (fs.existsSync(completedRawJobDir)) { + logger.warn('rip:raw-complete:rename-skip', { jobId, rawJobDir, completedRawJobDir }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nach Rip nicht umbenannt werden (Zielordner existiert): ${completedRawJobDir}` + ); + } else { + try { + fs.renameSync(rawJobDir, completedRawJobDir); + activeRawJobDir = completedRawJobDir; + await historyService.updateRawPathByOldPath(rawJobDir, completedRawJobDir); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner nach erfolgreichem Rip umbenannt: ${rawJobDir} → ${completedRawJobDir}` + ); + } catch (renameError) { + logger.warn('rip:raw-complete:rename-failed', { + jobId, + rawJobDir, + completedRawJobDir, + error: errorToMeta(renameError) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `RAW-Ordner konnte nach Rip nicht umbenannt werden: ${renameError.message}` + ); + } + } + } + + const review = await this.runReviewForRawJob(jobId, activeRawJobDir, { mode: 'rip', mediaProfile }); @@ -6784,6 +7398,16 @@ class PipelineService extends EventEmitter { throw error; } + const hasRawInput = Boolean( + hasBluRayBackupStructure(sourceJob.raw_path) + || findPreferredRawInput(sourceJob.raw_path) + ); + if (!hasRawInput) { + const error = new Error('Review-Neustart nicht möglich: keine Mediendateien im RAW-Pfad gefunden. Disc muss zuerst gerippt werden.'); + error.statusCode = 400; + throw error; + } + const currentStatus = String(sourceJob.status || '').trim().toUpperCase(); if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(currentStatus)) { const error = new Error(`Review-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); @@ -6839,6 +7463,19 @@ class PipelineService extends EventEmitter { `Review-Neustart aus RAW angefordert.${forcePlaylistReselection ? ' Playlist-Auswahl wird zurückgesetzt.' : ''}` ); + await this.setState('MEDIAINFO_CHECK', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: 'Titel-/Spurprüfung wird neu gestartet...', + context: { + ...(this.snapshot.context || {}), + jobId, + reviewConfirmed: false, + mediaInfoReview: null + } + }); + this.runReviewForRawJob(jobId, sourceJob.raw_path, { mode: options?.mode || 'reencode', sourceJobId: jobId, @@ -6893,6 +7530,31 @@ class PipelineService extends EventEmitter { if (!processHandle) { const runningJob = await historyService.getJobById(normalizedJobId); const status = String(runningJob?.status || '').trim().toUpperCase(); + + if (status === 'READY_TO_ENCODE') { + // Kein laufender Prozess – Job direkt abbrechen + await historyService.updateJob(normalizedJobId, { + status: 'CANCELLED', + last_state: 'CANCELLED', + end_time: nowIso(), + error_message: 'Vom Benutzer abgebrochen.' + }); + await historyService.appendLog(normalizedJobId, 'USER_ACTION', 'Abbruch im Status READY_TO_ENCODE.'); + await this.setState('CANCELLED', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: 'Vom Benutzer abgebrochen.', + context: { + jobId: normalizedJobId, + rawPath: runningJob?.raw_path || null, + error: 'Vom Benutzer abgebrochen.', + canRestartReviewFromRaw: Boolean(runningJob?.raw_path) + } + }); + return { cancelled: true, queuedOnly: false, jobId: normalizedJobId }; + } + if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(status)) { this.cancelRequestedByJob.add(normalizedJobId); await historyService.appendLog( @@ -7107,7 +7769,11 @@ class PipelineService extends EventEmitter { ); let hasRawPath = false; try { - hasRawPath = Boolean(job?.raw_path && fs.existsSync(job.raw_path)); + hasRawPath = Boolean( + job?.raw_path + && fs.existsSync(job.raw_path) + && (hasBluRayBackupStructure(job.raw_path) || findPreferredRawInput(job.raw_path)) + ); } catch (_error) { hasRawPath = false; } diff --git a/backend/src/services/scriptChainService.js b/backend/src/services/scriptChainService.js index 7c6df6b..6f4dba1 100644 --- a/backend/src/services/scriptChainService.js +++ b/backend/src/services/scriptChainService.js @@ -32,6 +32,7 @@ function mapChainRow(row, steps = []) { return { id: Number(row.id), name: String(row.name || ''), + orderIndex: Number(row.order_index || 0), steps: steps.map(mapStepRow), createdAt: row.created_at, updatedAt: row.updated_at @@ -115,9 +116,9 @@ class ScriptChainService { const db = await getDb(); const rows = await db.all( ` - SELECT id, name, created_at, updated_at + SELECT id, name, order_index, created_at, updated_at FROM script_chains - ORDER BY LOWER(name) ASC, id ASC + ORDER BY order_index ASC, id ASC ` ); @@ -164,7 +165,7 @@ class ScriptChainService { } const db = await getDb(); const row = await db.get( - `SELECT id, name, created_at, updated_at FROM script_chains WHERE id = ?`, + `SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id = ?`, [normalizedId] ); if (!row) { @@ -186,7 +187,7 @@ class ScriptChainService { const db = await getDb(); const placeholders = ids.map(() => '?').join(', '); const rows = await db.all( - `SELECT id, name, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`, + `SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`, ids ); const stepRows = await db.all( @@ -229,9 +230,13 @@ class ScriptChainService { const db = await getDb(); try { + const nextOrderIndex = await this._getNextOrderIndex(db); const result = await db.run( - `INSERT INTO script_chains (name, created_at, updated_at) VALUES (?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, - [name] + ` + INSERT INTO script_chains (name, order_index, created_at, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, + [name, nextOrderIndex] ); const chainId = result.lastID; await this._saveSteps(db, chainId, steps); @@ -289,6 +294,78 @@ class ScriptChainService { return existing; } + async reorderChains(orderedIds = []) { + const providedIds = Array.isArray(orderedIds) + ? orderedIds.map(normalizeChainId).filter(Boolean) + : []; + const db = await getDb(); + const rows = await db.all( + ` + SELECT id + FROM script_chains + ORDER BY order_index ASC, id ASC + ` + ); + if (rows.length === 0) { + return []; + } + + const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0); + const existingSet = new Set(existingIds); + const used = new Set(); + const nextOrder = []; + + for (const id of providedIds) { + if (!existingSet.has(id) || used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + for (const id of existingIds) { + if (used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + await db.exec('BEGIN'); + try { + for (let i = 0; i < nextOrder.length; i += 1) { + await db.run( + ` + UPDATE script_chains + SET order_index = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [i + 1, nextOrder[i]] + ); + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + return this.listChains(); + } + + async _getNextOrderIndex(db) { + const row = await db.get( + ` + SELECT COALESCE(MAX(order_index), 0) AS max_order_index + FROM script_chains + ` + ); + const maxOrder = Number(row?.max_order_index || 0); + if (!Number.isFinite(maxOrder) || maxOrder < 0) { + return 1; + } + return Math.trunc(maxOrder) + 1; + } + async _saveSteps(db, chainId, steps) { for (let i = 0; i < steps.length; i++) { const step = steps[i]; @@ -367,7 +444,7 @@ class ScriptChainService { `Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}` ); } - results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code }); + results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' }); if (!success) { logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code }); diff --git a/backend/src/services/scriptService.js b/backend/src/services/scriptService.js index 69deee1..d41d7e6 100644 --- a/backend/src/services/scriptService.js +++ b/backend/src/services/scriptService.js @@ -99,6 +99,7 @@ function mapScriptRow(row) { id: Number(row.id), name: String(row.name || ''), scriptBody: String(row.script_body || ''), + orderIndex: Number(row.order_index || 0), createdAt: row.created_at, updatedAt: row.updated_at }; @@ -225,9 +226,9 @@ class ScriptService { const db = await getDb(); const rows = await db.all( ` - SELECT id, name, script_body, created_at, updated_at + SELECT id, name, script_body, order_index, created_at, updated_at FROM scripts - ORDER BY LOWER(name) ASC, id ASC + ORDER BY order_index ASC, id ASC ` ); return rows.map(mapScriptRow); @@ -241,7 +242,7 @@ class ScriptService { const db = await getDb(); const row = await db.get( ` - SELECT id, name, script_body, created_at, updated_at + SELECT id, name, script_body, order_index, created_at, updated_at FROM scripts WHERE id = ? `, @@ -259,12 +260,13 @@ class ScriptService { const normalized = validateScriptPayload(payload, { partial: false }); const db = await getDb(); try { + const nextOrderIndex = await this._getNextOrderIndex(db); const result = await db.run( ` - INSERT INTO scripts (name, script_body, created_at, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO scripts (name, script_body, order_index, created_at, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `, - [normalized.name, normalized.scriptBody] + [normalized.name, normalized.scriptBody, nextOrderIndex] ); return this.getScriptById(result.lastID); } catch (error) { @@ -328,7 +330,7 @@ class ScriptService { const placeholders = ids.map(() => '?').join(', '); const rows = await db.all( ` - SELECT id, name, script_body, created_at, updated_at + SELECT id, name, script_body, order_index, created_at, updated_at FROM scripts WHERE id IN (${placeholders}) `, @@ -358,6 +360,76 @@ class ScriptService { return scripts; } + async reorderScripts(orderedIds = []) { + const db = await getDb(); + const providedIds = normalizeScriptIdList(orderedIds); + const rows = await db.all( + ` + SELECT id + FROM scripts + ORDER BY order_index ASC, id ASC + ` + ); + if (rows.length === 0) { + return []; + } + + const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0); + const existingSet = new Set(existingIds); + const used = new Set(); + const nextOrder = []; + + for (const id of providedIds) { + if (!existingSet.has(id) || used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + for (const id of existingIds) { + if (used.has(id)) { + continue; + } + used.add(id); + nextOrder.push(id); + } + + await db.exec('BEGIN'); + try { + for (let i = 0; i < nextOrder.length; i += 1) { + await db.run( + ` + UPDATE scripts + SET order_index = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [i + 1, nextOrder[i]] + ); + } + await db.exec('COMMIT'); + } catch (error) { + await db.exec('ROLLBACK'); + throw error; + } + + return this.listScripts(); + } + + async _getNextOrderIndex(db) { + const row = await db.get( + ` + SELECT COALESCE(MAX(order_index), 0) AS max_order_index + FROM scripts + ` + ); + const maxOrder = Number(row?.max_order_index || 0); + if (!Number.isFinite(maxOrder) || maxOrder < 0) { + return 1; + } + return Math.trunc(maxOrder) + 1; + } + async createExecutableScriptFile(script, context = {}) { const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`; const scriptBody = normalizeScriptBody(script?.scriptBody); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index a8c111b..cd55456 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -30,6 +30,16 @@ const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']); const LOG_DIR_SETTING_KEY = 'log_dir'; const MEDIA_PROFILES = ['bluray', 'dvd', 'other']; const PROFILED_SETTINGS = { + raw_dir: { + bluray: 'raw_dir_bluray', + dvd: 'raw_dir_dvd', + other: 'raw_dir_other' + }, + movie_dir: { + bluray: 'movie_dir_bluray', + dvd: 'movie_dir_dvd', + other: 'movie_dir_other' + }, mediainfo_extra_args: { bluray: 'mediainfo_extra_args_bluray', dvd: 'mediainfo_extra_args_dvd' @@ -67,6 +77,10 @@ const PROFILED_SETTINGS = { dvd: 'output_folder_template_dvd' } }; +const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([ + 'raw_dir', + 'movie_dir' +]); function applyRuntimeLogDirSetting(rawValue) { const resolved = setLogRootDir(rawValue); @@ -227,10 +241,28 @@ function normalizeMediaProfileValue(value) { if (!raw) { return null; } - if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') { + if ( + raw === 'bluray' + || raw === 'blu-ray' + || raw === 'blu_ray' + || raw === 'bd' + || raw === 'bdmv' + || raw === 'bdrom' + || raw === 'bd-rom' + || raw === 'bd-r' + || raw === 'bd-re' + ) { return 'bluray'; } - if (raw === 'dvd') { + if ( + raw === 'dvd' + || raw === 'dvdvideo' + || raw === 'dvd-video' + || raw === 'dvdrom' + || raw === 'dvd-rom' + || raw === 'video_ts' + || raw === 'iso9660' + ) { return 'dvd'; } if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') { @@ -253,6 +285,16 @@ function resolveProfileFallbackOrder(profile) { return ['dvd', 'bluray']; } +function hasUsableProfileSpecificValue(value) { + if (value === null || value === undefined) { + return false; + } + if (typeof value === 'string') { + return value.trim().length > 0; + } + return true; +} + function normalizePresetListLines(rawOutput) { const lines = String(rawOutput || '').split(/\r?\n/); const normalized = []; @@ -434,8 +476,9 @@ class SettingsService { resolveEffectiveToolSettings(settingsMap = {}, mediaProfile = null) { const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; - const fallbackOrder = resolveProfileFallbackOrder(mediaProfile); - const resolvedMediaProfile = normalizeMediaProfileValue(mediaProfile) || fallbackOrder[0] || 'dvd'; + const normalizedRequestedProfile = normalizeMediaProfileValue(mediaProfile); + const fallbackOrder = resolveProfileFallbackOrder(normalizedRequestedProfile); + const resolvedMediaProfile = normalizedRequestedProfile || fallbackOrder[0] || 'dvd'; const effective = { ...sourceMap, media_profile: resolvedMediaProfile @@ -443,6 +486,17 @@ class SettingsService { for (const [legacyKey, profileKeys] of Object.entries(PROFILED_SETTINGS)) { let resolvedValue = sourceMap[legacyKey]; + if (STRICT_PROFILE_ONLY_SETTING_KEYS.has(legacyKey)) { + const selectedProfileKey = normalizedRequestedProfile + ? profileKeys?.[normalizedRequestedProfile] + : null; + const selectedProfileValue = selectedProfileKey ? sourceMap[selectedProfileKey] : undefined; + if (hasUsableProfileSpecificValue(selectedProfileValue)) { + resolvedValue = selectedProfileValue; + } + effective[legacyKey] = resolvedValue; + continue; + } for (const profile of fallbackOrder) { const profileKey = profileKeys?.[profile]; if (!profileKey) { @@ -697,10 +751,10 @@ class SettingsService { const normalizedProfile = normalizeMediaProfileValue(options?.mediaProfile || deviceInfo?.mediaProfile || null); const isDvd = normalizedProfile === 'dvd'; if (isDvd) { - const isoBase = options?.isoOutputBase - ? path.join(rawJobDir, options.isoOutputBase) + const backupBase = options?.backupOutputBase + ? path.join(rawJobDir, options.backupOutputBase) : rawJobDir; - baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', '--noscan', sourceArg, isoBase]; + baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', '--noscan', sourceArg, backupBase]; } else { baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', sourceArg, rawJobDir]; } diff --git a/db/schema.sql b/db/schema.sql index e78d4cb..e4fd055 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -39,6 +39,7 @@ CREATE TABLE jobs ( detected_title TEXT, last_state TEXT, raw_path TEXT, + rip_successful INTEGER NOT NULL DEFAULT 0, makemkv_info_json TEXT, handbrake_info_json TEXT, mediainfo_info_json TEXT, @@ -56,20 +57,24 @@ CREATE TABLE scripts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, script_body TEXT NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_scripts_name ON scripts(name); +CREATE INDEX idx_scripts_order_index ON scripts(order_index, id); CREATE TABLE script_chains ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, + order_index INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_script_chains_name ON script_chains(name); +CREATE INDEX idx_script_chains_order_index ON script_chains(order_index, id); CREATE TABLE script_chain_steps ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -96,3 +101,33 @@ CREATE TABLE pipeline_state ( updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (active_job_id) REFERENCES jobs(id) ); + +CREATE TABLE cron_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + cron_expression TEXT NOT NULL, + source_type TEXT NOT NULL, + source_id INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + pushover_enabled INTEGER NOT NULL DEFAULT 1, + last_run_at TEXT, + last_run_status TEXT, + next_run_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_cron_jobs_enabled ON cron_jobs(enabled); + +CREATE TABLE cron_run_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cron_job_id INTEGER NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL, + output TEXT, + error_message TEXT, + FOREIGN KEY (cron_job_id) REFERENCES cron_jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_cron_run_logs_job ON cron_run_logs(cron_job_id, id DESC); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 07ac146..9c16f70 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ import { api } from './api/client'; import { useWebSocket } from './hooks/useWebSocket'; import DashboardPage from './pages/DashboardPage'; import SettingsPage from './pages/SettingsPage'; +import HistoryPage from './pages/HistoryPage'; import DatabasePage from './pages/DatabasePage'; function App() { @@ -122,7 +123,7 @@ function App() { } /> } /> - } /> + } /> } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index acc4257..6d8676b 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -48,6 +48,14 @@ export const api = { body: JSON.stringify(payload || {}) }); }, + reorderScripts(orderedScriptIds = []) { + return request('/settings/scripts/reorder', { + method: 'POST', + body: JSON.stringify({ + orderedScriptIds: Array.isArray(orderedScriptIds) ? orderedScriptIds : [] + }) + }); + }, updateScript(scriptId, payload = {}) { return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, { method: 'PUT', @@ -73,6 +81,14 @@ export const api = { body: JSON.stringify(payload) }); }, + reorderScriptChains(orderedChainIds = []) { + return request('/settings/script-chains/reorder', { + method: 'POST', + body: JSON.stringify({ + orderedChainIds: Array.isArray(orderedChainIds) ? orderedChainIds : [] + }) + }); + }, updateScriptChain(chainId, payload = {}) { return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, { method: 'PUT', @@ -84,6 +100,11 @@ export const api = { method: 'DELETE' }); }, + testScriptChain(chainId) { + return request(`/settings/script-chains/${encodeURIComponent(chainId)}/test`, { + method: 'POST' + }); + }, updateSetting(key, value) { return request(`/settings/${encodeURIComponent(key)}`, { method: 'PUT', @@ -243,6 +264,45 @@ export const api = { } const suffix = query.toString() ? `?${query.toString()}` : ''; return request(`/history/${jobId}${suffix}`); + }, + + // ── Cron Jobs ────────────────────────────────────────────────────────────── + getCronJobs() { + return request('/crons'); + }, + getCronJob(id) { + return request(`/crons/${encodeURIComponent(id)}`); + }, + createCronJob(payload = {}) { + return request('/crons', { + method: 'POST', + body: JSON.stringify(payload) + }); + }, + updateCronJob(id, payload = {}) { + return request(`/crons/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + }, + deleteCronJob(id) { + return request(`/crons/${encodeURIComponent(id)}`, { + method: 'DELETE' + }); + }, + getCronJobLogs(id, limit = 20) { + return request(`/crons/${encodeURIComponent(id)}/logs?limit=${limit}`); + }, + runCronJobNow(id) { + return request(`/crons/${encodeURIComponent(id)}/run`, { + method: 'POST' + }); + }, + validateCronExpression(cronExpression) { + return request('/crons/validate-expression', { + method: 'POST', + body: JSON.stringify({ cronExpression }) + }); } }; diff --git a/frontend/src/components/CronJobsTab.jsx b/frontend/src/components/CronJobsTab.jsx new file mode 100644 index 0000000..1eacbf6 --- /dev/null +++ b/frontend/src/components/CronJobsTab.jsx @@ -0,0 +1,554 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from 'primereact/button'; +import { Dialog } from 'primereact/dialog'; +import { InputText } from 'primereact/inputtext'; +import { Dropdown } from 'primereact/dropdown'; +import { InputSwitch } from 'primereact/inputswitch'; +import { Toast } from 'primereact/toast'; +import { api } from '../api/client'; + +// ── Hilfsfunktionen ────────────────────────────────────────────────────────── + +function formatDateTime(iso) { + if (!iso) return '–'; + try { + return new Date(iso).toLocaleString('de-DE', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit' + }); + } catch (_) { + return iso; + } +} + +function StatusBadge({ status }) { + if (!status) return ; + const map = { + success: { label: 'Erfolg', cls: 'success' }, + error: { label: 'Fehler', cls: 'error' }, + running: { label: 'Läuft…', cls: 'running' } + }; + const info = map[status] || { label: status, cls: 'none' }; + return {info.label}; +} + +const EMPTY_FORM = { + name: '', + cronExpression: '', + sourceType: 'script', + sourceId: null, + enabled: true, + pushoverEnabled: true +}; + +// ── Hauptkomponente ─────────────────────────────────────────────────────────── + +export default function CronJobsTab({ onWsMessage }) { + const toastRef = useRef(null); + + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(false); + const [scripts, setScripts] = useState([]); + const [chains, setChains] = useState([]); + + // Editor-Dialog + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState('create'); // 'create' | 'edit' + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + + // Cron-Validierung + const [exprValidation, setExprValidation] = useState(null); // { valid, error, nextRunAt } + const [exprValidating, setExprValidating] = useState(false); + const exprValidateTimer = useRef(null); + + // Logs-Dialog + const [logsJob, setLogsJob] = useState(null); + const [logs, setLogs] = useState([]); + const [logsLoading, setLogsLoading] = useState(false); + + // Aktionen Busy-State per Job-ID + const [busyId, setBusyId] = useState(null); + + // ── Daten laden ────────────────────────────────────────────────────────────── + + const loadAll = useCallback(async () => { + setLoading(true); + try { + const [cronResp, scriptsResp, chainsResp] = await Promise.allSettled([ + api.getCronJobs(), + api.getScripts(), + api.getScriptChains() + ]); + if (cronResp.status === 'fulfilled') setJobs(cronResp.value?.jobs || []); + if (scriptsResp.status === 'fulfilled') setScripts(scriptsResp.value?.scripts || []); + if (chainsResp.status === 'fulfilled') setChains(chainsResp.value?.chains || []); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadAll(); + }, [loadAll]); + + // WebSocket: Cronjob-Updates empfangen + useEffect(() => { + if (!onWsMessage) return; + // onWsMessage ist eine Funktion, die wir anmelden + }, [onWsMessage]); + + // ── Cron-Ausdruck validieren (debounced) ───────────────────────────────────── + + useEffect(() => { + const expr = form.cronExpression.trim(); + if (!expr) { + setExprValidation(null); + return; + } + if (exprValidateTimer.current) clearTimeout(exprValidateTimer.current); + setExprValidating(true); + exprValidateTimer.current = setTimeout(async () => { + try { + const result = await api.validateCronExpression(expr); + setExprValidation(result); + } catch (_) { + setExprValidation({ valid: false, error: 'Validierung fehlgeschlagen.' }); + } finally { + setExprValidating(false); + } + }, 500); + return () => clearTimeout(exprValidateTimer.current); + }, [form.cronExpression]); + + // ── Editor öffnen/schließen ────────────────────────────────────────────────── + + function openCreate() { + setForm(EMPTY_FORM); + setExprValidation(null); + setEditorMode('create'); + setEditingId(null); + setEditorOpen(true); + } + + function openEdit(job) { + setForm({ + name: job.name || '', + cronExpression: job.cronExpression || '', + sourceType: job.sourceType || 'script', + sourceId: job.sourceId || null, + enabled: job.enabled !== false, + pushoverEnabled: job.pushoverEnabled !== false + }); + setExprValidation(null); + setEditorMode('edit'); + setEditingId(job.id); + setEditorOpen(true); + } + + function closeEditor() { + setEditorOpen(false); + setSaving(false); + } + + // ── Speichern ──────────────────────────────────────────────────────────────── + + async function handleSave() { + const name = form.name.trim(); + const cronExpression = form.cronExpression.trim(); + + if (!name) { toastRef.current?.show({ severity: 'warn', summary: 'Name fehlt', life: 3000 }); return; } + if (!cronExpression) { toastRef.current?.show({ severity: 'warn', summary: 'Cron-Ausdruck fehlt', life: 3000 }); return; } + if (exprValidation && !exprValidation.valid) { toastRef.current?.show({ severity: 'warn', summary: 'Ungültiger Cron-Ausdruck', life: 3000 }); return; } + if (!form.sourceId) { toastRef.current?.show({ severity: 'warn', summary: 'Quelle fehlt', life: 3000 }); return; } + + setSaving(true); + try { + const payload = { ...form, name, cronExpression }; + if (editorMode === 'create') { + await api.createCronJob(payload); + toastRef.current?.show({ severity: 'success', summary: 'Cronjob erstellt', life: 3000 }); + } else { + await api.updateCronJob(editingId, payload); + toastRef.current?.show({ severity: 'success', summary: 'Cronjob gespeichert', life: 3000 }); + } + closeEditor(); + await loadAll(); + } catch (error) { + toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 5000 }); + } finally { + setSaving(false); + } + } + + // ── Toggle enabled/pushover ────────────────────────────────────────────────── + + async function handleToggle(job, field) { + setBusyId(job.id); + try { + const updated = await api.updateCronJob(job.id, { [field]: !job[field] }); + setJobs((prev) => prev.map((j) => j.id === job.id ? updated.job : j)); + } catch (error) { + toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 }); + } finally { + setBusyId(null); + } + } + + // ── Löschen ────────────────────────────────────────────────────────────────── + + async function handleDelete(job) { + if (!window.confirm(`Cronjob "${job.name}" wirklich löschen?`)) return; + setBusyId(job.id); + try { + await api.deleteCronJob(job.id); + toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', life: 3000 }); + setJobs((prev) => prev.filter((j) => j.id !== job.id)); + } catch (error) { + toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 }); + } finally { + setBusyId(null); + } + } + + // ── Manuell ausführen ──────────────────────────────────────────────────────── + + async function handleRunNow(job) { + setBusyId(job.id); + try { + await api.runCronJobNow(job.id); + toastRef.current?.show({ severity: 'info', summary: `"${job.name}" gestartet`, life: 3000 }); + // Kurz warten und dann neu laden + setTimeout(() => loadAll(), 1500); + } catch (error) { + toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 }); + } finally { + setBusyId(null); + } + } + + // ── Logs ───────────────────────────────────────────────────────────────────── + + async function openLogs(job) { + setLogsJob(job); + setLogs([]); + setLogsLoading(true); + try { + const resp = await api.getCronJobLogs(job.id, 30); + setLogs(resp.logs || []); + } catch (error) { + toastRef.current?.show({ severity: 'error', summary: 'Logs konnten nicht geladen werden', detail: error.message, life: 4000 }); + } finally { + setLogsLoading(false); + } + } + + // ── Source-Optionen ────────────────────────────────────────────────────────── + + const sourceOptions = form.sourceType === 'script' + ? scripts.map((s) => ({ label: s.name, value: s.id })) + : chains.map((c) => ({ label: c.name, value: c.id })); + + // ── Render ──────────────────────────────────────────────────────────────────── + + return ( +
+ + +
+
+ + {jobs.length === 0 && !loading && ( +

Keine Cronjobs vorhanden. Klicke auf “Neuer Cronjob”, um einen anzulegen.

+ )} + + {jobs.length > 0 && ( +
+ {jobs.map((job) => { + const isBusy = busyId === job.id; + return ( +
+
+ {job.name} + {job.cronExpression} +
+ +
+ + Quelle: + + {job.sourceType === 'chain' ? '⛓ ' : '📜 '} + {job.sourceName || `#${job.sourceId}`} + + + + Letzter Lauf: + + {formatDateTime(job.lastRunAt)} + {job.lastRunStatus && } + + + + Nächster Lauf: + {formatDateTime(job.nextRunAt)} + +
+ +
+ + +
+ +
+
+
+ ); + })} +
+ )} + + {/* ── Editor-Dialog ──────────────────────────────────────────────────── */} + +
+ } + > +
+ + {/* Name */} +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="z.B. Tägliche Bereinigung" + className="w-full" + /> +
+ + {/* Cron-Ausdruck */} +
+ + setForm((f) => ({ ...f, cronExpression: e.target.value }))} + placeholder="Minute Stunde Tag Monat Wochentag – z.B. 0 2 * * *" + className={`w-full${exprValidation && !exprValidation.valid ? ' p-invalid' : ''}`} + /> + {exprValidating && ( + Wird geprüft… + )} + {!exprValidating && exprValidation && exprValidation.valid && ( + + ✓ Gültig – nächste Ausführung: {formatDateTime(exprValidation.nextRunAt)} + + )} + {!exprValidating && exprValidation && !exprValidation.valid && ( + ✗ {exprValidation.error} + )} +
+ {[ + { label: 'Stündlich', expr: '0 * * * *' }, + { label: 'Täglich 2 Uhr', expr: '0 2 * * *' }, + { label: 'Wöchentlich Mo', expr: '0 3 * * 1' }, + { label: 'Monatlich 1.', expr: '0 4 1 * *' } + ].map(({ label, expr }) => ( + + ))} +
+
+ + {/* Quell-Typ */} +
+ +
+ {[ + { value: 'script', label: '📜 Skript' }, + { value: 'chain', label: '⛓ Skriptkette' } + ].map(({ value, label }) => ( + + ))} +
+
+ + {/* Quelle auswählen */} +
+ + setForm((f) => ({ ...f, sourceId: e.value }))} + placeholder={`${form.sourceType === 'script' ? 'Skript' : 'Skriptkette'} wählen…`} + className="w-full" + emptyMessage={form.sourceType === 'script' ? 'Keine Skripte vorhanden' : 'Keine Ketten vorhanden'} + /> +
+ + {/* Toggles */} +
+ + +
+ +
+ + + {/* ── Logs-Dialog ──────────────────────────────────────────────────────── */} + setLogsJob(null)} + style={{ width: '720px' }} + footer={ + + + ); +} diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 4b88571..b12e94a 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -55,12 +55,27 @@ function ScriptSummarySection({ title, summary }) { } function resolveMediaType(job) { - const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase(); - if (raw === 'bluray') { - return 'bluray'; - } - if (raw === 'dvd' || raw === 'disc') { - return 'dvd'; + const candidates = [ + job?.mediaType, + job?.media_type, + job?.mediaProfile, + job?.media_profile, + job?.encodePlan?.mediaProfile, + job?.makemkvInfo?.analyzeContext?.mediaProfile, + job?.makemkvInfo?.mediaProfile, + job?.mediainfoInfo?.mediaProfile + ]; + for (const candidate of candidates) { + const raw = String(candidate || '').trim().toLowerCase(); + if (!raw) { + continue; + } + if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) { + return 'bluray'; + } + if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) { + return 'dvd'; + } } return 'other'; } @@ -149,7 +164,7 @@ export default function JobDetailDialog({ reencodeBusy = false, deleteEntryBusy = false }) { - const mkDone = !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS'; + const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS'; const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status); const showFinalLog = !running; const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running); @@ -167,7 +182,8 @@ export default function JobDetailDialog({ const hasRestartInput = Boolean(job?.encode_input_path || job?.raw_path || job?.encodePlan?.encodeInputPath); const canRestartEncode = Boolean(hasConfirmedPlan && hasRestartInput && !running); const canRestartReview = Boolean( - (job?.rawStatus?.exists || job?.raw_path) + job?.rawStatus?.exists + && job?.rawStatus?.isEmpty !== true && !running && typeof onRestartReview === 'function' ); diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 00609dc..6c5c2d8 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -179,12 +179,27 @@ function getAnalyzeContext(job) { } function resolveMediaType(job) { - const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase(); - if (raw === 'bluray') { - return 'bluray'; - } - if (raw === 'dvd' || raw === 'disc') { - return 'dvd'; + const candidates = [ + job?.mediaType, + job?.media_type, + job?.mediaProfile, + job?.media_profile, + job?.encodePlan?.mediaProfile, + job?.makemkvInfo?.analyzeContext?.mediaProfile, + job?.makemkvInfo?.mediaProfile, + job?.mediainfoInfo?.mediaProfile + ]; + for (const candidate of candidates) { + const raw = String(candidate || '').trim().toLowerCase(); + if (!raw) { + continue; + } + if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) { + return 'bluray'; + } + if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) { + return 'dvd'; + } } return 'other'; } diff --git a/frontend/src/pages/DatabasePage.jsx b/frontend/src/pages/DatabasePage.jsx index 5d72ba5..f571bf2 100644 --- a/frontend/src/pages/DatabasePage.jsx +++ b/frontend/src/pages/DatabasePage.jsx @@ -21,12 +21,27 @@ import { } from '../utils/statusPresentation'; function resolveMediaType(row) { - const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase(); - if (raw === 'bluray') { - return 'bluray'; - } - if (raw === 'dvd' || raw === 'disc') { - return 'dvd'; + const candidates = [ + row?.mediaType, + row?.media_type, + row?.mediaProfile, + row?.media_profile, + row?.encodePlan?.mediaProfile, + row?.makemkvInfo?.analyzeContext?.mediaProfile, + row?.makemkvInfo?.mediaProfile, + row?.mediainfoInfo?.mediaProfile + ]; + for (const candidate of candidates) { + const raw = String(candidate || '').trim().toLowerCase(); + if (!raw) { + continue; + } + if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) { + return 'bluray'; + } + if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) { + return 'dvd'; + } } return 'other'; } @@ -666,7 +681,10 @@ export default function DatabasePage() { - +
- -
- {renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))} - {renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))} - {renderPresenceChip('Encode', Boolean(row?.encodeSuccess))} -
- -
- {renderRatings(row)} -
- - -
-
); }; - const renderGridItem = (row) => { + const gridItem = (row) => { const mediaMeta = resolveMediaTypeMeta(row); - const title = row?.title || row?.detected_title || '-'; return ( -
+
-
- {renderPoster(row, 'history-dv-poster-lg')} -
- {title} - - #{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'} - +
+ {renderPoster(row, 'history-dv-poster-grid')} +
+ +
+
+ {row?.title || row?.detected_title || '-'} + {renderStatusTag(row)} +
+ + + #{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'} + + +
{mediaMeta.alt} {mediaMeta.label} + Start: {formatDateTime(row?.start_time)} + Ende: {formatDateTime(row?.end_time)}
-
-
- {renderStatusTag(row)} -
+
+ {renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))} + {renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))} + {renderPresenceChip('Encode', Boolean(row?.encodeSuccess))} +
-
- Start: {formatDateTime(row?.start_time)} - Ende: {formatDateTime(row?.end_time)} -
- -
- {renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))} - {renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))} - {renderPresenceChip('Encode', Boolean(row?.encodeSuccess))} -
- -
- {renderRatings(row)} +
{renderRatings(row)}
@@ -683,107 +672,48 @@ export default function HistoryPage() { if (!row) { return null; } - if (currentLayout === 'grid') { - return renderGridItem(row); - } - return renderListItem(row); + return currentLayout === 'list' ? listItem(row) : gridItem(row); }; - const dataViewHeader = ( -
-
- setSearch(event.target.value)} - placeholder="Suche nach Titel oder IMDb" - /> - setStatus(event.value)} - placeholder="Status" - /> - setMediumFilter(event.value || '')} - placeholder="Medium" - /> -
+ const header = ( +
+ setSearch(event.target.value)} + placeholder="Suche nach Titel oder IMDb" + /> -
-
- 1. - setSortPrimaryField(event.value || 'start_time')} - placeholder="Primär" - /> - setSortPrimaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)} - placeholder="Richtung" - /> -
+ setStatus(event.value)} + placeholder="Status" + /> -
- 2. - setSortSecondaryField(event.value || '')} - placeholder="Sekundär" - /> - setSortSecondaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)} - placeholder="Richtung" - disabled={!sortSecondaryField} - /> -
+ setMediumFilter(event.value || '')} + placeholder="Medium" + /> -
- 3. - setSortTertiaryField(event.value || '')} - placeholder="Tertiär" - /> - setSortTertiaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)} - placeholder="Richtung" - disabled={!sortTertiaryField} - /> -
+ + +
); @@ -792,15 +722,17 @@ export default function HistoryPage() {
- + { setDetailVisible(false); setDetailLoading(false); diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index d6197ee..51fa9a8 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -8,6 +8,7 @@ import { InputText } from 'primereact/inputtext'; import { InputTextarea } from 'primereact/inputtextarea'; import { api } from '../api/client'; import DynamicSettingsForm from '../components/DynamicSettingsForm'; +import CronJobsTab from '../components/CronJobsTab'; function buildValuesMap(categories) { const next = {}; @@ -26,6 +27,30 @@ function isSameValue(a, b) { return a === b; } +function reorderListById(items, sourceId, targetIndex) { + const list = Array.isArray(items) ? items : []; + const normalizedSourceId = Number(sourceId); + const normalizedTargetIndex = Number(targetIndex); + if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0 || !Number.isFinite(normalizedTargetIndex)) { + return { changed: false, next: list }; + } + const fromIndex = list.findIndex((item) => Number(item?.id) === normalizedSourceId); + if (fromIndex < 0) { + return { changed: false, next: list }; + } + + const boundedTarget = Math.max(0, Math.min(Math.trunc(normalizedTargetIndex), list.length)); + const insertAt = fromIndex < boundedTarget ? boundedTarget - 1 : boundedTarget; + if (insertAt === fromIndex) { + return { changed: false, next: list }; + } + + const next = [...list]; + const [moved] = next.splice(fromIndex, 1); + next.splice(insertAt, 0, moved); + return { changed: true, next }; +} + function injectHandBrakePresetOptions(categories, presetPayload) { const list = Array.isArray(categories) ? categories : []; const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : []; @@ -103,6 +128,8 @@ export default function SettingsPage() { const [scripts, setScripts] = useState([]); const [scriptsLoading, setScriptsLoading] = useState(false); const [scriptSaving, setScriptSaving] = useState(false); + const [scriptReordering, setScriptReordering] = useState(false); + const [scriptListDragSourceId, setScriptListDragSourceId] = useState(null); const [scriptActionBusyId, setScriptActionBusyId] = useState(null); const [scriptEditor, setScriptEditor] = useState({ mode: 'none', @@ -117,6 +144,10 @@ export default function SettingsPage() { const [chains, setChains] = useState([]); const [chainsLoading, setChainsLoading] = useState(false); const [chainSaving, setChainSaving] = useState(false); + const [chainReordering, setChainReordering] = useState(false); + const [chainListDragSourceId, setChainListDragSourceId] = useState(null); + const [chainActionBusyId, setChainActionBusyId] = useState(null); + const [lastChainTestResult, setLastChainTestResult] = useState(null); const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] }); const [chainEditorErrors, setChainEditorErrors] = useState({}); const [chainDragSource, setChainDragSource] = useState(null); @@ -470,6 +501,88 @@ export default function SettingsPage() { } }; + const handleScriptListDragStart = (event, scriptId) => { + if (scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId)) { + event.preventDefault(); + return; + } + setScriptListDragSourceId(Number(scriptId)); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(scriptId)); + }; + + const handleScriptListDragOver = (event) => { + const sourceId = Number(scriptListDragSourceId); + if (!Number.isFinite(sourceId) || sourceId <= 0) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }; + + const handleScriptListDrop = async (event, targetIndex) => { + event.preventDefault(); + if (scriptReordering) { + setScriptListDragSourceId(null); + return; + } + const sourceId = Number(scriptListDragSourceId); + setScriptListDragSourceId(null); + const { changed, next } = reorderListById(scripts, sourceId, targetIndex); + if (!changed) { + return; + } + + const orderedScriptIds = next + .map((script) => Number(script?.id)) + .filter((id) => Number.isFinite(id) && id > 0); + setScripts(next); + setScriptReordering(true); + try { + await api.reorderScripts(orderedScriptIds); + } catch (error) { + toastRef.current?.show({ + severity: 'error', + summary: 'Script-Reihenfolge', + detail: error.message + }); + await loadScripts({ silent: true }); + } finally { + setScriptReordering(false); + } + }; + + const handleTestChain = async (chain) => { + const chainId = Number(chain?.id); + if (!Number.isFinite(chainId) || chainId <= 0) { + return; + } + setChainActionBusyId(chainId); + setLastChainTestResult(null); + try { + const response = await api.testScriptChain(chainId); + const result = response?.result || null; + setLastChainTestResult(result); + if (!result?.aborted) { + toastRef.current?.show({ + severity: 'success', + summary: 'Ketten-Test', + detail: `"${chain?.name || chainId}" erfolgreich ausgeführt (${result?.succeeded ?? 0}/${result?.steps ?? 0} Schritte).` + }); + } else { + toastRef.current?.show({ + severity: 'warn', + summary: 'Ketten-Test', + detail: `"${chain?.name || chainId}" abgebrochen (${result?.succeeded ?? 0}/${result?.steps ?? 0} Schritte OK).` + }); + } + } catch (error) { + toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message }); + } finally { + setChainActionBusyId(null); + } + }; + // Chain editor handlers const openChainEditor = (chain = null) => { if (chain) { @@ -583,6 +696,57 @@ export default function SettingsPage() { } }; + const handleChainListDragStart = (event, chainId) => { + if (chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId)) { + event.preventDefault(); + return; + } + setChainListDragSourceId(Number(chainId)); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(chainId)); + }; + + const handleChainListDragOver = (event) => { + const sourceId = Number(chainListDragSourceId); + if (!Number.isFinite(sourceId) || sourceId <= 0) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }; + + const handleChainListDrop = async (event, targetIndex) => { + event.preventDefault(); + if (chainReordering) { + setChainListDragSourceId(null); + return; + } + const sourceId = Number(chainListDragSourceId); + setChainListDragSourceId(null); + const { changed, next } = reorderListById(chains, sourceId, targetIndex); + if (!changed) { + return; + } + + const orderedChainIds = next + .map((chain) => Number(chain?.id)) + .filter((id) => Number.isFinite(id) && id > 0); + setChains(next); + setChainReordering(true); + try { + await api.reorderScriptChains(orderedChainIds); + } catch (error) { + toastRef.current?.show({ + severity: 'error', + summary: 'Ketten-Reihenfolge', + detail: error.message + }); + await loadChains({ silent: true }); + } finally { + setChainReordering(false); + } + }; + // Chain DnD handlers const handleChainPaletteDragStart = (event, data) => { setChainDragSource({ origin: 'palette', ...data }); @@ -626,6 +790,16 @@ export default function SettingsPage() { event.dataTransfer.dropEffect = chainDragSource?.origin === 'palette' ? 'copy' : 'move'; }; + const scriptListDnDDisabled = scriptSaving + || scriptsLoading + || scriptReordering + || scriptEditor?.mode === 'create' + || Boolean(scriptActionBusyId); + const chainListDnDDisabled = chainSaving + || chainsLoading + || chainReordering + || Boolean(chainActionBusyId); + return (
@@ -692,7 +866,7 @@ export default function SettingsPage() { onClick={startCreateScript} severity="success" outlined - disabled={scriptSaving || scriptEditor?.mode === 'create'} + disabled={scriptSaving || scriptReordering || scriptEditor?.mode === 'create'} />
Die ausgewählten Scripts werden später pro Job nach erfolgreichem Encode in Reihenfolge ausgeführt. + + Reihenfolge per Drag & Drop ändern. + {scriptReordering ? ' Speichere Reihenfolge ...' : ''} +

Verfügbare Scripts

{scriptsLoading ? (

Lade Scripts ...

) : ( -
+
{scriptEditor?.mode === 'create' ? (
@@ -761,45 +939,73 @@ export default function SettingsPage() {
) : null} - {scripts.length === 0 ?

Keine Scripts vorhanden.

: null} + {scripts.length === 0 ? ( +

Keine Scripts vorhanden.

+ ) : ( +
+ {scripts.map((script, index) => { + const isDragging = Number(scriptListDragSourceId) === Number(script.id); + return ( +
+
handleScriptListDrop(event, index)} + /> +
handleScriptListDragStart(event, script.id)} + onDragEnd={() => setScriptListDragSourceId(null)} + > +
+ +
+
+ {`ID #${script.id} - ${script.name}`} +
- {scripts.map((script) => { - return ( -
-
- {`ID #${script.id} - ${script.name}`} -
- -
-
-
- ); - })} +
+
+
+
+ ); + })} +
handleScriptListDrop(event, scripts.length)} + /> +
+ )}
)}
@@ -875,6 +1081,7 @@ export default function SettingsPage() { severity="success" outlined onClick={() => openChainEditor()} + disabled={chainReordering} />
@@ -889,6 +1097,10 @@ export default function SettingsPage() { Skriptketten kombinieren einzelne Scripte und Systemblöcke (z.B. Warten) zu einer ausführbaren Sequenz. Ketten können an Jobs als Pre- oder Post-Encode-Aktion hinterlegt werden. + + Reihenfolge per Drag & Drop ändern. + {chainReordering ? ' Speichere Reihenfolge ...' : ''} +

Verfügbare Skriptketten

@@ -897,45 +1109,108 @@ export default function SettingsPage() { ) : chains.length === 0 ? (

Keine Skriptketten vorhanden.

) : ( -
- {chains.map((chain) => ( -
-
- {`ID #${chain.id} - ${chain.name}`} - - {chain.steps?.length ?? 0} Schritt(e): - {' '} - {(chain.steps || []).map((s, i) => ( - - {i > 0 ? ' → ' : ''} - {s.stepType === 'wait' - ? `⏱ ${s.waitSeconds}s` - : (s.scriptName || `Script #${s.scriptId}`)} - - ))} - -
-
-
-
- ))} +
+
+ {chains.map((chain, index) => { + const isDragging = Number(chainListDragSourceId) === Number(chain.id); + return ( +
+
handleChainListDrop(event, index)} + /> +
handleChainListDragStart(event, chain.id)} + onDragEnd={() => setChainListDragSourceId(null)} + > +
+ +
+
+ {`ID #${chain.id} - ${chain.name}`} + + {chain.steps?.length ?? 0} Schritt(e): + {' '} + {(chain.steps || []).map((s, i) => ( + + {i > 0 ? ' → ' : ''} + {s.stepType === 'wait' + ? `⏱ ${s.waitSeconds}s` + : (s.scriptName || `Script #${s.scriptId}`)} + + ))} + +
+
+
+
+
+ ); + })} +
handleChainListDrop(event, chains.length)} + /> +
)}
+ {lastChainTestResult ? ( +
+

Letzter Ketten-Test: {lastChainTestResult.chainName}

+ + Status: {lastChainTestResult.aborted ? 'Abgebrochen' : 'Erfolgreich'} + {' | '}Schritte: {lastChainTestResult.succeeded ?? 0}/{lastChainTestResult.steps ?? 0} + {lastChainTestResult.failed > 0 ? ` | Fehler: ${lastChainTestResult.failed}` : ''} + + {(lastChainTestResult.results || []).map((step, i) => ( +
+ + {`Schritt ${i + 1}: `} + {step.stepType === 'wait' + ? `⏱ Warten (${step.waitSeconds}s)` + : (step.scriptName || `Script #${step.scriptId}`)} + {' — '} + {step.skipped ? 'Übersprungen' : (step.success ? '✓ OK' : `✗ Fehler (exit=${step.exitCode ?? 'n/a'})`)} + + {(step.stdout || step.stderr) ? ( +
{`${step.stdout || ''}${step.stderr ? `\n${step.stderr}` : ''}`.trim()}
+ ) : null} +
+ ))} +
+ ) : null}
{/* Chain editor dialog */} @@ -1099,6 +1374,10 @@ export default function SettingsPage() {
+ + + +
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 2b3d748..e118c55 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1126,6 +1126,36 @@ body { gap: 0.45rem; } +.script-list--reorderable { + grid-template-columns: 1fr; +} + +.script-order-list { + display: flex; + flex-direction: column; +} + +.script-order-wrapper { + display: flex; + flex-direction: column; +} + +.script-order-drop-zone { + min-height: 0.5rem; + border-radius: 0.25rem; + transition: min-height 0.12s, background 0.12s; +} + +.script-order-drop-zone--end { + min-height: 1rem; +} + +.script-order-drop-zone:hover, +.script-order-drop-zone:focus-within { + min-height: 1.2rem; + background: var(--rip-gold-200); +} + .script-list-item { border: 1px solid var(--rip-border); border-radius: 0.45rem; @@ -1136,6 +1166,37 @@ body { gap: 0.55rem; } +.script-list--reorderable .script-list-item:not(.script-list-item-editing) { + grid-template-columns: auto minmax(0, 1fr) minmax(21rem, auto); + align-items: center; + gap: 0.65rem; +} + +.script-list-item--dragging { + opacity: 0.55; +} + +.script-list-drag-handle { + color: var(--rip-muted); + cursor: grab; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.4rem; + min-height: 1.4rem; + border-radius: 0.25rem; +} + +.script-list-drag-handle:hover { + background: var(--rip-gold-200); + color: var(--rip-brown-700); +} + +.script-list-drag-handle.disabled { + cursor: not-allowed; + opacity: 0.45; +} + .script-list-item-editing { display: grid; grid-template-columns: 1fr; @@ -1225,6 +1286,15 @@ body { word-break: break-word; } +.script-test-step { + display: grid; + gap: 0.2rem; +} + +.script-test-step strong { + font-size: 0.82rem; +} + .required { color: #9d261b; margin-left: 0.25rem; @@ -1263,9 +1333,9 @@ body { } .history-dv-toolbar { - margin-bottom: 0.7rem; + margin-bottom: 0.5rem; display: grid; - grid-template-columns: minmax(0, 1fr) 12rem 10rem auto auto; + grid-template-columns: minmax(0, 1fr) 12rem 10rem 13rem auto auto; gap: 0.5rem; align-items: center; } @@ -1275,27 +1345,29 @@ body { justify-content: flex-end; } -.history-dv-sortbar { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.5rem; +.history-dataview .p-dataview-content .p-grid.grid { + display: flex; + flex-wrap: wrap; + margin: 0 -0.35rem; } -.history-dv-sort-rule { - display: grid; - grid-template-columns: 1.4rem minmax(0, 1fr) 9.4rem; - gap: 0.35rem; - align-items: center; - border: 1px solid var(--rip-border); - border-radius: 0.5rem; - background: var(--rip-panel-soft); - padding: 0.35rem 0.45rem; +.history-dataview .p-dataview-content .p-grid.grid > .col-12, +.history-dataview .p-dataview-content .p-grid.grid > .md-col-6, +.history-dataview .p-dataview-content .p-grid.grid > .xl-col-4 { + width: 100%; + padding: 0.35rem; } -.history-dv-sort-rule strong { - margin: 0; - color: var(--rip-brown-700); - text-align: center; +@media (min-width: 900px) { + .history-dataview.p-dataview-grid .p-dataview-content .p-grid.grid > .md-col-6 { + width: 50%; + } +} + +@media (min-width: 1280px) { + .history-dataview.p-dataview-grid .p-dataview-content .p-grid.grid > .xl-col-4 { + width: 33.3333%; + } } .history-dv-item { @@ -1397,7 +1469,7 @@ body { } .history-dv-poster, -.history-dv-poster-lg { +.history-dv-poster-grid { display: block; width: 100%; object-fit: cover; @@ -1410,9 +1482,8 @@ body { height: 88px; } -.history-dv-poster-lg { - width: 66px; - height: 96px; +.history-dv-poster-grid { + height: 164px; } .history-dv-poster-fallback { @@ -1435,42 +1506,26 @@ body { align-items: flex-start; } -.history-dv-grid-cell { - padding: 0.35rem; -} - .history-dv-item-grid { display: grid; - gap: 0.45rem; + gap: 0.5rem; padding: 0.65rem; - height: 100%; + grid-template-rows: auto 1fr auto; } -.history-dv-grid-head { - display: grid; - grid-template-columns: 66px minmax(0, 1fr); - gap: 0.65rem; +.history-dv-grid-poster-wrap { + width: min(120px, 100%); + margin: 0 auto; } -.history-dv-grid-title-wrap { +.history-dv-grid-main { display: grid; - gap: 0.22rem; + gap: 0.35rem; min-width: 0; } -.history-dv-grid-status-row { - display: flex; - justify-content: flex-start; -} - -.history-dv-grid-time-row { - display: grid; - gap: 0.12rem; -} - .history-dv-actions-grid { justify-content: flex-end; - margin-top: 0.2rem; } .table-scroll-wrap { @@ -2019,7 +2074,6 @@ body { .job-film-info-grid, .table-filters, .history-dv-toolbar, - .history-dv-sortbar, .job-head-row, .job-json-grid, .selected-meta, @@ -2047,6 +2101,10 @@ body { grid-template-columns: 1fr; } + .script-list--reorderable .script-list-item:not(.script-list-item-editing) { + grid-template-columns: 1fr; + } + .post-script-row { grid-template-columns: minmax(0, 1fr) auto; } @@ -2083,10 +2141,6 @@ body { justify-self: start; } - .history-dv-sort-rule { - grid-template-columns: 1.4rem minmax(0, 1fr) 8.8rem; - } - .history-dv-item-list { grid-template-columns: 52px minmax(0, 1fr); } @@ -2096,13 +2150,12 @@ body { justify-content: flex-end; } - .history-dv-grid-head { - grid-template-columns: 58px minmax(0, 1fr); + .history-dv-toolbar { + grid-template-columns: 1fr; } - .history-dv-poster-lg { - width: 58px; - height: 84px; + .history-dv-poster-grid { + height: 148px; } } @@ -2115,14 +2168,6 @@ body { grid-template-columns: 1fr; } - .history-dv-sort-rule { - grid-template-columns: 1.4rem minmax(0, 1fr); - } - - .history-dv-sort-rule .p-dropdown:last-child { - grid-column: 1 / -1; - } - .history-dv-item-list { grid-template-columns: 1fr; } @@ -2139,6 +2184,10 @@ body { height: 80px; } + .history-dv-poster-grid { + height: 136px; + } + .search-row { grid-template-columns: 1fr; } @@ -2164,6 +2213,15 @@ body { grid-template-columns: 1fr; } + .script-list--reorderable .script-list-item:not(.script-list-item-editing) { + grid-template-columns: 1fr; + } + + .script-list-drag-handle { + width: 100%; + justify-content: flex-start; + } + .script-action-spacer { display: none; } @@ -2446,3 +2504,261 @@ body { width: 100%; } } + +/* ── Cronjobs Tab ─────────────────────────────────────────────────────────── */ + +.cron-tab { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.cron-empty-hint { + color: var(--rip-muted, #888); + font-size: 0.9rem; + margin: 0.5rem 0; +} + +/* Job-Liste */ +.cron-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.cron-item { + border: 1px solid var(--surface-border, #dee2e6); + border-radius: 8px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.6rem; + background: var(--surface-card, #fff); + transition: opacity 0.2s; +} + +.cron-item--disabled { + opacity: 0.55; +} + +.cron-item-header { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.cron-item-name { + font-weight: 600; + font-size: 1rem; +} + +.cron-item-expr { + background: var(--surface-ground, #f4f4f4); + border-radius: 4px; + padding: 0.15rem 0.45rem; + font-size: 0.82rem; + color: var(--text-color-secondary, #555); + font-family: monospace; +} + +.cron-item-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; + font-size: 0.85rem; +} + +.cron-meta-entry { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.cron-meta-label { + color: var(--rip-muted, #777); + font-size: 0.8rem; +} + +.cron-meta-value { + display: flex; + align-items: center; + gap: 0.35rem; +} + +/* Status-Badge */ +.cron-status { + display: inline-block; + padding: 0.1rem 0.45rem; + border-radius: 99px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.4; +} + +.cron-status--success { background: #d4edda; color: #1c5e2e; } +.cron-status--error { background: #f8d7da; color: #842029; } +.cron-status--running { background: #d0e4ff; color: #0c3b7c; } +.cron-status--none { background: transparent; color: var(--rip-muted, #888); } + +/* Toggles-Zeile */ +.cron-item-toggles { + display: flex; + gap: 1.25rem; + flex-wrap: wrap; +} + +.cron-toggle-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.88rem; +} + +/* Aktionen-Zeile */ +.cron-item-actions { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +/* Editor-Dialog */ +.cron-editor-fields { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.cron-editor-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.cron-editor-label { + font-weight: 600; + font-size: 0.88rem; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.cron-help-link { + color: var(--rip-muted, #999); + font-size: 0.85rem; + text-decoration: none; +} +.cron-help-link:hover { color: var(--primary-color, #3b82f6); } + +.cron-expr-hint { + font-size: 0.8rem; + line-height: 1.4; +} +.cron-expr-hint--ok { color: #1c5e2e; } +.cron-expr-hint--err { color: #842029; } +.cron-expr-hint--checking { color: var(--rip-muted, #888); } + +.cron-expr-examples { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.25rem; +} + +.cron-expr-chip { + background: var(--surface-ground, #f4f4f4); + border: 1px solid var(--surface-border, #dee2e6); + border-radius: 99px; + padding: 0.2rem 0.7rem; + font-size: 0.78rem; + cursor: pointer; + transition: background 0.15s; +} +.cron-expr-chip:hover { background: var(--surface-hover, #e9ecef); } + +/* Quell-Typ-Auswahl */ +.cron-source-type-row { + display: flex; + gap: 0.5rem; +} + +.cron-source-type-btn { + flex: 1; + padding: 0.45rem 0.75rem; + border: 1px solid var(--surface-border, #dee2e6); + border-radius: 6px; + background: var(--surface-ground, #f4f4f4); + font-size: 0.88rem; + cursor: pointer; + transition: all 0.15s; +} +.cron-source-type-btn.active { + border-color: var(--primary-color, #3b82f6); + background: var(--primary-50, #eff6ff); + color: var(--primary-color, #3b82f6); + font-weight: 600; +} + +/* Editor-Toggles */ +.cron-editor-toggles { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +/* Logs-Dialog */ +.cron-log-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.cron-log-entry { + border: 1px solid var(--surface-border, #dee2e6); + border-radius: 6px; + overflow: hidden; +} + +.cron-log-summary { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + cursor: pointer; + background: var(--surface-ground, #f8f9fa); + list-style: none; + font-size: 0.88rem; + flex-wrap: wrap; +} + +.cron-log-time { + font-weight: 600; +} + +.cron-log-duration { + color: var(--rip-muted, #777); + font-size: 0.8rem; +} + +.cron-log-errmsg { + color: #842029; + font-size: 0.8rem; + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 260px; +} + +.cron-log-output { + margin: 0; + padding: 0.75rem; + background: #1e1e1e; + color: #d4d4d4; + font-size: 0.78rem; + max-height: 300px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; +} diff --git a/install-dev.sh b/install-dev.sh new file mode 100755 index 0000000..dd509ed --- /dev/null +++ b/install-dev.sh @@ -0,0 +1,605 @@ +#!/usr/bin/env bash +# ============================================================================= +# Ripster – Installationsskript +# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04 +# Benötigt: sudo / root +# +# Verwendung: +# chmod +x install.sh +# sudo ./install.sh [Optionen] +# +# Optionen: +# --dir Installationsverzeichnis (Standard: /opt/ripster) +# --user Systembenutzer für den Dienst (Standard: ripster) +# --port Backend-Port (Standard: 3001) +# --host Hostname/IP für die Weboberfläche (Standard: Maschinen-IP) +# --no-makemkv MakeMKV-Installation überspringen +# --no-handbrake HandBrake-Installation überspringen +# --no-nginx Nginx-Einrichtung überspringen (Frontend läuft dann auf Port 5173) +# --reinstall Vorhandene Installation ersetzen (Daten bleiben erhalten) +# -h, --help Diese Hilfe anzeigen +# ============================================================================= +set -euo pipefail + +# --- Farben ------------------------------------------------------------------- +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m' + +info() { echo -e "${BLUE}[INFO]${RESET} $*"; } +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; } +header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; \ + echo -e "${BOLD} $*${RESET}"; \ + echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; } +fatal() { error "$*"; exit 1; } + +# --- Standard-Optionen -------------------------------------------------------- +INSTALL_DIR="/opt/ripster" +SERVICE_USER="ripster" +BACKEND_PORT="3001" +FRONTEND_HOST="" # wird automatisch ermittelt, wenn leer +SKIP_MAKEMKV=false +SKIP_HANDBRAKE=false +SKIP_NGINX=false +REINSTALL=false +SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --- Argumente parsen --------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) INSTALL_DIR="$2"; shift 2 ;; + --user) SERVICE_USER="$2"; shift 2 ;; + --port) BACKEND_PORT="$2"; shift 2 ;; + --host) FRONTEND_HOST="$2"; shift 2 ;; + --no-makemkv) SKIP_MAKEMKV=true; shift ;; + --no-handbrake) SKIP_HANDBRAKE=true; shift ;; + --no-nginx) SKIP_NGINX=true; shift ;; + --reinstall) REINSTALL=true; shift ;; + -h|--help) + sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//' + exit 0 ;; + *) fatal "Unbekannte Option: $1" ;; + esac +done + +# --- Voraussetzungen prüfen --------------------------------------------------- +header "Ripster Installationsskript" + +if [[ $EUID -ne 0 ]]; then + fatal "Dieses Skript muss als root ausgeführt werden (sudo ./install.sh)" +fi + +# OS-Erkennung +if [[ ! -f /etc/os-release ]]; then + fatal "Betriebssystem nicht erkennbar. Nur Debian/Ubuntu wird unterstützt." +fi +. /etc/os-release +case "$ID" in + debian|ubuntu|linuxmint|pop) ok "Betriebssystem: $PRETTY_NAME" ;; + *) fatal "Nicht unterstütztes OS: $ID. Nur Debian/Ubuntu unterstützt." ;; +esac + +# Host-IP ermitteln +if [[ -z "$FRONTEND_HOST" ]]; then + FRONTEND_HOST=$(hostname -I | awk '{print $1}') + info "Erkannte IP: $FRONTEND_HOST" +fi + +# Quelldirectory prüfen +[[ -f "$SOURCE_DIR/backend/package.json" ]] || \ + fatal "Ripster-Quellen nicht gefunden in: $SOURCE_DIR" + +info "Quellverzeichnis: $SOURCE_DIR" +info "Installationsverzeichnis: $INSTALL_DIR" +info "Systembenutzer: $SERVICE_USER" +info "Backend-Port: $BACKEND_PORT" +info "Frontend-Host: $FRONTEND_HOST" + +# --- Hilfsfunktionen ---------------------------------------------------------- + +command_exists() { command -v "$1" &>/dev/null; } + +install_node() { + header "Node.js installieren" + local required_major=20 + + if command_exists node; then + local current_major + current_major=$(node -e "process.stdout.write(String(process.version.split('.')[0].replace('v','')))") + if [[ "$current_major" -ge "$required_major" ]]; then + ok "Node.js $(node --version) bereits installiert" + return + fi + warn "Node.js $(node --version) zu alt – Node.js 20 wird installiert" + fi + + info "Installiere Node.js 20.x über NodeSource..." + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs + ok "Node.js $(node --version) installiert" +} + +install_makemkv() { + header "MakeMKV installieren" + + if command_exists makemkvcon; then + ok "makemkvcon bereits installiert ($(makemkvcon --version 2>&1 | head -1))" + return + fi + + info "Installiere Build-Abhängigkeiten für MakeMKV..." + apt-get install -y \ + build-essential pkg-config libc6-dev libssl-dev \ + libexpat1-dev libavcodec-dev libgl1-mesa-dev \ + qtbase5-dev zlib1g-dev wget + + # Aktuelle Version ermitteln + info "Ermittle aktuelle MakeMKV-Version..." + local makemkv_version + makemkv_version=$(curl -s "https://www.makemkv.com/download/" \ + | grep -oP 'makemkv-oss-\K[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) + + if [[ -z "$makemkv_version" ]]; then + warn "MakeMKV-Version konnte nicht ermittelt werden." + warn "Bitte manuell installieren: https://www.makemkv.com/forum/viewtopic.php?t=224" + return + fi + + info "Baue MakeMKV $makemkv_version..." + local tmp_dir + tmp_dir=$(mktemp -d) + cd "$tmp_dir" + + local base_url="https://www.makemkv.com/download" + wget -q "${base_url}/makemkv-bin-${makemkv_version}.tar.gz" + wget -q "${base_url}/makemkv-oss-${makemkv_version}.tar.gz" + + tar xf "makemkv-oss-${makemkv_version}.tar.gz" + cd "makemkv-oss-${makemkv_version}" + ./configure + make -j"$(nproc)" + make install + + cd "$tmp_dir" + tar xf "makemkv-bin-${makemkv_version}.tar.gz" + cd "makemkv-bin-${makemkv_version}" + mkdir -p tmp && echo "accepted" > tmp/eula_accepted + make -j"$(nproc)" + make install + + cd / + rm -rf "$tmp_dir" + ok "MakeMKV $makemkv_version installiert" + warn "Hinweis: MakeMKV benötigt eine Lizenz oder den Beta-Key." + warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053" +} + +install_handbrake() { + header "HandBrake CLI installieren" + + if command_exists HandBrakeCLI; then + ok "HandBrakeCLI bereits installiert" + return + fi + + case "$ID" in + ubuntu) + info "Installiere HandBrake CLI über PPA..." + if ! grep -q "stebbins/handbrake" /etc/apt/sources.list.d/*.list 2>/dev/null; then + apt-get install -y software-properties-common + add-apt-repository -y ppa:stebbins/handbrake-releases + apt_update + fi + apt-get install -y handbrake-cli + ;; + debian) + info "Installiere HandBrake CLI (Debian Backports)..." + if ! grep -q "backports" /etc/apt/sources.list 2>/dev/null && \ + ! find /etc/apt/sources.list.d/ -name "*.list" -exec grep -l "backports" {} \; | grep -q .; then + echo "deb http://deb.debian.org/debian ${VERSION_CODENAME}-backports main" \ + > /etc/apt/sources.list.d/backports.list + apt_update + fi + apt-get install -y -t "${VERSION_CODENAME}-backports" handbrake-cli 2>/dev/null || \ + apt-get install -y handbrake-cli 2>/dev/null || { + warn "HandBrake CLI konnte nicht über Backports installiert werden." + warn "Bitte manuell installieren: https://handbrake.fr/downloads2.php" + return + } + ;; + esac + ok "HandBrakeCLI installiert" +} + +# --- apt-Hilfsfunktionen ------------------------------------------------------ + +apt_update() { + local output + if output=$(apt-get update 2>&1); then + return 0 + fi + + if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then + warn "apt-Sources fehlerhaft. Versuche Reparatur..." + + if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then + ok "apt-Update mit --allow-releaseinfo-change erfolgreich" + return 0 + fi + + if [[ -n "${VERSION_CODENAME:-}" ]]; then + warn "Schreibe minimale sources.list für $VERSION_CODENAME..." + local main_list=/etc/apt/sources.list + cp "$main_list" "${main_list}.bak-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true + + case "$ID" in + ubuntu) + cat > "$main_list" < "$main_list" </dev/null; then + ok "apt-Update nach Sources-Reparatur erfolgreich" + return 0 + fi + fi + + warn "Deaktiviere fehlerhafte Eintraege in /etc/apt/sources.list.d/ ..." + local broken_files + broken_files=$(apt-get update 2>&1 | grep -oP "(?<=The repository ').*?(?=' )" | \ + xargs -I{} grep -rl "{}" /etc/apt/sources.list.d/ 2>/dev/null || true) + if [[ -n "$broken_files" ]]; then + echo "$broken_files" | while read -r f; do + warn "Deaktiviere: $f" + mv "$f" "${f}.disabled" 2>/dev/null || true + done + if apt-get update -qq 2>/dev/null; then + ok "apt-Update nach Deaktivierung fehlerhafter Sources erfolgreich" + return 0 + fi + fi + + error "apt-Update fehlgeschlagen. Bitte Sources manuell pruefen:" + echo "$output" + fatal "Installation abgebrochen. Repariere /etc/apt/sources.list und starte erneut." + else + error "apt-Update fehlgeschlagen:" + echo "$output" + fatal "Installation abgebrochen." + fi +} + +# --- Systemabhängigkeiten ----------------------------------------------------- +header "Systemabhängigkeiten installieren" + +info "Paketlisten aktualisieren..." +apt_update + +info "Installiere Basispakete..." +apt-get install -y \ + curl wget git \ + mediainfo \ + util-linux udev \ + ca-certificates gnupg \ + lsb-release + +ok "Basispakete installiert" + +# Node.js +install_node + +# MakeMKV +if [[ "$SKIP_MAKEMKV" == false ]]; then + install_makemkv +else + warn "MakeMKV-Installation übersprungen (--no-makemkv)" +fi + +# HandBrake +if [[ "$SKIP_HANDBRAKE" == false ]]; then + install_handbrake +else + warn "HandBrake-Installation übersprungen (--no-handbrake)" +fi + +# Nginx +if [[ "$SKIP_NGINX" == false ]]; then + if ! command_exists nginx; then + info "Installiere nginx..." + apt-get install -y nginx + fi + ok "nginx installiert" +fi + +# --- Systembenutzer anlegen --------------------------------------------------- +header "Systembenutzer anlegen" + +if id "$SERVICE_USER" &>/dev/null; then + ok "Benutzer '$SERVICE_USER' existiert bereits" +else + info "Lege Systembenutzer '$SERVICE_USER' an..." + useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" + ok "Benutzer '$SERVICE_USER' angelegt" +fi + +# Optisches Laufwerk: Benutzer zur cdrom/optical-Gruppe hinzufügen +for grp in cdrom optical disk; do + if getent group "$grp" &>/dev/null; then + usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true + info "Benutzer '$SERVICE_USER' zur Gruppe '$grp' hinzugefügt" + fi +done + +# --- Dateien kopieren --------------------------------------------------------- +header "Ripster-Dateien installieren" + +if [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then + fatal "Verzeichnis $INSTALL_DIR existiert bereits.\nVerwende --reinstall um zu überschreiben (Daten bleiben erhalten)." +fi + +# Bei Reinstall: Daten sichern +if [[ -d "$INSTALL_DIR/backend/data" ]]; then + info "Sichere vorhandene Datenbank..." + cp -a "$INSTALL_DIR/backend/data" "/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)" + ok "Datenbank gesichert" +fi + +info "Kopiere Quellen nach $INSTALL_DIR..." +mkdir -p "$INSTALL_DIR" + +rsync -a --delete \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='backend/node_modules' \ + --exclude='frontend/node_modules' \ + --exclude='backend/data' \ + --exclude='backend/logs' \ + --exclude='frontend/dist' \ + --exclude='*.sh' \ + --exclude='deploy-ripster.sh' \ + --exclude='debug/' \ + --exclude='site/' \ + --exclude='docs/' \ + "$SOURCE_DIR/" "$INSTALL_DIR/" + +# Datenbank-/Log-Verzeichnisse anlegen +mkdir -p "$INSTALL_DIR/backend/data" +mkdir -p "$INSTALL_DIR/backend/logs" + +# Bei Reinstall: Daten wiederherstellen +if [[ -d "$INSTALL_DIR/../ripster-data-backup" ]]; then + cp -a /tmp/ripster-data-backup-*/ "$INSTALL_DIR/backend/data/" 2>/dev/null || true +fi + +ok "Dateien kopiert" + +# --- npm-Abhängigkeiten installieren ----------------------------------------- +header "npm-Abhängigkeiten installieren" + +info "Installiere Root-Abhängigkeiten..." +npm install --prefix "$INSTALL_DIR" --omit=dev --silent + +info "Installiere Backend-Abhängigkeiten..." +npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent + +info "Installiere Frontend-Abhängigkeiten..." +npm install --prefix "$INSTALL_DIR/frontend" --silent + +ok "npm-Abhängigkeiten installiert" + +# --- Frontend bauen ----------------------------------------------------------- +header "Frontend bauen" + +info "Baue Frontend für $FRONTEND_HOST..." + +# Env-Datei für den Build erstellen +cat > "$INSTALL_DIR/frontend/.env.production.local" < "$ENV_FILE" < /etc/systemd/system/ripster-backend.service < /etc/nginx/sites-available/ripster < Git-Branch (Standard: main) +# --dir Installationsverzeichnis (Standard: /opt/ripster) +# --user Systembenutzer für den Dienst (Standard: ripster) +# --port Backend-Port (Standard: 3001) +# --host Hostname/IP für die Weboberfläche (Standard: Maschinen-IP) +# --no-makemkv MakeMKV-Installation überspringen +# --no-handbrake HandBrake-Installation überspringen +# --no-nginx Nginx-Einrichtung überspringen +# --reinstall Vorhandene Installation aktualisieren (Daten bleiben erhalten) +# -h, --help Diese Hilfe anzeigen +# ============================================================================= +set -euo pipefail + +REPO_URL="https://github.com/Mboehmlaender/ripster.git" + +# --- Farben ------------------------------------------------------------------- +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m' + +info() { echo -e "${BLUE}[INFO]${RESET} $*"; } +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; } +header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; \ + echo -e "${BOLD} $*${RESET}"; \ + echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; } +fatal() { error "$*"; exit 1; } + +# --- Standard-Optionen -------------------------------------------------------- +GIT_BRANCH="main" +INSTALL_DIR="/opt/ripster" +SERVICE_USER="ripster" +BACKEND_PORT="3001" +FRONTEND_HOST="" +SKIP_MAKEMKV=false +SKIP_HANDBRAKE=false +SKIP_NGINX=false +REINSTALL=false + +# --- Argumente parsen --------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --branch) GIT_BRANCH="$2"; shift 2 ;; + --dir) INSTALL_DIR="$2"; shift 2 ;; + --user) SERVICE_USER="$2"; shift 2 ;; + --port) BACKEND_PORT="$2"; shift 2 ;; + --host) FRONTEND_HOST="$2"; shift 2 ;; + --no-makemkv) SKIP_MAKEMKV=true; shift ;; + --no-handbrake) SKIP_HANDBRAKE=true; shift ;; + --no-nginx) SKIP_NGINX=true; shift ;; + --reinstall) REINSTALL=true; shift ;; + -h|--help) + sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//' + exit 0 ;; + *) fatal "Unbekannte Option: $1" ;; + esac +done + +# --- Voraussetzungen prüfen --------------------------------------------------- +header "Ripster Installationsskript (Git)" + +if [[ $EUID -ne 0 ]]; then + fatal "Dieses Skript muss als root ausgeführt werden (sudo bash install.sh)" +fi + +if [[ ! -f /etc/os-release ]]; then + fatal "Betriebssystem nicht erkennbar. Nur Debian/Ubuntu wird unterstützt." +fi +. /etc/os-release +case "$ID" in + debian|ubuntu|linuxmint|pop) ok "Betriebssystem: $PRETTY_NAME" ;; + *) fatal "Nicht unterstütztes OS: $ID. Nur Debian/Ubuntu unterstützt." ;; +esac + +if [[ -z "$FRONTEND_HOST" ]]; then + FRONTEND_HOST=$(hostname -I | awk '{print $1}') + info "Erkannte IP: $FRONTEND_HOST" +fi + +info "Repository: $REPO_URL" +info "Branch: $GIT_BRANCH" +info "Installationsverzeichnis: $INSTALL_DIR" +info "Systembenutzer: $SERVICE_USER" +info "Backend-Port: $BACKEND_PORT" +info "Frontend-Host: $FRONTEND_HOST" + +# --- Hilfsfunktionen ---------------------------------------------------------- + +command_exists() { command -v "$1" &>/dev/null; } + +install_node() { + header "Node.js installieren" + local required_major=20 + + if command_exists node; then + local current_major + current_major=$(node -e "process.stdout.write(String(process.version.split('.')[0].replace('v','')))") + if [[ "$current_major" -ge "$required_major" ]]; then + ok "Node.js $(node --version) bereits installiert" + return + fi + warn "Node.js $(node --version) zu alt – Node.js 20 wird installiert" + fi + + info "Installiere Node.js 20.x über NodeSource..." + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs + ok "Node.js $(node --version) installiert" +} + +install_makemkv() { + header "MakeMKV installieren" + + if command_exists makemkvcon; then + ok "makemkvcon bereits installiert ($(makemkvcon --version 2>&1 | head -1))" + return + fi + + info "Installiere Build-Abhängigkeiten für MakeMKV..." + apt-get install -y \ + build-essential pkg-config libc6-dev libssl-dev \ + libexpat1-dev libavcodec-dev libgl1-mesa-dev \ + qtbase5-dev zlib1g-dev wget + + info "Ermittle aktuelle MakeMKV-Version..." + local makemkv_version + makemkv_version=$(curl -s "https://www.makemkv.com/download/" \ + | grep -oP 'makemkv-oss-\K[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) + + if [[ -z "$makemkv_version" ]]; then + warn "MakeMKV-Version konnte nicht ermittelt werden." + warn "Bitte manuell installieren: https://www.makemkv.com/forum/viewtopic.php?t=224" + return + fi + + info "Baue MakeMKV $makemkv_version..." + local tmp_dir + tmp_dir=$(mktemp -d) + cd "$tmp_dir" + + local base_url="https://www.makemkv.com/download" + wget -q "${base_url}/makemkv-bin-${makemkv_version}.tar.gz" + wget -q "${base_url}/makemkv-oss-${makemkv_version}.tar.gz" + + tar xf "makemkv-oss-${makemkv_version}.tar.gz" + cd "makemkv-oss-${makemkv_version}" + ./configure + make -j"$(nproc)" + make install + + cd "$tmp_dir" + tar xf "makemkv-bin-${makemkv_version}.tar.gz" + cd "makemkv-bin-${makemkv_version}" + mkdir -p tmp && echo "accepted" > tmp/eula_accepted + make -j"$(nproc)" + make install + + cd / + rm -rf "$tmp_dir" + ok "MakeMKV $makemkv_version installiert" + warn "Hinweis: MakeMKV benötigt eine Lizenz oder den Beta-Key." + warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053" +} + +install_handbrake() { + header "HandBrake CLI installieren" + + if command_exists HandBrakeCLI; then + ok "HandBrakeCLI bereits installiert" + return + fi + + case "$ID" in + ubuntu) + info "Installiere HandBrake CLI über PPA..." + apt-get install -y software-properties-common + add-apt-repository -y ppa:stebbins/handbrake-releases + apt_update + apt-get install -y handbrake-cli + ;; + debian) + info "Installiere HandBrake CLI (Debian Backports)..." + if ! find /etc/apt/sources.list.d/ -name "*.list" -exec grep -l "backports" {} \; 2>/dev/null | grep -q .; then + echo "deb http://deb.debian.org/debian ${VERSION_CODENAME}-backports main" \ + > /etc/apt/sources.list.d/backports.list + apt_update + fi + apt-get install -y -t "${VERSION_CODENAME}-backports" handbrake-cli 2>/dev/null || \ + apt-get install -y handbrake-cli 2>/dev/null || { + warn "HandBrake CLI konnte nicht installiert werden." + warn "Bitte manuell installieren: https://handbrake.fr/downloads2.php" + return + } + ;; + esac + ok "HandBrakeCLI installiert" +} + +# --- apt-Hilfsfunktionen ------------------------------------------------------ + +# Führt apt-get update aus. Bei Release-Fehlern wird versucht, die Sources zu +# reparieren (Proxmox-Container, veraltete Spiegelserver, etc.). +apt_update() { + local output + if output=$(apt-get update 2>&1); then + return 0 + fi + + # Release-Datei fehlt → versuche Repair + if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then + warn "apt-Sources fehlerhaft. Versuche Reparatur..." + + # Strategie 1: --allow-releaseinfo-change + if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then + ok "apt-Update mit --allow-releaseinfo-change erfolgreich" + return 0 + fi + + # Strategie 2: Kaputte Einträge aus sources.list.d entfernen und Fallback + # auf offizielle Spiegel schreiben + if [[ -n "${VERSION_CODENAME:-}" ]]; then + warn "Schreibe minimale sources.list für $VERSION_CODENAME..." + local main_list=/etc/apt/sources.list + + # Backup + cp "$main_list" "${main_list}.bak-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true + + case "$ID" in + ubuntu) + cat > "$main_list" < "$main_list" </dev/null; then + ok "apt-Update nach Sources-Reparatur erfolgreich" + return 0 + fi + fi + + # Strategie 3: Kaputte .list-Dateien in sources.list.d deaktivieren + warn "Deaktiviere fehlerhafte Eintraege in /etc/apt/sources.list.d/ ..." + local broken_files + broken_files=$(apt-get update 2>&1 | grep -oP "(?<=The repository ').*?(?=' )" | \ + xargs -I{} grep -rl "{}" /etc/apt/sources.list.d/ 2>/dev/null || true) + if [[ -n "$broken_files" ]]; then + echo "$broken_files" | while read -r f; do + warn "Deaktiviere: $f" + mv "$f" "${f}.disabled" 2>/dev/null || true + done + if apt-get update -qq 2>/dev/null; then + ok "apt-Update nach Deaktivierung fehlerhafter Sources erfolgreich" + return 0 + fi + fi + + error "apt-Update fehlgeschlagen. Bitte Sources manuell pruefen:" + echo "$output" + fatal "Installation abgebrochen. Repariere /etc/apt/sources.list und starte erneut." + else + error "apt-Update fehlgeschlagen:" + echo "$output" + fatal "Installation abgebrochen." + fi +} + +# --- Systemabhängigkeiten ----------------------------------------------------- +header "Systemabhängigkeiten installieren" + +info "Paketlisten aktualisieren..." +apt_update + +info "Installiere Basispakete..." +apt-get install -y \ + curl wget git \ + mediainfo \ + util-linux udev \ + ca-certificates gnupg \ + lsb-release + +ok "Basispakete installiert" + +install_node + +if [[ "$SKIP_MAKEMKV" == false ]]; then + install_makemkv +else + warn "MakeMKV-Installation übersprungen (--no-makemkv)" +fi + +if [[ "$SKIP_HANDBRAKE" == false ]]; then + install_handbrake +else + warn "HandBrake-Installation übersprungen (--no-handbrake)" +fi + +if [[ "$SKIP_NGINX" == false ]]; then + if ! command_exists nginx; then + info "Installiere nginx..." + apt-get install -y nginx + fi + ok "nginx installiert" +fi + +# --- Systembenutzer anlegen --------------------------------------------------- +header "Systembenutzer anlegen" + +if id "$SERVICE_USER" &>/dev/null; then + ok "Benutzer '$SERVICE_USER' existiert bereits" +else + info "Lege Systembenutzer '$SERVICE_USER' an..." + useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" + ok "Benutzer '$SERVICE_USER' angelegt" +fi + +for grp in cdrom optical disk; do + if getent group "$grp" &>/dev/null; then + usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true + info "Benutzer '$SERVICE_USER' zur Gruppe '$grp' hinzugefügt" + fi +done + +# --- Repository klonen / aktualisieren ---------------------------------------- +header "Repository holen (Git)" + +if [[ -d "$INSTALL_DIR/.git" ]]; then + if [[ "$REINSTALL" == true ]]; then + info "Aktualisiere bestehendes Repository..." + # Daten sichern + if [[ -d "$INSTALL_DIR/backend/data" ]]; then + DATA_BACKUP="/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)" + cp -a "$INSTALL_DIR/backend/data" "$DATA_BACKUP" + info "Datenbank gesichert nach: $DATA_BACKUP" + fi + git -C "$INSTALL_DIR" fetch --quiet origin + git -C "$INSTALL_DIR" checkout --quiet "$GIT_BRANCH" + git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH" + ok "Repository aktualisiert auf Branch '$GIT_BRANCH'" + else + fatal "$INSTALL_DIR enthält bereits ein Git-Repository.\nVerwende --reinstall um zu aktualisieren." + fi +elif [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then + fatal "Verzeichnis $INSTALL_DIR existiert bereits (kein Git-Repo).\nBitte manuell entfernen oder --reinstall verwenden." +else + info "Klone $REPO_URL (Branch: $GIT_BRANCH)..." + git clone --quiet --branch "$GIT_BRANCH" --depth 1 "$REPO_URL" "$INSTALL_DIR" + ok "Repository geklont nach $INSTALL_DIR" +fi + +# Daten- und Log-Verzeichnisse sicherstellen +mkdir -p "$INSTALL_DIR/backend/data" +mkdir -p "$INSTALL_DIR/backend/logs" + +# Gesicherte Daten zurückspielen +if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then + cp -a "$DATA_BACKUP/." "$INSTALL_DIR/backend/data/" + ok "Datenbank wiederhergestellt" +fi + +# --- npm-Abhängigkeiten installieren ----------------------------------------- +header "npm-Abhängigkeiten installieren" + +info "Root-Abhängigkeiten..." +npm install --prefix "$INSTALL_DIR" --omit=dev --silent + +info "Backend-Abhängigkeiten..." +npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent + +info "Frontend-Abhängigkeiten..." +npm install --prefix "$INSTALL_DIR/frontend" --silent + +ok "npm-Abhängigkeiten installiert" + +# --- Frontend bauen ----------------------------------------------------------- +header "Frontend bauen" + +info "Baue Frontend für $FRONTEND_HOST..." + +cat > "$INSTALL_DIR/frontend/.env.production.local" < "$ENV_FILE" < /etc/systemd/system/ripster-backend.service < /etc/nginx/sites-available/ripster <