1.0.0-rc2 RC2
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-05-21 10:19:00 +02:00
parent 9b38d78042
commit eeef1fdb73
31 changed files with 4046 additions and 1449 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "1.0.0-rc1",
"version": "1.0.0-rc2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "1.0.0-rc1",
"version": "1.0.0-rc2",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "1.0.0-rc1",
"version": "1.0.0-rc2",
"private": true,
"type": "commonjs",
"scripts": {
+4 -11
View File
@@ -1169,17 +1169,10 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_playall_tolerance_lower_pct', 'Tools', 'Serie: PlayAll Untergrenze (%)', 'number', 1, 'Untere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 234)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_lower_pct', '10')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_playall_tolerance_upper_pct', 'Tools', 'Serie: PlayAll Obergrenze (%)', 'number', 1, 'Obere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 235)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_upper_pct', '10')`);
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_lower_pct'`);
await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_lower_pct'`);
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_upper_pct'`);
await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_upper_pct'`);
await db.run(`DELETE FROM settings_values WHERE key = 'handbrake_pre_metadata_scan_enabled'`);
await db.run(`DELETE FROM settings_schema WHERE key = 'handbrake_pre_metadata_scan_enabled'`);
+80 -5
View File
@@ -31,6 +31,68 @@ router.get(
})
);
router.get(
'/hardware/history',
asyncHandler(async (req, res) => {
const hoursRaw = Number(req.query?.hours);
const maxPointsRaw = Number(req.query?.maxPoints);
const hours = Number.isFinite(hoursRaw) ? Math.max(1, Math.min(96, Math.trunc(hoursRaw))) : 24;
const maxPoints = Number.isFinite(maxPointsRaw) ? Math.max(120, Math.min(5000, Math.trunc(maxPointsRaw))) : 720;
const sinceIso = new Date(Date.now() - (hours * 60 * 60 * 1000)).toISOString();
const db = await getDb();
const rows = await db.all(
`
SELECT
captured_at,
cpu_usage_percent,
cpu_temperature_c,
ram_usage_percent,
ram_used_bytes,
ram_total_bytes,
gpu_usage_percent,
gpu_temperature_c,
vram_used_bytes,
vram_total_bytes
FROM hardware_metrics_history
WHERE captured_at >= ?
ORDER BY captured_at ASC
`,
[sinceIso]
);
const stride = rows.length > maxPoints ? Math.ceil(rows.length / maxPoints) : 1;
const sampled = [];
for (let index = 0; index < rows.length; index += stride) {
sampled.push(rows[index]);
}
if (rows.length > 0 && sampled[sampled.length - 1] !== rows[rows.length - 1]) {
sampled.push(rows[rows.length - 1]);
}
const points = sampled.map((row) => ({
capturedAt: row.captured_at,
cpuUsagePercent: row.cpu_usage_percent,
cpuTemperatureC: row.cpu_temperature_c,
ramUsagePercent: row.ram_usage_percent,
ramUsedBytes: row.ram_used_bytes,
ramTotalBytes: row.ram_total_bytes,
gpuUsagePercent: row.gpu_usage_percent,
gpuTemperatureC: row.gpu_temperature_c,
vramUsedBytes: row.vram_used_bytes,
vramTotalBytes: row.vram_total_bytes
}));
res.json({
history: {
hours,
maxPoints,
totalPoints: rows.length,
points
}
});
})
);
router.post(
'/analyze',
asyncHandler(async (req, res) => {
@@ -539,8 +601,9 @@ router.post(
'/retry/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('post:retry', { reqId: req.reqId, jobId });
const result = await pipelineService.retry(jobId);
const createNewJob = Boolean(req.body?.createNewJob);
logger.info('post:retry', { reqId: req.reqId, jobId, createNewJob });
const result = await pipelineService.retry(jobId, { createNewJob });
res.json({ result });
})
);
@@ -593,15 +656,24 @@ router.post(
const jobId = Number(req.params.jobId);
const keepBoth = Boolean(req.body?.keepBoth);
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
const reuseCurrentJob = Boolean(req.body?.reuseCurrentJob);
const createNewJob = Boolean(req.body?.createNewJob);
const reuseCurrentJob = Object.prototype.hasOwnProperty.call(req.body || {}, 'reuseCurrentJob')
? Boolean(req.body?.reuseCurrentJob)
: !createNewJob;
logger.info('post:restart-review', {
reqId: req.reqId,
jobId,
keepBoth,
createNewJob,
reuseCurrentJob,
deleteFolderCount: deleteFolders.length
});
const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders, reuseCurrentJob });
const result = await pipelineService.restartReviewFromRaw(jobId, {
keepBoth,
deleteFolders,
reuseCurrentJob,
createNewJob
});
res.json({ result });
})
);
@@ -613,17 +685,20 @@ router.post(
const keepBoth = Boolean(req.body?.keepBoth);
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all';
const createNewJob = Boolean(req.body?.createNewJob);
logger.info('post:restart-encode', {
reqId: req.reqId,
jobId,
keepBoth,
createNewJob,
restartMode,
deleteFolderCount: deleteFolders.length
});
const result = await pipelineService.restartEncodeWithLastSettings(jobId, {
keepBoth,
deleteFolders,
restartMode
restartMode,
createNewJob
});
res.json({ result });
})
+36
View File
@@ -158,6 +158,26 @@ router.put(
})
);
router.put(
'/scripts/:id/favorite',
asyncHandler(async (req, res) => {
const scriptId = Number(req.params.id);
const isFavorite = req.body?.isFavorite === true;
logger.info('put:settings:scripts:favorite', {
reqId: req.reqId,
scriptId,
isFavorite
});
const script = await scriptService.setScriptFavorite(scriptId, isFavorite);
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', {
action: 'favorite-updated',
id: script.id,
isFavorite: script.isFavorite === true
});
res.json({ script });
})
);
router.delete(
'/scripts/:id',
asyncHandler(async (req, res) => {
@@ -251,6 +271,22 @@ router.put(
})
);
router.put(
'/script-chains/:id/favorite',
asyncHandler(async (req, res) => {
const chainId = Number(req.params.id);
const isFavorite = req.body?.isFavorite === true;
logger.info('put:settings:script-chains:favorite', { reqId: req.reqId, chainId, isFavorite });
const chain = await scriptChainService.setChainFavorite(chainId, isFavorite);
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', {
action: 'favorite-updated',
id: chain.id,
isFavorite: chain.isFavorite === true
});
res.json({ chain });
})
);
router.delete(
'/script-chains/:id',
asyncHandler(async (req, res) => {
+145 -2
View File
@@ -7,12 +7,14 @@ const settingsService = require('./settingsService');
const wsService = require('./websocketService');
const logger = require('./logger').child('HWMON');
const { errorToMeta } = require('../utils/errorMeta');
const { getDb } = require('../db/database');
const execFileAsync = promisify(execFile);
const DEFAULT_INTERVAL_MS = 5000;
const MIN_INTERVAL_MS = 1000;
const MAX_INTERVAL_MS = 60000;
const HISTORY_RETENTION_PREVIOUS_DAYS = 3;
const DF_TIMEOUT_MS = 1800;
const SENSORS_TIMEOUT_MS = 1800;
const NVIDIA_SMI_TIMEOUT_MS = 1800;
@@ -34,6 +36,20 @@ function nowIso() {
return new Date().toISOString();
}
function toLocalDayKey(inputDate = new Date()) {
const date = inputDate instanceof Date ? inputDate : new Date(inputDate);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function addLocalDays(inputDate, deltaDays) {
const date = inputDate instanceof Date ? new Date(inputDate.getTime()) : new Date(inputDate);
date.setDate(date.getDate() + Number(deltaDays || 0));
return date;
}
function isAbortError(error) {
if (!error || typeof error !== 'object') {
return false;
@@ -356,6 +372,7 @@ class HardwareMonitorService {
sample: null,
error: null
};
this.lastHistoryCleanupDayKey = null;
}
async init() {
@@ -432,11 +449,19 @@ class HardwareMonitorService {
if (!this.enabled) {
this.stopPolling();
let storage = [];
try {
storage = await this.collectStorageMetrics();
} catch (error) {
logger.debug('storage:collect:disabled-mode:failed', { error: errorToMeta(error) });
}
this.lastSnapshot = {
enabled: false,
intervalMs: this.intervalMs,
updatedAt: nowIso(),
sample: null,
sample: {
storage
},
error: null
};
this.broadcastUpdate();
@@ -579,6 +604,11 @@ class HardwareMonitorService {
this.activePollAbortController = pollAbortController;
try {
const sample = await this.collectSample(pollAbortController.signal);
try {
await this.persistHistorySample(sample);
} catch (historyError) {
logger.warn('history:write:failed', { error: errorToMeta(historyError) });
}
this.lastSnapshot = {
enabled: true,
intervalMs: this.intervalMs,
@@ -612,6 +642,97 @@ class HardwareMonitorService {
}
}
buildHistoryRecord(sample = {}) {
const cpu = sample?.cpu && typeof sample.cpu === 'object' ? sample.cpu : {};
const memory = sample?.memory && typeof sample.memory === 'object' ? sample.memory : {};
const gpu = sample?.gpu && typeof sample.gpu === 'object' ? sample.gpu : {};
const gpuDevices = Array.isArray(gpu.devices) ? gpu.devices : [];
const primaryGpu = gpuDevices[0] || null;
const capturedAt = new Date();
return {
capturedAtIso: capturedAt.toISOString(),
dayKey: toLocalDayKey(capturedAt),
cpuUsagePercent: roundNumber(cpu?.overallUsagePercent, 1),
cpuTemperatureC: roundNumber(cpu?.overallTemperatureC, 1),
ramUsagePercent: roundNumber(memory?.usagePercent, 1),
ramUsedBytes: Number.isFinite(Number(memory?.usedBytes)) ? Math.max(0, Math.round(Number(memory.usedBytes))) : null,
ramTotalBytes: Number.isFinite(Number(memory?.totalBytes)) ? Math.max(0, Math.round(Number(memory.totalBytes))) : null,
gpuUsagePercent: roundNumber(primaryGpu?.utilizationPercent, 1),
gpuTemperatureC: roundNumber(primaryGpu?.temperatureC, 1),
vramUsedBytes: Number.isFinite(Number(primaryGpu?.memoryUsedBytes)) ? Math.max(0, Math.round(Number(primaryGpu.memoryUsedBytes))) : null,
vramTotalBytes: Number.isFinite(Number(primaryGpu?.memoryTotalBytes)) ? Math.max(0, Math.round(Number(primaryGpu.memoryTotalBytes))) : null
};
}
async cleanupHistoryForDay(currentDayKey, referenceDate = new Date()) {
const dayKey = String(currentDayKey || '').trim();
if (!dayKey || this.lastHistoryCleanupDayKey === dayKey) {
return;
}
const cutoffDayKey = toLocalDayKey(addLocalDays(referenceDate, -HISTORY_RETENTION_PREVIOUS_DAYS));
const db = await getDb();
const result = await db.run(
`
DELETE FROM hardware_metrics_history
WHERE day_key < ?
`,
[cutoffDayKey]
);
this.lastHistoryCleanupDayKey = dayKey;
logger.info('history:cleanup', {
dayKey,
cutoffDayKey,
deletedRows: Number(result?.changes || 0)
});
}
async persistHistorySample(sample = {}) {
if (!this.enabled) {
return;
}
const record = this.buildHistoryRecord(sample);
if (!record.dayKey) {
return;
}
await this.cleanupHistoryForDay(record.dayKey, new Date(record.capturedAtIso));
const db = await getDb();
await db.run(
`
INSERT INTO hardware_metrics_history (
captured_at,
day_key,
cpu_usage_percent,
cpu_temperature_c,
ram_usage_percent,
ram_used_bytes,
ram_total_bytes,
gpu_usage_percent,
gpu_temperature_c,
vram_used_bytes,
vram_total_bytes
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
record.capturedAtIso,
record.dayKey,
record.cpuUsagePercent,
record.cpuTemperatureC,
record.ramUsagePercent,
record.ramUsedBytes,
record.ramTotalBytes,
record.gpuUsagePercent,
record.gpuTemperatureC,
record.vramUsedBytes,
record.vramTotalBytes
]
);
}
broadcastUpdate() {
wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot());
}
@@ -931,6 +1052,10 @@ class HardwareMonitorService {
.filter(Boolean);
if (devices.length === 0) {
const fallback = this.getLastKnownGpuMetrics();
if (fallback) {
return fallback;
}
return {
source: 'nvidia-smi',
available: false,
@@ -954,17 +1079,35 @@ class HardwareMonitorService {
this.nvidiaSmiAvailable = false;
}
logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) });
const fallback = this.getLastKnownGpuMetrics();
if (fallback) {
return fallback;
}
return {
source: 'nvidia-smi',
available: false,
devices: [],
message: commandMissing
? 'nvidia-smi ist nicht verfuegbar.'
: (String(error?.stderr || error?.message || 'GPU-Abfrage fehlgeschlagen').trim().slice(0, 220))
: 'GPU-Daten derzeit nicht verfuegbar.'
};
}
}
getLastKnownGpuMetrics() {
const previousGpu = this.lastSnapshot?.sample?.gpu;
if (!previousGpu || typeof previousGpu !== 'object') {
return null;
}
if (!Array.isArray(previousGpu.devices) || previousGpu.devices.length === 0) {
return null;
}
return {
...previousGpu,
message: null
};
}
async collectStorageMetrics(signal) {
const list = [];
for (const entry of this.monitoredPaths) {
+340 -11
View File
@@ -4688,7 +4688,9 @@ class HistoryService {
}
}
}
if (existingCountFromSeriesBatch > existingCount) {
// For History UI with FS checks enabled, never override real filesystem
// counts with stale series-batch metadata counters.
if (!includeFsChecks && existingCountFromSeriesBatch > existingCount) {
existingCount = existingCountFromSeriesBatch;
}
if (!encodeSuccessAny && existingCount > 0) {
@@ -5202,7 +5204,7 @@ class HistoryService {
`
SELECT *
FROM jobs
WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING')
WHERE status IN ('ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING')
AND COALESCE(job_kind, '') != 'dvd_series_container'
ORDER BY updated_at ASC, id ASC
`
@@ -8354,6 +8356,195 @@ class HistoryService {
return { deleted, failed };
}
async _cleanupEmptyDirectoriesForDeletedJobs(jobRows = [], options = {}) {
const rows = Array.isArray(jobRows)
? jobRows.filter((row) => normalizeJobIdValue(row?.id))
: [];
if (rows.length === 0) {
return {
attemptedCandidates: { raw: 0, movie: 0 },
removed: { raw: 0, movie: 0, total: 0 },
removedPaths: [],
failures: []
};
}
const jobIds = Array.from(new Set(
rows
.map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean)
));
const settings = options?.settings && typeof options.settings === 'object'
? options.settings
: await settingsService.getSettingsMap();
const lineageArtifactsByJobId = options?.lineageArtifactsByJobId instanceof Map
? options.lineageArtifactsByJobId
: await this.listJobLineageArtifactsByJobIds(jobIds).catch(() => new Map());
const outputFoldersByJobId = options?.outputFoldersByJobId instanceof Map
? options.outputFoldersByJobId
: await this.listJobOutputFoldersByJobIds(jobIds).catch(() => new Map());
const candidateSets = {
raw: new Set(),
movie: new Set()
};
const protectedRootSets = {
raw: new Set(),
movie: new Set()
};
for (const row of rows) {
const rowId = normalizeJobIdValue(row?.id);
if (!rowId) {
continue;
}
const lineageArtifacts = lineageArtifactsByJobId.get(rowId) || [];
const trackedOutputPaths = (outputFoldersByJobId.get(rowId) || [])
.map((folder) => String(folder?.output_path || '').trim())
.filter(Boolean);
const collected = this._collectDeleteCandidatesForJob(row, settings, {
lineageArtifacts,
trackedOutputPaths
});
for (const rootPath of (collected?.rawRoots || [])) {
const normalizedRoot = normalizeComparablePath(rootPath);
if (normalizedRoot && !isFilesystemRootPath(normalizedRoot)) {
protectedRootSets.raw.add(normalizedRoot);
}
}
for (const rootPath of (collected?.movieRoots || [])) {
const normalizedRoot = normalizeComparablePath(rootPath);
if (normalizedRoot && !isFilesystemRootPath(normalizedRoot)) {
protectedRootSets.movie.add(normalizedRoot);
}
}
for (const candidate of (collected?.rawCandidates || [])) {
const normalizedPath = normalizeComparablePath(candidate?.path);
if (normalizedPath && !isFilesystemRootPath(normalizedPath)) {
candidateSets.raw.add(normalizedPath);
}
}
for (const candidate of (collected?.movieCandidates || [])) {
const normalizedPath = normalizeComparablePath(candidate?.path);
if (normalizedPath && !isFilesystemRootPath(normalizedPath)) {
candidateSets.movie.add(normalizedPath);
}
}
}
const removedPaths = [];
const removedPathSet = new Set();
const failures = [];
const removedCountByTarget = {
raw: 0,
movie: 0
};
const rememberRemovedPath = (target, candidatePath) => {
const normalizedPath = normalizeComparablePath(candidatePath);
if (!normalizedPath || removedPathSet.has(normalizedPath)) {
return;
}
removedPathSet.add(normalizedPath);
removedPaths.push(normalizedPath);
if (target === 'raw' || target === 'movie') {
removedCountByTarget[target] += 1;
}
};
const cleanupTarget = (target) => {
const protectedRoots = Array.from(protectedRootSets[target] || []);
const protectedRootSet = new Set(protectedRoots);
const candidates = Array.from(candidateSets[target] || [])
.filter(Boolean)
.sort((left, right) => String(right || '').length - String(left || '').length);
for (const candidatePathRaw of candidates) {
const candidatePath = normalizeComparablePath(candidatePathRaw);
if (!candidatePath || isFilesystemRootPath(candidatePath)) {
continue;
}
const inspection = inspectDeletionPath(candidatePath);
if (inspection.exists && inspection.isDirectory) {
if (protectedRootSet.has(inspection.path)) {
continue;
}
if (protectedRoots.length > 0 && !protectedRoots.some((rootPath) => isPathInside(rootPath, inspection.path))) {
continue;
}
try {
const entries = fs.readdirSync(inspection.path);
if (entries.length > 0) {
continue;
}
fs.rmdirSync(inspection.path);
rememberRemovedPath(target, inspection.path);
if (protectedRoots.length > 0) {
const removedParentDirs = removeEmptyParentDirectories(path.dirname(inspection.path), {
protectedRoots
});
for (const removedPath of removedParentDirs) {
rememberRemovedPath(target, removedPath);
}
}
} catch (error) {
if (error?.code !== 'ENOENT') {
failures.push({
target,
path: inspection.path,
error: error?.message || String(error)
});
}
}
continue;
}
const parentDir = normalizeComparablePath(path.dirname(candidatePath));
if (!parentDir || isFilesystemRootPath(parentDir)) {
continue;
}
if (protectedRoots.length === 0) {
continue;
}
if (!protectedRoots.some((rootPath) => isPathInside(rootPath, parentDir))) {
continue;
}
try {
const removedParentDirs = removeEmptyParentDirectories(parentDir, {
protectedRoots
});
for (const removedPath of removedParentDirs) {
rememberRemovedPath(target, removedPath);
}
} catch (error) {
failures.push({
target,
path: parentDir,
error: error?.message || String(error)
});
}
}
};
cleanupTarget('raw');
cleanupTarget('movie');
return {
attemptedCandidates: {
raw: (candidateSets.raw || new Set()).size,
movie: (candidateSets.movie || new Set()).size
},
removed: {
raw: removedCountByTarget.raw,
movie: removedCountByTarget.movie,
total: removedPaths.length
},
removedPaths: [...removedPaths].sort((left, right) => left.localeCompare(right, 'de')),
failures
};
}
async deleteJob(jobId, fileTarget = 'none', options = {}) {
const allowedTargets = new Set(['none', 'raw', 'movie', 'both']);
if (!allowedTargets.has(fileTarget)) {
@@ -8406,6 +8597,46 @@ class HistoryService {
.map((row) => String(row?.path || '').trim())
.filter(Boolean);
};
const collectIncompleteRawPathsFromPreview = (preview, selectedJobRows = []) => {
const rows = Array.isArray(selectedJobRows) ? selectedJobRows : [];
if (rows.length === 0) {
return [];
}
const incompleteCancelledJobIds = new Set(
rows
.filter((row) => {
if (!row || typeof row !== 'object') {
return false;
}
if (isOrphanRawImportJobRow(row)) {
return false;
}
const status = String(row?.status || '').trim().toUpperCase();
const lastState = String(row?.last_state || '').trim().toUpperCase();
const isCancelled = status === 'CANCELLED' || lastState === 'CANCELLED';
if (!isCancelled) {
return false;
}
return Number(row?.rip_successful || 0) !== 1;
})
.map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean)
);
if (incompleteCancelledJobIds.size === 0) {
return [];
}
const rawRows = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
return rawRows
.filter((row) => Boolean(row?.exists))
.filter((row) => {
const jobIds = Array.isArray(row?.jobIds) ? row.jobIds : [];
return jobIds.some((jobId) => incompleteCancelledJobIds.has(normalizeJobIdValue(jobId)));
})
.map((row) => String(row?.path || '').trim())
.filter(Boolean);
};
const cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => {
const normalizedOwnerJobIds = Array.from(new Set(
(Array.isArray(ownerJobIds) ? ownerJobIds : [])
@@ -8533,6 +8764,31 @@ class HistoryService {
}
}
const cleanupDeletedJobRows = [existing];
let emptyDirectoryCleanupContext = {
settings: null,
lineageArtifactsByJobId: new Map(),
outputFoldersByJobId: new Map()
};
try {
const [cleanupSettings, cleanupLineageArtifactsByJobId, cleanupOutputFoldersByJobId] = await Promise.all([
settingsService.getSettingsMap(),
this.listJobLineageArtifactsByJobIds([normalizedJobId]),
this.listJobOutputFoldersByJobIds([normalizedJobId])
]);
emptyDirectoryCleanupContext = {
settings: cleanupSettings,
lineageArtifactsByJobId: cleanupLineageArtifactsByJobId,
outputFoldersByJobId: cleanupOutputFoldersByJobId
};
} catch (error) {
logger.warn('job:delete:empty-dir-context-failed', {
jobId: normalizedJobId,
includeRelated: false,
error: error?.message || String(error)
});
}
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
@@ -8541,8 +8797,15 @@ class HistoryService {
if (effectiveFileTarget === 'none') {
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
const autoIncompleteRawPaths = collectIncompleteRawPathsFromPreview(preview, [existing]);
if (autoIncompleteRawPaths.length > 0) {
effectiveFileTarget = autoIncompleteMoviePaths.length > 0 ? 'both' : 'raw';
if (!selectedRawPathsForDelete || selectedRawPathsForDelete.length === 0) {
selectedRawPathsForDelete = autoIncompleteRawPaths;
}
}
if (autoIncompleteMoviePaths.length > 0) {
effectiveFileTarget = 'movie';
effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie';
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
}
@@ -8567,7 +8830,7 @@ class HistoryService {
);
const isActivePipelineJob = Number(pipelineRow?.active_job_id || 0) === Number(normalizedJobId);
const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
const runningStates = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
if (isActivePipelineJob && runningStates.has(String(pipelineRow?.state || ''))) {
const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.');
@@ -8626,6 +8889,19 @@ class HistoryService {
if (includeMovieCleanup) {
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs([existing], [normalizedJobId]);
}
let emptyDirectoryCleanup = null;
try {
emptyDirectoryCleanup = await this._cleanupEmptyDirectoriesForDeletedJobs(
cleanupDeletedJobRows,
emptyDirectoryCleanupContext
);
} catch (error) {
logger.warn('job:delete:empty-dir-cleanup-failed', {
jobId: normalizedJobId,
includeRelated: false,
error: error?.message || String(error)
});
}
logger.warn('job:deleted', {
jobId: normalizedJobId,
@@ -8634,6 +8910,7 @@ class HistoryService {
includeRelated: false,
pipelineStateReset: isActivePipelineJob,
incompleteCleanup: incompleteCleanupSummary,
emptyDirectoryCleanup,
filesDeleted: fileSummary
? {
raw: fileSummary.raw?.filesDeleted ?? 0,
@@ -8650,7 +8927,8 @@ class HistoryService {
includeRelated: false,
deletedJobIds: [Number(normalizedJobId)],
fileSummary,
incompleteCleanup: incompleteCleanupSummary
incompleteCleanup: incompleteCleanupSummary,
emptyDirectoryCleanup
};
}
@@ -8715,20 +8993,58 @@ class HistoryService {
const pipelineRow = await db.get('SELECT state, active_job_id FROM pipeline_state WHERE id = 1');
const activePipelineJobId = normalizeJobIdValue(pipelineRow?.active_job_id);
const activeJobIncluded = Boolean(activePipelineJobId && deleteJobIds.includes(activePipelineJobId));
const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
const runningStates = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
if (activeJobIncluded && runningStates.has(String(pipelineRow?.state || ''))) {
const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.');
error.statusCode = 409;
throw error;
}
const deletedJobRows = deleteJobIds
.map((id) => rowById.get(id))
.filter(Boolean);
let emptyDirectoryCleanupContext = {
settings: null,
lineageArtifactsByJobId: new Map(),
outputFoldersByJobId: new Map()
};
try {
const [cleanupSettings, cleanupLineageArtifactsByJobId, cleanupOutputFoldersByJobId] = await Promise.all([
settingsService.getSettingsMap(),
this.listJobLineageArtifactsByJobIds(deleteJobIds),
this.listJobOutputFoldersByJobIds(deleteJobIds)
]);
emptyDirectoryCleanupContext = {
settings: cleanupSettings,
lineageArtifactsByJobId: cleanupLineageArtifactsByJobId,
outputFoldersByJobId: cleanupOutputFoldersByJobId
};
} catch (error) {
logger.warn('job:delete:empty-dir-context-failed', {
jobId: normalizedJobId,
includeRelated: true,
deleteJobIds,
error: error?.message || String(error)
});
}
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
if (effectiveFileTarget === 'none') {
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
const selectedDeleteRows = deleteJobIds
.map((id) => rowById.get(id))
.filter(Boolean);
const autoIncompleteRawPaths = collectIncompleteRawPathsFromPreview(preview, selectedDeleteRows);
if (autoIncompleteRawPaths.length > 0) {
effectiveFileTarget = autoIncompleteMoviePaths.length > 0 ? 'both' : 'raw';
if (!selectedRawPathsForDelete || selectedRawPathsForDelete.length === 0) {
selectedRawPathsForDelete = autoIncompleteRawPaths;
}
}
if (autoIncompleteMoviePaths.length > 0) {
effectiveFileTarget = 'movie';
effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie';
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
}
@@ -8806,11 +9122,22 @@ class HistoryService {
const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both';
let incompleteCleanupSummary = null;
if (includeMovieCleanup) {
const deletedJobRows = deleteJobIds
.map((id) => rowById.get(id))
.filter(Boolean);
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs(deletedJobRows, deleteJobIds);
}
let emptyDirectoryCleanup = null;
try {
emptyDirectoryCleanup = await this._cleanupEmptyDirectoriesForDeletedJobs(
deletedJobRows,
emptyDirectoryCleanupContext
);
} catch (error) {
logger.warn('job:delete:empty-dir-cleanup-failed', {
jobId: normalizedJobId,
includeRelated: true,
deleteJobIds,
error: error?.message || String(error)
});
}
const deletedJobIdSet = new Set(deleteJobIds);
const deletedJobs = Array.isArray(preview?.relatedJobs)
@@ -8826,6 +9153,7 @@ class HistoryService {
deletedJobCount: deleteJobIds.length,
pipelineStateReset: activeJobIncluded,
incompleteCleanup: incompleteCleanupSummary,
emptyDirectoryCleanup,
filesDeleted: fileSummary
? {
raw: fileSummary.raw?.filesDeleted ?? 0,
@@ -8843,7 +9171,8 @@ class HistoryService {
deletedJobIds: deleteJobIds,
deletedJobs,
fileSummary,
incompleteCleanup: incompleteCleanupSummary
incompleteCleanup: incompleteCleanupSummary,
emptyDirectoryCleanup
};
}
}
File diff suppressed because it is too large Load Diff
+63 -8
View File
@@ -5,6 +5,7 @@ const { spawnTrackedProcess } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
const CHAIN_NAME_MAX_LENGTH = 120;
const CHAIN_DESCRIPTION_MAX_LENGTH = 50;
const STEP_TYPE_SCRIPT = 'script';
const STEP_TYPE_WAIT = 'wait';
const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]);
@@ -26,6 +27,31 @@ function createValidationError(message, details = null) {
return error;
}
function normalizeFavoriteFlag(rawValue, fallback = 0) {
if (typeof rawValue === 'boolean') {
return rawValue ? 1 : 0;
}
const parsed = Number(rawValue);
if (Number.isFinite(parsed)) {
return parsed !== 0 ? 1 : 0;
}
const normalized = String(rawValue || '').trim().toLowerCase();
if (!normalized) {
return fallback ? 1 : 0;
}
if (['true', 'yes', 'on', '1'].includes(normalized)) {
return 1;
}
if (['false', 'no', 'off', '0'].includes(normalized)) {
return 0;
}
return fallback ? 1 : 0;
}
function normalizeChainDescription(rawValue) {
return String(rawValue || '').trim();
}
function mapChainRow(row, steps = []) {
if (!row) {
return null;
@@ -33,6 +59,8 @@ function mapChainRow(row, steps = []) {
return {
id: Number(row.id),
name: String(row.name || ''),
description: normalizeChainDescription(row.description),
isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1,
orderIndex: Number(row.order_index || 0),
steps: steps.map(mapStepRow),
createdAt: row.created_at,
@@ -164,7 +192,7 @@ class ScriptChainService {
const db = await getDb();
const rows = await db.all(
`
SELECT id, name, order_index, created_at, updated_at
SELECT id, name, description, is_favorite, order_index, created_at, updated_at
FROM script_chains
ORDER BY order_index ASC, id ASC
`
@@ -213,7 +241,7 @@ class ScriptChainService {
}
const db = await getDb();
const row = await db.get(
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id = ?`,
`SELECT id, name, description, is_favorite, order_index, created_at, updated_at FROM script_chains WHERE id = ?`,
[normalizedId]
);
if (!row) {
@@ -235,7 +263,7 @@ class ScriptChainService {
const db = await getDb();
const placeholders = ids.map(() => '?').join(', ');
const rows = await db.all(
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`,
`SELECT id, name, description, is_favorite, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`,
ids
);
const stepRows = await db.all(
@@ -268,12 +296,16 @@ class ScriptChainService {
async createChain(payload = {}) {
const body = payload && typeof payload === 'object' ? payload : {};
const name = String(body.name || '').trim();
const description = normalizeChainDescription(body.description);
if (!name) {
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
}
if (name.length > CHAIN_NAME_MAX_LENGTH) {
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
}
if (description.length > CHAIN_DESCRIPTION_MAX_LENGTH) {
throw createValidationError('Beschreibung zu lang.', [{ field: 'description', message: `Maximal ${CHAIN_DESCRIPTION_MAX_LENGTH} Zeichen.` }]);
}
const steps = validateSteps(body.steps);
const db = await getDb();
@@ -281,10 +313,10 @@ class ScriptChainService {
const nextOrderIndex = await this._getNextOrderIndex(db);
const result = await db.run(
`
INSERT INTO script_chains (name, order_index, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
INSERT INTO script_chains (name, description, is_favorite, order_index, created_at, updated_at)
VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`,
[name, nextOrderIndex]
[name, description || null, nextOrderIndex]
);
const chainId = result.lastID;
await this._saveSteps(db, chainId, steps);
@@ -304,12 +336,16 @@ class ScriptChainService {
}
const body = payload && typeof payload === 'object' ? payload : {};
const name = String(body.name || '').trim();
const description = normalizeChainDescription(body.description);
if (!name) {
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
}
if (name.length > CHAIN_NAME_MAX_LENGTH) {
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
}
if (description.length > CHAIN_DESCRIPTION_MAX_LENGTH) {
throw createValidationError('Beschreibung zu lang.', [{ field: 'description', message: `Maximal ${CHAIN_DESCRIPTION_MAX_LENGTH} Zeichen.` }]);
}
const steps = validateSteps(body.steps);
await this.getChainById(normalizedId);
@@ -317,8 +353,8 @@ class ScriptChainService {
const db = await getDb();
try {
await db.run(
`UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[name, normalizedId]
`UPDATE script_chains SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[name, description || null, normalizedId]
);
await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]);
await this._saveSteps(db, normalizedId, steps);
@@ -342,6 +378,25 @@ class ScriptChainService {
return existing;
}
async setChainFavorite(chainId, isFavorite) {
const normalizedId = normalizeChainId(chainId);
if (!normalizedId) {
throw createValidationError('Ungültige chainId.');
}
const favoriteFlag = normalizeFavoriteFlag(isFavorite, 0);
const db = await getDb();
await this.getChainById(normalizedId);
await db.run(
`
UPDATE script_chains
SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
[favoriteFlag, normalizedId]
);
return this.getChainById(normalizedId);
}
async reorderChains(orderedIds = []) {
const providedIds = Array.isArray(orderedIds)
? orderedIds.map(normalizeChainId).filter(Boolean)
+46 -5
View File
@@ -72,6 +72,27 @@ function normalizeScriptBody(rawValue) {
return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}
function normalizeFavoriteFlag(rawValue, fallback = 0) {
if (typeof rawValue === 'boolean') {
return rawValue ? 1 : 0;
}
const parsed = Number(rawValue);
if (Number.isFinite(parsed)) {
return parsed !== 0 ? 1 : 0;
}
const normalized = String(rawValue || '').trim().toLowerCase();
if (!normalized) {
return fallback ? 1 : 0;
}
if (['true', 'yes', 'on', '1'].includes(normalized)) {
return 1;
}
if (['false', 'no', 'off', '0'].includes(normalized)) {
return 0;
}
return fallback ? 1 : 0;
}
function createValidationError(message, details = null) {
const error = new Error(message);
error.statusCode = 400;
@@ -125,6 +146,7 @@ function mapScriptRow(row) {
id: Number(row.id),
name: String(row.name || ''),
scriptBody: String(row.script_body || ''),
isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1,
orderIndex: Number(row.order_index || 0),
createdAt: row.created_at,
updatedAt: row.updated_at
@@ -325,7 +347,7 @@ class ScriptService {
const db = await getDb();
const rows = await db.all(
`
SELECT id, name, script_body, order_index, created_at, updated_at
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
FROM scripts
ORDER BY order_index ASC, id ASC
`
@@ -341,7 +363,7 @@ class ScriptService {
const db = await getDb();
const row = await db.get(
`
SELECT id, name, script_body, order_index, created_at, updated_at
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
FROM scripts
WHERE id = ?
`,
@@ -362,8 +384,8 @@ class ScriptService {
const nextOrderIndex = await this._getNextOrderIndex(db);
const result = await db.run(
`
INSERT INTO scripts (name, script_body, order_index, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
INSERT INTO scripts (name, script_body, is_favorite, order_index, created_at, updated_at)
VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`,
[normalized.name, normalized.scriptBody, nextOrderIndex]
);
@@ -420,6 +442,25 @@ class ScriptService {
return existing;
}
async setScriptFavorite(scriptId, isFavorite) {
const normalizedId = normalizeScriptId(scriptId);
if (!normalizedId) {
throw createValidationError('Ungültige scriptId.');
}
const favoriteFlag = normalizeFavoriteFlag(isFavorite, 0);
const db = await getDb();
await this.getScriptById(normalizedId);
await db.run(
`
UPDATE scripts
SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
[favoriteFlag, normalizedId]
);
return this.getScriptById(normalizedId);
}
async getScriptsByIds(rawIds = []) {
const ids = normalizeScriptIdList(rawIds);
if (ids.length === 0) {
@@ -429,7 +470,7 @@ class ScriptService {
const placeholders = ids.map(() => '?').join(', ');
const rows = await db.all(
`
SELECT id, name, script_body, order_index, created_at, updated_at
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
FROM scripts
WHERE id IN (${placeholders})
`,