1.0.0-rc2 RC2
This commit is contained in:
@@ -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'`);
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
`,
|
||||
|
||||
Reference in New Issue
Block a user