some pload
This commit is contained in:
@@ -7,7 +7,8 @@
|
|||||||
"Bash(pip install -q -r requirements-docs.txt)",
|
"Bash(pip install -q -r requirements-docs.txt)",
|
||||||
"Bash(mkdocs build --strict)",
|
"Bash(mkdocs build --strict)",
|
||||||
"Read(//mnt/external/media/**)",
|
"Read(//mnt/external/media/**)",
|
||||||
"WebFetch(domain:www.makemkv.com)"
|
"WebFetch(domain:www.makemkv.com)",
|
||||||
|
"Bash(node --check backend/src/services/pipelineService.js)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,42 @@ const defaultSchema = [
|
|||||||
validation: { minLength: 1 },
|
validation: { minLength: 1 },
|
||||||
orderIndex: 100
|
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',
|
key: 'movie_dir',
|
||||||
category: 'Pfade',
|
category: 'Pfade',
|
||||||
@@ -74,6 +110,42 @@ const defaultSchema = [
|
|||||||
validation: { minLength: 1 },
|
validation: { minLength: 1 },
|
||||||
orderIndex: 110
|
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',
|
key: 'log_dir',
|
||||||
category: 'Pfade',
|
category: 'Pfade',
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ const requestLogger = require('./middleware/requestLogger');
|
|||||||
const settingsRoutes = require('./routes/settingsRoutes');
|
const settingsRoutes = require('./routes/settingsRoutes');
|
||||||
const pipelineRoutes = require('./routes/pipelineRoutes');
|
const pipelineRoutes = require('./routes/pipelineRoutes');
|
||||||
const historyRoutes = require('./routes/historyRoutes');
|
const historyRoutes = require('./routes/historyRoutes');
|
||||||
|
const cronRoutes = require('./routes/cronRoutes');
|
||||||
const wsService = require('./services/websocketService');
|
const wsService = require('./services/websocketService');
|
||||||
const pipelineService = require('./services/pipelineService');
|
const pipelineService = require('./services/pipelineService');
|
||||||
|
const cronService = require('./services/cronService');
|
||||||
const diskDetectionService = require('./services/diskDetectionService');
|
const diskDetectionService = require('./services/diskDetectionService');
|
||||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||||
const logger = require('./services/logger').child('BOOT');
|
const logger = require('./services/logger').child('BOOT');
|
||||||
@@ -21,6 +23,7 @@ async function start() {
|
|||||||
logger.info('backend:start:init');
|
logger.info('backend:start:init');
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
await pipelineService.init();
|
await pipelineService.init();
|
||||||
|
await cronService.init();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({ origin: corsOrigin }));
|
app.use(cors({ origin: corsOrigin }));
|
||||||
@@ -34,6 +37,7 @@ async function start() {
|
|||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/pipeline', pipelineRoutes);
|
app.use('/api/pipeline', pipelineRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
app.use('/api/crons', cronRoutes);
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
@@ -72,6 +76,7 @@ async function start() {
|
|||||||
logger.warn('backend:shutdown:received');
|
logger.warn('backend:shutdown:received');
|
||||||
diskDetectionService.stop();
|
diskDetectionService.stop();
|
||||||
hardwareMonitorService.stop();
|
hardwareMonitorService.stop();
|
||||||
|
cronService.stop();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.warn('backend:shutdown:completed');
|
logger.warn('backend:shutdown:completed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
101
backend/src/routes/cronRoutes.js
Normal file
101
backend/src/routes/cronRoutes.js
Normal file
@@ -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;
|
||||||
@@ -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(
|
router.put(
|
||||||
'/scripts/:id',
|
'/scripts/:id',
|
||||||
asyncHandler(async (req, res) => {
|
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(
|
router.get(
|
||||||
'/script-chains',
|
'/script-chains',
|
||||||
asyncHandler(async (req, res) => {
|
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(
|
router.get(
|
||||||
'/script-chains/:id',
|
'/script-chains/:id',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
560
backend/src/services/cronService.js
Normal file
560
backend/src/services/cronService.js
Normal file
@@ -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();
|
||||||
@@ -28,10 +28,28 @@ function normalizeMediaProfile(rawValue) {
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
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';
|
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';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') {
|
if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') {
|
||||||
@@ -40,6 +58,10 @@ function normalizeMediaProfile(rawValue) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSpecificMediaProfile(value) {
|
||||||
|
return value === 'bluray' || value === 'dvd';
|
||||||
|
}
|
||||||
|
|
||||||
function inferMediaProfileFromTextParts(parts) {
|
function inferMediaProfileFromTextParts(parts) {
|
||||||
const markerText = (parts || [])
|
const markerText = (parts || [])
|
||||||
.map((value) => String(value || '').trim().toLowerCase())
|
.map((value) => String(value || '').trim().toLowerCase())
|
||||||
@@ -49,15 +71,55 @@ function inferMediaProfileFromTextParts(parts) {
|
|||||||
if (!markerText) {
|
if (!markerText) {
|
||||||
return null;
|
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';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(markerText)) {
|
if (/(^|[\s_-])video_ts($|[\s_-])|dvd|iso9660/.test(markerText)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
return null;
|
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 {
|
class DiskDetectionService extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -290,8 +352,8 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMedia = await this.checkMediaPresent(devicePath);
|
const mediaState = await this.checkMediaPresent(devicePath);
|
||||||
if (!hasMedia) {
|
if (!mediaState.hasMedia) {
|
||||||
logger.debug('detect:explicit:no-media', { devicePath });
|
logger.debug('detect:explicit:no-media', { devicePath });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -299,12 +361,13 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
|
|
||||||
const details = await this.getBlockDeviceInfo();
|
const details = await this.getBlockDeviceInfo();
|
||||||
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
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, {
|
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
||||||
discLabel,
|
discLabel,
|
||||||
label: match.label,
|
label: match.label,
|
||||||
model: match.model,
|
model: match.model,
|
||||||
fstype: match.fstype,
|
fstype: detectedFsType,
|
||||||
mountpoint: match.mountpoint
|
mountpoint: match.mountpoint
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -316,7 +379,7 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
label: match.label || null,
|
label: match.label || null,
|
||||||
discLabel: discLabel || null,
|
discLabel: discLabel || null,
|
||||||
mountpoint: match.mountpoint || null,
|
mountpoint: match.mountpoint || null,
|
||||||
fstype: match.fstype || null,
|
fstype: detectedFsType,
|
||||||
mediaProfile: mediaProfile || null,
|
mediaProfile: mediaProfile || null,
|
||||||
index: this.guessDiscIndex(match.name || devicePath)
|
index: this.guessDiscIndex(match.name || devicePath)
|
||||||
};
|
};
|
||||||
@@ -342,17 +405,18 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMedia = await this.checkMediaPresent(path);
|
const mediaState = await this.checkMediaPresent(path);
|
||||||
if (!hasMedia) {
|
if (!mediaState.hasMedia) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const discLabel = await this.getDiscLabel(path);
|
const discLabel = await this.getDiscLabel(path);
|
||||||
|
const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null;
|
||||||
|
|
||||||
const mediaProfile = await this.inferMediaProfile(path, {
|
const mediaProfile = await this.inferMediaProfile(path, {
|
||||||
discLabel,
|
discLabel,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
model: item.model,
|
model: item.model,
|
||||||
fstype: item.fstype,
|
fstype: detectedFsType,
|
||||||
mountpoint: item.mountpoint
|
mountpoint: item.mountpoint
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -364,7 +428,7 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
label: item.label || null,
|
label: item.label || null,
|
||||||
discLabel: discLabel || null,
|
discLabel: discLabel || null,
|
||||||
mountpoint: item.mountpoint || null,
|
mountpoint: item.mountpoint || null,
|
||||||
fstype: item.fstype || null,
|
fstype: detectedFsType,
|
||||||
mediaProfile: mediaProfile || null,
|
mediaProfile: mediaProfile || null,
|
||||||
index: this.guessDiscIndex(item.name)
|
index: this.guessDiscIndex(item.name)
|
||||||
};
|
};
|
||||||
@@ -404,12 +468,19 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
async checkMediaPresent(devicePath) {
|
async checkMediaPresent(devicePath) {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
||||||
const has = stdout.trim().length > 0;
|
const type = String(stdout || '').trim().toLowerCase();
|
||||||
logger.debug('blkid:result', { devicePath, hasMedia: has, type: stdout.trim() });
|
const has = type.length > 0;
|
||||||
return has;
|
logger.debug('blkid:result', { devicePath, hasMedia: has, type });
|
||||||
|
return {
|
||||||
|
hasMedia: has,
|
||||||
|
type: type || null
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug('blkid:no-media-or-fail', { devicePath, error: errorToMeta(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 = {}) {
|
async inferMediaProfile(devicePath, hints = {}) {
|
||||||
const explicit = normalizeMediaProfile(hints?.mediaProfile);
|
const explicit = normalizeMediaProfile(hints?.mediaProfile);
|
||||||
if (explicit) {
|
if (isSpecificMediaProfile(explicit)) {
|
||||||
return explicit;
|
return explicit;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hinted = inferMediaProfileFromTextParts([
|
const hinted = inferMediaProfileFromTextParts([
|
||||||
hints?.discLabel,
|
hints?.discLabel,
|
||||||
hints?.label,
|
hints?.label,
|
||||||
hints?.fstype
|
hints?.fstype,
|
||||||
|
hints?.model
|
||||||
]);
|
]);
|
||||||
if (hinted) {
|
if (hinted) {
|
||||||
return 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();
|
const mountpoint = String(hints?.mountpoint || '').trim();
|
||||||
if (mountpoint) {
|
if (mountpoint) {
|
||||||
try {
|
try {
|
||||||
@@ -477,19 +558,24 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
const byBlkidMarker = inferMediaProfileFromTextParts([
|
const byBlkidMarker = inferMediaProfileFromTextParts([
|
||||||
payload.LABEL,
|
payload.LABEL,
|
||||||
payload.TYPE,
|
payload.TYPE,
|
||||||
payload.VERSION
|
payload.VERSION,
|
||||||
|
payload.APPLICATION_ID,
|
||||||
|
hints?.model
|
||||||
]);
|
]);
|
||||||
if (byBlkidMarker) {
|
if (byBlkidMarker) {
|
||||||
return byBlkidMarker;
|
return byBlkidMarker;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = String(payload.TYPE || '').trim().toLowerCase();
|
const type = String(payload.TYPE || '').trim().toLowerCase();
|
||||||
if (type === 'udf') {
|
const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model);
|
||||||
|
if (byBlkidFsType) {
|
||||||
|
if (type.includes('udf')) {
|
||||||
const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.'));
|
const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.'));
|
||||||
if (Number.isFinite(version)) {
|
if (Number.isFinite(version)) {
|
||||||
return version >= 2 ? 'bluray' : 'dvd';
|
return version >= 2 ? 'bluray' : 'dvd';
|
||||||
}
|
}
|
||||||
return 'dvd';
|
}
|
||||||
|
return byBlkidFsType;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug('infer-media-profile:blkid-failed', {
|
logger.debug('infer-media-profile:blkid-failed', {
|
||||||
@@ -498,7 +584,7 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return explicit === 'other' ? 'other' : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
guessDiscIndex(name) {
|
guessDiscIndex(name) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function parseJsonSafe(raw, fallback = null) {
|
|||||||
|
|
||||||
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
||||||
const processLogStreams = new Map();
|
const processLogStreams = new Map();
|
||||||
|
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'other'];
|
||||||
|
|
||||||
function inspectDirectory(dirPath) {
|
function inspectDirectory(dirPath) {
|
||||||
if (!dirPath) {
|
if (!dirPath) {
|
||||||
@@ -181,10 +182,28 @@ function normalizeMediaTypeValue(value) {
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
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';
|
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';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
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);
|
return path.join(String(movieDir).trim(), folderName, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enrichJobRow(job, settings = null) {
|
function getConfiguredMediaPathList(settings = {}, baseKey) {
|
||||||
const rawDir = String(settings?.raw_dir || '').trim();
|
const source = settings && typeof settings === 'object' ? settings : {};
|
||||||
const movieDir = String(settings?.movie_dir || '').trim();
|
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)
|
? resolveEffectiveRawPath(job.raw_path, rawDir)
|
||||||
: (job.raw_path || null);
|
: (job?.raw_path || null);
|
||||||
const effectiveOutputPath = movieDir && job.output_path
|
const effectiveOutputPath = movieDir && job?.output_path
|
||||||
? resolveEffectiveOutputPath(job.output_path, movieDir)
|
? resolveEffectiveOutputPath(job.output_path, movieDir)
|
||||||
: (job.output_path || null);
|
: (job?.output_path || null);
|
||||||
|
|
||||||
const rawStatus = inspectDirectory(effectiveRawPath);
|
return {
|
||||||
const outputStatus = inspectOutputFile(effectiveOutputPath);
|
mediaType,
|
||||||
const movieDirPath = effectiveOutputPath ? path.dirname(effectiveOutputPath) : null;
|
rawDir,
|
||||||
const movieDirStatus = inspectDirectory(movieDirPath);
|
movieDir,
|
||||||
const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null);
|
effectiveRawPath,
|
||||||
|
effectiveOutputPath,
|
||||||
|
makemkvInfo: mkInfo,
|
||||||
|
mediainfoInfo: miInfo,
|
||||||
|
encodePlan: plan
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichJobRow(job, settings = null) {
|
||||||
const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, 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 omdbInfo = parseJsonSafe(job.omdb_json, null);
|
||||||
const encodePlan = parseJsonSafe(job.encode_plan_json, null);
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
||||||
const mediaType = inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan);
|
const rawStatus = inspectDirectory(resolvedPaths.effectiveRawPath);
|
||||||
const backupSuccess = String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
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';
|
const encodeSuccess = String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
raw_path: effectiveRawPath,
|
raw_path: resolvedPaths.effectiveRawPath,
|
||||||
output_path: effectiveOutputPath,
|
output_path: resolvedPaths.effectiveOutputPath,
|
||||||
makemkvInfo,
|
makemkvInfo,
|
||||||
handbrakeInfo,
|
handbrakeInfo,
|
||||||
mediainfoInfo,
|
mediainfoInfo,
|
||||||
omdbInfo,
|
omdbInfo,
|
||||||
encodePlan,
|
encodePlan,
|
||||||
mediaType,
|
mediaType,
|
||||||
|
ripSuccessful,
|
||||||
backupSuccess,
|
backupSuccess,
|
||||||
encodeSuccess,
|
encodeSuccess,
|
||||||
rawStatus,
|
rawStatus,
|
||||||
@@ -370,9 +432,10 @@ function normalizeComparablePath(inputPath) {
|
|||||||
|
|
||||||
function parseRawFolderMetadata(folderName) {
|
function parseRawFolderMetadata(folderName) {
|
||||||
const rawName = String(folderName || '').trim();
|
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;
|
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 imdbMatch = working.match(/\[(tt\d{6,12})\]/i);
|
||||||
const imdbId = imdbMatch ? String(imdbMatch[1] || '').toLowerCase() : null;
|
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) {
|
appendLog(jobId, source, message) {
|
||||||
this.appendProcessLog(jobId, source, message);
|
this.appendProcessLog(jobId, source, message);
|
||||||
}
|
}
|
||||||
@@ -820,25 +893,17 @@ class HistoryService {
|
|||||||
|
|
||||||
async getOrphanRawFolders() {
|
async getOrphanRawFolders() {
|
||||||
const settings = await settingsService.getSettingsMap();
|
const settings = await settingsService.getSettingsMap();
|
||||||
const rawDir = String(settings.raw_dir || '').trim();
|
const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir');
|
||||||
if (!rawDir) {
|
if (rawDirs.length === 0) {
|
||||||
const error = new Error('raw_dir ist nicht konfiguriert.');
|
const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,other}).');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawDirInfo = inspectDirectory(rawDir);
|
|
||||||
if (!rawDirInfo.exists || !rawDirInfo.isDirectory) {
|
|
||||||
return {
|
|
||||||
rawDir,
|
|
||||||
rows: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const linkedRows = await db.all(
|
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
|
FROM jobs
|
||||||
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
|
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
|
||||||
`
|
`
|
||||||
@@ -846,21 +911,32 @@ class HistoryService {
|
|||||||
|
|
||||||
const linkedPathMap = new Map();
|
const linkedPathMap = new Map();
|
||||||
for (const row of linkedRows) {
|
for (const row of linkedRows) {
|
||||||
const normalized = normalizeComparablePath(row.raw_path);
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, row);
|
||||||
if (!normalized) {
|
const linkedCandidates = [
|
||||||
continue;
|
normalizeComparablePath(row.raw_path),
|
||||||
|
normalizeComparablePath(resolvedPaths.effectiveRawPath)
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const linkedPath of linkedCandidates) {
|
||||||
|
if (!linkedPathMap.has(linkedPath)) {
|
||||||
|
linkedPathMap.set(linkedPath, []);
|
||||||
}
|
}
|
||||||
if (!linkedPathMap.has(normalized)) {
|
linkedPathMap.get(linkedPath).push({
|
||||||
linkedPathMap.set(normalized, []);
|
|
||||||
}
|
|
||||||
linkedPathMap.get(normalized).push({
|
|
||||||
id: row.id,
|
id: row.id,
|
||||||
status: row.status
|
status: row.status
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
|
|
||||||
const orphanRows = [];
|
const orphanRows = [];
|
||||||
|
const seenOrphanPaths = new Set();
|
||||||
|
|
||||||
|
for (const rawDir of rawDirs) {
|
||||||
|
const rawDirInfo = inspectDirectory(rawDir);
|
||||||
|
if (!rawDirInfo.exists || !rawDirInfo.isDirectory) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
|
||||||
|
|
||||||
for (const entry of dirEntries) {
|
for (const entry of dirEntries) {
|
||||||
if (!entry.isDirectory()) {
|
if (!entry.isDirectory()) {
|
||||||
@@ -869,7 +945,7 @@ class HistoryService {
|
|||||||
|
|
||||||
const rawPath = path.join(rawDir, entry.name);
|
const rawPath = path.join(rawDir, entry.name);
|
||||||
const normalizedPath = normalizeComparablePath(rawPath);
|
const normalizedPath = normalizeComparablePath(rawPath);
|
||||||
if (linkedPathMap.has(normalizedPath)) {
|
if (!normalizedPath || linkedPathMap.has(normalizedPath) || seenOrphanPaths.has(normalizedPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,18 +967,21 @@ class HistoryService {
|
|||||||
hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')),
|
hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')),
|
||||||
lastModifiedAt: stat.mtime.toISOString()
|
lastModifiedAt: stat.mtime.toISOString()
|
||||||
});
|
});
|
||||||
|
seenOrphanPaths.add(normalizedPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
orphanRows.sort((a, b) => String(b.lastModifiedAt).localeCompare(String(a.lastModifiedAt)));
|
orphanRows.sort((a, b) => String(b.lastModifiedAt).localeCompare(String(a.lastModifiedAt)));
|
||||||
return {
|
return {
|
||||||
rawDir,
|
rawDir: rawDirs[0] || null,
|
||||||
|
rawDirs,
|
||||||
rows: orphanRows
|
rows: orphanRows
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async importOrphanRawFolder(rawPath) {
|
async importOrphanRawFolder(rawPath) {
|
||||||
const settings = await settingsService.getSettingsMap();
|
const settings = await settingsService.getSettingsMap();
|
||||||
const rawDir = String(settings.raw_dir || '').trim();
|
const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir');
|
||||||
const requestedRawPath = String(rawPath || '').trim();
|
const requestedRawPath = String(rawPath || '').trim();
|
||||||
|
|
||||||
if (!requestedRawPath) {
|
if (!requestedRawPath) {
|
||||||
@@ -911,14 +990,15 @@ class HistoryService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rawDir) {
|
if (rawDirs.length === 0) {
|
||||||
const error = new Error('raw_dir ist nicht konfiguriert.');
|
const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,other}).');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPathInside(rawDir, requestedRawPath)) {
|
const insideConfiguredRawDir = rawDirs.some((candidate) => isPathInside(candidate, requestedRawPath));
|
||||||
const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${requestedRawPath}`);
|
if (!insideConfiguredRawDir) {
|
||||||
|
const error = new Error(`RAW-Pfad liegt außerhalb der konfigurierten RAW-Verzeichnisse: ${requestedRawPath}`);
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -1004,6 +1084,7 @@ class HistoryService {
|
|||||||
poster_url: omdbById?.poster || null,
|
poster_url: omdbById?.poster || null,
|
||||||
omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : null,
|
omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : null,
|
||||||
selected_from_omdb: omdbById ? 1 : 0,
|
selected_from_omdb: omdbById ? 1 : 0,
|
||||||
|
rip_successful: 1,
|
||||||
raw_path: finalRawPath,
|
raw_path: finalRawPath,
|
||||||
output_path: null,
|
output_path: null,
|
||||||
handbrake_info_json: null,
|
handbrake_info_json: null,
|
||||||
@@ -1125,12 +1206,11 @@ class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings = await settingsService.getSettingsMap();
|
const settings = await settingsService.getSettingsMap();
|
||||||
const effectiveRawPath = settings.raw_dir && job.raw_path
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
||||||
? resolveEffectiveRawPath(job.raw_path, settings.raw_dir)
|
const effectiveRawPath = resolvedPaths.effectiveRawPath;
|
||||||
: job.raw_path;
|
const effectiveOutputPath = resolvedPaths.effectiveOutputPath;
|
||||||
const effectiveOutputPath = settings.movie_dir && job.output_path
|
const effectiveRawDir = resolvedPaths.rawDir;
|
||||||
? resolveEffectiveOutputPath(job.output_path, settings.movie_dir)
|
const effectiveMovieDir = resolvedPaths.movieDir;
|
||||||
: job.output_path;
|
|
||||||
const summary = {
|
const summary = {
|
||||||
target,
|
target,
|
||||||
raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null },
|
raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null },
|
||||||
@@ -1141,8 +1221,12 @@ class HistoryService {
|
|||||||
summary.raw.attempted = true;
|
summary.raw.attempted = true;
|
||||||
if (!effectiveRawPath) {
|
if (!effectiveRawPath) {
|
||||||
summary.raw.reason = 'Kein raw_path im Job gesetzt.';
|
summary.raw.reason = 'Kein raw_path im Job gesetzt.';
|
||||||
} else if (!isPathInside(settings.raw_dir, effectiveRawPath)) {
|
} else if (!effectiveRawDir) {
|
||||||
const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${effectiveRawPath}`);
|
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;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
} else if (!fs.existsSync(effectiveRawPath)) {
|
} else if (!fs.existsSync(effectiveRawPath)) {
|
||||||
@@ -1159,15 +1243,19 @@ class HistoryService {
|
|||||||
summary.movie.attempted = true;
|
summary.movie.attempted = true;
|
||||||
if (!effectiveOutputPath) {
|
if (!effectiveOutputPath) {
|
||||||
summary.movie.reason = 'Kein output_path im Job gesetzt.';
|
summary.movie.reason = 'Kein output_path im Job gesetzt.';
|
||||||
} else if (!isPathInside(settings.movie_dir, effectiveOutputPath)) {
|
} else if (!effectiveMovieDir) {
|
||||||
const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${effectiveOutputPath}`);
|
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;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
} else if (!fs.existsSync(effectiveOutputPath)) {
|
} else if (!fs.existsSync(effectiveOutputPath)) {
|
||||||
summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.';
|
summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.';
|
||||||
} else {
|
} else {
|
||||||
const outputPath = normalizeComparablePath(effectiveOutputPath);
|
const outputPath = normalizeComparablePath(effectiveOutputPath);
|
||||||
const movieRoot = normalizeComparablePath(settings.movie_dir);
|
const movieRoot = normalizeComparablePath(effectiveMovieDir);
|
||||||
const stat = fs.lstatSync(outputPath);
|
const stat = fs.lstatSync(outputPath);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
const keepRoot = outputPath === movieRoot;
|
const keepRoot = outputPath === movieRoot;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ function mapChainRow(row, steps = []) {
|
|||||||
return {
|
return {
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
name: String(row.name || ''),
|
name: String(row.name || ''),
|
||||||
|
orderIndex: Number(row.order_index || 0),
|
||||||
steps: steps.map(mapStepRow),
|
steps: steps.map(mapStepRow),
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at
|
updatedAt: row.updated_at
|
||||||
@@ -115,9 +116,9 @@ class ScriptChainService {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.all(
|
const rows = await db.all(
|
||||||
`
|
`
|
||||||
SELECT id, name, created_at, updated_at
|
SELECT id, name, order_index, created_at, updated_at
|
||||||
FROM script_chains
|
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 db = await getDb();
|
||||||
const row = await db.get(
|
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]
|
[normalizedId]
|
||||||
);
|
);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -186,7 +187,7 @@ class ScriptChainService {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const placeholders = ids.map(() => '?').join(', ');
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
const rows = await db.all(
|
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
|
ids
|
||||||
);
|
);
|
||||||
const stepRows = await db.all(
|
const stepRows = await db.all(
|
||||||
@@ -229,9 +230,13 @@ class ScriptChainService {
|
|||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
try {
|
try {
|
||||||
|
const nextOrderIndex = await this._getNextOrderIndex(db);
|
||||||
const result = await db.run(
|
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;
|
const chainId = result.lastID;
|
||||||
await this._saveSteps(db, chainId, steps);
|
await this._saveSteps(db, chainId, steps);
|
||||||
@@ -289,6 +294,78 @@ class ScriptChainService {
|
|||||||
return existing;
|
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) {
|
async _saveSteps(db, chainId, steps) {
|
||||||
for (let i = 0; i < steps.length; i++) {
|
for (let i = 0; i < steps.length; i++) {
|
||||||
const step = steps[i];
|
const step = steps[i];
|
||||||
@@ -367,7 +444,7 @@ class ScriptChainService {
|
|||||||
`Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}`
|
`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) {
|
if (!success) {
|
||||||
logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code });
|
logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code });
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ function mapScriptRow(row) {
|
|||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
name: String(row.name || ''),
|
name: String(row.name || ''),
|
||||||
scriptBody: String(row.script_body || ''),
|
scriptBody: String(row.script_body || ''),
|
||||||
|
orderIndex: Number(row.order_index || 0),
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at
|
updatedAt: row.updated_at
|
||||||
};
|
};
|
||||||
@@ -225,9 +226,9 @@ class ScriptService {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.all(
|
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
|
FROM scripts
|
||||||
ORDER BY LOWER(name) ASC, id ASC
|
ORDER BY order_index ASC, id ASC
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
return rows.map(mapScriptRow);
|
return rows.map(mapScriptRow);
|
||||||
@@ -241,7 +242,7 @@ class ScriptService {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const row = await db.get(
|
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
|
FROM scripts
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`,
|
`,
|
||||||
@@ -259,12 +260,13 @@ class ScriptService {
|
|||||||
const normalized = validateScriptPayload(payload, { partial: false });
|
const normalized = validateScriptPayload(payload, { partial: false });
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
try {
|
try {
|
||||||
|
const nextOrderIndex = await this._getNextOrderIndex(db);
|
||||||
const result = await db.run(
|
const result = await db.run(
|
||||||
`
|
`
|
||||||
INSERT INTO scripts (name, script_body, created_at, updated_at)
|
INSERT INTO scripts (name, script_body, order_index, created_at, updated_at)
|
||||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`,
|
`,
|
||||||
[normalized.name, normalized.scriptBody]
|
[normalized.name, normalized.scriptBody, nextOrderIndex]
|
||||||
);
|
);
|
||||||
return this.getScriptById(result.lastID);
|
return this.getScriptById(result.lastID);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -328,7 +330,7 @@ class ScriptService {
|
|||||||
const placeholders = ids.map(() => '?').join(', ');
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
const rows = await db.all(
|
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
|
FROM scripts
|
||||||
WHERE id IN (${placeholders})
|
WHERE id IN (${placeholders})
|
||||||
`,
|
`,
|
||||||
@@ -358,6 +360,76 @@ class ScriptService {
|
|||||||
return scripts;
|
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 = {}) {
|
async createExecutableScriptFile(script, context = {}) {
|
||||||
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
|
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
|
||||||
const scriptBody = normalizeScriptBody(script?.scriptBody);
|
const scriptBody = normalizeScriptBody(script?.scriptBody);
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
|||||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'other'];
|
const MEDIA_PROFILES = ['bluray', 'dvd', 'other'];
|
||||||
const PROFILED_SETTINGS = {
|
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: {
|
mediainfo_extra_args: {
|
||||||
bluray: 'mediainfo_extra_args_bluray',
|
bluray: 'mediainfo_extra_args_bluray',
|
||||||
dvd: 'mediainfo_extra_args_dvd'
|
dvd: 'mediainfo_extra_args_dvd'
|
||||||
@@ -67,6 +77,10 @@ const PROFILED_SETTINGS = {
|
|||||||
dvd: 'output_folder_template_dvd'
|
dvd: 'output_folder_template_dvd'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
||||||
|
'raw_dir',
|
||||||
|
'movie_dir'
|
||||||
|
]);
|
||||||
|
|
||||||
function applyRuntimeLogDirSetting(rawValue) {
|
function applyRuntimeLogDirSetting(rawValue) {
|
||||||
const resolved = setLogRootDir(rawValue);
|
const resolved = setLogRootDir(rawValue);
|
||||||
@@ -227,10 +241,28 @@ function normalizeMediaProfileValue(value) {
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
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';
|
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';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
||||||
@@ -253,6 +285,16 @@ function resolveProfileFallbackOrder(profile) {
|
|||||||
return ['dvd', 'bluray'];
|
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) {
|
function normalizePresetListLines(rawOutput) {
|
||||||
const lines = String(rawOutput || '').split(/\r?\n/);
|
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
@@ -434,8 +476,9 @@ class SettingsService {
|
|||||||
|
|
||||||
resolveEffectiveToolSettings(settingsMap = {}, mediaProfile = null) {
|
resolveEffectiveToolSettings(settingsMap = {}, mediaProfile = null) {
|
||||||
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||||
const fallbackOrder = resolveProfileFallbackOrder(mediaProfile);
|
const normalizedRequestedProfile = normalizeMediaProfileValue(mediaProfile);
|
||||||
const resolvedMediaProfile = normalizeMediaProfileValue(mediaProfile) || fallbackOrder[0] || 'dvd';
|
const fallbackOrder = resolveProfileFallbackOrder(normalizedRequestedProfile);
|
||||||
|
const resolvedMediaProfile = normalizedRequestedProfile || fallbackOrder[0] || 'dvd';
|
||||||
const effective = {
|
const effective = {
|
||||||
...sourceMap,
|
...sourceMap,
|
||||||
media_profile: resolvedMediaProfile
|
media_profile: resolvedMediaProfile
|
||||||
@@ -443,6 +486,17 @@ class SettingsService {
|
|||||||
|
|
||||||
for (const [legacyKey, profileKeys] of Object.entries(PROFILED_SETTINGS)) {
|
for (const [legacyKey, profileKeys] of Object.entries(PROFILED_SETTINGS)) {
|
||||||
let resolvedValue = sourceMap[legacyKey];
|
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) {
|
for (const profile of fallbackOrder) {
|
||||||
const profileKey = profileKeys?.[profile];
|
const profileKey = profileKeys?.[profile];
|
||||||
if (!profileKey) {
|
if (!profileKey) {
|
||||||
@@ -697,10 +751,10 @@ class SettingsService {
|
|||||||
const normalizedProfile = normalizeMediaProfileValue(options?.mediaProfile || deviceInfo?.mediaProfile || null);
|
const normalizedProfile = normalizeMediaProfileValue(options?.mediaProfile || deviceInfo?.mediaProfile || null);
|
||||||
const isDvd = normalizedProfile === 'dvd';
|
const isDvd = normalizedProfile === 'dvd';
|
||||||
if (isDvd) {
|
if (isDvd) {
|
||||||
const isoBase = options?.isoOutputBase
|
const backupBase = options?.backupOutputBase
|
||||||
? path.join(rawJobDir, options.isoOutputBase)
|
? path.join(rawJobDir, options.backupOutputBase)
|
||||||
: rawJobDir;
|
: rawJobDir;
|
||||||
baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', '--noscan', sourceArg, isoBase];
|
baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', '--noscan', sourceArg, backupBase];
|
||||||
} else {
|
} else {
|
||||||
baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', sourceArg, rawJobDir];
|
baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', sourceArg, rawJobDir];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ CREATE TABLE jobs (
|
|||||||
detected_title TEXT,
|
detected_title TEXT,
|
||||||
last_state TEXT,
|
last_state TEXT,
|
||||||
raw_path TEXT,
|
raw_path TEXT,
|
||||||
|
rip_successful INTEGER NOT NULL DEFAULT 0,
|
||||||
makemkv_info_json TEXT,
|
makemkv_info_json TEXT,
|
||||||
handbrake_info_json TEXT,
|
handbrake_info_json TEXT,
|
||||||
mediainfo_info_json TEXT,
|
mediainfo_info_json TEXT,
|
||||||
@@ -56,20 +57,24 @@ CREATE TABLE scripts (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
script_body TEXT NOT NULL,
|
script_body TEXT NOT NULL,
|
||||||
|
order_index INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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_name ON scripts(name);
|
||||||
|
CREATE INDEX idx_scripts_order_index ON scripts(order_index, id);
|
||||||
|
|
||||||
CREATE TABLE script_chains (
|
CREATE TABLE script_chains (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
order_index INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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_name ON script_chains(name);
|
||||||
|
CREATE INDEX idx_script_chains_order_index ON script_chains(order_index, id);
|
||||||
|
|
||||||
CREATE TABLE script_chain_steps (
|
CREATE TABLE script_chain_steps (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -96,3 +101,33 @@ CREATE TABLE pipeline_state (
|
|||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (active_job_id) REFERENCES jobs(id)
|
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);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api } from './api/client';
|
|||||||
import { useWebSocket } from './hooks/useWebSocket';
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import HistoryPage from './pages/HistoryPage';
|
||||||
import DatabasePage from './pages/DatabasePage';
|
import DatabasePage from './pages/DatabasePage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -122,7 +123,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/history" element={<DatabasePage />} />
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
<Route path="/database" element={<DatabasePage />} />
|
<Route path="/database" element={<DatabasePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export const api = {
|
|||||||
body: JSON.stringify(payload || {})
|
body: JSON.stringify(payload || {})
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
reorderScripts(orderedScriptIds = []) {
|
||||||
|
return request('/settings/scripts/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
orderedScriptIds: Array.isArray(orderedScriptIds) ? orderedScriptIds : []
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
updateScript(scriptId, payload = {}) {
|
updateScript(scriptId, payload = {}) {
|
||||||
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -73,6 +81,14 @@ export const api = {
|
|||||||
body: JSON.stringify(payload)
|
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 = {}) {
|
updateScriptChain(chainId, payload = {}) {
|
||||||
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -84,6 +100,11 @@ export const api = {
|
|||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
testScriptChain(chainId) {
|
||||||
|
return request(`/settings/script-chains/${encodeURIComponent(chainId)}/test`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
updateSetting(key, value) {
|
updateSetting(key, value) {
|
||||||
return request(`/settings/${encodeURIComponent(key)}`, {
|
return request(`/settings/${encodeURIComponent(key)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -243,6 +264,45 @@ export const api = {
|
|||||||
}
|
}
|
||||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
return request(`/history/${jobId}${suffix}`);
|
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 })
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
554
frontend/src/components/CronJobsTab.jsx
Normal file
554
frontend/src/components/CronJobsTab.jsx
Normal file
@@ -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 <span className="cron-status cron-status--none">–</span>;
|
||||||
|
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 <span className={`cron-status cron-status--${info.cls}`}>{info.label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="cron-tab">
|
||||||
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
label="Neuer Cronjob"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
onClick={openCreate}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Aktualisieren"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
onClick={loadAll}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{jobs.length === 0 && !loading && (
|
||||||
|
<p className="cron-empty-hint">Keine Cronjobs vorhanden. Klicke auf “Neuer Cronjob”, um einen anzulegen.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<div className="cron-list">
|
||||||
|
{jobs.map((job) => {
|
||||||
|
const isBusy = busyId === job.id;
|
||||||
|
return (
|
||||||
|
<div key={job.id} className={`cron-item${job.enabled ? '' : ' cron-item--disabled'}`}>
|
||||||
|
<div className="cron-item-header">
|
||||||
|
<span className="cron-item-name">{job.name}</span>
|
||||||
|
<code className="cron-item-expr">{job.cronExpression}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cron-item-meta">
|
||||||
|
<span className="cron-meta-entry">
|
||||||
|
<span className="cron-meta-label">Quelle:</span>
|
||||||
|
<span className="cron-meta-value">
|
||||||
|
{job.sourceType === 'chain' ? '⛓ ' : '📜 '}
|
||||||
|
{job.sourceName || `#${job.sourceId}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="cron-meta-entry">
|
||||||
|
<span className="cron-meta-label">Letzter Lauf:</span>
|
||||||
|
<span className="cron-meta-value">
|
||||||
|
{formatDateTime(job.lastRunAt)}
|
||||||
|
{job.lastRunStatus && <StatusBadge status={job.lastRunStatus} />}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="cron-meta-entry">
|
||||||
|
<span className="cron-meta-label">Nächster Lauf:</span>
|
||||||
|
<span className="cron-meta-value">{formatDateTime(job.nextRunAt)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cron-item-toggles">
|
||||||
|
<label className="cron-toggle-label">
|
||||||
|
<InputSwitch
|
||||||
|
checked={job.enabled}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={() => handleToggle(job, 'enabled')}
|
||||||
|
/>
|
||||||
|
<span>Aktiviert</span>
|
||||||
|
</label>
|
||||||
|
<label className="cron-toggle-label">
|
||||||
|
<InputSwitch
|
||||||
|
checked={job.pushoverEnabled}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={() => handleToggle(job, 'pushoverEnabled')}
|
||||||
|
/>
|
||||||
|
<span>Pushover</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cron-item-actions">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-play"
|
||||||
|
tooltip="Jetzt ausführen"
|
||||||
|
tooltipOptions={{ position: 'top' }}
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
outlined
|
||||||
|
loading={isBusy && busyId === job.id}
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => handleRunNow(job)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-list"
|
||||||
|
tooltip="Logs anzeigen"
|
||||||
|
tooltipOptions={{ position: 'top' }}
|
||||||
|
size="small"
|
||||||
|
severity="info"
|
||||||
|
outlined
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => openLogs(job)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
tooltip="Bearbeiten"
|
||||||
|
tooltipOptions={{ position: 'top' }}
|
||||||
|
size="small"
|
||||||
|
outlined
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => openEdit(job)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
tooltip="Löschen"
|
||||||
|
tooltipOptions={{ position: 'top' }}
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => handleDelete(job)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Editor-Dialog ──────────────────────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
header={editorMode === 'create' ? 'Neuer Cronjob' : 'Cronjob bearbeiten'}
|
||||||
|
visible={editorOpen}
|
||||||
|
onHide={closeEditor}
|
||||||
|
style={{ width: '520px' }}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||||
|
<Button label="Abbrechen" severity="secondary" outlined onClick={closeEditor} disabled={saving} />
|
||||||
|
<Button label="Speichern" icon="pi pi-save" onClick={handleSave} loading={saving} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="cron-editor-fields">
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div className="cron-editor-field">
|
||||||
|
<label className="cron-editor-label">Name</label>
|
||||||
|
<InputText
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="z.B. Tägliche Bereinigung"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron-Ausdruck */}
|
||||||
|
<div className="cron-editor-field">
|
||||||
|
<label className="cron-editor-label">
|
||||||
|
Cron-Ausdruck
|
||||||
|
<a
|
||||||
|
href="https://crontab.guru/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="cron-help-link"
|
||||||
|
title="crontab.guru öffnen"
|
||||||
|
>
|
||||||
|
<i className="pi pi-question-circle" />
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
<InputText
|
||||||
|
value={form.cronExpression}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<small className="cron-expr-hint cron-expr-hint--checking">Wird geprüft…</small>
|
||||||
|
)}
|
||||||
|
{!exprValidating && exprValidation && exprValidation.valid && (
|
||||||
|
<small className="cron-expr-hint cron-expr-hint--ok">
|
||||||
|
✓ Gültig – nächste Ausführung: {formatDateTime(exprValidation.nextRunAt)}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
{!exprValidating && exprValidation && !exprValidation.valid && (
|
||||||
|
<small className="cron-expr-hint cron-expr-hint--err">✗ {exprValidation.error}</small>
|
||||||
|
)}
|
||||||
|
<div className="cron-expr-examples">
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<button
|
||||||
|
key={expr}
|
||||||
|
type="button"
|
||||||
|
className="cron-expr-chip"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, cronExpression: expr }))}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quell-Typ */}
|
||||||
|
<div className="cron-editor-field">
|
||||||
|
<label className="cron-editor-label">Quell-Typ</label>
|
||||||
|
<div className="cron-source-type-row">
|
||||||
|
{[
|
||||||
|
{ value: 'script', label: '📜 Skript' },
|
||||||
|
{ value: 'chain', label: '⛓ Skriptkette' }
|
||||||
|
].map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`cron-source-type-btn${form.sourceType === value ? ' active' : ''}`}
|
||||||
|
onClick={() => setForm((f) => ({ ...f, sourceType: value, sourceId: null }))}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quelle auswählen */}
|
||||||
|
<div className="cron-editor-field">
|
||||||
|
<label className="cron-editor-label">
|
||||||
|
{form.sourceType === 'script' ? 'Skript' : 'Skriptkette'}
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
value={form.sourceId}
|
||||||
|
options={sourceOptions}
|
||||||
|
onChange={(e) => 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'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggles */}
|
||||||
|
<div className="cron-editor-toggles">
|
||||||
|
<label className="cron-toggle-label">
|
||||||
|
<InputSwitch
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, enabled: e.value }))}
|
||||||
|
/>
|
||||||
|
<span>Aktiviert</span>
|
||||||
|
</label>
|
||||||
|
<label className="cron-toggle-label">
|
||||||
|
<InputSwitch
|
||||||
|
checked={form.pushoverEnabled}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, pushoverEnabled: e.value }))}
|
||||||
|
/>
|
||||||
|
<span>Pushover-Benachrichtigung</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Logs-Dialog ──────────────────────────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
header={logsJob ? `Logs: ${logsJob.name}` : 'Logs'}
|
||||||
|
visible={Boolean(logsJob)}
|
||||||
|
onHide={() => setLogsJob(null)}
|
||||||
|
style={{ width: '720px' }}
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
label="Schließen"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => setLogsJob(null)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{logsLoading && <p>Lade Logs…</p>}
|
||||||
|
{!logsLoading && logs.length === 0 && (
|
||||||
|
<p className="cron-empty-hint">Noch keine Ausführungen protokolliert.</p>
|
||||||
|
)}
|
||||||
|
{!logsLoading && logs.length > 0 && (
|
||||||
|
<div className="cron-log-list">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<details key={log.id} className="cron-log-entry">
|
||||||
|
<summary className="cron-log-summary">
|
||||||
|
<StatusBadge status={log.status} />
|
||||||
|
<span className="cron-log-time">{formatDateTime(log.startedAt)}</span>
|
||||||
|
{log.finishedAt && (
|
||||||
|
<span className="cron-log-duration">
|
||||||
|
{Math.round((new Date(log.finishedAt) - new Date(log.startedAt)) / 1000)}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.errorMessage && (
|
||||||
|
<span className="cron-log-errmsg">{log.errorMessage}</span>
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
{log.output && (
|
||||||
|
<pre className="cron-log-output">{log.output}</pre>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,13 +55,28 @@ function ScriptSummarySection({ title, summary }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveMediaType(job) {
|
function resolveMediaType(job) {
|
||||||
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
const candidates = [
|
||||||
if (raw === 'bluray') {
|
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';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
if (raw === 'dvd' || raw === 'disc') {
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +164,7 @@ export default function JobDetailDialog({
|
|||||||
reencodeBusy = false,
|
reencodeBusy = false,
|
||||||
deleteEntryBusy = 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 running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||||
const showFinalLog = !running;
|
const showFinalLog = !running;
|
||||||
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !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 hasRestartInput = Boolean(job?.encode_input_path || job?.raw_path || job?.encodePlan?.encodeInputPath);
|
||||||
const canRestartEncode = Boolean(hasConfirmedPlan && hasRestartInput && !running);
|
const canRestartEncode = Boolean(hasConfirmedPlan && hasRestartInput && !running);
|
||||||
const canRestartReview = Boolean(
|
const canRestartReview = Boolean(
|
||||||
(job?.rawStatus?.exists || job?.raw_path)
|
job?.rawStatus?.exists
|
||||||
|
&& job?.rawStatus?.isEmpty !== true
|
||||||
&& !running
|
&& !running
|
||||||
&& typeof onRestartReview === 'function'
|
&& typeof onRestartReview === 'function'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -179,13 +179,28 @@ function getAnalyzeContext(job) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveMediaType(job) {
|
function resolveMediaType(job) {
|
||||||
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
const candidates = [
|
||||||
if (raw === 'bluray') {
|
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';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
if (raw === 'dvd' || raw === 'disc') {
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,28 @@ import {
|
|||||||
} from '../utils/statusPresentation';
|
} from '../utils/statusPresentation';
|
||||||
|
|
||||||
function resolveMediaType(row) {
|
function resolveMediaType(row) {
|
||||||
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
const candidates = [
|
||||||
if (raw === 'bluray') {
|
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';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
if (raw === 'dvd' || raw === 'disc') {
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,7 +681,10 @@ export default function DatabasePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="RAW ohne Historie" subTitle="Ordner in raw_dir ohne zugehörigen Job können hier importiert werden">
|
<Card
|
||||||
|
title="RAW ohne Historie"
|
||||||
|
subTitle="Ordner in den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,other}) ohne zugehörigen Job können hier importiert werden"
|
||||||
|
>
|
||||||
<div className="table-filters">
|
<div className="table-filters">
|
||||||
<Button
|
<Button
|
||||||
label="RAW prüfen"
|
label="RAW prüfen"
|
||||||
|
|||||||
@@ -25,37 +25,40 @@ const MEDIA_FILTER_OPTIONS = [
|
|||||||
{ label: 'Sonstiges', value: 'other' }
|
{ label: 'Sonstiges', value: 'other' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const BASE_SORT_FIELD_OPTIONS = [
|
const SORT_OPTIONS = [
|
||||||
{ label: 'Startzeit', value: 'start_time' },
|
{ label: 'Startzeit: Neu -> Alt', value: '!start_time' },
|
||||||
{ label: 'Endzeit', value: 'end_time' },
|
{ label: 'Startzeit: Alt -> Neu', value: 'start_time' },
|
||||||
{ label: 'Titel', value: 'title' },
|
{ label: 'Endzeit: Neu -> Alt', value: '!end_time' },
|
||||||
{ label: 'Medium', value: 'mediaType' }
|
{ label: 'Endzeit: Alt -> Neu', value: 'end_time' },
|
||||||
|
{ label: 'Titel: A -> Z', value: 'sortTitle' },
|
||||||
|
{ label: 'Titel: Z -> A', value: '!sortTitle' },
|
||||||
|
{ label: 'Medium: A -> Z', value: 'sortMediaType' },
|
||||||
|
{ label: 'Medium: Z -> A', value: '!sortMediaType' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const OPTIONAL_SORT_FIELD_OPTIONS = [
|
|
||||||
{ label: 'Keine', value: '' },
|
|
||||||
...BASE_SORT_FIELD_OPTIONS
|
|
||||||
];
|
|
||||||
|
|
||||||
const SORT_DIRECTION_OPTIONS = [
|
|
||||||
{ label: 'Aufsteigend', value: 1 },
|
|
||||||
{ label: 'Absteigend', value: -1 }
|
|
||||||
];
|
|
||||||
|
|
||||||
const MEDIA_SORT_RANK = {
|
|
||||||
bluray: 0,
|
|
||||||
dvd: 1,
|
|
||||||
other: 2
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveMediaType(row) {
|
function resolveMediaType(row) {
|
||||||
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
const candidates = [
|
||||||
if (raw === 'bluray') {
|
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';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
if (raw === 'dvd' || raw === 'disc') {
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,57 +104,6 @@ function normalizeSortText(value) {
|
|||||||
return String(value || '').trim().toLocaleLowerCase('de-DE');
|
return String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSortDate(value) {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const ts = new Date(value).getTime();
|
|
||||||
return Number.isFinite(ts) ? ts : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareSortValues(a, b) {
|
|
||||||
const aMissing = a === null || a === undefined || a === '';
|
|
||||||
const bMissing = b === null || b === undefined || b === '';
|
|
||||||
if (aMissing && bMissing) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (aMissing) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (bMissing) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof a === 'number' && typeof b === 'number') {
|
|
||||||
if (a === b) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return a > b ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(a).localeCompare(String(b), 'de', {
|
|
||||||
sensitivity: 'base',
|
|
||||||
numeric: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSortValue(row, field) {
|
|
||||||
switch (field) {
|
|
||||||
case 'start_time':
|
|
||||||
return normalizeSortDate(row?.start_time);
|
|
||||||
case 'end_time':
|
|
||||||
return normalizeSortDate(row?.end_time);
|
|
||||||
case 'title':
|
|
||||||
return normalizeSortText(row?.title || row?.detected_title || '');
|
|
||||||
case 'mediaType': {
|
|
||||||
const mediaType = resolveMediaType(row);
|
|
||||||
return MEDIA_SORT_RANK[mediaType] ?? MEDIA_SORT_RANK.other;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeRating(value) {
|
function sanitizeRating(value) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw || raw.toUpperCase() === 'N/A') {
|
if (!raw || raw.toUpperCase() === 'N/A') {
|
||||||
@@ -211,18 +163,16 @@ export default function HistoryPage() {
|
|||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
const [mediumFilter, setMediumFilter] = useState('');
|
const [mediumFilter, setMediumFilter] = useState('');
|
||||||
const [layout, setLayout] = useState('list');
|
const [layout, setLayout] = useState('list');
|
||||||
const [sortPrimaryField, setSortPrimaryField] = useState('start_time');
|
const [sortKey, setSortKey] = useState('!start_time');
|
||||||
const [sortPrimaryOrder, setSortPrimaryOrder] = useState(-1);
|
const [sortField, setSortField] = useState('start_time');
|
||||||
const [sortSecondaryField, setSortSecondaryField] = useState('title');
|
const [sortOrder, setSortOrder] = useState(-1);
|
||||||
const [sortSecondaryOrder, setSortSecondaryOrder] = useState(1);
|
|
||||||
const [sortTertiaryField, setSortTertiaryField] = useState('mediaType');
|
|
||||||
const [sortTertiaryOrder, setSortTertiaryOrder] = useState(1);
|
|
||||||
const [selectedJob, setSelectedJob] = useState(null);
|
const [selectedJob, setSelectedJob] = useState(null);
|
||||||
const [detailVisible, setDetailVisible] = useState(false);
|
const [detailVisible, setDetailVisible] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [logLoadingMode, setLogLoadingMode] = useState(null);
|
const [logLoadingMode, setLogLoadingMode] = useState(null);
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||||
|
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
@@ -238,51 +188,21 @@ export default function HistoryPage() {
|
|||||||
return next;
|
return next;
|
||||||
}, [queuedJobIds]);
|
}, [queuedJobIds]);
|
||||||
|
|
||||||
const sortDescriptors = useMemo(() => {
|
const preparedJobs = useMemo(
|
||||||
const seen = new Set();
|
() => jobs.map((job) => ({
|
||||||
const rawDescriptors = [
|
...job,
|
||||||
{ field: String(sortPrimaryField || '').trim(), order: Number(sortPrimaryOrder || -1) >= 0 ? 1 : -1 },
|
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
|
||||||
{ field: String(sortSecondaryField || '').trim(), order: Number(sortSecondaryOrder || -1) >= 0 ? 1 : -1 },
|
sortMediaType: resolveMediaType(job)
|
||||||
{ field: String(sortTertiaryField || '').trim(), order: Number(sortTertiaryOrder || -1) >= 0 ? 1 : -1 }
|
})),
|
||||||
];
|
[jobs]
|
||||||
|
);
|
||||||
|
|
||||||
const descriptors = [];
|
const visibleJobs = useMemo(
|
||||||
for (const descriptor of rawDescriptors) {
|
() => (mediumFilter
|
||||||
if (!descriptor.field || seen.has(descriptor.field)) {
|
? preparedJobs.filter((job) => job.sortMediaType === mediumFilter)
|
||||||
continue;
|
: preparedJobs),
|
||||||
}
|
[preparedJobs, mediumFilter]
|
||||||
seen.add(descriptor.field);
|
);
|
||||||
descriptors.push(descriptor);
|
|
||||||
}
|
|
||||||
return descriptors;
|
|
||||||
}, [sortPrimaryField, sortPrimaryOrder, sortSecondaryField, sortSecondaryOrder, sortTertiaryField, sortTertiaryOrder]);
|
|
||||||
|
|
||||||
const visibleJobs = useMemo(() => {
|
|
||||||
const filtered = mediumFilter
|
|
||||||
? jobs.filter((job) => resolveMediaType(job) === mediumFilter)
|
|
||||||
: [...jobs];
|
|
||||||
|
|
||||||
if (sortDescriptors.length === 0) {
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
for (const descriptor of sortDescriptors) {
|
|
||||||
const valueA = resolveSortValue(a, descriptor.field);
|
|
||||||
const valueB = resolveSortValue(b, descriptor.field);
|
|
||||||
const compared = compareSortValues(valueA, valueB);
|
|
||||||
if (compared !== 0) {
|
|
||||||
return compared * descriptor.order;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const idA = Number(a?.id || 0);
|
|
||||||
const idB = Number(b?.id || 0);
|
|
||||||
return idB - idA;
|
|
||||||
});
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}, [jobs, mediumFilter, sortDescriptors]);
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -297,7 +217,9 @@ export default function HistoryPage() {
|
|||||||
setJobs([]);
|
setJobs([]);
|
||||||
}
|
}
|
||||||
if (queueResponse.status === 'fulfilled') {
|
if (queueResponse.status === 'fulfilled') {
|
||||||
const queuedRows = Array.isArray(queueResponse.value?.queue?.queuedJobs) ? queueResponse.value.queue.queuedJobs : [];
|
const queuedRows = Array.isArray(queueResponse.value?.queue?.queuedJobs)
|
||||||
|
? queueResponse.value.queue.queuedJobs
|
||||||
|
: [];
|
||||||
const queuedIds = queuedRows
|
const queuedIds = queuedRows
|
||||||
.map((item) => normalizeJobId(item?.jobId))
|
.map((item) => normalizeJobId(item?.jobId))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -320,6 +242,25 @@ export default function HistoryPage() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [search, status]);
|
}, [search, status]);
|
||||||
|
|
||||||
|
const onSortChange = (event) => {
|
||||||
|
const value = String(event.value || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
setSortKey('!start_time');
|
||||||
|
setSortField('start_time');
|
||||||
|
setSortOrder(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('!')) {
|
||||||
|
setSortOrder(-1);
|
||||||
|
setSortField(value.substring(1));
|
||||||
|
} else {
|
||||||
|
setSortOrder(1);
|
||||||
|
setSortField(value);
|
||||||
|
}
|
||||||
|
setSortKey(value);
|
||||||
|
};
|
||||||
|
|
||||||
const openDetail = async (row) => {
|
const openDetail = async (row) => {
|
||||||
const jobId = Number(row?.id || 0);
|
const jobId = Number(row?.id || 0);
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -474,6 +415,57 @@ export default function HistoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRestartReview = async (row) => {
|
||||||
|
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||||
|
const confirmed = window.confirm(`Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionBusy(true);
|
||||||
|
try {
|
||||||
|
await api.restartReviewFromRaw(row.id);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Review-Neustart',
|
||||||
|
detail: 'Analyse gestartet. Job ist jetzt im Dashboard verfügbar.',
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
await refreshDetailIfOpen(row.id);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEntry = async (row) => {
|
||||||
|
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||||
|
const confirmed = window.confirm(`Historieneintrag für "${title}" wirklich löschen?\nDateien werden NICHT gelöscht.`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteEntryBusy(true);
|
||||||
|
try {
|
||||||
|
await api.deleteJobEntry(row.id, 'none');
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Eintrag gelöscht',
|
||||||
|
detail: `"${title}" wurde aus der Historie entfernt.`,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
setDetailVisible(false);
|
||||||
|
setSelectedJob(null);
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
|
||||||
|
} finally {
|
||||||
|
setDeleteEntryBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveFromQueue = async (row) => {
|
const handleRemoveFromQueue = async (row) => {
|
||||||
const jobId = normalizeJobId(row?.id || row);
|
const jobId = normalizeJobId(row?.id || row);
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -535,6 +527,7 @@ export default function HistoryPage() {
|
|||||||
if (ratings.length === 0) {
|
if (ratings.length === 0) {
|
||||||
return <span className="history-dv-subtle">Keine Ratings</span>;
|
return <span className="history-dv-subtle">Keine Ratings</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ratings.map((rating) => (
|
return ratings.map((rating) => (
|
||||||
<span key={`${row?.id}-${rating.key}`} className="history-dv-rating-chip">
|
<span key={`${row?.id}-${rating.key}`} className="history-dv-rating-chip">
|
||||||
<strong>{rating.label}</strong>
|
<strong>{rating.label}</strong>
|
||||||
@@ -550,12 +543,11 @@ export default function HistoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderListItem = (row) => {
|
const listItem = (row) => {
|
||||||
const mediaMeta = resolveMediaTypeMeta(row);
|
const mediaMeta = resolveMediaTypeMeta(row);
|
||||||
const title = row?.title || row?.detected_title || '-';
|
|
||||||
const imdb = row?.imdb_id || '-';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="col-12" key={row.id}>
|
||||||
<div
|
<div
|
||||||
className="history-dv-item history-dv-item-list"
|
className="history-dv-item history-dv-item-list"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -572,9 +564,9 @@ export default function HistoryPage() {
|
|||||||
<div className="history-dv-main">
|
<div className="history-dv-main">
|
||||||
<div className="history-dv-head">
|
<div className="history-dv-head">
|
||||||
<div className="history-dv-title-block">
|
<div className="history-dv-title-block">
|
||||||
<strong className="history-dv-title">{title}</strong>
|
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
||||||
<small className="history-dv-subtle">
|
<small className="history-dv-subtle">
|
||||||
#{row?.id || '-'} | {row?.year || '-'} | {imdb}
|
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{renderStatusTag(row)}
|
{renderStatusTag(row)}
|
||||||
@@ -595,9 +587,7 @@ export default function HistoryPage() {
|
|||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-ratings-row">
|
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
||||||
{renderRatings(row)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-actions">
|
<div className="history-dv-actions">
|
||||||
@@ -612,15 +602,15 @@ export default function HistoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderGridItem = (row) => {
|
const gridItem = (row) => {
|
||||||
const mediaMeta = resolveMediaTypeMeta(row);
|
const mediaMeta = resolveMediaTypeMeta(row);
|
||||||
const title = row?.title || row?.detected_title || '-';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="history-dv-grid-cell">
|
<div className="col-12 md-col-6 xl-col-4" key={row.id}>
|
||||||
<div
|
<div
|
||||||
className="history-dv-item history-dv-item-grid"
|
className="history-dv-item history-dv-item-grid"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -630,25 +620,25 @@ export default function HistoryPage() {
|
|||||||
void openDetail(row);
|
void openDetail(row);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="history-dv-grid-head">
|
<div className="history-dv-grid-poster-wrap">
|
||||||
{renderPoster(row, 'history-dv-poster-lg')}
|
{renderPoster(row, 'history-dv-poster-grid')}
|
||||||
<div className="history-dv-grid-title-wrap">
|
</div>
|
||||||
<strong className="history-dv-title">{title}</strong>
|
|
||||||
|
<div className="history-dv-grid-main">
|
||||||
|
<div className="history-dv-head">
|
||||||
|
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
||||||
|
{renderStatusTag(row)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<small className="history-dv-subtle">
|
<small className="history-dv-subtle">
|
||||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
|
<div className="history-dv-meta-row">
|
||||||
<span className="job-step-cell">
|
<span className="job-step-cell">
|
||||||
<img src={mediaMeta.icon} alt={mediaMeta.alt} title={mediaMeta.label} className="media-indicator-icon" />
|
<img src={mediaMeta.icon} alt={mediaMeta.alt} title={mediaMeta.label} className="media-indicator-icon" />
|
||||||
<span>{mediaMeta.label}</span>
|
<span>{mediaMeta.label}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="history-dv-grid-status-row">
|
|
||||||
{renderStatusTag(row)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="history-dv-grid-time-row">
|
|
||||||
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
|
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
|
||||||
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
|
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -659,8 +649,7 @@ export default function HistoryPage() {
|
|||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-ratings-row">
|
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
||||||
{renderRatings(row)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-actions history-dv-actions-grid">
|
<div className="history-dv-actions history-dv-actions-grid">
|
||||||
@@ -683,20 +672,17 @@ export default function HistoryPage() {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (currentLayout === 'grid') {
|
return currentLayout === 'list' ? listItem(row) : gridItem(row);
|
||||||
return renderGridItem(row);
|
|
||||||
}
|
|
||||||
return renderListItem(row);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dataViewHeader = (
|
const header = (
|
||||||
<div>
|
|
||||||
<div className="history-dv-toolbar">
|
<div className="history-dv-toolbar">
|
||||||
<InputText
|
<InputText
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
placeholder="Suche nach Titel oder IMDb"
|
placeholder="Suche nach Titel oder IMDb"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={status}
|
value={status}
|
||||||
options={STATUS_FILTER_OPTIONS}
|
options={STATUS_FILTER_OPTIONS}
|
||||||
@@ -705,6 +691,7 @@ export default function HistoryPage() {
|
|||||||
onChange={(event) => setStatus(event.value)}
|
onChange={(event) => setStatus(event.value)}
|
||||||
placeholder="Status"
|
placeholder="Status"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={mediumFilter}
|
value={mediumFilter}
|
||||||
options={MEDIA_FILTER_OPTIONS}
|
options={MEDIA_FILTER_OPTIONS}
|
||||||
@@ -713,77 +700,20 @@ export default function HistoryPage() {
|
|||||||
onChange={(event) => setMediumFilter(event.value || '')}
|
onChange={(event) => setMediumFilter(event.value || '')}
|
||||||
placeholder="Medium"
|
placeholder="Medium"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
value={sortKey}
|
||||||
|
options={SORT_OPTIONS}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={onSortChange}
|
||||||
|
placeholder="Sortieren"
|
||||||
|
/>
|
||||||
|
|
||||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||||
|
|
||||||
<div className="history-dv-layout-toggle">
|
<div className="history-dv-layout-toggle">
|
||||||
<DataViewLayoutOptions
|
<DataViewLayoutOptions layout={layout} onChange={(event) => setLayout(event.value)} />
|
||||||
layout={layout}
|
|
||||||
onChange={(event) => setLayout(event.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="history-dv-sortbar">
|
|
||||||
<div className="history-dv-sort-rule">
|
|
||||||
<strong>1.</strong>
|
|
||||||
<Dropdown
|
|
||||||
value={sortPrimaryField}
|
|
||||||
options={BASE_SORT_FIELD_OPTIONS}
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
onChange={(event) => setSortPrimaryField(event.value || 'start_time')}
|
|
||||||
placeholder="Primär"
|
|
||||||
/>
|
|
||||||
<Dropdown
|
|
||||||
value={sortPrimaryOrder}
|
|
||||||
options={SORT_DIRECTION_OPTIONS}
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
onChange={(event) => setSortPrimaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
|
|
||||||
placeholder="Richtung"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="history-dv-sort-rule">
|
|
||||||
<strong>2.</strong>
|
|
||||||
<Dropdown
|
|
||||||
value={sortSecondaryField}
|
|
||||||
options={OPTIONAL_SORT_FIELD_OPTIONS}
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
onChange={(event) => setSortSecondaryField(event.value || '')}
|
|
||||||
placeholder="Sekundär"
|
|
||||||
/>
|
|
||||||
<Dropdown
|
|
||||||
value={sortSecondaryOrder}
|
|
||||||
options={SORT_DIRECTION_OPTIONS}
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
onChange={(event) => setSortSecondaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
|
|
||||||
placeholder="Richtung"
|
|
||||||
disabled={!sortSecondaryField}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="history-dv-sort-rule">
|
|
||||||
<strong>3.</strong>
|
|
||||||
<Dropdown
|
|
||||||
value={sortTertiaryField}
|
|
||||||
options={OPTIONAL_SORT_FIELD_OPTIONS}
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
onChange={(event) => setSortTertiaryField(event.value || '')}
|
|
||||||
placeholder="Tertiär"
|
|
||||||
/>
|
|
||||||
<Dropdown
|
|
||||||
value={sortTertiaryOrder}
|
|
||||||
options={SORT_DIRECTION_OPTIONS}
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
onChange={(event) => setSortTertiaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
|
|
||||||
placeholder="Richtung"
|
|
||||||
disabled={!sortTertiaryField}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -792,15 +722,17 @@ export default function HistoryPage() {
|
|||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
<Card title="Historie" subTitle="DataView mit Poster, Status, Dateiverfügbarkeit, Encode-Status und Ratings">
|
<Card title="Historie" subTitle="PrimeReact DataView">
|
||||||
<DataView
|
<DataView
|
||||||
value={visibleJobs}
|
value={visibleJobs}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
|
header={header}
|
||||||
itemTemplate={itemTemplate}
|
itemTemplate={itemTemplate}
|
||||||
paginator
|
paginator
|
||||||
rows={12}
|
rows={12}
|
||||||
rowsPerPageOptions={[12, 24, 48]}
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
header={dataViewHeader}
|
sortField={sortField}
|
||||||
|
sortOrder={sortOrder}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
emptyMessage="Keine Einträge"
|
emptyMessage="Keine Einträge"
|
||||||
className="history-dataview"
|
className="history-dataview"
|
||||||
@@ -814,12 +746,15 @@ export default function HistoryPage() {
|
|||||||
onLoadLog={handleLoadLog}
|
onLoadLog={handleLoadLog}
|
||||||
logLoadingMode={logLoadingMode}
|
logLoadingMode={logLoadingMode}
|
||||||
onRestartEncode={handleRestartEncode}
|
onRestartEncode={handleRestartEncode}
|
||||||
|
onRestartReview={handleRestartReview}
|
||||||
onReencode={handleReencode}
|
onReencode={handleReencode}
|
||||||
onDeleteFiles={handleDeleteFiles}
|
onDeleteFiles={handleDeleteFiles}
|
||||||
|
onDeleteEntry={handleDeleteEntry}
|
||||||
onRemoveFromQueue={handleRemoveFromQueue}
|
onRemoveFromQueue={handleRemoveFromQueue}
|
||||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||||
actionBusy={actionBusy}
|
actionBusy={actionBusy}
|
||||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||||
|
deleteEntryBusy={deleteEntryBusy}
|
||||||
onHide={() => {
|
onHide={() => {
|
||||||
setDetailVisible(false);
|
setDetailVisible(false);
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { InputText } from 'primereact/inputtext';
|
|||||||
import { InputTextarea } from 'primereact/inputtextarea';
|
import { InputTextarea } from 'primereact/inputtextarea';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||||
|
import CronJobsTab from '../components/CronJobsTab';
|
||||||
|
|
||||||
function buildValuesMap(categories) {
|
function buildValuesMap(categories) {
|
||||||
const next = {};
|
const next = {};
|
||||||
@@ -26,6 +27,30 @@ function isSameValue(a, b) {
|
|||||||
return 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) {
|
function injectHandBrakePresetOptions(categories, presetPayload) {
|
||||||
const list = Array.isArray(categories) ? categories : [];
|
const list = Array.isArray(categories) ? categories : [];
|
||||||
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
||||||
@@ -103,6 +128,8 @@ export default function SettingsPage() {
|
|||||||
const [scripts, setScripts] = useState([]);
|
const [scripts, setScripts] = useState([]);
|
||||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||||
const [scriptSaving, setScriptSaving] = useState(false);
|
const [scriptSaving, setScriptSaving] = useState(false);
|
||||||
|
const [scriptReordering, setScriptReordering] = useState(false);
|
||||||
|
const [scriptListDragSourceId, setScriptListDragSourceId] = useState(null);
|
||||||
const [scriptActionBusyId, setScriptActionBusyId] = useState(null);
|
const [scriptActionBusyId, setScriptActionBusyId] = useState(null);
|
||||||
const [scriptEditor, setScriptEditor] = useState({
|
const [scriptEditor, setScriptEditor] = useState({
|
||||||
mode: 'none',
|
mode: 'none',
|
||||||
@@ -117,6 +144,10 @@ export default function SettingsPage() {
|
|||||||
const [chains, setChains] = useState([]);
|
const [chains, setChains] = useState([]);
|
||||||
const [chainsLoading, setChainsLoading] = useState(false);
|
const [chainsLoading, setChainsLoading] = useState(false);
|
||||||
const [chainSaving, setChainSaving] = 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 [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
|
||||||
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||||||
const [chainDragSource, setChainDragSource] = useState(null);
|
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
|
// Chain editor handlers
|
||||||
const openChainEditor = (chain = null) => {
|
const openChainEditor = (chain = null) => {
|
||||||
if (chain) {
|
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
|
// Chain DnD handlers
|
||||||
const handleChainPaletteDragStart = (event, data) => {
|
const handleChainPaletteDragStart = (event, data) => {
|
||||||
setChainDragSource({ origin: 'palette', ...data });
|
setChainDragSource({ origin: 'palette', ...data });
|
||||||
@@ -626,6 +790,16 @@ export default function SettingsPage() {
|
|||||||
event.dataTransfer.dropEffect = chainDragSource?.origin === 'palette' ? 'copy' : 'move';
|
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 (
|
return (
|
||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
@@ -692,7 +866,7 @@ export default function SettingsPage() {
|
|||||||
onClick={startCreateScript}
|
onClick={startCreateScript}
|
||||||
severity="success"
|
severity="success"
|
||||||
outlined
|
outlined
|
||||||
disabled={scriptSaving || scriptEditor?.mode === 'create'}
|
disabled={scriptSaving || scriptReordering || scriptEditor?.mode === 'create'}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Scripts neu laden"
|
label="Scripts neu laden"
|
||||||
@@ -700,20 +874,24 @@ export default function SettingsPage() {
|
|||||||
severity="secondary"
|
severity="secondary"
|
||||||
onClick={() => loadScripts()}
|
onClick={() => loadScripts()}
|
||||||
loading={scriptsLoading}
|
loading={scriptsLoading}
|
||||||
disabled={scriptSaving}
|
disabled={scriptSaving || scriptReordering}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<small>
|
<small>
|
||||||
Die ausgewählten Scripts werden später pro Job nach erfolgreichem Encode in Reihenfolge ausgeführt.
|
Die ausgewählten Scripts werden später pro Job nach erfolgreichem Encode in Reihenfolge ausgeführt.
|
||||||
</small>
|
</small>
|
||||||
|
<small className="muted-inline">
|
||||||
|
Reihenfolge per Drag & Drop ändern.
|
||||||
|
{scriptReordering ? ' Speichere Reihenfolge ...' : ''}
|
||||||
|
</small>
|
||||||
|
|
||||||
<div className="script-list-box">
|
<div className="script-list-box">
|
||||||
<h4>Verfügbare Scripts</h4>
|
<h4>Verfügbare Scripts</h4>
|
||||||
{scriptsLoading ? (
|
{scriptsLoading ? (
|
||||||
<p>Lade Scripts ...</p>
|
<p>Lade Scripts ...</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="script-list">
|
<div className="script-list script-list--reorderable">
|
||||||
{scriptEditor?.mode === 'create' ? (
|
{scriptEditor?.mode === 'create' ? (
|
||||||
<div className="script-list-item script-list-item-editing">
|
<div className="script-list-item script-list-item-editing">
|
||||||
<div className="script-list-main">
|
<div className="script-list-main">
|
||||||
@@ -761,11 +939,31 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{scripts.length === 0 ? <p>Keine Scripts vorhanden.</p> : null}
|
{scripts.length === 0 ? (
|
||||||
|
<p>Keine Scripts vorhanden.</p>
|
||||||
{scripts.map((script) => {
|
) : (
|
||||||
|
<div className="script-order-list">
|
||||||
|
{scripts.map((script, index) => {
|
||||||
|
const isDragging = Number(scriptListDragSourceId) === Number(script.id);
|
||||||
return (
|
return (
|
||||||
<div key={script.id} className="script-list-item">
|
<div key={script.id} className="script-order-wrapper">
|
||||||
|
<div
|
||||||
|
className="script-order-drop-zone"
|
||||||
|
onDragOver={handleScriptListDragOver}
|
||||||
|
onDrop={(event) => handleScriptListDrop(event, index)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`script-list-item${isDragging ? ' script-list-item--dragging' : ''}`}
|
||||||
|
draggable={!scriptListDnDDisabled}
|
||||||
|
onDragStart={(event) => handleScriptListDragStart(event, script.id)}
|
||||||
|
onDragEnd={() => setScriptListDragSourceId(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`script-list-drag-handle${scriptListDnDDisabled ? ' disabled' : ''}`}
|
||||||
|
title={scriptListDnDDisabled ? 'Sortierung aktuell nicht verfügbar' : 'Ziehen zum Sortieren'}
|
||||||
|
>
|
||||||
|
<i className="pi pi-bars" />
|
||||||
|
</div>
|
||||||
<div className="script-list-main">
|
<div className="script-list-main">
|
||||||
<strong className="script-id-title">{`ID #${script.id} - ${script.name}`}</strong>
|
<strong className="script-id-title">{`ID #${script.id} - ${script.name}`}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -777,7 +975,7 @@ export default function SettingsPage() {
|
|||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => startEditScript(script)}
|
onClick={() => startEditScript(script)}
|
||||||
disabled={Boolean(scriptActionBusyId) || scriptSaving || scriptEditor?.mode === 'create'}
|
disabled={Boolean(scriptActionBusyId) || scriptSaving || scriptReordering || scriptEditor?.mode === 'create'}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-play"
|
icon="pi pi-play"
|
||||||
@@ -785,7 +983,7 @@ export default function SettingsPage() {
|
|||||||
severity="info"
|
severity="info"
|
||||||
onClick={() => handleTestScript(script)}
|
onClick={() => handleTestScript(script)}
|
||||||
loading={scriptActionBusyId === script.id}
|
loading={scriptActionBusyId === script.id}
|
||||||
disabled={Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id}
|
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
@@ -794,12 +992,20 @@ export default function SettingsPage() {
|
|||||||
outlined
|
outlined
|
||||||
onClick={() => handleDeleteScript(script)}
|
onClick={() => handleDeleteScript(script)}
|
||||||
loading={scriptActionBusyId === script.id}
|
loading={scriptActionBusyId === script.id}
|
||||||
disabled={Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id}
|
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<div
|
||||||
|
className="script-order-drop-zone script-order-drop-zone--end"
|
||||||
|
onDragOver={handleScriptListDragOver}
|
||||||
|
onDrop={(event) => handleScriptListDrop(event, scripts.length)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -875,6 +1081,7 @@ export default function SettingsPage() {
|
|||||||
severity="success"
|
severity="success"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => openChainEditor()}
|
onClick={() => openChainEditor()}
|
||||||
|
disabled={chainReordering}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Ketten neu laden"
|
label="Ketten neu laden"
|
||||||
@@ -882,6 +1089,7 @@ export default function SettingsPage() {
|
|||||||
severity="secondary"
|
severity="secondary"
|
||||||
onClick={() => loadChains()}
|
onClick={() => loadChains()}
|
||||||
loading={chainsLoading}
|
loading={chainsLoading}
|
||||||
|
disabled={chainReordering}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -889,6 +1097,10 @@ export default function SettingsPage() {
|
|||||||
Skriptketten kombinieren einzelne Scripte und Systemblöcke (z.B. Warten) zu einer ausführbaren Sequenz.
|
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.
|
Ketten können an Jobs als Pre- oder Post-Encode-Aktion hinterlegt werden.
|
||||||
</small>
|
</small>
|
||||||
|
<small className="muted-inline">
|
||||||
|
Reihenfolge per Drag & Drop ändern.
|
||||||
|
{chainReordering ? ' Speichere Reihenfolge ...' : ''}
|
||||||
|
</small>
|
||||||
|
|
||||||
<div className="script-list-box">
|
<div className="script-list-box">
|
||||||
<h4>Verfügbare Skriptketten</h4>
|
<h4>Verfügbare Skriptketten</h4>
|
||||||
@@ -897,9 +1109,29 @@ export default function SettingsPage() {
|
|||||||
) : chains.length === 0 ? (
|
) : chains.length === 0 ? (
|
||||||
<p>Keine Skriptketten vorhanden.</p>
|
<p>Keine Skriptketten vorhanden.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="script-list">
|
<div className="script-list script-list--reorderable">
|
||||||
{chains.map((chain) => (
|
<div className="script-order-list">
|
||||||
<div key={chain.id} className="script-list-item">
|
{chains.map((chain, index) => {
|
||||||
|
const isDragging = Number(chainListDragSourceId) === Number(chain.id);
|
||||||
|
return (
|
||||||
|
<div key={chain.id} className="script-order-wrapper">
|
||||||
|
<div
|
||||||
|
className="script-order-drop-zone"
|
||||||
|
onDragOver={handleChainListDragOver}
|
||||||
|
onDrop={(event) => handleChainListDrop(event, index)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`script-list-item${isDragging ? ' script-list-item--dragging' : ''}`}
|
||||||
|
draggable={!chainListDnDDisabled}
|
||||||
|
onDragStart={(event) => handleChainListDragStart(event, chain.id)}
|
||||||
|
onDragEnd={() => setChainListDragSourceId(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`script-list-drag-handle${chainListDnDDisabled ? ' disabled' : ''}`}
|
||||||
|
title={chainListDnDDisabled ? 'Sortierung aktuell nicht verfügbar' : 'Ziehen zum Sortieren'}
|
||||||
|
>
|
||||||
|
<i className="pi pi-bars" />
|
||||||
|
</div>
|
||||||
<div className="script-list-main">
|
<div className="script-list-main">
|
||||||
<strong className="script-id-title">{`ID #${chain.id} - ${chain.name}`}</strong>
|
<strong className="script-id-title">{`ID #${chain.id} - ${chain.name}`}</strong>
|
||||||
<small>
|
<small>
|
||||||
@@ -922,6 +1154,15 @@ export default function SettingsPage() {
|
|||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => openChainEditor(chain)}
|
onClick={() => openChainEditor(chain)}
|
||||||
|
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-play"
|
||||||
|
label="Test"
|
||||||
|
severity="info"
|
||||||
|
onClick={() => handleTestChain(chain)}
|
||||||
|
loading={chainActionBusyId === chain.id}
|
||||||
|
disabled={chainReordering || (Boolean(chainActionBusyId) && chainActionBusyId !== chain.id)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
@@ -929,13 +1170,47 @@ export default function SettingsPage() {
|
|||||||
severity="danger"
|
severity="danger"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => handleDeleteChain(chain)}
|
onClick={() => handleDeleteChain(chain)}
|
||||||
|
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div
|
||||||
|
className="script-order-drop-zone script-order-drop-zone--end"
|
||||||
|
onDragOver={handleChainListDragOver}
|
||||||
|
onDrop={(event) => handleChainListDrop(event, chains.length)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{lastChainTestResult ? (
|
||||||
|
<div className="script-test-result">
|
||||||
|
<h4>Letzter Ketten-Test: {lastChainTestResult.chainName}</h4>
|
||||||
|
<small>
|
||||||
|
Status: {lastChainTestResult.aborted ? 'Abgebrochen' : 'Erfolgreich'}
|
||||||
|
{' | '}Schritte: {lastChainTestResult.succeeded ?? 0}/{lastChainTestResult.steps ?? 0}
|
||||||
|
{lastChainTestResult.failed > 0 ? ` | Fehler: ${lastChainTestResult.failed}` : ''}
|
||||||
|
</small>
|
||||||
|
{(lastChainTestResult.results || []).map((step, i) => (
|
||||||
|
<div key={i} className="script-test-step">
|
||||||
|
<strong>
|
||||||
|
{`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'})`)}
|
||||||
|
</strong>
|
||||||
|
{(step.stdout || step.stderr) ? (
|
||||||
|
<pre>{`${step.stdout || ''}${step.stderr ? `\n${step.stderr}` : ''}`.trim()}</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chain editor dialog */}
|
{/* Chain editor dialog */}
|
||||||
@@ -1099,6 +1374,10 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel header="Cronjobs">
|
||||||
|
<CronJobsTab />
|
||||||
|
</TabPanel>
|
||||||
</TabView>
|
</TabView>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1126,6 +1126,36 @@ body {
|
|||||||
gap: 0.45rem;
|
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 {
|
.script-list-item {
|
||||||
border: 1px solid var(--rip-border);
|
border: 1px solid var(--rip-border);
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
@@ -1136,6 +1166,37 @@ body {
|
|||||||
gap: 0.55rem;
|
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 {
|
.script-list-item-editing {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1225,6 +1286,15 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.script-test-step {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-test-step strong {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.required {
|
.required {
|
||||||
color: #9d261b;
|
color: #9d261b;
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
@@ -1263,9 +1333,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-toolbar {
|
.history-dv-toolbar {
|
||||||
margin-bottom: 0.7rem;
|
margin-bottom: 0.5rem;
|
||||||
display: grid;
|
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;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -1275,27 +1345,29 @@ body {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-sortbar {
|
.history-dataview .p-dataview-content .p-grid.grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
margin: 0 -0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-sort-rule {
|
.history-dataview .p-dataview-content .p-grid.grid > .col-12,
|
||||||
display: grid;
|
.history-dataview .p-dataview-content .p-grid.grid > .md-col-6,
|
||||||
grid-template-columns: 1.4rem minmax(0, 1fr) 9.4rem;
|
.history-dataview .p-dataview-content .p-grid.grid > .xl-col-4 {
|
||||||
gap: 0.35rem;
|
width: 100%;
|
||||||
align-items: center;
|
padding: 0.35rem;
|
||||||
border: 1px solid var(--rip-border);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: var(--rip-panel-soft);
|
|
||||||
padding: 0.35rem 0.45rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-sort-rule strong {
|
@media (min-width: 900px) {
|
||||||
margin: 0;
|
.history-dataview.p-dataview-grid .p-dataview-content .p-grid.grid > .md-col-6 {
|
||||||
color: var(--rip-brown-700);
|
width: 50%;
|
||||||
text-align: center;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.history-dataview.p-dataview-grid .p-dataview-content .p-grid.grid > .xl-col-4 {
|
||||||
|
width: 33.3333%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-item {
|
.history-dv-item {
|
||||||
@@ -1397,7 +1469,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-poster,
|
.history-dv-poster,
|
||||||
.history-dv-poster-lg {
|
.history-dv-poster-grid {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@@ -1410,9 +1482,8 @@ body {
|
|||||||
height: 88px;
|
height: 88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-poster-lg {
|
.history-dv-poster-grid {
|
||||||
width: 66px;
|
height: 164px;
|
||||||
height: 96px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-poster-fallback {
|
.history-dv-poster-fallback {
|
||||||
@@ -1435,42 +1506,26 @@ body {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-grid-cell {
|
|
||||||
padding: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-dv-item-grid {
|
.history-dv-item-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.45rem;
|
gap: 0.5rem;
|
||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
height: 100%;
|
grid-template-rows: auto 1fr auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-grid-head {
|
.history-dv-grid-poster-wrap {
|
||||||
display: grid;
|
width: min(120px, 100%);
|
||||||
grid-template-columns: 66px minmax(0, 1fr);
|
margin: 0 auto;
|
||||||
gap: 0.65rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-grid-title-wrap {
|
.history-dv-grid-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.22rem;
|
gap: 0.35rem;
|
||||||
min-width: 0;
|
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 {
|
.history-dv-actions-grid {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-scroll-wrap {
|
.table-scroll-wrap {
|
||||||
@@ -2019,7 +2074,6 @@ body {
|
|||||||
.job-film-info-grid,
|
.job-film-info-grid,
|
||||||
.table-filters,
|
.table-filters,
|
||||||
.history-dv-toolbar,
|
.history-dv-toolbar,
|
||||||
.history-dv-sortbar,
|
|
||||||
.job-head-row,
|
.job-head-row,
|
||||||
.job-json-grid,
|
.job-json-grid,
|
||||||
.selected-meta,
|
.selected-meta,
|
||||||
@@ -2047,6 +2101,10 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.script-list--reorderable .script-list-item:not(.script-list-item-editing) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.post-script-row {
|
.post-script-row {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
@@ -2083,10 +2141,6 @@ body {
|
|||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-sort-rule {
|
|
||||||
grid-template-columns: 1.4rem minmax(0, 1fr) 8.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-dv-item-list {
|
.history-dv-item-list {
|
||||||
grid-template-columns: 52px minmax(0, 1fr);
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -2096,13 +2150,12 @@ body {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-grid-head {
|
.history-dv-toolbar {
|
||||||
grid-template-columns: 58px minmax(0, 1fr);
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-poster-lg {
|
.history-dv-poster-grid {
|
||||||
width: 58px;
|
height: 148px;
|
||||||
height: 84px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2115,14 +2168,6 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.history-dv-item-list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -2139,6 +2184,10 @@ body {
|
|||||||
height: 80px;
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-dv-poster-grid {
|
||||||
|
height: 136px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-row {
|
.search-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -2164,6 +2213,15 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.script-action-spacer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -2446,3 +2504,261 @@ body {
|
|||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
605
install-dev.sh
Executable file
605
install-dev.sh
Executable file
@@ -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 <pfad> Installationsverzeichnis (Standard: /opt/ripster)
|
||||||
|
# --user <benutzer> Systembenutzer für den Dienst (Standard: ripster)
|
||||||
|
# --port <port> Backend-Port (Standard: 3001)
|
||||||
|
# --host <hostname> 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" <<EOF
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME} main restricted universe multiverse
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-updates main restricted universe multiverse
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-security main restricted universe multiverse
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
debian)
|
||||||
|
cat > "$main_list" <<EOF
|
||||||
|
deb http://deb.debian.org/debian ${VERSION_CODENAME} main contrib non-free
|
||||||
|
deb http://deb.debian.org/debian ${VERSION_CODENAME}-updates main contrib non-free
|
||||||
|
deb http://security.debian.org/debian-security ${VERSION_CODENAME}-security main contrib non-free
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if apt-get update -qq 2>/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" <<EOF
|
||||||
|
VITE_API_BASE=http://${FRONTEND_HOST}/api
|
||||||
|
VITE_WS_URL=ws://${FRONTEND_HOST}/ws
|
||||||
|
EOF
|
||||||
|
|
||||||
|
npm run build --prefix "$INSTALL_DIR/frontend" --silent
|
||||||
|
ok "Frontend gebaut: $INSTALL_DIR/frontend/dist"
|
||||||
|
|
||||||
|
# --- Backend-Konfiguration ---------------------------------------------------
|
||||||
|
header "Backend konfigurieren"
|
||||||
|
|
||||||
|
ENV_FILE="$INSTALL_DIR/backend/.env"
|
||||||
|
|
||||||
|
if [[ -f "$ENV_FILE" && "$REINSTALL" == false ]]; then
|
||||||
|
warn "Backend .env existiert bereits – wird nicht überschrieben"
|
||||||
|
else
|
||||||
|
info "Erstelle Backend .env..."
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
# Ripster Backend – Konfiguration
|
||||||
|
# Generiert von install.sh am $(date)
|
||||||
|
|
||||||
|
PORT=${BACKEND_PORT}
|
||||||
|
DB_PATH=./data/ripster.db
|
||||||
|
LOG_DIR=./logs
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# CORS: Erlaube Anfragen vom Frontend (nginx)
|
||||||
|
CORS_ORIGIN=http://${FRONTEND_HOST}
|
||||||
|
EOF
|
||||||
|
ok "Backend .env erstellt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Berechtigungen setzen ---------------------------------------------------
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
|
||||||
|
chmod -R 755 "$INSTALL_DIR"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# --- Systemd-Dienst: Backend -------------------------------------------------
|
||||||
|
header "Systemd-Dienst (Backend) erstellen"
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/ripster-backend.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Ripster Backend API
|
||||||
|
Documentation=https://github.com/your-repo/ripster
|
||||||
|
After=network.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${SERVICE_USER}
|
||||||
|
Group=${SERVICE_USER}
|
||||||
|
WorkingDirectory=${INSTALL_DIR}/backend
|
||||||
|
ExecStart=$(command -v node) src/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=3
|
||||||
|
|
||||||
|
# Umgebung
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=ripster-backend
|
||||||
|
|
||||||
|
# Sicherheit
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ok "ripster-backend.service erstellt"
|
||||||
|
|
||||||
|
# --- nginx konfigurieren -----------------------------------------------------
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
header "nginx konfigurieren"
|
||||||
|
|
||||||
|
cat > /etc/nginx/sites-available/ripster <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ${FRONTEND_HOST} _;
|
||||||
|
|
||||||
|
# Frontend (statische Dateien)
|
||||||
|
root ${INSTALL_DIR}/frontend/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA: alle unbekannten Pfade → index.html
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API → Backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket → Backend
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:${BACKEND_PORT}/ws;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Alte Default-Seite deaktivieren, Ripster aktivieren
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
ln -sf /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/ripster
|
||||||
|
|
||||||
|
nginx -t && ok "nginx-Konfiguration gültig" || fatal "nginx-Konfiguration fehlerhaft!"
|
||||||
|
|
||||||
|
ok "nginx konfiguriert"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Dienste aktivieren und starten ------------------------------------------
|
||||||
|
header "Dienste starten"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
systemctl enable ripster-backend
|
||||||
|
systemctl restart ripster-backend
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if systemctl is-active --quiet ripster-backend; then
|
||||||
|
ok "ripster-backend läuft"
|
||||||
|
else
|
||||||
|
error "ripster-backend konnte nicht gestartet werden!"
|
||||||
|
journalctl -u ripster-backend -n 30 --no-pager
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
systemctl enable nginx
|
||||||
|
systemctl restart nginx
|
||||||
|
if systemctl is-active --quiet nginx; then
|
||||||
|
ok "nginx läuft"
|
||||||
|
else
|
||||||
|
error "nginx konnte nicht gestartet werden!"
|
||||||
|
journalctl -u nginx -n 20 --no-pager
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Zusammenfassung ----------------------------------------------------------
|
||||||
|
header "Installation abgeschlossen!"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}${BOLD}Ripster ist installiert und läuft.${RESET}"
|
||||||
|
echo ""
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
echo -e " ${BOLD}Weboberfläche:${RESET} http://${FRONTEND_HOST}"
|
||||||
|
else
|
||||||
|
echo -e " ${BOLD}Backend API:${RESET} http://${FRONTEND_HOST}:${BACKEND_PORT}/api"
|
||||||
|
warn "nginx deaktiviert – Frontend nicht automatisch erreichbar."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Dienste verwalten:${RESET}"
|
||||||
|
echo -e " sudo systemctl status ripster-backend"
|
||||||
|
echo -e " sudo systemctl restart ripster-backend"
|
||||||
|
echo -e " sudo systemctl stop ripster-backend"
|
||||||
|
echo -e " sudo journalctl -u ripster-backend -f"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Konfiguration:${RESET} $INSTALL_DIR/backend/.env"
|
||||||
|
echo -e " ${BOLD}Datenbank:${RESET} $INSTALL_DIR/backend/data/ripster.db"
|
||||||
|
echo -e " ${BOLD}Logs:${RESET} $INSTALL_DIR/backend/logs/"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Warnungen zu fehlenden Tools
|
||||||
|
missing_tools=()
|
||||||
|
command_exists makemkvcon || missing_tools+=("makemkvcon")
|
||||||
|
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
|
||||||
|
command_exists mediainfo || missing_tools+=("mediainfo")
|
||||||
|
|
||||||
|
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
||||||
|
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"
|
||||||
|
for t in "${missing_tools[@]}"; do
|
||||||
|
echo -e " ${YELLOW}✗${RESET} $t"
|
||||||
|
done
|
||||||
|
echo -e " Diese können in den Ripster-Einstellungen konfiguriert werden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
590
install.sh
Executable file
590
install.sh
Executable file
@@ -0,0 +1,590 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Ripster – Installationsskript (Git)
|
||||||
|
# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04
|
||||||
|
# Benötigt: sudo / root, Internetzugang
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
||||||
|
# oder:
|
||||||
|
# wget -qO- https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
|
||||||
|
#
|
||||||
|
# Mit Optionen (nur via Datei möglich):
|
||||||
|
# sudo bash install.sh [Optionen]
|
||||||
|
#
|
||||||
|
# Optionen:
|
||||||
|
# --branch <branch> Git-Branch (Standard: main)
|
||||||
|
# --dir <pfad> Installationsverzeichnis (Standard: /opt/ripster)
|
||||||
|
# --user <benutzer> Systembenutzer für den Dienst (Standard: ripster)
|
||||||
|
# --port <port> Backend-Port (Standard: 3001)
|
||||||
|
# --host <hostname> 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" <<EOF
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME} main restricted universe multiverse
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-updates main restricted universe multiverse
|
||||||
|
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-security main restricted universe multiverse
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
debian)
|
||||||
|
cat > "$main_list" <<EOF
|
||||||
|
deb http://deb.debian.org/debian ${VERSION_CODENAME} main contrib non-free
|
||||||
|
deb http://deb.debian.org/debian ${VERSION_CODENAME}-updates main contrib non-free
|
||||||
|
deb http://security.debian.org/debian-security ${VERSION_CODENAME}-security main contrib non-free
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if apt-get update -qq 2>/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" <<EOF
|
||||||
|
VITE_API_BASE=http://${FRONTEND_HOST}/api
|
||||||
|
VITE_WS_URL=ws://${FRONTEND_HOST}/ws
|
||||||
|
EOF
|
||||||
|
|
||||||
|
npm run build --prefix "$INSTALL_DIR/frontend" --silent
|
||||||
|
ok "Frontend gebaut: $INSTALL_DIR/frontend/dist"
|
||||||
|
|
||||||
|
# --- Backend-Konfiguration ---------------------------------------------------
|
||||||
|
header "Backend konfigurieren"
|
||||||
|
|
||||||
|
ENV_FILE="$INSTALL_DIR/backend/.env"
|
||||||
|
|
||||||
|
if [[ -f "$ENV_FILE" && "$REINSTALL" == true ]]; then
|
||||||
|
warn "Bestehende .env bleibt erhalten (--reinstall)"
|
||||||
|
else
|
||||||
|
info "Erstelle Backend .env..."
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
# Ripster Backend – Konfiguration
|
||||||
|
# Generiert von install.sh am $(date)
|
||||||
|
|
||||||
|
PORT=${BACKEND_PORT}
|
||||||
|
DB_PATH=./data/ripster.db
|
||||||
|
LOG_DIR=./logs
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# CORS: Erlaube Anfragen vom Frontend (nginx)
|
||||||
|
CORS_ORIGIN=http://${FRONTEND_HOST}
|
||||||
|
EOF
|
||||||
|
ok "Backend .env erstellt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Berechtigungen setzen ---------------------------------------------------
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
|
||||||
|
chmod -R 755 "$INSTALL_DIR"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
# --- Systemd-Dienst: Backend -------------------------------------------------
|
||||||
|
header "Systemd-Dienst (Backend) erstellen"
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/ripster-backend.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Ripster Backend API
|
||||||
|
After=network.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${SERVICE_USER}
|
||||||
|
Group=${SERVICE_USER}
|
||||||
|
WorkingDirectory=${INSTALL_DIR}/backend
|
||||||
|
ExecStart=$(command -v node) src/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=3
|
||||||
|
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
EnvironmentFile=${INSTALL_DIR}/backend/.env
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=ripster-backend
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ok "ripster-backend.service erstellt"
|
||||||
|
|
||||||
|
# --- nginx konfigurieren -----------------------------------------------------
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
header "nginx konfigurieren"
|
||||||
|
|
||||||
|
cat > /etc/nginx/sites-available/ripster <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ${FRONTEND_HOST} _;
|
||||||
|
|
||||||
|
root ${INSTALL_DIR}/frontend/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:${BACKEND_PORT}/ws;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
ln -sf /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/ripster
|
||||||
|
|
||||||
|
nginx -t && ok "nginx-Konfiguration gültig" || fatal "nginx-Konfiguration fehlerhaft!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Dienste starten ----------------------------------------------------------
|
||||||
|
header "Dienste starten"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
systemctl enable ripster-backend
|
||||||
|
systemctl restart ripster-backend
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if systemctl is-active --quiet ripster-backend; then
|
||||||
|
ok "ripster-backend läuft"
|
||||||
|
else
|
||||||
|
error "ripster-backend konnte nicht gestartet werden!"
|
||||||
|
journalctl -u ripster-backend -n 30 --no-pager
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
systemctl enable nginx
|
||||||
|
systemctl restart nginx
|
||||||
|
if systemctl is-active --quiet nginx; then
|
||||||
|
ok "nginx läuft"
|
||||||
|
else
|
||||||
|
error "nginx konnte nicht gestartet werden!"
|
||||||
|
journalctl -u nginx -n 20 --no-pager
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Zusammenfassung ----------------------------------------------------------
|
||||||
|
header "Installation abgeschlossen!"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}${BOLD}Ripster ist installiert und läuft.${RESET}"
|
||||||
|
echo ""
|
||||||
|
if [[ "$SKIP_NGINX" == false ]]; then
|
||||||
|
echo -e " ${BOLD}Weboberfläche:${RESET} http://${FRONTEND_HOST}"
|
||||||
|
else
|
||||||
|
echo -e " ${BOLD}Backend API:${RESET} http://${FRONTEND_HOST}:${BACKEND_PORT}/api"
|
||||||
|
warn "nginx deaktiviert – Frontend nicht automatisch erreichbar."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Dienste verwalten:${RESET}"
|
||||||
|
echo -e " sudo systemctl status ripster-backend"
|
||||||
|
echo -e " sudo systemctl restart ripster-backend"
|
||||||
|
echo -e " sudo systemctl stop ripster-backend"
|
||||||
|
echo -e " sudo journalctl -u ripster-backend -f"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Konfiguration:${RESET} $INSTALL_DIR/backend/.env"
|
||||||
|
echo -e " ${BOLD}Datenbank:${RESET} $INSTALL_DIR/backend/data/ripster.db"
|
||||||
|
echo -e " ${BOLD}Logs:${RESET} $INSTALL_DIR/backend/logs/"
|
||||||
|
echo -e " ${BOLD}Aktualisieren:${RESET} sudo bash $INSTALL_DIR/install.sh --reinstall"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
missing_tools=()
|
||||||
|
command_exists makemkvcon || missing_tools+=("makemkvcon")
|
||||||
|
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
|
||||||
|
command_exists mediainfo || missing_tools+=("mediainfo")
|
||||||
|
|
||||||
|
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
||||||
|
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"
|
||||||
|
for t in "${missing_tools[@]}"; do
|
||||||
|
echo -e " ${YELLOW}✗${RESET} $t"
|
||||||
|
done
|
||||||
|
echo -e " Diese können in den Ripster-Einstellungen konfiguriert werden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user