1.0.0-rc2 RC2
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0-rc1",
|
"version": "1.0.0-rc2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0-rc1",
|
"version": "1.0.0-rc2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0-rc1",
|
"version": "1.0.0-rc2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
|
||||||
|
|
||||||
await db.run(
|
await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_lower_pct'`);
|
||||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_lower_pct'`);
|
||||||
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(`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(`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 = 'handbrake_pre_metadata_scan_enabled'`);
|
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'`);
|
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(
|
router.post(
|
||||||
'/analyze',
|
'/analyze',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
@@ -539,8 +601,9 @@ router.post(
|
|||||||
'/retry/:jobId',
|
'/retry/:jobId',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
logger.info('post:retry', { reqId: req.reqId, jobId });
|
const createNewJob = Boolean(req.body?.createNewJob);
|
||||||
const result = await pipelineService.retry(jobId);
|
logger.info('post:retry', { reqId: req.reqId, jobId, createNewJob });
|
||||||
|
const result = await pipelineService.retry(jobId, { createNewJob });
|
||||||
res.json({ result });
|
res.json({ result });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -593,15 +656,24 @@ router.post(
|
|||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
const keepBoth = Boolean(req.body?.keepBoth);
|
const keepBoth = Boolean(req.body?.keepBoth);
|
||||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
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', {
|
logger.info('post:restart-review', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
jobId,
|
jobId,
|
||||||
keepBoth,
|
keepBoth,
|
||||||
|
createNewJob,
|
||||||
reuseCurrentJob,
|
reuseCurrentJob,
|
||||||
deleteFolderCount: deleteFolders.length
|
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 });
|
res.json({ result });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -613,17 +685,20 @@ router.post(
|
|||||||
const keepBoth = Boolean(req.body?.keepBoth);
|
const keepBoth = Boolean(req.body?.keepBoth);
|
||||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||||
const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all';
|
const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all';
|
||||||
|
const createNewJob = Boolean(req.body?.createNewJob);
|
||||||
logger.info('post:restart-encode', {
|
logger.info('post:restart-encode', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
jobId,
|
jobId,
|
||||||
keepBoth,
|
keepBoth,
|
||||||
|
createNewJob,
|
||||||
restartMode,
|
restartMode,
|
||||||
deleteFolderCount: deleteFolders.length
|
deleteFolderCount: deleteFolders.length
|
||||||
});
|
});
|
||||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId, {
|
const result = await pipelineService.restartEncodeWithLastSettings(jobId, {
|
||||||
keepBoth,
|
keepBoth,
|
||||||
deleteFolders,
|
deleteFolders,
|
||||||
restartMode
|
restartMode,
|
||||||
|
createNewJob
|
||||||
});
|
});
|
||||||
res.json({ result });
|
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(
|
router.delete(
|
||||||
'/scripts/:id',
|
'/scripts/:id',
|
||||||
asyncHandler(async (req, res) => {
|
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(
|
router.delete(
|
||||||
'/script-chains/:id',
|
'/script-chains/:id',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ const settingsService = require('./settingsService');
|
|||||||
const wsService = require('./websocketService');
|
const wsService = require('./websocketService');
|
||||||
const logger = require('./logger').child('HWMON');
|
const logger = require('./logger').child('HWMON');
|
||||||
const { errorToMeta } = require('../utils/errorMeta');
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const DEFAULT_INTERVAL_MS = 5000;
|
const DEFAULT_INTERVAL_MS = 5000;
|
||||||
const MIN_INTERVAL_MS = 1000;
|
const MIN_INTERVAL_MS = 1000;
|
||||||
const MAX_INTERVAL_MS = 60000;
|
const MAX_INTERVAL_MS = 60000;
|
||||||
|
const HISTORY_RETENTION_PREVIOUS_DAYS = 3;
|
||||||
const DF_TIMEOUT_MS = 1800;
|
const DF_TIMEOUT_MS = 1800;
|
||||||
const SENSORS_TIMEOUT_MS = 1800;
|
const SENSORS_TIMEOUT_MS = 1800;
|
||||||
const NVIDIA_SMI_TIMEOUT_MS = 1800;
|
const NVIDIA_SMI_TIMEOUT_MS = 1800;
|
||||||
@@ -34,6 +36,20 @@ function nowIso() {
|
|||||||
return new Date().toISOString();
|
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) {
|
function isAbortError(error) {
|
||||||
if (!error || typeof error !== 'object') {
|
if (!error || typeof error !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
@@ -356,6 +372,7 @@ class HardwareMonitorService {
|
|||||||
sample: null,
|
sample: null,
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
|
this.lastHistoryCleanupDayKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@@ -432,11 +449,19 @@ class HardwareMonitorService {
|
|||||||
|
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
|
let storage = [];
|
||||||
|
try {
|
||||||
|
storage = await this.collectStorageMetrics();
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('storage:collect:disabled-mode:failed', { error: errorToMeta(error) });
|
||||||
|
}
|
||||||
this.lastSnapshot = {
|
this.lastSnapshot = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
intervalMs: this.intervalMs,
|
intervalMs: this.intervalMs,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
sample: null,
|
sample: {
|
||||||
|
storage
|
||||||
|
},
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
this.broadcastUpdate();
|
this.broadcastUpdate();
|
||||||
@@ -579,6 +604,11 @@ class HardwareMonitorService {
|
|||||||
this.activePollAbortController = pollAbortController;
|
this.activePollAbortController = pollAbortController;
|
||||||
try {
|
try {
|
||||||
const sample = await this.collectSample(pollAbortController.signal);
|
const sample = await this.collectSample(pollAbortController.signal);
|
||||||
|
try {
|
||||||
|
await this.persistHistorySample(sample);
|
||||||
|
} catch (historyError) {
|
||||||
|
logger.warn('history:write:failed', { error: errorToMeta(historyError) });
|
||||||
|
}
|
||||||
this.lastSnapshot = {
|
this.lastSnapshot = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
intervalMs: this.intervalMs,
|
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() {
|
broadcastUpdate() {
|
||||||
wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot());
|
wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot());
|
||||||
}
|
}
|
||||||
@@ -931,6 +1052,10 @@ class HardwareMonitorService {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
|
const fallback = this.getLastKnownGpuMetrics();
|
||||||
|
if (fallback) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
source: 'nvidia-smi',
|
source: 'nvidia-smi',
|
||||||
available: false,
|
available: false,
|
||||||
@@ -954,17 +1079,35 @@ class HardwareMonitorService {
|
|||||||
this.nvidiaSmiAvailable = false;
|
this.nvidiaSmiAvailable = false;
|
||||||
}
|
}
|
||||||
logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) });
|
logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) });
|
||||||
|
const fallback = this.getLastKnownGpuMetrics();
|
||||||
|
if (fallback) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
source: 'nvidia-smi',
|
source: 'nvidia-smi',
|
||||||
available: false,
|
available: false,
|
||||||
devices: [],
|
devices: [],
|
||||||
message: commandMissing
|
message: commandMissing
|
||||||
? 'nvidia-smi ist nicht verfuegbar.'
|
? '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) {
|
async collectStorageMetrics(signal) {
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const entry of this.monitoredPaths) {
|
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;
|
existingCount = existingCountFromSeriesBatch;
|
||||||
}
|
}
|
||||||
if (!encodeSuccessAny && existingCount > 0) {
|
if (!encodeSuccessAny && existingCount > 0) {
|
||||||
@@ -5202,7 +5204,7 @@ class HistoryService {
|
|||||||
`
|
`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM jobs
|
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'
|
AND COALESCE(job_kind, '') != 'dvd_series_container'
|
||||||
ORDER BY updated_at ASC, id ASC
|
ORDER BY updated_at ASC, id ASC
|
||||||
`
|
`
|
||||||
@@ -8354,6 +8356,195 @@ class HistoryService {
|
|||||||
return { deleted, failed };
|
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 = {}) {
|
async deleteJob(jobId, fileTarget = 'none', options = {}) {
|
||||||
const allowedTargets = new Set(['none', 'raw', 'movie', 'both']);
|
const allowedTargets = new Set(['none', 'raw', 'movie', 'both']);
|
||||||
if (!allowedTargets.has(fileTarget)) {
|
if (!allowedTargets.has(fileTarget)) {
|
||||||
@@ -8406,6 +8597,46 @@ class HistoryService {
|
|||||||
.map((row) => String(row?.path || '').trim())
|
.map((row) => String(row?.path || '').trim())
|
||||||
.filter(Boolean);
|
.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 cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => {
|
||||||
const normalizedOwnerJobIds = Array.from(new Set(
|
const normalizedOwnerJobIds = Array.from(new Set(
|
||||||
(Array.isArray(ownerJobIds) ? ownerJobIds : [])
|
(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 effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||||
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
|
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
|
||||||
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
|
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
|
||||||
@@ -8541,8 +8797,15 @@ class HistoryService {
|
|||||||
if (effectiveFileTarget === 'none') {
|
if (effectiveFileTarget === 'none') {
|
||||||
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
|
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
|
||||||
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
|
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) {
|
if (autoIncompleteMoviePaths.length > 0) {
|
||||||
effectiveFileTarget = 'movie';
|
effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie';
|
||||||
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
|
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
|
||||||
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
|
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
|
||||||
}
|
}
|
||||||
@@ -8567,7 +8830,7 @@ class HistoryService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isActivePipelineJob = Number(pipelineRow?.active_job_id || 0) === Number(normalizedJobId);
|
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 || ''))) {
|
if (isActivePipelineJob && runningStates.has(String(pipelineRow?.state || ''))) {
|
||||||
const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.');
|
const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.');
|
||||||
@@ -8626,6 +8889,19 @@ class HistoryService {
|
|||||||
if (includeMovieCleanup) {
|
if (includeMovieCleanup) {
|
||||||
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs([existing], [normalizedJobId]);
|
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', {
|
logger.warn('job:deleted', {
|
||||||
jobId: normalizedJobId,
|
jobId: normalizedJobId,
|
||||||
@@ -8634,6 +8910,7 @@ class HistoryService {
|
|||||||
includeRelated: false,
|
includeRelated: false,
|
||||||
pipelineStateReset: isActivePipelineJob,
|
pipelineStateReset: isActivePipelineJob,
|
||||||
incompleteCleanup: incompleteCleanupSummary,
|
incompleteCleanup: incompleteCleanupSummary,
|
||||||
|
emptyDirectoryCleanup,
|
||||||
filesDeleted: fileSummary
|
filesDeleted: fileSummary
|
||||||
? {
|
? {
|
||||||
raw: fileSummary.raw?.filesDeleted ?? 0,
|
raw: fileSummary.raw?.filesDeleted ?? 0,
|
||||||
@@ -8650,7 +8927,8 @@ class HistoryService {
|
|||||||
includeRelated: false,
|
includeRelated: false,
|
||||||
deletedJobIds: [Number(normalizedJobId)],
|
deletedJobIds: [Number(normalizedJobId)],
|
||||||
fileSummary,
|
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 pipelineRow = await db.get('SELECT state, active_job_id FROM pipeline_state WHERE id = 1');
|
||||||
const activePipelineJobId = normalizeJobIdValue(pipelineRow?.active_job_id);
|
const activePipelineJobId = normalizeJobIdValue(pipelineRow?.active_job_id);
|
||||||
const activeJobIncluded = Boolean(activePipelineJobId && deleteJobIds.includes(activePipelineJobId));
|
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 || ''))) {
|
if (activeJobIncluded && runningStates.has(String(pipelineRow?.state || ''))) {
|
||||||
const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.');
|
const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.');
|
||||||
error.statusCode = 409;
|
error.statusCode = 409;
|
||||||
throw error;
|
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 effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||||
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
|
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
|
||||||
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
|
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
|
||||||
if (effectiveFileTarget === 'none') {
|
if (effectiveFileTarget === 'none') {
|
||||||
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
|
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) {
|
if (autoIncompleteMoviePaths.length > 0) {
|
||||||
effectiveFileTarget = 'movie';
|
effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie';
|
||||||
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
|
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
|
||||||
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
|
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
|
||||||
}
|
}
|
||||||
@@ -8806,11 +9122,22 @@ class HistoryService {
|
|||||||
const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both';
|
const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both';
|
||||||
let incompleteCleanupSummary = null;
|
let incompleteCleanupSummary = null;
|
||||||
if (includeMovieCleanup) {
|
if (includeMovieCleanup) {
|
||||||
const deletedJobRows = deleteJobIds
|
|
||||||
.map((id) => rowById.get(id))
|
|
||||||
.filter(Boolean);
|
|
||||||
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs(deletedJobRows, deleteJobIds);
|
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 deletedJobIdSet = new Set(deleteJobIds);
|
||||||
const deletedJobs = Array.isArray(preview?.relatedJobs)
|
const deletedJobs = Array.isArray(preview?.relatedJobs)
|
||||||
@@ -8826,6 +9153,7 @@ class HistoryService {
|
|||||||
deletedJobCount: deleteJobIds.length,
|
deletedJobCount: deleteJobIds.length,
|
||||||
pipelineStateReset: activeJobIncluded,
|
pipelineStateReset: activeJobIncluded,
|
||||||
incompleteCleanup: incompleteCleanupSummary,
|
incompleteCleanup: incompleteCleanupSummary,
|
||||||
|
emptyDirectoryCleanup,
|
||||||
filesDeleted: fileSummary
|
filesDeleted: fileSummary
|
||||||
? {
|
? {
|
||||||
raw: fileSummary.raw?.filesDeleted ?? 0,
|
raw: fileSummary.raw?.filesDeleted ?? 0,
|
||||||
@@ -8843,7 +9171,8 @@ class HistoryService {
|
|||||||
deletedJobIds: deleteJobIds,
|
deletedJobIds: deleteJobIds,
|
||||||
deletedJobs,
|
deletedJobs,
|
||||||
fileSummary,
|
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 { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
const CHAIN_NAME_MAX_LENGTH = 120;
|
const CHAIN_NAME_MAX_LENGTH = 120;
|
||||||
|
const CHAIN_DESCRIPTION_MAX_LENGTH = 50;
|
||||||
const STEP_TYPE_SCRIPT = 'script';
|
const STEP_TYPE_SCRIPT = 'script';
|
||||||
const STEP_TYPE_WAIT = 'wait';
|
const STEP_TYPE_WAIT = 'wait';
|
||||||
const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]);
|
const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]);
|
||||||
@@ -26,6 +27,31 @@ function createValidationError(message, details = null) {
|
|||||||
return error;
|
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 = []) {
|
function mapChainRow(row, steps = []) {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return null;
|
return null;
|
||||||
@@ -33,6 +59,8 @@ function mapChainRow(row, steps = []) {
|
|||||||
return {
|
return {
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
name: String(row.name || ''),
|
name: String(row.name || ''),
|
||||||
|
description: normalizeChainDescription(row.description),
|
||||||
|
isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1,
|
||||||
orderIndex: Number(row.order_index || 0),
|
orderIndex: Number(row.order_index || 0),
|
||||||
steps: steps.map(mapStepRow),
|
steps: steps.map(mapStepRow),
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
@@ -164,7 +192,7 @@ class ScriptChainService {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.all(
|
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
|
FROM script_chains
|
||||||
ORDER BY order_index ASC, id ASC
|
ORDER BY order_index ASC, id ASC
|
||||||
`
|
`
|
||||||
@@ -213,7 +241,7 @@ class ScriptChainService {
|
|||||||
}
|
}
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const row = await db.get(
|
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]
|
[normalizedId]
|
||||||
);
|
);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -235,7 +263,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, 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
|
ids
|
||||||
);
|
);
|
||||||
const stepRows = await db.all(
|
const stepRows = await db.all(
|
||||||
@@ -268,12 +296,16 @@ class ScriptChainService {
|
|||||||
async createChain(payload = {}) {
|
async createChain(payload = {}) {
|
||||||
const body = payload && typeof payload === 'object' ? payload : {};
|
const body = payload && typeof payload === 'object' ? payload : {};
|
||||||
const name = String(body.name || '').trim();
|
const name = String(body.name || '').trim();
|
||||||
|
const description = normalizeChainDescription(body.description);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
||||||
}
|
}
|
||||||
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
||||||
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
|
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 steps = validateSteps(body.steps);
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
@@ -281,10 +313,10 @@ class ScriptChainService {
|
|||||||
const nextOrderIndex = await this._getNextOrderIndex(db);
|
const nextOrderIndex = await this._getNextOrderIndex(db);
|
||||||
const result = await db.run(
|
const result = await db.run(
|
||||||
`
|
`
|
||||||
INSERT INTO script_chains (name, order_index, created_at, updated_at)
|
INSERT INTO script_chains (name, description, is_favorite, order_index, created_at, updated_at)
|
||||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`,
|
`,
|
||||||
[name, nextOrderIndex]
|
[name, description || null, nextOrderIndex]
|
||||||
);
|
);
|
||||||
const chainId = result.lastID;
|
const chainId = result.lastID;
|
||||||
await this._saveSteps(db, chainId, steps);
|
await this._saveSteps(db, chainId, steps);
|
||||||
@@ -304,12 +336,16 @@ class ScriptChainService {
|
|||||||
}
|
}
|
||||||
const body = payload && typeof payload === 'object' ? payload : {};
|
const body = payload && typeof payload === 'object' ? payload : {};
|
||||||
const name = String(body.name || '').trim();
|
const name = String(body.name || '').trim();
|
||||||
|
const description = normalizeChainDescription(body.description);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
|
||||||
}
|
}
|
||||||
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
if (name.length > CHAIN_NAME_MAX_LENGTH) {
|
||||||
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
|
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 steps = validateSteps(body.steps);
|
||||||
|
|
||||||
await this.getChainById(normalizedId);
|
await this.getChainById(normalizedId);
|
||||||
@@ -317,8 +353,8 @@ class ScriptChainService {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
try {
|
try {
|
||||||
await db.run(
|
await db.run(
|
||||||
`UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
`UPDATE script_chains SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
[name, normalizedId]
|
[name, description || null, normalizedId]
|
||||||
);
|
);
|
||||||
await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]);
|
await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]);
|
||||||
await this._saveSteps(db, normalizedId, steps);
|
await this._saveSteps(db, normalizedId, steps);
|
||||||
@@ -342,6 +378,25 @@ class ScriptChainService {
|
|||||||
return existing;
|
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 = []) {
|
async reorderChains(orderedIds = []) {
|
||||||
const providedIds = Array.isArray(orderedIds)
|
const providedIds = Array.isArray(orderedIds)
|
||||||
? orderedIds.map(normalizeChainId).filter(Boolean)
|
? orderedIds.map(normalizeChainId).filter(Boolean)
|
||||||
|
|||||||
@@ -72,6 +72,27 @@ function normalizeScriptBody(rawValue) {
|
|||||||
return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
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) {
|
function createValidationError(message, details = null) {
|
||||||
const error = new Error(message);
|
const error = new Error(message);
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -125,6 +146,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 || ''),
|
||||||
|
isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1,
|
||||||
orderIndex: Number(row.order_index || 0),
|
orderIndex: Number(row.order_index || 0),
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at
|
updatedAt: row.updated_at
|
||||||
@@ -325,7 +347,7 @@ 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, order_index, created_at, updated_at
|
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
|
||||||
FROM scripts
|
FROM scripts
|
||||||
ORDER BY order_index ASC, id ASC
|
ORDER BY order_index ASC, id ASC
|
||||||
`
|
`
|
||||||
@@ -341,7 +363,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, order_index, created_at, updated_at
|
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
|
||||||
FROM scripts
|
FROM scripts
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`,
|
`,
|
||||||
@@ -362,8 +384,8 @@ class ScriptService {
|
|||||||
const nextOrderIndex = await this._getNextOrderIndex(db);
|
const nextOrderIndex = await this._getNextOrderIndex(db);
|
||||||
const result = await db.run(
|
const result = await db.run(
|
||||||
`
|
`
|
||||||
INSERT INTO scripts (name, script_body, order_index, created_at, updated_at)
|
INSERT INTO scripts (name, script_body, is_favorite, order_index, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`,
|
`,
|
||||||
[normalized.name, normalized.scriptBody, nextOrderIndex]
|
[normalized.name, normalized.scriptBody, nextOrderIndex]
|
||||||
);
|
);
|
||||||
@@ -420,6 +442,25 @@ class ScriptService {
|
|||||||
return existing;
|
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 = []) {
|
async getScriptsByIds(rawIds = []) {
|
||||||
const ids = normalizeScriptIdList(rawIds);
|
const ids = normalizeScriptIdList(rawIds);
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
@@ -429,7 +470,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, order_index, created_at, updated_at
|
SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at
|
||||||
FROM scripts
|
FROM scripts
|
||||||
WHERE id IN (${placeholders})
|
WHERE id IN (${placeholders})
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ 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,
|
||||||
|
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
order_index INTEGER NOT NULL DEFAULT 0,
|
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
|
||||||
@@ -110,6 +111,8 @@ 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,
|
||||||
|
description TEXT,
|
||||||
|
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
order_index INTEGER NOT NULL DEFAULT 0,
|
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
|
||||||
@@ -132,6 +135,25 @@ CREATE TABLE script_chain_steps (
|
|||||||
|
|
||||||
CREATE INDEX idx_script_chain_steps_chain ON script_chain_steps(chain_id, position);
|
CREATE INDEX idx_script_chain_steps_chain ON script_chain_steps(chain_id, position);
|
||||||
|
|
||||||
|
CREATE TABLE hardware_metrics_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
captured_at TEXT NOT NULL,
|
||||||
|
day_key TEXT NOT NULL,
|
||||||
|
cpu_usage_percent REAL,
|
||||||
|
cpu_temperature_c REAL,
|
||||||
|
ram_usage_percent REAL,
|
||||||
|
ram_used_bytes INTEGER,
|
||||||
|
ram_total_bytes INTEGER,
|
||||||
|
gpu_usage_percent REAL,
|
||||||
|
gpu_temperature_c REAL,
|
||||||
|
vram_used_bytes INTEGER,
|
||||||
|
vram_total_bytes INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_hardware_metrics_history_captured_at ON hardware_metrics_history(captured_at);
|
||||||
|
CREATE INDEX idx_hardware_metrics_history_day_key ON hardware_metrics_history(day_key);
|
||||||
|
|
||||||
CREATE TABLE pipeline_state (
|
CREATE TABLE pipeline_state (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
state TEXT NOT NULL,
|
state TEXT NOT NULL,
|
||||||
|
|||||||
@@ -1,191 +1,179 @@
|
|||||||
# Einstellungsreferenz
|
# Einstellungsreferenz
|
||||||
|
|
||||||
Diese Seite dokumentiert den aktuellen Stand der Settings auf Basis von `settings_schema` und der aktiven GUI-Logik.
|
Diese Seite beschreibt **jede** aktuelle Einstellung aus der Settings-GUI: was technisch passiert und warum man sie setzt.
|
||||||
|
|
||||||
## Wichtiges Verhalten in der GUI
|
## Lesehilfe
|
||||||
|
|
||||||
- Speichern erfolgt gesammelt über `Änderungen speichern` (Bulk-Update).
|
- `Sichtbarkeit`: `Standard`, `Expertenmodus` oder `Ausgeblendet (nur API/DB)`.
|
||||||
- `ui_expert_mode` wird sofort einzeln gespeichert (nicht erst mit Bulk-Speichern).
|
- `Default`: UI-/Schema-Default (sensible Werte werden als `leer` dargestellt).
|
||||||
- `*_owner`-Felder sind in der GUI erst aktiv, wenn der zugehörige Pfad gesetzt ist.
|
- `Was passiert technisch?`: direkte Wirkung im Backend/Pipeline-Verhalten.
|
||||||
- Einige Keys sind absichtlich ausgeblendet und nur technisch (API/DB) nutzbar.
|
- `Warum/Wann setzen?`: Praxisgrund inkl. typischem Einsatzzweck.
|
||||||
- Benachrichtigungs-Event-Toggles sind nur sichtbar, wenn `pushover_enabled=true`.
|
|
||||||
|
|
||||||
## Fallbacks und Pfadauflösung
|
## Vollstaendige Feldliste
|
||||||
|
|
||||||
- Leere profilspezifische Pfade fallen auf Installationsdefaults zurück.
|
|
||||||
- `download_dir` nutzt bei leerem Wert den Default-Downloadpfad.
|
|
||||||
- Die Konfigurationsseite zeigt im Block `Effektive Pfade` die tatsächlich verwendeten Laufzeitpfade.
|
|
||||||
|
|
||||||
## Vollständige Feldliste
|
|
||||||
|
|
||||||
## Kategorie: Benachrichtigungen
|
## Kategorie: Benachrichtigungen
|
||||||
|
|
||||||
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System |
|
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `Expertenmodus` | `ui_expert_mode` | `boolean` | `false` | Standard | Schaltet erweiterte Einstellungen in der UI ein. |
|
| `Expertenmodus` | `ui_expert_mode` | `boolean` | `false` | Standard | Schaltet erweiterte Einstellungen in der UI ein. Wird sofort einzeln gespeichert und beeinflusst Sichtbarkeit von Feldern/Kategorien. | Wenn du selten benoetigte technische Felder sehen willst (z.B. Logging- oder CLI-Details). |
|
||||||
| `PushOver aktiviert` | `pushover_enabled` | `boolean` | `false` | Standard | Master-Schalter für PushOver Versand. |
|
| `PushOver aktiviert` | `pushover_enabled` | `boolean` | `false` | Standard | Master-Schalter für PushOver Versand. | Wenn du aktive Laufzeit-Benachrichtigungen auf Mobilgeraete erhalten willst. |
|
||||||
| `PushOver Token` | `pushover_token` | `string` | `leer` | Standard | Application Token für PushOver. |
|
| `PushOver Token` | `pushover_token` | `string` | `leer` | Standard | Application Token für PushOver. | Wenn Ripster in deine PushOver-App senden soll (Token aus PushOver-App-Config). |
|
||||||
| `PushOver User` | `pushover_user` | `string` | `leer` | Standard | User-Key für PushOver. |
|
| `PushOver User` | `pushover_user` | `string` | `leer` | Standard | User-Key für PushOver. | Wenn Benachrichtigungen an einen bestimmten PushOver-Account gehen sollen. |
|
||||||
| `PushOver Device (optional)` | `pushover_device` | `string` | `leer` | Expertenmodus | Optionales Ziel-Device in PushOver. |
|
| `PushOver Device (optional)` | `pushover_device` | `string` | `leer` | Expertenmodus | Optionales Ziel-Device in PushOver. | Wenn Nachrichten nur auf ein bestimmtes Endgeraet zugestellt werden sollen. |
|
||||||
| `PushOver Titel-Präfix` | `pushover_title_prefix` | `string` | `Ripster` | Standard | Prefix im PushOver Titel. |
|
| `PushOver Titel-Präfix` | `pushover_title_prefix` | `string` | `Ripster` | Standard | Prefix im PushOver Titel. | Wenn du Meldungen mehrerer Instanzen sauber unterscheiden willst (z.B. "Ripper-Wohnzimmer"). |
|
||||||
| `PushOver Priority` | `pushover_priority` | `number` | `0` | Expertenmodus | Priorität -2 bis 2. |
|
| `PushOver Priority` | `pushover_priority` | `number` | `0` | Expertenmodus | Priorität -2 bis 2. | Wenn Meldungen bewusst leiser (niedrig) oder dringender (hoch) zugestellt werden sollen. |
|
||||||
| `PushOver Timeout (ms)` | `pushover_timeout_ms` | `number` | `7000` | Expertenmodus | HTTP Timeout für PushOver Requests. |
|
| `PushOver Timeout (ms)` | `pushover_timeout_ms` | `number` | `7000` | Expertenmodus | HTTP Timeout für PushOver Requests. | Wenn PushOver bei schlechter Verbindung mehr oder weniger Zeit fuer den Request bekommen soll. |
|
||||||
| `Bei manueller Auswahl senden` | `pushover_notify_metadata_ready` | `boolean` | `true` | Standard | Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist). |
|
| `Bei manueller Auswahl senden` | `pushover_notify_metadata_ready` | `boolean` | `true` | Standard | Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist). | Wenn 'Bei manueller Auswahl senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. |
|
||||||
| `Bei Rip-Start senden` | `pushover_notify_rip_started` | `boolean` | `true` | Standard | Sendet beim Start des MakeMKV-Rips. |
|
| `Bei Rip-Start senden` | `pushover_notify_rip_started` | `boolean` | `true` | Standard | Sendet beim Start des MakeMKV-Rips. | Wenn 'Bei Rip-Start senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. |
|
||||||
| `Bei Encode-Start senden` | `pushover_notify_encoding_started` | `boolean` | `true` | Standard | Sendet beim Start von HandBrake. |
|
| `Bei Encode-Start senden` | `pushover_notify_encoding_started` | `boolean` | `true` | Standard | Sendet beim Start von HandBrake. | Wenn 'Bei Encode-Start senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. |
|
||||||
| `Bei Erfolg senden` | `pushover_notify_job_finished` | `boolean` | `true` | Standard | Sendet bei erfolgreich abgeschlossenem Job. |
|
| `Bei Erfolg senden` | `pushover_notify_job_finished` | `boolean` | `true` | Standard | Sendet bei erfolgreich abgeschlossenem Job. | Wenn 'Bei Erfolg senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. |
|
||||||
| `Bei Fehler senden` | `pushover_notify_job_error` | `boolean` | `true` | Standard | Sendet bei Fehlern in der Pipeline. |
|
| `Bei Fehler senden` | `pushover_notify_job_error` | `boolean` | `true` | Standard | Sendet bei Fehlern in der Pipeline. | Wenn 'Bei Fehler senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. |
|
||||||
| `Bei Abbruch senden` | `pushover_notify_job_cancelled` | `boolean` | `true` | Standard | Sendet wenn Job manuell abgebrochen wurde. |
|
| `Bei Abbruch senden` | `pushover_notify_job_cancelled` | `boolean` | `true` | Standard | Sendet wenn Job manuell abgebrochen wurde. | Wenn 'Bei Abbruch senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. |
|
||||||
| `Bei Re-Encode Start senden` | `pushover_notify_reencode_started` | `boolean` | `true` | Standard | Sendet beim Start von RAW Re-Encode. |
|
| `Bei Re-Encode Start senden` | `pushover_notify_reencode_started` | `boolean` | `true` | Standard | Sendet beim Start von RAW Re-Encode. | Wenn 'Bei Re-Encode Start senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. |
|
||||||
| `Bei Re-Encode Erfolg senden` | `pushover_notify_reencode_finished` | `boolean` | `true` | Standard | Sendet bei erfolgreichem RAW Re-Encode. |
|
| `Bei Re-Encode Erfolg senden` | `pushover_notify_reencode_finished` | `boolean` | `true` | Standard | Sendet bei erfolgreichem RAW Re-Encode. | Wenn 'Bei Re-Encode Erfolg senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. |
|
||||||
|
|
||||||
## Kategorie: Converter
|
## Kategorie: Converter
|
||||||
|
|
||||||
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System |
|
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `Erlaubte Datei-Endungen` | `converter_scan_extensions` | `string` | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus` | Standard | Komma-getrennte Liste erlaubter Dateiendungen für den Scan (ohne Punkt). |
|
| `Erlaubte Datei-Endungen` | `converter_scan_extensions` | `string` | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus` | Standard | Komma-getrennte Liste erlaubter Dateiendungen für den Scan (ohne Punkt). Steuert sowohl Upload-Validierung als auch Converter-Scan-Filter. | Wenn 'Erlaubte Datei-Endungen' vom Default abweichen soll. |
|
||||||
| `Auto-Scan (Polling)` | `converter_polling_enabled` | `boolean` | `false` | Standard | Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen. |
|
| `Auto-Scan (Polling)` | `converter_polling_enabled` | `boolean` | `false` | Standard | Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen. | Wenn neue Dateien im Converter-Ordner automatisch erscheinen sollen, ohne manuellen Scan. |
|
||||||
| `Polling-Intervall (Sekunden)` | `converter_polling_interval` | `number` | `300` | Standard | Intervall für automatischen Scan des Converter-Ordners. |
|
| `Polling-Intervall (Sekunden)` | `converter_polling_interval` | `number` | `300` | Standard | Intervall für automatischen Scan des Converter-Ordners. | Wenn der Auto-Scan schneller (kleiner Wert) oder ressourcenschonender (groesserer Wert) laufen soll. |
|
||||||
|
|
||||||
## Kategorie: Laufwerk
|
## Kategorie: Laufwerk
|
||||||
|
|
||||||
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System |
|
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `Laufwerksmodus` | `drive_mode` | `select` | `auto` | Standard | Auto-Discovery oder explizites Device. |
|
| `Laufwerksmodus` | `drive_mode` | `select` | `auto` | Standard | Auto-Discovery oder explizites Device. | Wenn du zwischen automatischer Laufwerkserkennung und fixer Device-Liste umschalten willst. |
|
||||||
| `Explizite Laufwerke` | `drive_devices` | `path` | `[]` | Standard | Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{"path":"/dev/sr0","makemkvIndex":0},{"path":"/dev/sr1","makemkvIndex":1}]. Nur relevant wenn Modus = Explizites Device. |
|
| `Explizite Laufwerke` | `drive_devices` | `path` | `[]` | Standard | Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{"path":"/dev/sr0","makemkvIndex":0},{"path":"/dev/sr1","makemkvIndex":1}]. Nur relevant wenn Modus = Explizites Device. Wird als JSON gespeichert und vom Laufwerksdienst fuer explizite Zuordnung genutzt. | Wenn auf dem Host mehrere Laufwerke stabil gemappt werden muessen (z.B. /dev/sr0=disc:0, /dev/sr1=disc:1). |
|
||||||
| `Device Pfad` | `drive_device` | `path` | `/dev/sr0` | Ausgeblendet (nur API/DB) | Nur für expliziten Modus, z.B. /dev/sr0. |
|
| `Device Pfad` | `drive_device` | `path` | `/dev/sr0` | Ausgeblendet (nur API/DB) | Nur für expliziten Modus, z.B. /dev/sr0. | Wenn 'Device Pfad' vom Default abweichen soll. |
|
||||||
| `MakeMKV Source Index` | `makemkv_source_index` | `number` | `0` | Ausgeblendet (nur API/DB) | Disc Index im Auto-Modus. |
|
| `MakeMKV Source Index` | `makemkv_source_index` | `number` | `0` | Ausgeblendet (nur API/DB) | Disc Index im Auto-Modus. | Wenn 'MakeMKV Source Index' fuer Performance, Last oder Genauigkeit feinjustiert werden soll. |
|
||||||
| `Automatische Disk-Erkennung` | `disc_auto_detection_enabled` | `boolean` | `true` | Standard | Wenn deaktiviert, findet keine automatische Laufwerksprüfung statt. Neue Disks werden nur per "Laufwerk neu lesen" erkannt. |
|
| `Automatische Disk-Erkennung` | `disc_auto_detection_enabled` | `boolean` | `true` | Standard | Wenn deaktiviert, findet keine automatische Laufwerksprüfung statt. Neue Disks werden nur per "Laufwerk neu lesen" erkannt. | Wenn Discs nur manuell statt automatisch erkannt werden sollen. |
|
||||||
| `Polling Intervall (ms)` | `disc_poll_interval_ms` | `number` | `4000` | Expertenmodus | Intervall für Disk-Erkennung. |
|
| `Polling Intervall (ms)` | `disc_poll_interval_ms` | `number` | `4000` | Expertenmodus | Intervall für Disk-Erkennung. | Wenn die Erkennung schneller reagieren oder weniger oft pollen soll. |
|
||||||
| `Laufwerk nach Rip auswerfen` | `auto_eject_after_rip` | `boolean` | `false` | Standard | Wenn aktiv, wird das Laufwerk nach erfolgreichem Abschluss eines Rip-Vorgangs automatisch ausgeworfen (eject). |
|
| `Laufwerk nach Rip auswerfen` | `auto_eject_after_rip` | `boolean` | `false` | Standard | Wenn aktiv, wird das Laufwerk nach erfolgreichem Abschluss eines Rip-Vorgangs automatisch ausgeworfen (eject). | Wenn ein Laufwerk nach erfolgreichem Rip direkt freigegeben/ausgeworfen werden soll. |
|
||||||
|
|
||||||
## Kategorie: Logging
|
## Kategorie: Logging
|
||||||
|
|
||||||
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System |
|
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `Terminal-Ausgabe aktiv` | `server_console_log_output_enabled` | `boolean` | `true` | Expertenmodus | Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv. |
|
| `Terminal-Ausgabe aktiv` | `server_console_log_output_enabled` | `boolean` | `true` | Expertenmodus | Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn Terminal-Logs bei Betrieb unter Service/SSH ein- oder ausgeschaltet werden sollen. |
|
||||||
| `HTTP-Logs im Terminal` | `server_console_log_http_enabled` | `boolean` | `true` | Expertenmodus | Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert. |
|
| `HTTP-Logs im Terminal` | `server_console_log_http_enabled` | `boolean` | `true` | Expertenmodus | Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn HTTP-Request-Logs im Terminal fuer API-Debugging gebraucht werden. |
|
||||||
| `DEBUG im Terminal` | `server_console_log_debug_enabled` | `boolean` | `true` | Expertenmodus | Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. |
|
| `DEBUG im Terminal` | `server_console_log_debug_enabled` | `boolean` | `true` | Expertenmodus | Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn detailreiche DEBUG-Logs im Terminal benoetigt werden (Fehlersuche). |
|
||||||
| `INFO im Terminal` | `server_console_log_info_enabled` | `boolean` | `true` | Expertenmodus | Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. |
|
| `INFO im Terminal` | `server_console_log_info_enabled` | `boolean` | `true` | Expertenmodus | Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn normale INFO-Laufzeitmeldungen im Terminal sichtbar sein sollen. |
|
||||||
| `WARN im Terminal` | `server_console_log_warn_enabled` | `boolean` | `true` | Expertenmodus | Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. |
|
| `WARN im Terminal` | `server_console_log_warn_enabled` | `boolean` | `true` | Expertenmodus | Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn Warnungen im Terminal hervorgehoben bleiben sollen. |
|
||||||
| `ERROR im Terminal` | `server_console_log_error_enabled` | `boolean` | `true` | Expertenmodus | Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. |
|
| `ERROR im Terminal` | `server_console_log_error_enabled` | `boolean` | `true` | Expertenmodus | Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn Fehler zwingend im Terminal sichtbar sein sollen. |
|
||||||
|
|
||||||
## Kategorie: Metadaten
|
## Kategorie: Metadaten
|
||||||
|
|
||||||
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System |
|
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Standard | Read Access Token für Serien-Metadaten über TheMovieDB (TMDb). |
|
| `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Standard | Read Access Token für Serien-Metadaten über TheMovieDB (TMDb). Wird von TMDb-Service und Serienzuordnung in Analyse/Metadaten-Dialog genutzt. | Wenn TMDb-Metadaten (Suche/Zuordnung/Serienepisoden) verlässlich funktionieren sollen. |
|
||||||
| `Film-Sprache` | `dvd_series_language` | `string` | `de-DE` | Standard | Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US. |
|
| `Film-Sprache` | `dvd_series_language` | `string` | `de-DE` | Standard | Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US. Wird von TMDb-Service und Serienzuordnung in Analyse/Metadaten-Dialog genutzt. | Wenn bevorzugt deutsche, englische oder andere lokalisierte Metadaten genutzt werden sollen. |
|
||||||
| `Fallback Sprache` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Standard | Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR. |
|
| `Fallback Sprache` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Standard | Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR. Wird von TMDb-Service und Serienzuordnung in Analyse/Metadaten-Dialog genutzt. | Wenn bei fehlender Primärsprache automatisch andere Sprachversionen ausprobiert werden sollen. |
|
||||||
| `Coverart-Nachladen aktiv` | `coverart_recovery_enabled` | `boolean` | `true` | Standard | Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen. |
|
| `Coverart-Nachladen aktiv` | `coverart_recovery_enabled` | `boolean` | `true` | Standard | Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen. Aenderung aktualisiert den Coverart-Recovery-Scheduler. | Wenn fehlende Cover später automatisch nachgezogen werden sollen. |
|
||||||
| `Coverart-Prüfintervall (Stunden)` | `coverart_recovery_interval_hours` | `number` | `6` | Standard | Intervall für automatische Prüfung fehlender Coverarts in Stunden. |
|
| `Coverart-Prüfintervall (Stunden)` | `coverart_recovery_interval_hours` | `number` | `6` | Standard | Intervall für automatische Prüfung fehlender Coverarts in Stunden. Aenderung aktualisiert den Coverart-Recovery-Scheduler. | Wenn der Nachlade-Job haeufiger oder seltener laufen soll. |
|
||||||
| `NFO nach Encode erzeugen` | `generate_nfo_after_encode` | `boolean` | `false` | Standard | Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei. |
|
| `NFO nach Encode erzeugen` | `generate_nfo_after_encode` | `boolean` | `false` | Standard | Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei. Wird nach erfolgreichem Encode in der Historie/Pipeline ausgewertet. | Wenn neben der Ausgabedatei automatisch eine .nfo fuer Media-Center erzeugt werden soll. |
|
||||||
|
|
||||||
## Kategorie: Monitoring
|
## Kategorie: Monitoring
|
||||||
|
|
||||||
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System |
|
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `Hardware Monitoring aktiviert` | `hardware_monitoring_enabled` | `boolean` | `true` | Standard | Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates). |
|
| `Hardware Monitoring aktiviert` | `hardware_monitoring_enabled` | `boolean` | `true` | Standard | Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates). Aenderung triggert Re-Konfiguration des Hardware-Monitoring-Dienstes. | Wenn CPU/RAM/GPU/Speicher live sichtbar sein sollen. |
|
||||||
| `Hardware Monitoring Intervall (ms)` | `hardware_monitoring_interval_ms` | `number` | `5000` | Expertenmodus | Polling-Intervall für CPU/RAM/GPU/Storage-Metriken. |
|
| `Hardware Monitoring Intervall (ms)` | `hardware_monitoring_interval_ms` | `number` | `5000` | Expertenmodus | Polling-Intervall für CPU/RAM/GPU/Storage-Metriken. Aenderung triggert Re-Konfiguration des Hardware-Monitoring-Dienstes. | Wenn Monitoring häufiger aktualisiert oder Last reduziert werden soll. |
|
||||||
|
|
||||||
## Kategorie: Pfade
|
## Kategorie: Pfade
|
||||||
|
|
||||||
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System |
|
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `Raw Ausgabeordner (Blu-ray)` | `raw_dir_bluray` | `path` | `leer` | Standard | Optionaler RAW-Zielpfad nur für Blu-ray. Leer = Fallback auf "Raw Ausgabeordner". |
|
| `Raw Ausgabeordner (Blu-ray)` | `raw_dir_bluray` | `path` | `leer` | Standard | Optionaler RAW-Zielpfad nur für Blu-ray. Leer = Fallback auf "Raw Ausgabeordner". | Wenn 'Raw Ausgabeordner (Blu-ray)' vom Default abweichen soll. |
|
||||||
| `Raw Ausgabeordner (DVD)` | `raw_dir_dvd` | `path` | `leer` | Standard | Optionaler RAW-Zielpfad nur für DVD. Leer = Fallback auf "Raw Ausgabeordner". |
|
| `Raw Ausgabeordner (DVD)` | `raw_dir_dvd` | `path` | `leer` | Standard | Optionaler RAW-Zielpfad nur für DVD. Leer = Fallback auf "Raw Ausgabeordner". | Wenn 'Raw Ausgabeordner (DVD)' vom Default abweichen soll. |
|
||||||
| `CD RAW-Ordner` | `raw_dir_cd` | `path` | `leer` | Standard | Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd). |
|
| `CD RAW-Ordner` | `raw_dir_cd` | `path` | `leer` | Standard | Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd). | Wenn 'CD RAW-Ordner' vom Default abweichen soll. |
|
||||||
| `Audiobook RAW-Ordner` | `raw_dir_audiobook` | `path` | `leer` | Standard | Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw). |
|
| `Audiobook RAW-Ordner` | `raw_dir_audiobook` | `path` | `leer` | Standard | Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw). | Wenn 'Audiobook RAW-Ordner' vom Default abweichen soll. |
|
||||||
| `Serien-Ordner (Blu-ray)` | `series_dir_bluray` | `path` | `leer` | Standard | Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series). |
|
| `Serien-Ordner (Blu-ray)` | `series_dir_bluray` | `path` | `leer` | Standard | Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series). | Wenn 'Serien-Ordner (Blu-ray)' vom Default abweichen soll. |
|
||||||
| `Film Ausgabeordner (Blu-ray)` | `movie_dir_bluray` | `path` | `leer` | Standard | Optionaler Encode-Zielpfad nur für Blu-ray. Leer = Fallback auf "Film Ausgabeordner". |
|
| `Film Ausgabeordner (Blu-ray)` | `movie_dir_bluray` | `path` | `leer` | Standard | Optionaler Encode-Zielpfad nur für Blu-ray. Leer = Fallback auf "Film Ausgabeordner". | Wenn 'Film Ausgabeordner (Blu-ray)' vom Default abweichen soll. |
|
||||||
| `Film Ausgabeordner (DVD)` | `movie_dir_dvd` | `path` | `leer` | Standard | Optionaler Encode-Zielpfad nur für DVD. Leer = Fallback auf "Film Ausgabeordner". |
|
| `Film Ausgabeordner (DVD)` | `movie_dir_dvd` | `path` | `leer` | Standard | Optionaler Encode-Zielpfad nur für DVD. Leer = Fallback auf "Film Ausgabeordner". | Wenn 'Film Ausgabeordner (DVD)' vom Default abweichen soll. |
|
||||||
| `Serien-Ordner (DVD)` | `series_dir_dvd` | `path` | `leer` | Standard | Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series). |
|
| `Serien-Ordner (DVD)` | `series_dir_dvd` | `path` | `leer` | Standard | Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series). | Wenn 'Serien-Ordner (DVD)' vom Default abweichen soll. |
|
||||||
| `CD Output-Ordner` | `movie_dir_cd` | `path` | `leer` | Standard | Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner. |
|
| `CD Output-Ordner` | `movie_dir_cd` | `path` | `leer` | Standard | Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner. | Wenn 'CD Output-Ordner' vom Default abweichen soll. |
|
||||||
| `Audiobook Output-Ordner` | `movie_dir_audiobook` | `path` | `leer` | Standard | Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks). |
|
| `Audiobook Output-Ordner` | `movie_dir_audiobook` | `path` | `leer` | Standard | Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks). | Wenn 'Audiobook Output-Ordner' vom Default abweichen soll. |
|
||||||
| `Download ZIP-Ordner` | `download_dir` | `path` | `leer` | Standard | Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads). |
|
| `Download ZIP-Ordner` | `download_dir` | `path` | `leer` | Standard | Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads). Leere Werte nutzen automatisch den Installations-Defaultpfad. | Wenn RAW/Output/Download auf andere Datentraeger verschoben werden sollen (z.B. grosse HDD/NAS). |
|
||||||
| `Externe Speicher` | `external_storage_paths` | `path` | `[]` | Standard | Wird links unter "Freie Speicher" angezeigt. |
|
| `Externe Speicher` | `external_storage_paths` | `path` | `[]` | Standard | Wird links unter "Freie Speicher" angezeigt. | Wenn 'Externe Speicher' vom Default abweichen soll. |
|
||||||
| `Log Ordner` | `log_dir` | `path` | `installationsabhängig` | Standard | Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend. |
|
| `Log Ordner` | `log_dir` | `path` | `installationsabhaengig` | Standard | Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend. Wird zur Laufzeit angewendet; bei ungueltigem Pfad faellt Ripster auf Fallback-Logpfad zurueck. | Wenn Logs auf ein anderes Medium sollen (z.B. persistentes NAS/SSD statt Standardpfad). |
|
||||||
| `CD Output Template` | `cd_output_template` | `string` | `{artist} - {album} ({year})/{trackNr} {artist} - {title}` | Standard | Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich. Die Endung wird über das gewählte Ausgabeformat gesetzt. |
|
| `CD Output Template` | `cd_output_template` | `string` | `{artist} - {album} ({year})/{trackNr} {artist} - {title}` | Standard | Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich. Die Endung wird über das gewählte Ausgabeformat gesetzt. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Output Template (Blu-ray)` | `output_template_bluray` | `string` | `${title} (${year})/${title} (${year})` | Standard | Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt. |
|
| `Output Template (Blu-ray)` | `output_template_bluray` | `string` | `${title} (${year})/${title} (${year})` | Standard | Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Output Template (Blu-ray Serie, Episode)` | `output_template_bluray_series_episode` | `string` | `{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}` | Standard | Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. |
|
| `Output Template (Blu-ray Serie, Episode)` | `output_template_bluray_series_episode` | `string` | `{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}` | Standard | Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Output Template (Blu-ray Serie, Multi-Episode)` | `output_template_bluray_series_multi_episode` | `string` | `{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})` | Standard | Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. |
|
| `Output Template (Blu-ray Serie, Multi-Episode)` | `output_template_bluray_series_multi_episode` | `string` | `{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})` | Standard | Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Output Template (DVD)` | `output_template_dvd` | `string` | `${title} (${year})/${title} (${year})` | Standard | Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt. |
|
| `Output Template (DVD)` | `output_template_dvd` | `string` | `${title} (${year})/${title} (${year})` | Standard | Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Output Template (DVD Serie, Episode)` | `output_template_dvd_series_episode` | `string` | `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeNo} - {episodeTitle}` | Standard | Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. |
|
| `Output Template (DVD Serie, Episode)` | `output_template_dvd_series_episode` | `string` | `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeNo} - {episodeTitle}` | Standard | Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Output Template (DVD Serie, Multi-Episode)` | `output_template_dvd_series_multi_episode` | `string` | `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeRange}` | Standard | Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. |
|
| `Output Template (DVD Serie, Multi-Episode)` | `output_template_dvd_series_multi_episode` | `string` | `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeRange}` | Standard | Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Output Template (Audiobook)` | `output_template_audiobook` | `string` | `{author}/{author} - {title} ({year})` | Standard | Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich. |
|
| `Output Template (Audiobook)` | `output_template_audiobook` | `string` | `{author}/{author} - {title} ({year})` | Standard | Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Audiobook RAW Template` | `audiobook_raw_template` | `string` | `{author} - {title} ({year})` | Standard | Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}. |
|
| `Audiobook RAW Template` | `audiobook_raw_template` | `string` | `{author} - {title} ({year})` | Standard | Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Converter Raw-Ordner` | `converter_raw_dir` | `path` | `leer` | Standard | Eingangsordner für den Converter (Import-Scan und Datei-Uploads). Jeder Upload erhält einen Unterordner. Leer = Standardpfad (data/output/converter-raw). |
|
| `Converter Raw-Ordner` | `converter_raw_dir` | `path` | `leer` | Standard | Eingangsordner für den Converter (Import-Scan und Datei-Uploads). Jeder Upload erhält einen Unterordner. Leer = Standardpfad (data/output/converter-raw). Leere Werte nutzen automatisch den Installations-Defaultpfad. | Wenn RAW/Output/Download auf andere Datentraeger verschoben werden sollen (z.B. grosse HDD/NAS). |
|
||||||
| `Converter Ausgabe (Video)` | `converter_movie_dir` | `path` | `leer` | Standard | Ausgabepfad für konvertierte Video-Dateien. Leer = Standardpfad (data/output/converted-movies). |
|
| `Converter Ausgabe (Video)` | `converter_movie_dir` | `path` | `leer` | Standard | Ausgabepfad für konvertierte Video-Dateien. Leer = Standardpfad (data/output/converted-movies). Leere Werte nutzen automatisch den Installations-Defaultpfad. | Wenn RAW/Output/Download auf andere Datentraeger verschoben werden sollen (z.B. grosse HDD/NAS). |
|
||||||
| `Converter Ausgabe (Audio)` | `converter_audio_dir` | `path` | `leer` | Standard | Ausgabepfad für konvertierte Audio-Dateien. Leer = Standardpfad (data/output/converted-audio). |
|
| `Converter Ausgabe (Audio)` | `converter_audio_dir` | `path` | `leer` | Standard | Ausgabepfad für konvertierte Audio-Dateien. Leer = Standardpfad (data/output/converted-audio). Leere Werte nutzen automatisch den Installations-Defaultpfad. | Wenn RAW/Output/Download auf andere Datentraeger verschoben werden sollen (z.B. grosse HDD/NAS). |
|
||||||
| `Output-Template (Video)` | `converter_output_template_video` | `string` | `{title}` | Standard | Dateiname-Template für konvertierte Video-Dateien (ohne Endung). Platzhalter: {title}, {year}. |
|
| `Output-Template (Video)` | `converter_output_template_video` | `string` | `{title}` | Standard | Dateiname-Template für konvertierte Video-Dateien (ohne Endung). Platzhalter: {title}, {year}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `Output-Template (Audio)` | `converter_output_template_audio` | `string` | `{artist} - {title}` | Standard | Dateiname-Template für konvertierte Audio-Dateien (ohne Endung). Platzhalter: {artist}, {title}, {album}, {year}, {trackNr}. |
|
| `Output-Template (Audio)` | `converter_output_template_audio` | `string` | `{artist} - {title}` | Standard | Dateiname-Template für konvertierte Audio-Dateien (ohne Endung). Platzhalter: {artist}, {title}, {album}, {year}, {trackNr}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
| `RAW-Ordner (Blu-ray Serie)` | `raw_dir_bluray_series` | `path` | `leer` | Standard | RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray). |
|
| `RAW-Ordner (Blu-ray Serie)` | `raw_dir_bluray_series` | `path` | `leer` | Standard | RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray). | Wenn 'RAW-Ordner (Blu-ray Serie)' vom Default abweichen soll. |
|
||||||
| `Eigentümer RAW-Ordner (Blu-ray Serie)` | `raw_dir_bluray_series_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray). |
|
| `Eigentümer RAW-Ordner (Blu-ray Serie)` | `raw_dir_bluray_series_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray). Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Raw-Ordner (Blu-ray)` | `raw_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Raw-Ordner (Blu-ray)` | `raw_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `RAW-Ordner (DVD Serie)` | `raw_dir_dvd_series` | `path` | `leer` | Standard | RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD). |
|
| `RAW-Ordner (DVD Serie)` | `raw_dir_dvd_series` | `path` | `leer` | Standard | RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD). | Wenn 'RAW-Ordner (DVD Serie)' vom Default abweichen soll. |
|
||||||
| `Eigentümer RAW-Ordner (DVD Serie)` | `raw_dir_dvd_series_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD). |
|
| `Eigentümer RAW-Ordner (DVD Serie)` | `raw_dir_dvd_series_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD). Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Raw-Ordner (DVD)` | `raw_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Raw-Ordner (DVD)` | `raw_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer CD RAW-Ordner` | `raw_dir_cd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer CD RAW-Ordner` | `raw_dir_cd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Audiobook RAW-Ordner` | `raw_dir_audiobook_owner` | `string` | `leer` | Standard | Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Audiobook RAW-Ordner` | `raw_dir_audiobook_owner` | `string` | `leer` | Standard | Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Converter RAW-Ordner` | `converter_raw_dir_owner` | `string` | `leer` | Standard | Eigentümer der Converter-Eingabedateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Converter RAW-Ordner` | `converter_raw_dir_owner` | `string` | `leer` | Standard | Eigentümer der Converter-Eingabedateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Serien-Ordner (Blu-ray)` | `series_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Serien-Ordner (Blu-ray)` | `series_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Film-Ordner (Blu-ray)` | `movie_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Film-Ordner (Blu-ray)` | `movie_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Film-Ordner (DVD)` | `movie_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Film-Ordner (DVD)` | `movie_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Serien-Ordner (DVD)` | `series_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Serien-Ordner (DVD)` | `series_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer CD Output-Ordner` | `movie_dir_cd_owner` | `string` | `leer` | Standard | Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer CD Output-Ordner` | `movie_dir_cd_owner` | `string` | `leer` | Standard | Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Audiobook Output-Ordner` | `movie_dir_audiobook_owner` | `string` | `leer` | Standard | Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Audiobook Output-Ordner` | `movie_dir_audiobook_owner` | `string` | `leer` | Standard | Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Converter Video Output-Ordner` | `converter_movie_dir_owner` | `string` | `leer` | Standard | Eigentümer der konvertierten Video-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Converter Video Output-Ordner` | `converter_movie_dir_owner` | `string` | `leer` | Standard | Eigentümer der konvertierten Video-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Converter Audio Output-Ordner` | `converter_audio_dir_owner` | `string` | `leer` | Standard | Eigentümer der konvertierten Audio-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Converter Audio Output-Ordner` | `converter_audio_dir_owner` | `string` | `leer` | Standard | Eigentümer der konvertierten Audio-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Eigentümer Download ZIP-Ordner` | `download_dir_owner` | `string` | `leer` | Standard | Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. |
|
| `Eigentümer Download ZIP-Ordner` | `download_dir_owner` | `string` | `leer` | Standard | Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. Greift bei ZIP-Ordner, Archivdatei und Metadatei. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. |
|
||||||
| `Kapitel Template (Audiobook)` | `output_chapter_template_audiobook` | `string` | `{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}` | Standard | Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich. |
|
| `Kapitel Template (Audiobook)` | `output_chapter_template_audiobook` | `string` | `{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}` | Standard | Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. |
|
||||||
|
|
||||||
## Kategorie: Tools
|
## Kategorie: Tools
|
||||||
|
|
||||||
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System |
|
| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `MakeMKV Kommando` | `makemkv_command` | `string` | `makemkvcon` | Expertenmodus | Pfad oder Befehl für makemkvcon. |
|
| `MakeMKV Kommando` | `makemkv_command` | `string` | `makemkvcon` | Expertenmodus | Pfad oder Befehl für makemkvcon. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). |
|
||||||
| `MakeMKV Key` | `makemkv_registration_key` | `string` | `leer` | Standard | Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden. |
|
| `MakeMKV Key` | `makemkv_registration_key` | `string` | `leer` | Standard | Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden. | Wenn MakeMKV-Features ohne Trial-Limit genutzt werden sollen (inkl. Betakey-Usecase). |
|
||||||
| `Mediainfo Kommando` | `mediainfo_command` | `string` | `mediainfo` | Expertenmodus | Pfad oder Befehl für mediainfo. |
|
| `Mediainfo Kommando` | `mediainfo_command` | `string` | `mediainfo` | Expertenmodus | Pfad oder Befehl für mediainfo. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). |
|
||||||
| `Minimale Titellänge (Minuten)` | `makemkv_min_length_minutes` | `number` | `60` | Standard | Filtert kurze Titel beim Rip. |
|
| `Minimale Titellänge (Minuten)` | `makemkv_min_length_minutes` | `number` | `60` | Standard | Filtert kurze Titel beim Rip. | Wenn kurze Bonus-/Trailer-Titel automatisch ausgefiltert werden sollen. |
|
||||||
| `HandBrake Kommando` | `handbrake_command` | `string` | `HandBrakeCLI` | Expertenmodus | Pfad oder Befehl für HandBrakeCLI. |
|
| `HandBrake Kommando` | `handbrake_command` | `string` | `HandBrakeCLI` | Expertenmodus | Pfad oder Befehl für HandBrakeCLI. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). |
|
||||||
| `Encode-Neustart: unvollständige Ausgabe löschen` | `handbrake_restart_delete_incomplete_output` | `boolean` | `true` | Standard | Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt. |
|
| `Encode-Neustart: unvollständige Ausgabe löschen` | `handbrake_restart_delete_incomplete_output` | `boolean` | `true` | Standard | Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt. | Wenn bei Encode-Neustart alte, unvollstaendige Dateien sicher entfernt werden sollen. |
|
||||||
| `Parallele Jobs` | `pipeline_max_parallel_jobs` | `number` | `1` | Standard | Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue. |
|
| `Parallele Jobs` | `pipeline_max_parallel_jobs` | `number` | `1` | Standard | Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue. Aenderung beeinflusst Queue-Pump und Startentscheidungen fuer neue Jobs. | Wenn Durchsatz und CPU-Last fuer Videojobs ausbalanciert werden sollen. |
|
||||||
| `Max. parallele Audio CD Jobs` | `pipeline_max_parallel_cd_encodes` | `number` | `2` | Standard | Maximale Anzahl parallel laufender Audio CD Jobs (Rip + Encode als Einheit). Gilt zusätzlich zum Gesamtlimit. |
|
| `Max. parallele Audio CD Jobs` | `pipeline_max_parallel_cd_encodes` | `number` | `2` | Standard | Maximale Anzahl parallel laufender Audio CD Jobs (Rip + Encode als Einheit). Gilt zusätzlich zum Gesamtlimit. Aenderung beeinflusst Queue-Pump und Startentscheidungen fuer neue Jobs. | Wenn mehrere Audio-CD-Jobs parallel erlaubt/begrenzt werden sollen. |
|
||||||
| `Script-Test Timeout (ms)` | `script_test_timeout_ms` | `number` | `0` | Expertenmodus | Timeout für Script-Tests in den Settings. 0 = kein Timeout. |
|
| `Script-Test Timeout (ms)` | `script_test_timeout_ms` | `number` | `0` | Expertenmodus | Timeout fuer Script-Tests in den Settings. 0 = kein Timeout. Wird beim Testlauf von Scripts in Settings angewendet. | Wenn Script-Tests bei Haengern automatisch abgebrochen werden sollen. |
|
||||||
| `Max. Encodes gesamt (medienunabhängig)` | `pipeline_max_total_encodes` | `number` | `3` | Standard | Gesamtlimit für alle parallel laufenden Encode-Jobs (Film + Audio CD). Dieses Limit hat Vorrang vor den Einzellimits. |
|
| `Max. Encodes gesamt (medienunabhängig)` | `pipeline_max_total_encodes` | `number` | `3` | Standard | Gesamtlimit für alle parallel laufenden Encode-Jobs (Film + Audio CD). Dieses Limit hat Vorrang vor den Einzellimits. Aenderung beeinflusst Queue-Pump und Startentscheidungen fuer neue Jobs. | Wenn ein harter Deckel fuer alle Encodes zusammen gelten soll. |
|
||||||
| `Audio CDs: Queue-Reihenfolge überspringen` | `pipeline_cd_bypasses_queue` | `boolean` | `false` | Standard | Wenn aktiv, können Audio CD Jobs unabhängig von Film-Jobs starten (überspringen die Film-Queue-Reihenfolge). Einzellimits und Gesamtlimit gelten weiterhin. |
|
| `Audio CDs: Queue-Reihenfolge überspringen` | `pipeline_cd_bypasses_queue` | `boolean` | `false` | Standard | Wenn aktiv, können Audio CD Jobs unabhängig von Film-Jobs starten (überspringen die Film-Queue-Reihenfolge). Einzellimits und Gesamtlimit gelten weiterhin. Aenderung beeinflusst Queue-Pump und Startentscheidungen fuer neue Jobs. | Wenn Audio-CD-Jobs nicht hinter langen Video-Warteschlangen blockieren sollen. |
|
||||||
| `cdparanoia Kommando` | `cdparanoia_command` | `string` | `cdparanoia` | Expertenmodus | Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist. |
|
| `cdparanoia Kommando` | `cdparanoia_command` | `string` | `cdparanoia` | Expertenmodus | Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). |
|
||||||
| `FFmpeg Kommando` | `ffmpeg_command` | `string` | `ffmpeg` | Standard | Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt. |
|
| `FFmpeg Kommando` | `ffmpeg_command` | `string` | `ffmpeg` | Standard | Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). |
|
||||||
| `FFprobe Kommando` | `ffprobe_command` | `string` | `ffprobe` | Standard | Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt. |
|
| `FFprobe Kommando` | `ffprobe_command` | `string` | `ffprobe` | Standard | Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). |
|
||||||
| `Serie: PlayAll Untergrenze (%)` | `series_playall_tolerance_lower_pct` | `number` | `10` | Standard | Untere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray). |
|
| `Mediainfo Extra Args` | `mediainfo_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `Serie: PlayAll Obergrenze (%)` | `series_playall_tolerance_upper_pct` | `number` | `10` | Standard | Obere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray). |
|
| `MakeMKV Rip Modus` | `makemkv_rip_mode_bluray` | `select` | `backup` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner. | Wenn fuer 'MakeMKV Rip Modus' ein anderes Standardverhalten benoetigt wird. |
|
||||||
| `Mediainfo Extra Args` | `mediainfo_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (Blu-ray). |
|
| `MakeMKV Analyze Extra Args` | `makemkv_analyze_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Analyze (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `MakeMKV Rip Modus` | `makemkv_rip_mode_bluray` | `select` | `backup` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner. |
|
| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `MakeMKV Analyze Extra Args` | `makemkv_analyze_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Analyze (Blu-ray). |
|
| `HandBrake Preset` | `handbrake_preset_bluray` | `string` | `H.264 MKV 1080p30` | Standard | Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet. | Wenn ein bevorzugtes Standard-Preset fuer Blu-ray nicht jedes Mal manuell gewaehlt werden soll. |
|
||||||
| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (Blu-ray). |
|
| `HandBrake Extra Args` | `handbrake_extra_args_bluray` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-ma...` | Standard | Zusätzliche CLI-Argumente (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `HandBrake Preset` | `handbrake_preset_bluray` | `string` | `H.264 MKV 1080p30` | Standard | Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet. |
|
| `Ausgabeformat` | `output_extension_bluray` | `select` | `mkv` | Standard | Dateiendung für finale Datei (Blu-ray). | Wenn finale Blu-ray-Dateien standardmaessig als MKV oder MP4 ausgegeben werden sollen. |
|
||||||
| `HandBrake Extra Args` | `handbrake_extra_args_bluray` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --au...` | Standard | Zusätzliche CLI-Argumente (Blu-ray). |
|
| `Mediainfo Extra Args` | `mediainfo_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `Ausgabeformat` | `output_extension_bluray` | `select` | `mkv` | Standard | Dateiendung für finale Datei (Blu-ray). |
|
| `MakeMKV Rip Modus` | `makemkv_rip_mode_dvd` | `select` | `mkv` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner. | Wenn fuer 'MakeMKV Rip Modus' ein anderes Standardverhalten benoetigt wird. |
|
||||||
| `Mediainfo Extra Args` | `mediainfo_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (DVD). |
|
| `MakeMKV Analyze Extra Args` | `makemkv_analyze_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Analyze (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `MakeMKV Rip Modus` | `makemkv_rip_mode_dvd` | `select` | `mkv` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner. |
|
| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `MakeMKV Analyze Extra Args` | `makemkv_analyze_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Analyze (DVD). |
|
| `HandBrake Preset` | `handbrake_preset_dvd` | `string` | `H.264 MKV 480p30` | Standard | Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet. | Wenn ein bevorzugtes Standard-Preset fuer DVD nicht jedes Mal manuell gewaehlt werden soll. |
|
||||||
| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (DVD). |
|
| `HandBrake Extra Args` | `handbrake_extra_args_dvd` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-ma...` | Standard | Zusätzliche CLI-Argumente (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). |
|
||||||
| `HandBrake Preset` | `handbrake_preset_dvd` | `string` | `H.264 MKV 480p30` | Standard | Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet. |
|
| `Ausgabeformat` | `output_extension_dvd` | `select` | `mkv` | Standard | Dateiendung für finale Datei (DVD). | Wenn finale DVD-Dateien standardmaessig als MKV oder MP4 ausgegeben werden sollen. |
|
||||||
| `HandBrake Extra Args` | `handbrake_extra_args_dvd` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --au...` | Standard | Zusätzliche CLI-Argumente (DVD). |
|
| `mkvmerge Kommando` | `mkvmerge_command` | `string` | `mkvmerge` | Standard | Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). |
|
||||||
| `Ausgabeformat` | `output_extension_dvd` | `select` | `mkv` | Standard | Dateiendung für finale Datei (DVD). |
|
|
||||||
| `mkvmerge Kommando` | `mkvmerge_command` | `string` | `mkvmerge` | Standard | Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt. |
|
|
||||||
|
|
||||||
## Beispiele für häufige Settings
|
## Spezielle Werteformate
|
||||||
|
|
||||||
- `drive_devices`: `[{"path":"/dev/sr0","makemkvIndex":0},{"path":"/dev/sr1","makemkvIndex":1}]`
|
- `Eigentümer (user:gruppe)`: z.B. `media:media` oder `1000:1000`.
|
||||||
- `external_storage_paths`: `[{"name":"USB HDD","path":"/mnt/usb-hdd"}]`
|
- `drive_devices` (JSON): `[{"path":"/dev/sr0","makemkvIndex":0},{"path":"/dev/sr1","makemkvIndex":1}]`
|
||||||
- `converter_scan_extensions`: `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus`
|
- `external_storage_paths` (JSON): `[{"name":"USB HDD","path":"/mnt/usb-hdd"}]`
|
||||||
- `output_template_bluray`: `${title} (${year})/${title} (${year})`
|
- `converter_scan_extensions`: kommagetrennt, ohne Punkt, z.B. `mkv,mp4,flac,mp3`.
|
||||||
- `output_template_audiobook`: `{author}/{author} - {title} ({year})`
|
- Template-Felder: erlauben Platzhalter (`{...}` / `${...}`) und `/` fuer Unterordner.
|
||||||
- `output_chapter_template_audiobook`: `{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}`
|
|
||||||
- `download_dir_owner`: `ripster:media`
|
|
||||||
|
|
||||||
## Hinweise zu ausgeblendeten Keys
|
## Hinweis zu ausgeblendeten Feldern
|
||||||
|
|
||||||
Folgende Keys liegen im Schema, sind aber im normalen Settings-Formular absichtlich ausgeblendet: `drive_device`, `makemkv_source_index`, `makemkv_rip_mode_bluray`, `makemkv_rip_mode_dvd`.
|
Die Keys `drive_device`, `makemkv_source_index`, `makemkv_rip_mode_bluray`, `makemkv_rip_mode_dvd` liegen im Schema, werden aber bewusst nicht im normalen Formular angezeigt.
|
||||||
Änderungen daran erfolgen nur über API/DB oder technische Migrationen.
|
|
||||||
|
|||||||
Generated
+19
-2
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0-rc1",
|
"version": "1.0.0-rc2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0-rc1",
|
"version": "1.0.0-rc2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -703,6 +704,11 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
@@ -1191,6 +1197,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0-rc1",
|
"version": "1.0.0-rc2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
+32
-1
@@ -13,6 +13,7 @@ import DownloadsPage from './pages/DownloadsPage';
|
|||||||
import ConverterPage from './pages/ConverterPage';
|
import ConverterPage from './pages/ConverterPage';
|
||||||
import AudiobooksPage from './pages/AudiobooksPage';
|
import AudiobooksPage from './pages/AudiobooksPage';
|
||||||
import TmdbMigrationPage from './pages/TmdbMigrationPage';
|
import TmdbMigrationPage from './pages/TmdbMigrationPage';
|
||||||
|
import HardwarePage from './pages/HardwarePage';
|
||||||
|
|
||||||
function normalizeJobId(value) {
|
function normalizeJobId(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
@@ -64,6 +65,35 @@ function isTerminalStage(value) {
|
|||||||
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
|
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTimestamp(value) {
|
||||||
|
if (!value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const ts = Date.parse(String(value));
|
||||||
|
return Number.isFinite(ts) ? ts : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePipelineStatePreservingNewestQueue(previousPipeline, incomingPipeline) {
|
||||||
|
const prev = previousPipeline && typeof previousPipeline === 'object' ? previousPipeline : {};
|
||||||
|
const incoming = incomingPipeline && typeof incomingPipeline === 'object' ? incomingPipeline : {};
|
||||||
|
const prevQueue = prev?.queue && typeof prev.queue === 'object' ? prev.queue : null;
|
||||||
|
const incomingQueue = incoming?.queue && typeof incoming.queue === 'object' ? incoming.queue : null;
|
||||||
|
if (!prevQueue || !incomingQueue) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
...incoming
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const prevQueueTs = parseTimestamp(prevQueue.updatedAt);
|
||||||
|
const incomingQueueTs = parseTimestamp(incomingQueue.updatedAt);
|
||||||
|
const queue = incomingQueueTs >= prevQueueTs ? incomingQueue : prevQueue;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
...incoming,
|
||||||
|
queue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createInitialAudiobookUploadState() {
|
function createInitialAudiobookUploadState() {
|
||||||
return {
|
return {
|
||||||
phase: 'idle',
|
phase: 'idle',
|
||||||
@@ -363,7 +393,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'PIPELINE_STATE_CHANGED') {
|
if (message.type === 'PIPELINE_STATE_CHANGED') {
|
||||||
setPipeline(message.payload);
|
setPipeline((prev) => mergePipelineStatePreservingNewestQueue(prev, message.payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'PIPELINE_PROGRESS') {
|
if (message.type === 'PIPELINE_PROGRESS') {
|
||||||
@@ -705,6 +735,7 @@ function App() {
|
|||||||
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||||
<Route path="database" element={<DatabasePage />} />
|
<Route path="database" element={<DatabasePage />} />
|
||||||
<Route path="converter" element={<ConverterPage />} />
|
<Route path="converter" element={<ConverterPage />} />
|
||||||
|
<Route path="hardware" element={<HardwarePage hardwareMonitoring={hardwareMonitoring} />} />
|
||||||
<Route
|
<Route
|
||||||
path="audiobooks"
|
path="audiobooks"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -428,6 +428,14 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/settings/scripts']);
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
async setScriptFavorite(scriptId, isFavorite) {
|
||||||
|
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}/favorite`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ isFavorite: isFavorite === true })
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/scripts']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
async deleteScript(scriptId) {
|
async deleteScript(scriptId) {
|
||||||
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
@@ -472,6 +480,14 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/settings/script-chains']);
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
async setScriptChainFavorite(chainId, isFavorite) {
|
||||||
|
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}/favorite`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ isFavorite: isFavorite === true })
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/script-chains']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
async deleteScriptChain(chainId) {
|
async deleteScriptChain(chainId) {
|
||||||
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
@@ -526,6 +542,21 @@ export const api = {
|
|||||||
getPipelineState() {
|
getPipelineState() {
|
||||||
return request('/pipeline/state');
|
return request('/pipeline/state');
|
||||||
},
|
},
|
||||||
|
getHardwareHistory(options = {}) {
|
||||||
|
const hoursRaw = Number(options?.hours);
|
||||||
|
const maxPointsRaw = Number(options?.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 query = new URLSearchParams({
|
||||||
|
hours: String(hours),
|
||||||
|
maxPoints: String(maxPoints)
|
||||||
|
});
|
||||||
|
const path = `/pipeline/hardware/history?${query.toString()}`;
|
||||||
|
return requestCachedGet(path, {
|
||||||
|
ttlMs: 10 * 1000,
|
||||||
|
forceRefresh: options.forceRefresh
|
||||||
|
});
|
||||||
|
},
|
||||||
getRuntimeActivities() {
|
getRuntimeActivities() {
|
||||||
return request('/runtime/activities');
|
return request('/runtime/activities');
|
||||||
},
|
},
|
||||||
@@ -709,9 +740,12 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
async retryJob(jobId) {
|
async retryJob(jobId, options = {}) {
|
||||||
|
const body = {};
|
||||||
|
if (options.createNewJob) body.createNewJob = true;
|
||||||
const result = await request(`/pipeline/retry/${jobId}`, {
|
const result = await request(`/pipeline/retry/${jobId}`, {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
||||||
});
|
});
|
||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
@@ -739,6 +773,7 @@ export const api = {
|
|||||||
if (options.keepBoth) body.keepBoth = true;
|
if (options.keepBoth) body.keepBoth = true;
|
||||||
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
|
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
|
||||||
if (options.reuseCurrentJob) body.reuseCurrentJob = true;
|
if (options.reuseCurrentJob) body.reuseCurrentJob = true;
|
||||||
|
if (options.createNewJob) body.createNewJob = true;
|
||||||
const result = await request(`/pipeline/restart-review/${jobId}`, {
|
const result = await request(`/pipeline/restart-review/${jobId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
||||||
@@ -751,6 +786,7 @@ export const api = {
|
|||||||
if (options.keepBoth) body.keepBoth = true;
|
if (options.keepBoth) body.keepBoth = true;
|
||||||
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
|
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
|
||||||
if (options.restartMode) body.restartMode = options.restartMode;
|
if (options.restartMode) body.restartMode = options.restartMode;
|
||||||
|
if (options.createNewJob) body.createNewJob = true;
|
||||||
const result = await request(`/pipeline/restart-encode/${jobId}`, {
|
const result = await request(`/pipeline/restart-encode/${jobId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
||||||
|
|||||||
@@ -508,10 +508,32 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
|
|||||||
}), [job.id, audioTracks, trackFields]);
|
}), [job.id, audioTracks, trackFields]);
|
||||||
|
|
||||||
// ── Dialog handlers ─────────────────────────────────────────────────────────
|
// ── Dialog handlers ─────────────────────────────────────────────────────────
|
||||||
const handleMetadataSearch = async (query) => {
|
const handleMetadataSearch = async (query, options = {}) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.searchTmdbMovie(query);
|
const filterRaw = String(options?.resultFilter || '').trim().toLowerCase();
|
||||||
return Array.isArray(response?.results) ? response.results : [];
|
const includeMovies = filterRaw !== 'series';
|
||||||
|
const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie';
|
||||||
|
const [movieResponse, seriesResponse] = await Promise.all([
|
||||||
|
includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }),
|
||||||
|
includeSeries ? api.searchTmdbSeries(query) : Promise.resolve({ results: [] })
|
||||||
|
]);
|
||||||
|
const movieRows = Array.isArray(movieResponse?.results)
|
||||||
|
? movieResponse.results.map((row) => ({
|
||||||
|
...row,
|
||||||
|
workflowKind: 'film',
|
||||||
|
metadataKind: 'movie',
|
||||||
|
resultType: 'movie'
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const seriesRows = Array.isArray(seriesResponse?.results)
|
||||||
|
? seriesResponse.results.map((row) => ({
|
||||||
|
...row,
|
||||||
|
workflowKind: 'series',
|
||||||
|
metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series',
|
||||||
|
resultType: 'series'
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
return [...movieRows, ...seriesRows];
|
||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
|
|||||||
'makemkv_rip_mode',
|
'makemkv_rip_mode',
|
||||||
'makemkv_rip_mode_bluray',
|
'makemkv_rip_mode_bluray',
|
||||||
'makemkv_rip_mode_dvd',
|
'makemkv_rip_mode_dvd',
|
||||||
'makemkv_backup_mode',
|
'makemkv_backup_mode'
|
||||||
'handbrake_pre_metadata_scan_enabled'
|
|
||||||
]);
|
]);
|
||||||
const EXPERT_ONLY_SETTING_KEYS = new Set([
|
const EXPERT_ONLY_SETTING_KEYS = new Set([
|
||||||
'pushover_device',
|
'pushover_device',
|
||||||
@@ -727,6 +726,42 @@ function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActivationBytesCacheBox({ entries = [] }) {
|
||||||
|
const list = Array.isArray(entries) ? entries : [];
|
||||||
|
return (
|
||||||
|
<section className="settings-section grouped activation-bytes-cache-box">
|
||||||
|
<div className="settings-section-head">
|
||||||
|
<h4>Activation Bytes Cache</h4>
|
||||||
|
<small>Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt.</small>
|
||||||
|
</div>
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p>Keine Einträge vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="activation-bytes-table-wrap">
|
||||||
|
<table className="activation-bytes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Checksum</th>
|
||||||
|
<th>Activation Bytes</th>
|
||||||
|
<th>Gespeichert am</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.map((entry, index) => (
|
||||||
|
<tr key={String(entry?.checksum || `activation-bytes-${index}`)}>
|
||||||
|
<td>{String(entry?.checksum || '-')}</td>
|
||||||
|
<td>{String(entry?.activation_bytes || '-')}</td>
|
||||||
|
<td>{entry?.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SettingField({
|
function SettingField({
|
||||||
setting,
|
setting,
|
||||||
value,
|
value,
|
||||||
@@ -1124,6 +1159,7 @@ export default function DynamicSettingsForm({
|
|||||||
dirtyKeys,
|
dirtyKeys,
|
||||||
onChange,
|
onChange,
|
||||||
effectivePaths,
|
effectivePaths,
|
||||||
|
activationBytes = [],
|
||||||
onNotify
|
onNotify
|
||||||
}) {
|
}) {
|
||||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||||
@@ -1217,6 +1253,7 @@ export default function DynamicSettingsForm({
|
|||||||
const pushoverEnabled = toBoolean(values?.[PUSHOVER_ENABLED_SETTING_KEY]);
|
const pushoverEnabled = toBoolean(values?.[PUSHOVER_ENABLED_SETTING_KEY]);
|
||||||
|
|
||||||
const isDriveCategory = normalizeText(category?.category) === 'laufwerk';
|
const isDriveCategory = normalizeText(category?.category) === 'laufwerk';
|
||||||
|
const isToolsCategory = normalizeText(category?.category) === 'tools';
|
||||||
return (
|
return (
|
||||||
<div className="settings-sections">
|
<div className="settings-sections">
|
||||||
{isDriveCategory && <DetectedDrivesInfo expertModeEnabled={expertModeEnabled} onNotify={onNotify} />}
|
{isDriveCategory && <DetectedDrivesInfo expertModeEnabled={expertModeEnabled} onNotify={onNotify} />}
|
||||||
@@ -1345,6 +1382,7 @@ export default function DynamicSettingsForm({
|
|||||||
})()}
|
})()}
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
{isToolsCategory ? <ActivationBytesCacheBox entries={activationBytes} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -266,6 +266,14 @@ function normalizeSubtitleVariantSelectionMap(rawSelection) {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSubtitleSelectionMode(value, fallback = 'variants') {
|
||||||
|
const mode = String(value || '').trim().toLowerCase();
|
||||||
|
if (mode === 'track_ids' || mode === 'variants') {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSubtitleConfidenceValue(value) {
|
function normalizeSubtitleConfidenceValue(value) {
|
||||||
const raw = String(value || '').trim().toLowerCase();
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
if (raw === 'high' || raw === 'medium' || raw === 'low') {
|
if (raw === 'high' || raw === 'medium' || raw === 'low') {
|
||||||
@@ -338,10 +346,34 @@ function compareFullSubtitleCandidate(a, b) {
|
|||||||
return a.originalIndex - b.originalIndex;
|
return a.originalIndex - b.originalIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSubtitleVariantSelectionForPreviewFromTrackIds(subtitleTracks, selectedTrackIds = []) {
|
||||||
|
const selectedSet = new Set(normalizeTrackIdList(selectedTrackIds).map((id) => String(id)));
|
||||||
|
const map = {};
|
||||||
|
const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!selectedSet.has(String(track?.id))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
|
||||||
|
if (!map[language]) {
|
||||||
|
map[language] = { full: false, forced: false };
|
||||||
|
}
|
||||||
|
if (track.isForcedOnly) {
|
||||||
|
map[language].forced = true;
|
||||||
|
} else {
|
||||||
|
map[language].full = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDeterministicSubtitleCliSelectionForPreview({
|
function resolveDeterministicSubtitleCliSelectionForPreview({
|
||||||
subtitleTracks,
|
subtitleTracks,
|
||||||
subtitleVariantSelection,
|
subtitleVariantSelection,
|
||||||
subtitleLanguageOrder
|
subtitleLanguageOrder,
|
||||||
|
fallbackSelectedSubtitleTrackIds = [],
|
||||||
|
fallbackSelectedSubtitleTrackIdsOrdered = [],
|
||||||
|
hasExplicitVariantSelection = false
|
||||||
}) {
|
}) {
|
||||||
const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : [])
|
const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : [])
|
||||||
.map((track, index) => {
|
.map((track, index) => {
|
||||||
@@ -375,8 +407,17 @@ function resolveDeterministicSubtitleCliSelectionForPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(subtitleVariantSelection || {});
|
const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(subtitleVariantSelection || {});
|
||||||
const hasExplicitVariantSelection = Object.keys(normalizedVariantSelection).length > 0;
|
|
||||||
const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder || []);
|
const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder || []);
|
||||||
|
const fallbackSelectionFromTrackIds = normalizeSubtitleVariantSelectionMap(
|
||||||
|
buildSubtitleVariantSelectionForPreviewFromTrackIds(tracks, fallbackSelectedSubtitleTrackIds)
|
||||||
|
);
|
||||||
|
const fallbackSelectionFromOrderedTrackIds = normalizeSubtitleVariantSelectionMap(
|
||||||
|
buildSubtitleVariantSelectionForPreviewFromTrackIds(
|
||||||
|
tracks,
|
||||||
|
normalizeTrackIdSequence(fallbackSelectedSubtitleTrackIdsOrdered, { dedupe: false })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const fallbackSelectionSet = new Set(normalizeTrackIdList(fallbackSelectedSubtitleTrackIds).map((id) => String(id)));
|
||||||
const sortedLanguages = Array.from(grouped.entries())
|
const sortedLanguages = Array.from(grouped.entries())
|
||||||
.map(([language, languageTracks]) => ({
|
.map(([language, languageTracks]) => ({
|
||||||
language,
|
language,
|
||||||
@@ -395,6 +436,11 @@ function resolveDeterministicSubtitleCliSelectionForPreview({
|
|||||||
order.push(language);
|
order.push(language);
|
||||||
};
|
};
|
||||||
normalizedLanguageOrder.forEach(pushLanguage);
|
normalizedLanguageOrder.forEach(pushLanguage);
|
||||||
|
if (!hasExplicitVariantSelection) {
|
||||||
|
Object.keys(normalizedVariantSelection).forEach(pushLanguage);
|
||||||
|
Object.keys(fallbackSelectionFromOrderedTrackIds).forEach(pushLanguage);
|
||||||
|
Object.keys(fallbackSelectionFromTrackIds).forEach(pushLanguage);
|
||||||
|
}
|
||||||
sortedLanguages.forEach(pushLanguage);
|
sortedLanguages.forEach(pushLanguage);
|
||||||
|
|
||||||
const subtitleTrackIds = [];
|
const subtitleTrackIds = [];
|
||||||
@@ -406,15 +452,33 @@ function resolveDeterministicSubtitleCliSelectionForPreview({
|
|||||||
const bestFull = fullTracks[0] || null;
|
const bestFull = fullTracks[0] || null;
|
||||||
const bestFullHasForced = fullTracks.filter((track) => track.fullHasForced).sort(compareFullSubtitleCandidate)[0] || null;
|
const bestFullHasForced = fullTracks.filter((track) => track.fullHasForced).sort(compareFullSubtitleCandidate)[0] || null;
|
||||||
|
|
||||||
const explicit = normalizedVariantSelection[language] || null;
|
const explicitEntry = normalizedVariantSelection[language] || null;
|
||||||
const fallbackFull = languageTracks.some((track) => track.selectedForEncode && !track.isForcedOnly) || Boolean(bestFull);
|
const fallbackEntryFromOrderedTrackIds = fallbackSelectionFromOrderedTrackIds[language] || null;
|
||||||
const fallbackForced = languageTracks.some((track) => track.selectedForEncode && track.isForcedOnly) || Boolean(forcedOnly);
|
const fallbackEntryFromTrackIds = fallbackSelectionFromTrackIds[language] || null;
|
||||||
const requestedFull = explicit
|
const hasAnyFallbackSelectedTrack = languageTracks.some((track) => (
|
||||||
? Boolean(explicit.full)
|
fallbackSelectionSet.has(String(track.id)) || track.selectedForEncode
|
||||||
: (hasExplicitVariantSelection ? false : fallbackFull);
|
));
|
||||||
const requestedForced = explicit
|
let requestedFull = false;
|
||||||
? Boolean(explicit.forced)
|
let requestedForced = false;
|
||||||
: (hasExplicitVariantSelection ? false : fallbackForced);
|
if (explicitEntry) {
|
||||||
|
requestedFull = Boolean(explicitEntry.full);
|
||||||
|
requestedForced = Boolean(explicitEntry.forced);
|
||||||
|
} else if (hasExplicitVariantSelection) {
|
||||||
|
requestedFull = false;
|
||||||
|
requestedForced = false;
|
||||||
|
} else if (fallbackEntryFromOrderedTrackIds) {
|
||||||
|
requestedFull = Boolean(fallbackEntryFromOrderedTrackIds.full);
|
||||||
|
requestedForced = Boolean(fallbackEntryFromOrderedTrackIds.forced);
|
||||||
|
} else if (fallbackEntryFromTrackIds) {
|
||||||
|
requestedFull = Boolean(fallbackEntryFromTrackIds.full);
|
||||||
|
requestedForced = Boolean(fallbackEntryFromTrackIds.forced);
|
||||||
|
} else if (hasAnyFallbackSelectedTrack) {
|
||||||
|
requestedFull = Boolean(bestFull);
|
||||||
|
requestedForced = Boolean(forcedOnly);
|
||||||
|
} else {
|
||||||
|
requestedFull = Boolean(bestFull);
|
||||||
|
requestedForced = Boolean(forcedOnly);
|
||||||
|
}
|
||||||
if (!requestedFull && !requestedForced) {
|
if (!requestedFull && !requestedForced) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -601,6 +665,7 @@ function buildHandBrakeCommandPreview({
|
|||||||
title,
|
title,
|
||||||
selectedAudioTrackIds,
|
selectedAudioTrackIds,
|
||||||
selectedSubtitleTrackIds,
|
selectedSubtitleTrackIds,
|
||||||
|
selectedSubtitleSelectionMode = 'variants',
|
||||||
selectedSubtitleVariantSelection = {},
|
selectedSubtitleVariantSelection = {},
|
||||||
selectedSubtitleLanguageOrder = [],
|
selectedSubtitleLanguageOrder = [],
|
||||||
commandOutputPath = null,
|
commandOutputPath = null,
|
||||||
@@ -628,18 +693,31 @@ function buildHandBrakeCommandPreview({
|
|||||||
? Math.trunc(rawMappedTitleId)
|
? Math.trunc(rawMappedTitleId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const resolvedSubtitleSelection = resolveDeterministicSubtitleCliSelectionForPreview({
|
|
||||||
subtitleTracks: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [],
|
|
||||||
subtitleVariantSelection: selectedSubtitleVariantSelection,
|
|
||||||
subtitleLanguageOrder: selectedSubtitleLanguageOrder
|
|
||||||
});
|
|
||||||
const normalizedSelectedVariantSelection = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
|
const normalizedSelectedVariantSelection = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
|
||||||
const hasExplicitSelectedVariantSelection = Object.keys(normalizedSelectedVariantSelection).length > 0;
|
const hasExplicitSelectedVariantSelection = Object.keys(normalizedSelectedVariantSelection).length > 0;
|
||||||
|
const normalizedSubtitleSelectionMode = normalizeSubtitleSelectionMode(
|
||||||
|
selectedSubtitleSelectionMode,
|
||||||
|
hasExplicitSelectedVariantSelection ? 'variants' : 'track_ids'
|
||||||
|
);
|
||||||
|
const normalizedSelectedSubtitleTrackIds = normalizeTrackIdSequence(selectedSubtitleTrackIds, { dedupe: false });
|
||||||
|
|
||||||
|
const resolvedSubtitleSelection = resolveDeterministicSubtitleCliSelectionForPreview({
|
||||||
|
subtitleTracks: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [],
|
||||||
|
subtitleVariantSelection: normalizedSubtitleSelectionMode === 'variants'
|
||||||
|
? normalizedSelectedVariantSelection
|
||||||
|
: {},
|
||||||
|
subtitleLanguageOrder: selectedSubtitleLanguageOrder,
|
||||||
|
fallbackSelectedSubtitleTrackIds: normalizedSelectedSubtitleTrackIds,
|
||||||
|
fallbackSelectedSubtitleTrackIdsOrdered: normalizedSelectedSubtitleTrackIds,
|
||||||
|
hasExplicitVariantSelection: normalizedSubtitleSelectionMode === 'track_ids'
|
||||||
|
? true
|
||||||
|
: hasExplicitSelectedVariantSelection
|
||||||
|
});
|
||||||
const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0
|
const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0
|
||||||
? resolvedSubtitleSelection.subtitleTrackIds
|
? resolvedSubtitleSelection.subtitleTrackIds
|
||||||
: (hasExplicitSelectedVariantSelection
|
: (hasExplicitSelectedVariantSelection
|
||||||
? []
|
? []
|
||||||
: normalizeTrackIdSequence(selectedSubtitleTrackIds, { dedupe: false }));
|
: normalizedSelectedSubtitleTrackIds);
|
||||||
const selectedSubtitleSet = new Set(normalizeTrackIdList(subtitleTrackIds).map((id) => String(id)));
|
const selectedSubtitleSet = new Set(normalizeTrackIdList(subtitleTrackIds).map((id) => String(id)));
|
||||||
const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => {
|
const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => {
|
||||||
const id = normalizeTrackId(track?.id);
|
const id = normalizeTrackId(track?.id);
|
||||||
@@ -1480,6 +1558,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
commandOutputPathByTitle = {},
|
commandOutputPathByTitle = {},
|
||||||
selectedEncodeTitleId = null,
|
selectedEncodeTitleId = null,
|
||||||
selectedEncodeTitleIds = [],
|
selectedEncodeTitleIds = [],
|
||||||
|
titleSelectionTouched = false,
|
||||||
allowTitleSelection = false,
|
allowTitleSelection = false,
|
||||||
compactTitleSelection = false,
|
compactTitleSelection = false,
|
||||||
onSelectEncodeTitle = null,
|
onSelectEncodeTitle = null,
|
||||||
@@ -1546,13 +1625,16 @@ export default function MediaInfoReviewPanel({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
const displayTitles = candidateTitles && candidateTitles.length > 0 ? candidateTitles : titles;
|
const displayTitles = candidateTitles && candidateTitles.length > 0 ? candidateTitles : titles;
|
||||||
const selectedTitleIds = normalizeTrackIdList([
|
const explicitSelectedTitleIds = normalizeTrackIdList(selectedEncodeTitleIds);
|
||||||
...normalizeTrackIdList(selectedEncodeTitleIds),
|
const fallbackSelectedTitleIds = normalizeTrackIdList([
|
||||||
...normalizeTrackIdList(review?.selectedTitleIds || []),
|
...normalizeTrackIdList(review?.selectedTitleIds || []),
|
||||||
...displayTitles.filter((item) => Boolean(item?.selectedForEncode)).map((item) => item?.id)
|
...displayTitles.filter((item) => Boolean(item?.selectedForEncode)).map((item) => item?.id)
|
||||||
]);
|
]);
|
||||||
|
const selectedTitleIds = titleSelectionTouched
|
||||||
|
? explicitSelectedTitleIds
|
||||||
|
: (explicitSelectedTitleIds.length > 0 ? explicitSelectedTitleIds : fallbackSelectedTitleIds);
|
||||||
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId)
|
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId)
|
||||||
|| normalizeTitleId(review.encodeInputTitleId)
|
|| (titleSelectionTouched ? null : normalizeTitleId(review.encodeInputTitleId))
|
||||||
|| selectedTitleIds[0]
|
|| selectedTitleIds[0]
|
||||||
|| null;
|
|| null;
|
||||||
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
|
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
|
||||||
@@ -1993,6 +2075,12 @@ export default function MediaInfoReviewPanel({
|
|||||||
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
|
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
|
||||||
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
|
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
|
||||||
);
|
);
|
||||||
|
const selectedSubtitleSelectionMode = normalizeSubtitleSelectionMode(
|
||||||
|
titleSelectionEntry?.subtitleSelectionMode,
|
||||||
|
Object.keys(selectedSubtitleVariantSelection).length > 0
|
||||||
|
? 'variants'
|
||||||
|
: 'track_ids'
|
||||||
|
);
|
||||||
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
|
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
|
||||||
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
|
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
|
||||||
? titleSelectionEntry.subtitleLanguageOrder
|
? titleSelectionEntry.subtitleLanguageOrder
|
||||||
@@ -2003,6 +2091,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
subtitleTracks,
|
subtitleTracks,
|
||||||
selectedAudioTrackIds,
|
selectedAudioTrackIds,
|
||||||
selectedSubtitleTrackIds,
|
selectedSubtitleTrackIds,
|
||||||
|
selectedSubtitleSelectionMode,
|
||||||
selectedSubtitleVariantSelection,
|
selectedSubtitleVariantSelection,
|
||||||
selectedSubtitleLanguageOrder
|
selectedSubtitleLanguageOrder
|
||||||
};
|
};
|
||||||
@@ -2621,6 +2710,12 @@ export default function MediaInfoReviewPanel({
|
|||||||
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
|
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
|
||||||
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
|
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
|
||||||
);
|
);
|
||||||
|
const selectedSubtitleSelectionMode = normalizeSubtitleSelectionMode(
|
||||||
|
titleSelectionEntry?.subtitleSelectionMode,
|
||||||
|
Object.keys(selectedSubtitleVariantSelection).length > 0
|
||||||
|
? 'variants'
|
||||||
|
: 'track_ids'
|
||||||
|
);
|
||||||
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
|
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
|
||||||
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
|
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
|
||||||
? titleSelectionEntry.subtitleLanguageOrder
|
? titleSelectionEntry.subtitleLanguageOrder
|
||||||
@@ -2803,6 +2898,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
title,
|
title,
|
||||||
selectedAudioTrackIds,
|
selectedAudioTrackIds,
|
||||||
selectedSubtitleTrackIds,
|
selectedSubtitleTrackIds,
|
||||||
|
selectedSubtitleSelectionMode,
|
||||||
selectedSubtitleVariantSelection,
|
selectedSubtitleVariantSelection,
|
||||||
selectedSubtitleLanguageOrder,
|
selectedSubtitleLanguageOrder,
|
||||||
commandOutputPath: resolvedCommandOutputPath,
|
commandOutputPath: resolvedCommandOutputPath,
|
||||||
|
|||||||
@@ -5,6 +5,49 @@ import { DataTable } from 'primereact/datatable';
|
|||||||
import { Column } from 'primereact/column';
|
import { Column } from 'primereact/column';
|
||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
|
|
||||||
|
function normalizeWorkflowKind(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (['film', 'movie', 'feature'].includes(raw)) {
|
||||||
|
return 'film';
|
||||||
|
}
|
||||||
|
if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) {
|
||||||
|
return 'series';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResultType(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (raw === 'series') {
|
||||||
|
return 'series';
|
||||||
|
}
|
||||||
|
if (raw === 'movie' || raw === 'film' || raw === 'movies') {
|
||||||
|
return 'movie';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMetadataRow(row = {}) {
|
||||||
|
const workflowKind = normalizeWorkflowKind(
|
||||||
|
row?.workflowKind
|
||||||
|
|| row?.kind
|
||||||
|
|| row?.metadataKind
|
||||||
|
|| null
|
||||||
|
);
|
||||||
|
const metadataKindRaw = String(row?.metadataKind || '').trim().toLowerCase();
|
||||||
|
const metadataKind = metadataKindRaw || (workflowKind === 'series' ? 'series' : 'movie');
|
||||||
|
const resultType = normalizeResultType(row?.resultType)
|
||||||
|
|| (workflowKind === 'series' ? 'series' : (workflowKind === 'film' ? 'movie' : null))
|
||||||
|
|| ((metadataKind === 'series' || metadataKind === 'season') ? 'series' : 'movie');
|
||||||
|
const normalizedWorkflowKind = workflowKind || (resultType === 'series' ? 'series' : 'film');
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
metadataKind,
|
||||||
|
resultType,
|
||||||
|
workflowKind: normalizedWorkflowKind
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function MetadataSelectionDialog({
|
export default function MetadataSelectionDialog({
|
||||||
visible,
|
visible,
|
||||||
context,
|
context,
|
||||||
@@ -91,26 +134,6 @@ export default function MetadataSelectionDialog({
|
|||||||
const preferred = stripped || separatorNormalized || raw;
|
const preferred = stripped || separatorNormalized || raw;
|
||||||
return toSearchTitleCase(preferred);
|
return toSearchTitleCase(preferred);
|
||||||
};
|
};
|
||||||
const normalizeWorkflowKind = (value) => {
|
|
||||||
const raw = String(value || '').trim().toLowerCase();
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (['film', 'movie', 'feature'].includes(raw)) {
|
|
||||||
return 'film';
|
|
||||||
}
|
|
||||||
if (['series', 'tv', 'season', 'episode'].includes(raw)) {
|
|
||||||
return 'series';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const normalizeSeriesClassification = (value) => {
|
|
||||||
const raw = String(value || '').trim().toLowerCase();
|
|
||||||
if (raw === 'series' || raw === 'film' || raw === 'ambiguous') {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const parseDiscNumber = (rawValue) => {
|
const parseDiscNumber = (rawValue) => {
|
||||||
const text = String(rawValue ?? '').trim();
|
const text = String(rawValue ?? '').trim();
|
||||||
if (!/^\d+$/.test(text)) {
|
if (!/^\d+$/.test(text)) {
|
||||||
@@ -122,6 +145,7 @@ export default function MetadataSelectionDialog({
|
|||||||
}
|
}
|
||||||
return Math.trunc(parsed);
|
return Math.trunc(parsed);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [manualTitle, setManualTitle] = useState('');
|
const [manualTitle, setManualTitle] = useState('');
|
||||||
@@ -138,18 +162,13 @@ export default function MetadataSelectionDialog({
|
|||||||
const [conflictNewDiscNumber, setConflictNewDiscNumber] = useState('');
|
const [conflictNewDiscNumber, setConflictNewDiscNumber] = useState('');
|
||||||
const [conflictError, setConflictError] = useState('');
|
const [conflictError, setConflictError] = useState('');
|
||||||
const [seasonFilter, setSeasonFilter] = useState('');
|
const [seasonFilter, setSeasonFilter] = useState('');
|
||||||
|
const [resultFilter, setResultFilter] = useState('all');
|
||||||
const [discValidationError, setDiscValidationError] = useState('');
|
const [discValidationError, setDiscValidationError] = useState('');
|
||||||
const [manualWorkflowKind, setManualWorkflowKind] = useState(null);
|
|
||||||
const activeSearchRequestRef = useRef(0);
|
const activeSearchRequestRef = useRef(0);
|
||||||
const autoSearchDoneRef = useRef(false);
|
const autoSearchDoneRef = useRef(false);
|
||||||
const autoSearchWorkflowRef = useRef(null);
|
|
||||||
const wasVisibleRef = useRef(false);
|
const wasVisibleRef = useRef(false);
|
||||||
const lastVisibleContextIdentityRef = useRef(null);
|
const lastVisibleContextIdentityRef = useRef(null);
|
||||||
const contextWorkflowKind = normalizeWorkflowKind(
|
|
||||||
context?.selectedMetadata?.workflowKind
|
|
||||||
|| context?.workflowKind
|
|
||||||
|| null
|
|
||||||
);
|
|
||||||
const mediaProfile = String(
|
const mediaProfile = String(
|
||||||
context?.mediaProfile
|
context?.mediaProfile
|
||||||
|| context?.selectedMetadata?.mediaProfile
|
|| context?.selectedMetadata?.mediaProfile
|
||||||
@@ -157,46 +176,7 @@ export default function MetadataSelectionDialog({
|
|||||||
).trim().toLowerCase();
|
).trim().toLowerCase();
|
||||||
const metadataProvider = 'tmdb';
|
const metadataProvider = 'tmdb';
|
||||||
const providerLabel = 'TMDb';
|
const providerLabel = 'TMDb';
|
||||||
const seriesDecisionClassification = normalizeSeriesClassification(context?.seriesDecision?.classification);
|
|
||||||
const hasSeriesSignals = useMemo(() => {
|
|
||||||
if (contextWorkflowKind === 'series') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const selectedMetadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
|
|
||||||
? context.selectedMetadata
|
|
||||||
: {};
|
|
||||||
const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase();
|
|
||||||
if (metadataKind === 'series' || metadataKind === 'season') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (Number(selectedMetadata?.seasonNumber || 0) > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (Number(context?.seriesLookupHint?.seasonNumber || 0) > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (Boolean(context?.seriesAnalysis?.summary?.seriesLike)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const contextCandidates = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [];
|
|
||||||
return contextCandidates.some((row) => Number(row?.seasonNumber || 0) > 0);
|
|
||||||
}, [context, contextWorkflowKind]);
|
|
||||||
const recommendedWorkflowKind = normalizeWorkflowKind(
|
|
||||||
context?.seriesDecision?.recommendedWorkflowKind
|
|
||||||
|| contextWorkflowKind
|
|
||||||
|| null
|
|
||||||
) || (hasSeriesSignals ? 'series' : 'film');
|
|
||||||
const effectiveWorkflowKind = manualWorkflowKind || recommendedWorkflowKind;
|
|
||||||
const allowsWorkflowSwitch = (
|
|
||||||
(mediaProfile === 'dvd' || mediaProfile === 'bluray')
|
|
||||||
&& seriesDecisionClassification === 'ambiguous'
|
|
||||||
);
|
|
||||||
const supportsExternalIdInput = false;
|
const supportsExternalIdInput = false;
|
||||||
const requiresDiscNumber = metadataProvider === 'tmdb'
|
|
||||||
&& effectiveWorkflowKind === 'series'
|
|
||||||
&& (mediaProfile === 'dvd' || mediaProfile === 'bluray');
|
|
||||||
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
|
|
||||||
const hasValidDiscNumber = effectiveDiscNumber !== null;
|
|
||||||
const contextJobId = Number(context?.jobId || 0) || null;
|
const contextJobId = Number(context?.jobId || 0) || null;
|
||||||
const contextIdentity = contextJobId
|
const contextIdentity = contextJobId
|
||||||
? `job:${contextJobId}`
|
? `job:${contextJobId}`
|
||||||
@@ -236,29 +216,19 @@ export default function MetadataSelectionDialog({
|
|||||||
setConflictNewDiscNumber('');
|
setConflictNewDiscNumber('');
|
||||||
setConflictError('');
|
setConflictError('');
|
||||||
setSeasonFilter('');
|
setSeasonFilter('');
|
||||||
|
setResultFilter('all');
|
||||||
setDiscValidationError('');
|
setDiscValidationError('');
|
||||||
setManualWorkflowKind(allowsWorkflowSwitch ? recommendedWorkflowKind : null);
|
|
||||||
autoSearchDoneRef.current = false;
|
autoSearchDoneRef.current = false;
|
||||||
autoSearchWorkflowRef.current = null;
|
|
||||||
activeSearchRequestRef.current += 1;
|
activeSearchRequestRef.current += 1;
|
||||||
wasVisibleRef.current = true;
|
wasVisibleRef.current = true;
|
||||||
lastVisibleContextIdentityRef.current = contextIdentity;
|
lastVisibleContextIdentityRef.current = contextIdentity;
|
||||||
}, [visible, contextIdentity, context, allowsWorkflowSwitch, recommendedWorkflowKind]);
|
}, [visible, contextIdentity, context]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!discValidationError) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!requiresDiscNumber || hasValidDiscNumber) {
|
|
||||||
setDiscValidationError('');
|
|
||||||
}
|
|
||||||
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (submitError) {
|
if (submitError) {
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
}
|
}
|
||||||
}, [manualDiscNumber, seasonFilter, selected, metadataProvider, conflictExistingDiscNumber, conflictNewDiscNumber]);
|
}, [manualDiscNumber, seasonFilter, selected, resultFilter, conflictExistingDiscNumber, conflictNewDiscNumber]);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const base = Array.isArray(context?.metadataCandidates)
|
const base = Array.isArray(context?.metadataCandidates)
|
||||||
@@ -269,17 +239,17 @@ export default function MetadataSelectionDialog({
|
|||||||
const map = new Map();
|
const map = new Map();
|
||||||
|
|
||||||
all.forEach((item) => {
|
all.forEach((item) => {
|
||||||
|
const normalized = normalizeMetadataRow(item);
|
||||||
|
const rowType = normalizeResultType(normalized?.resultType) || 'movie';
|
||||||
const rowKey = String(
|
const rowKey = String(
|
||||||
item?.providerId
|
normalized?.providerId
|
||||||
|| item?.imdbId
|
|| `${rowType}:${normalized?.imdbId || normalized?.tmdbId || '-'}:${normalized?.seasonNumber || '-'}:${normalized?.title || '-'}:${normalized?.year || '-'}`
|
||||||
|| item?.tmdbId
|
|
||||||
|| `${item?.title || '-'}::${item?.year || '-'}::${item?.seasonNumber || '-'}`
|
|
||||||
).trim();
|
).trim();
|
||||||
if (!rowKey) {
|
if (!rowKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
map.set(rowKey, {
|
map.set(rowKey, {
|
||||||
...item,
|
...normalized,
|
||||||
_metadataKey: rowKey
|
_metadataKey: rowKey
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -287,20 +257,56 @@ export default function MetadataSelectionDialog({
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
}, [context, extraResults, hasSearchOverride]);
|
}, [context, extraResults, hasSearchOverride]);
|
||||||
|
|
||||||
|
const seriesRowsAvailable = useMemo(
|
||||||
|
() => rows.some((row) => normalizeResultType(row?.resultType) === 'series'),
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedResultType = normalizeResultType(selected?.resultType)
|
||||||
|
|| (normalizeWorkflowKind(selected?.workflowKind) === 'series' ? 'series' : 'movie');
|
||||||
|
const selectedIsSeries = Boolean(selected) && selectedResultType === 'series';
|
||||||
|
const showSeasonFilter = resultFilter !== 'movies' && seriesRowsAvailable;
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
if (effectiveWorkflowKind !== 'series') {
|
let output = [...rows];
|
||||||
return rows;
|
|
||||||
|
if (resultFilter === 'movies') {
|
||||||
|
output = output.filter((row) => normalizeResultType(row?.resultType) !== 'series');
|
||||||
|
} else if (resultFilter === 'series') {
|
||||||
|
output = output.filter((row) => normalizeResultType(row?.resultType) === 'series');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSeasonFilter) {
|
||||||
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
|
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
|
||||||
if (!normalizedFilter) {
|
if (normalizedFilter) {
|
||||||
return rows;
|
output = output.filter((row) => {
|
||||||
|
if (normalizeResultType(row?.resultType) !== 'series') {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return rows.filter((row) => {
|
|
||||||
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
|
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
|
||||||
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
|
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
|
||||||
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
|
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
|
||||||
});
|
});
|
||||||
}, [metadataProvider, rows, seasonFilter]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}, [rows, resultFilter, seasonFilter, showSeasonFilter]);
|
||||||
|
|
||||||
|
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
|
||||||
|
const hasValidDiscNumber = effectiveDiscNumber !== null;
|
||||||
|
const requiresDiscNumber = metadataProvider === 'tmdb'
|
||||||
|
&& selectedIsSeries
|
||||||
|
&& (mediaProfile === 'dvd' || mediaProfile === 'bluray');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!discValidationError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!requiresDiscNumber || hasValidDiscNumber) {
|
||||||
|
setDiscValidationError('');
|
||||||
|
}
|
||||||
|
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
|
||||||
|
|
||||||
const titleWithPosterBody = (row) => (
|
const titleWithPosterBody = (row) => (
|
||||||
<div className="metadata-row">
|
<div className="metadata-row">
|
||||||
@@ -341,8 +347,7 @@ export default function MetadataSelectionDialog({
|
|||||||
let effectiveResults = [];
|
let effectiveResults = [];
|
||||||
for (const candidateQuery of queryVariants) {
|
for (const candidateQuery of queryVariants) {
|
||||||
const results = await onSearch(candidateQuery, {
|
const results = await onSearch(candidateQuery, {
|
||||||
metadataProvider,
|
metadataProvider
|
||||||
workflowKind: effectiveWorkflowKind
|
|
||||||
});
|
});
|
||||||
if (activeSearchRequestRef.current !== requestId) {
|
if (activeSearchRequestRef.current !== requestId) {
|
||||||
return;
|
return;
|
||||||
@@ -371,21 +376,18 @@ export default function MetadataSelectionDialog({
|
|||||||
}
|
}
|
||||||
if (typeof onSearch !== 'function') {
|
if (typeof onSearch !== 'function') {
|
||||||
autoSearchDoneRef.current = true;
|
autoSearchDoneRef.current = true;
|
||||||
autoSearchWorkflowRef.current = effectiveWorkflowKind;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const trimmedQuery = String(query || '').trim();
|
const trimmedQuery = String(query || '').trim();
|
||||||
if (!trimmedQuery) {
|
if (!trimmedQuery) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workflowChanged = autoSearchWorkflowRef.current !== effectiveWorkflowKind;
|
if (autoSearchDoneRef.current) {
|
||||||
if (autoSearchDoneRef.current && !workflowChanged) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
autoSearchDoneRef.current = true;
|
autoSearchDoneRef.current = true;
|
||||||
autoSearchWorkflowRef.current = effectiveWorkflowKind;
|
|
||||||
void handleSearch();
|
void handleSearch();
|
||||||
}, [visible, query, effectiveWorkflowKind, onSearch]);
|
}, [visible, query, onSearch]);
|
||||||
|
|
||||||
const handleSubmitError = (error, pendingPayload = null) => {
|
const handleSubmitError = (error, pendingPayload = null) => {
|
||||||
const details = Array.isArray(error?.details) ? error.details : [];
|
const details = Array.isArray(error?.details) ? error.details : [];
|
||||||
@@ -435,6 +437,9 @@ export default function MetadataSelectionDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectionWorkflowKind = selectedIsSeries ? 'series' : 'film';
|
||||||
|
const manualWorkflowKind = resultFilter === 'series' ? 'series' : 'film';
|
||||||
|
|
||||||
const payload = selected
|
const payload = selected
|
||||||
? {
|
? {
|
||||||
jobId: context.jobId,
|
jobId: context.jobId,
|
||||||
@@ -443,10 +448,10 @@ export default function MetadataSelectionDialog({
|
|||||||
imdbId: selected.imdbId,
|
imdbId: selected.imdbId,
|
||||||
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
|
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
|
||||||
metadataProvider,
|
metadataProvider,
|
||||||
workflowKind: effectiveWorkflowKind,
|
workflowKind: selectionWorkflowKind,
|
||||||
providerId: selected.providerId || null,
|
providerId: selected.providerId || null,
|
||||||
tmdbId: selected.tmdbId || null,
|
tmdbId: selected.tmdbId || null,
|
||||||
metadataKind: selected.metadataKind || null,
|
metadataKind: selected.metadataKind || (selectedIsSeries ? 'series' : 'movie'),
|
||||||
voteAverage: Number.isFinite(Number(selected?.voteAverage))
|
voteAverage: Number.isFinite(Number(selected?.voteAverage))
|
||||||
? Number(Number(selected.voteAverage).toFixed(1))
|
? Number(Number(selected.voteAverage).toFixed(1))
|
||||||
: null,
|
: null,
|
||||||
@@ -458,10 +463,10 @@ export default function MetadataSelectionDialog({
|
|||||||
tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object'
|
tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object'
|
||||||
? selected.tmdbDetails
|
? selected.tmdbDetails
|
||||||
: null,
|
: null,
|
||||||
seasonNumber: selected.seasonNumber || null,
|
seasonNumber: selectedIsSeries ? (selected.seasonNumber || null) : null,
|
||||||
seasonName: selected.seasonName || null,
|
seasonName: selectedIsSeries ? (selected.seasonName || null) : null,
|
||||||
episodeCount: selected.episodeCount || 0,
|
episodeCount: selectedIsSeries ? (selected.episodeCount || 0) : 0,
|
||||||
episodes: Array.isArray(selected.episodes) ? selected.episodes : [],
|
episodes: selectedIsSeries && Array.isArray(selected.episodes) ? selected.episodes : [],
|
||||||
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
|
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -471,7 +476,7 @@ export default function MetadataSelectionDialog({
|
|||||||
imdbId: null,
|
imdbId: null,
|
||||||
poster: null,
|
poster: null,
|
||||||
metadataProvider,
|
metadataProvider,
|
||||||
workflowKind: effectiveWorkflowKind,
|
workflowKind: manualWorkflowKind,
|
||||||
seasonNumber: null,
|
seasonNumber: null,
|
||||||
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
|
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
|
||||||
};
|
};
|
||||||
@@ -624,11 +629,12 @@ export default function MetadataSelectionDialog({
|
|||||||
header="Metadaten auswählen"
|
header="Metadaten auswählen"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onHide={handleHideRequest}
|
onHide={handleHideRequest}
|
||||||
style={{ width: '52rem', maxWidth: '95vw' }}
|
style={{ width: '58.25rem', maxWidth: '95vw', maxHeight: '950px' }}
|
||||||
className="metadata-selection-dialog"
|
className="metadata-selection-dialog"
|
||||||
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
|
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
|
||||||
modal
|
modal
|
||||||
>
|
>
|
||||||
|
<div className="metadata-dialog-controls">
|
||||||
<div className="search-row">
|
<div className="search-row">
|
||||||
<InputText
|
<InputText
|
||||||
value={query}
|
value={query}
|
||||||
@@ -643,26 +649,9 @@ export default function MetadataSelectionDialog({
|
|||||||
/>
|
/>
|
||||||
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
|
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
|
||||||
</div>
|
</div>
|
||||||
{allowsWorkflowSwitch ? (
|
|
||||||
<div className="dialog-actions" style={{ justifyContent: 'flex-start', marginTop: '-0.25rem' }}>
|
<div className={`metadata-filter-grid${showSeasonFilter ? '' : ' metadata-filter-grid-single'}`}>
|
||||||
<Button
|
{showSeasonFilter ? (
|
||||||
label="Als Film suchen"
|
|
||||||
severity={effectiveWorkflowKind === 'film' ? 'primary' : 'secondary'}
|
|
||||||
outlined={effectiveWorkflowKind !== 'film'}
|
|
||||||
onClick={() => setManualWorkflowKind('film')}
|
|
||||||
disabled={searchBusy || busy}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Als Serie suchen"
|
|
||||||
severity={effectiveWorkflowKind === 'series' ? 'primary' : 'secondary'}
|
|
||||||
outlined={effectiveWorkflowKind !== 'series'}
|
|
||||||
onClick={() => setManualWorkflowKind('series')}
|
|
||||||
disabled={searchBusy || busy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className={`metadata-filter-grid${effectiveWorkflowKind !== 'series' ? ' metadata-filter-grid-single' : ''}`}>
|
|
||||||
{effectiveWorkflowKind === 'series' ? (
|
|
||||||
<InputText
|
<InputText
|
||||||
value={seasonFilter}
|
value={seasonFilter}
|
||||||
onChange={(event) => setSeasonFilter(event.target.value)}
|
onChange={(event) => setSeasonFilter(event.target.value)}
|
||||||
@@ -680,11 +669,37 @@ export default function MetadataSelectionDialog({
|
|||||||
/>
|
/>
|
||||||
<small className="metadata-filter-subtitle">
|
<small className="metadata-filter-subtitle">
|
||||||
{requiresDiscNumber
|
{requiresDiscNumber
|
||||||
? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht uebernommen werden.'
|
? 'Pflichtfeld für Serien-DVD/Blu-ray. Ohne Disc-Nummer können die Metadaten nicht übernommen werden.'
|
||||||
: 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'}
|
: 'Optional für Mapping und Output-Templates. Gespeichert werden nur Werte > 0.'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="metadata-result-filter-row">
|
||||||
|
<Button
|
||||||
|
label="Beides"
|
||||||
|
severity={resultFilter === 'all' ? 'primary' : 'secondary'}
|
||||||
|
outlined={resultFilter !== 'all'}
|
||||||
|
onClick={() => setResultFilter('all')}
|
||||||
|
disabled={searchBusy || busy}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Filme"
|
||||||
|
severity={resultFilter === 'movies' ? 'primary' : 'secondary'}
|
||||||
|
outlined={resultFilter !== 'movies'}
|
||||||
|
onClick={() => setResultFilter('movies')}
|
||||||
|
disabled={searchBusy || busy}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Serien"
|
||||||
|
severity={resultFilter === 'series' ? 'primary' : 'secondary'}
|
||||||
|
outlined={resultFilter !== 'series'}
|
||||||
|
onClick={() => setResultFilter('series')}
|
||||||
|
disabled={searchBusy || busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{searchError ? <small className="error-text">{searchError}</small> : null}
|
{searchError ? <small className="error-text">{searchError}</small> : null}
|
||||||
{discValidationError ? <small className="error-text">{discValidationError}</small> : null}
|
{discValidationError ? <small className="error-text">{discValidationError}</small> : null}
|
||||||
{submitError ? <small className="error-text">{submitError}</small> : null}
|
{submitError ? <small className="error-text">{submitError}</small> : null}
|
||||||
@@ -705,12 +720,17 @@ export default function MetadataSelectionDialog({
|
|||||||
>
|
>
|
||||||
<Column header="Titel" body={titleWithPosterBody} />
|
<Column header="Titel" body={titleWithPosterBody} />
|
||||||
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
|
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
|
||||||
|
<Column
|
||||||
|
header="Typ"
|
||||||
|
body={(row) => (normalizeResultType(row?.resultType) === 'series' ? 'Serie' : 'Film')}
|
||||||
|
style={{ width: '7rem' }}
|
||||||
|
/>
|
||||||
<Column
|
<Column
|
||||||
header="Staffel"
|
header="Staffel"
|
||||||
body={(row) => (effectiveWorkflowKind === 'series'
|
body={(row) => (normalizeResultType(row?.resultType) === 'series'
|
||||||
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
|
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
|
||||||
: (row.imdbId || '-'))}
|
: '-')}
|
||||||
style={{ width: '10rem' }}
|
style={{ width: '8rem' }}
|
||||||
/>
|
/>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -858,6 +858,10 @@ function isPlaylistTrackPreparationStatus(value) {
|
|||||||
&& normalized.includes('VORBEREIT');
|
&& normalized.includes('VORBEREIT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMediainfoCheckStatus(value) {
|
||||||
|
return String(value || '').trim().toUpperCase() === 'MEDIAINFO_CHECK';
|
||||||
|
}
|
||||||
|
|
||||||
function getSeriesBatchTagSeverity(status) {
|
function getSeriesBatchTagSeverity(status) {
|
||||||
const normalized = normalizeSeriesBatchStatus(status);
|
const normalized = normalizeSeriesBatchStatus(status);
|
||||||
if (normalized === 'FINISHED') {
|
if (normalized === 'FINISHED') {
|
||||||
@@ -1655,6 +1659,7 @@ export default function PipelineStatusCard({
|
|||||||
const [liveJobLog, setLiveJobLog] = useState('');
|
const [liveJobLog, setLiveJobLog] = useState('');
|
||||||
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
|
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
|
||||||
const [selectedEncodeTitleIds, setSelectedEncodeTitleIds] = useState([]);
|
const [selectedEncodeTitleIds, setSelectedEncodeTitleIds] = useState([]);
|
||||||
|
const [titleSelectionTouched, setTitleSelectionTouched] = useState(false);
|
||||||
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
||||||
const [selectedHandBrakeTitleIdsState, setSelectedHandBrakeTitleIdsState] = useState([]);
|
const [selectedHandBrakeTitleIdsState, setSelectedHandBrakeTitleIdsState] = useState([]);
|
||||||
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
||||||
@@ -1832,6 +1837,7 @@ export default function PipelineStatusCard({
|
|||||||
const effectiveSelected = defaultAllTitles
|
const effectiveSelected = defaultAllTitles
|
||||||
? normalizeTitleIdList(reviewTitles.map((title) => title?.id))
|
? normalizeTitleIdList(reviewTitles.map((title) => title?.id))
|
||||||
: selectedFromReview;
|
: selectedFromReview;
|
||||||
|
setTitleSelectionTouched(false);
|
||||||
setSelectedEncodeTitleIds(effectiveSelected);
|
setSelectedEncodeTitleIds(effectiveSelected);
|
||||||
setSelectedEncodeTitleId(effectiveSelected[0] || fromReview || null);
|
setSelectedEncodeTitleId(effectiveSelected[0] || fromReview || null);
|
||||||
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
||||||
@@ -1912,6 +1918,7 @@ export default function PipelineStatusCard({
|
|||||||
if (mediaInfoReview) {
|
if (mediaInfoReview) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setTitleSelectionTouched(false);
|
||||||
setSelectedEncodeTitleIds([]);
|
setSelectedEncodeTitleIds([]);
|
||||||
setSelectedEncodeTitleId(null);
|
setSelectedEncodeTitleId(null);
|
||||||
setTrackSelectionByTitle({});
|
setTrackSelectionByTitle({});
|
||||||
@@ -1923,6 +1930,26 @@ export default function PipelineStatusCard({
|
|||||||
setSelectedHandBrakePreset('');
|
setSelectedHandBrakePreset('');
|
||||||
}, [mediaInfoReview, retryJobId]);
|
}, [mediaInfoReview, retryJobId]);
|
||||||
|
|
||||||
|
const resolveEffectiveSelectedTitleIds = (reviewTitles = []) => {
|
||||||
|
const normalizedReviewTitles = Array.isArray(reviewTitles) ? reviewTitles : [];
|
||||||
|
const explicitSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds);
|
||||||
|
if (titleSelectionTouched) {
|
||||||
|
return explicitSelectedIds;
|
||||||
|
}
|
||||||
|
const fallbackSelectedIds = normalizeTitleIdList([
|
||||||
|
...explicitSelectedIds,
|
||||||
|
selectedEncodeTitleId,
|
||||||
|
mediaInfoReview?.encodeInputTitleId,
|
||||||
|
...normalizedReviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
|
||||||
|
]);
|
||||||
|
const selectedTitleIdsInReviewOrder = normalizedReviewTitles
|
||||||
|
.map((title) => normalizeTitleId(title?.id))
|
||||||
|
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
|
||||||
|
return selectedTitleIdsInReviewOrder.length > 0
|
||||||
|
? selectedTitleIdsInReviewOrder
|
||||||
|
: fallbackSelectedIds;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds);
|
const normalizedSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds);
|
||||||
const primaryTitleId = normalizeTitleId(selectedEncodeTitleId);
|
const primaryTitleId = normalizeTitleId(selectedEncodeTitleId);
|
||||||
@@ -1962,11 +1989,7 @@ export default function PipelineStatusCard({
|
|||||||
const resolveEpisodeFillContext = () => {
|
const resolveEpisodeFillContext = () => {
|
||||||
const episodeRows = Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [];
|
const episodeRows = Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [];
|
||||||
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
|
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
|
||||||
const selectedTitleIds = normalizeTitleIdList([
|
const selectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles);
|
||||||
...normalizeTitleIdList(selectedEncodeTitleIds),
|
|
||||||
...(Array.isArray(mediaInfoReview?.selectedTitleIds) ? mediaInfoReview.selectedTitleIds : []),
|
|
||||||
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
|
|
||||||
]);
|
|
||||||
const orderedTitles = reviewTitles
|
const orderedTitles = reviewTitles
|
||||||
.map((title) => ({
|
.map((title) => ({
|
||||||
id: normalizeTitleId(title?.id),
|
id: normalizeTitleId(title?.id),
|
||||||
@@ -2268,18 +2291,7 @@ export default function PipelineStatusCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
|
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
|
||||||
const fallbackSelectedIds = normalizeTitleIdList([
|
const effectiveSelectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles);
|
||||||
...selectedEncodeTitleIds,
|
|
||||||
selectedEncodeTitleId,
|
|
||||||
mediaInfoReview?.encodeInputTitleId,
|
|
||||||
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
|
|
||||||
]);
|
|
||||||
const selectedTitleIdsInReviewOrder = reviewTitles
|
|
||||||
.map((title) => normalizeTitleId(title?.id))
|
|
||||||
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
|
|
||||||
const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0
|
|
||||||
? selectedTitleIdsInReviewOrder
|
|
||||||
: fallbackSelectedIds;
|
|
||||||
|
|
||||||
const hasEpisodeAssignmentPayload = (assignment) => {
|
const hasEpisodeAssignmentPayload = (assignment) => {
|
||||||
if (!assignment || typeof assignment !== 'object') {
|
if (!assignment || typeof assignment !== 'object') {
|
||||||
@@ -3113,8 +3125,12 @@ export default function PipelineStatusCard({
|
|||||||
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
|
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
|
||||||
)
|
)
|
||||||
: (pipeline?.statusText || 'Bereit');
|
: (pipeline?.statusText || 'Bereit');
|
||||||
const showIndeterminatePrimaryProgress = !hasSeriesBatchProgress
|
const showMediainfoIndeterminatePrimaryProgress = !hasSeriesBatchProgress
|
||||||
&& isPlaylistTrackPreparationStatus(pipeline?.statusText);
|
&& isMediainfoCheckStatus(state);
|
||||||
|
const showIndeterminatePrimaryProgress = showMediainfoIndeterminatePrimaryProgress || (
|
||||||
|
!hasSeriesBatchProgress
|
||||||
|
&& isPlaylistTrackPreparationStatus(pipeline?.statusText)
|
||||||
|
);
|
||||||
const ripIssueEntries = useMemo(
|
const ripIssueEntries = useMemo(
|
||||||
() => extractMakeMkvRipIssues(jobRow?.makemkvInfo),
|
() => extractMakeMkvRipIssues(jobRow?.makemkvInfo),
|
||||||
[jobRow?.makemkvInfo]
|
[jobRow?.makemkvInfo]
|
||||||
@@ -3461,18 +3477,7 @@ export default function PipelineStatusCard({
|
|||||||
.map((title) => [normalizeTitleId(title?.id), title])
|
.map((title) => [normalizeTitleId(title?.id), title])
|
||||||
.filter((entry) => entry[0] !== null)
|
.filter((entry) => entry[0] !== null)
|
||||||
);
|
);
|
||||||
const fallbackSelectedIds = normalizeTitleIdList([
|
const effectiveSelectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles);
|
||||||
...selectedEncodeTitleIds,
|
|
||||||
selectedEncodeTitleId,
|
|
||||||
mediaInfoReview?.encodeInputTitleId,
|
|
||||||
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
|
|
||||||
]);
|
|
||||||
const selectedTitleIdsInReviewOrder = reviewTitles
|
|
||||||
.map((title) => normalizeTitleId(title?.id))
|
|
||||||
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
|
|
||||||
const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0
|
|
||||||
? selectedTitleIdsInReviewOrder
|
|
||||||
: fallbackSelectedIds;
|
|
||||||
const preferredPrimaryTitleId = normalizeTitleId(selectedEncodeTitleId)
|
const preferredPrimaryTitleId = normalizeTitleId(selectedEncodeTitleId)
|
||||||
|| normalizeTitleId(mediaInfoReview?.encodeInputTitleId)
|
|| normalizeTitleId(mediaInfoReview?.encodeInputTitleId)
|
||||||
|| null;
|
|| null;
|
||||||
@@ -3657,7 +3662,7 @@ export default function PipelineStatusCard({
|
|||||||
)}
|
)}
|
||||||
<small>
|
<small>
|
||||||
{showIndeterminatePrimaryProgress
|
{showIndeterminatePrimaryProgress
|
||||||
? 'Vorbereitung läuft …'
|
? (showMediainfoIndeterminatePrimaryProgress ? 'Mediainfo-Prüfung läuft …' : 'Vorbereitung läuft …')
|
||||||
: primaryEta
|
: primaryEta
|
||||||
? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`)
|
? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`)
|
||||||
: 'ETA unbekannt'}
|
: 'ETA unbekannt'}
|
||||||
@@ -4342,11 +4347,13 @@ export default function PipelineStatusCard({
|
|||||||
)}
|
)}
|
||||||
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||||
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
|
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
|
||||||
|
titleSelectionTouched={titleSelectionTouched}
|
||||||
allowTitleSelection={allowTitleSelectionInReview}
|
allowTitleSelection={allowTitleSelectionInReview}
|
||||||
compactTitleSelection={isDiscTitleSelectionRequired}
|
compactTitleSelection={isDiscTitleSelectionRequired}
|
||||||
onSelectEncodeTitle={(titleId) => {
|
onSelectEncodeTitle={(titleId) => {
|
||||||
const normalizedTitleId = normalizeTitleId(titleId);
|
const normalizedTitleId = normalizeTitleId(titleId);
|
||||||
setSelectedEncodeTitleId(normalizedTitleId);
|
setSelectedEncodeTitleId(normalizedTitleId);
|
||||||
|
setTitleSelectionTouched(true);
|
||||||
if (!normalizedTitleId) {
|
if (!normalizedTitleId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4357,6 +4364,7 @@ export default function PipelineStatusCard({
|
|||||||
if (!normalizedTitleId) {
|
if (!normalizedTitleId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setTitleSelectionTouched(true);
|
||||||
setSelectedEncodeTitleIds((prev) => {
|
setSelectedEncodeTitleIds((prev) => {
|
||||||
const current = normalizeTitleIdList(prev);
|
const current = normalizeTitleIdList(prev);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
|
|||||||
@@ -911,6 +911,13 @@ export default function ConverterPage() {
|
|||||||
<div className="ripper-subpage-content">
|
<div className="ripper-subpage-content">
|
||||||
<Toast ref={toastRef} position="top-right" />
|
<Toast ref={toastRef} position="top-right" />
|
||||||
|
|
||||||
|
<div className="converter-beta-notice" role="note" aria-live="polite">
|
||||||
|
<i className="pi pi-exclamation-triangle" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Hinweis: Der Converter befindet sich aktuell im Beta-Stadium und ist noch nicht vollständig geprüft.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Import-Ordner */}
|
{/* Import-Ordner */}
|
||||||
<Card title="Import-Ordner" subTitle="Dateien aus dem Raw-Ordner auswählen und als Job anlegen">
|
<Card title="Import-Ordner" subTitle="Dateien aus dem Raw-Ordner auswählen und als Job anlegen">
|
||||||
<ConverterFileExplorer
|
<ConverterFileExplorer
|
||||||
|
|||||||
@@ -0,0 +1,634 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import 'chart.js/auto';
|
||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
|
import { Chart } from 'primereact/chart';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
function clampPercent(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(100, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return `${Math.round(parsed)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
const bytes = Number(value);
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
if (bytes === 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
const normalized = bytes / (1024 ** exponent);
|
||||||
|
return `${normalized.toFixed(normalized >= 100 ? 0 : (normalized >= 10 ? 1 : 2))} ${units[exponent]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpdatedAt(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(raw);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return new Date(parsed).toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHardwareMonitoringPayload(rawPayload) {
|
||||||
|
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
||||||
|
return {
|
||||||
|
enabled: Boolean(payload.enabled),
|
||||||
|
intervalMs: Number(payload.intervalMs || 0),
|
||||||
|
updatedAt: payload.updatedAt || null,
|
||||||
|
sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null,
|
||||||
|
error: payload.error ? String(payload.error) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGaugeDataset(value, color) {
|
||||||
|
const safe = clampPercent(value);
|
||||||
|
return {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [safe, Math.max(0, 100 - safe)],
|
||||||
|
backgroundColor: [color, 'rgba(255,255,255,0.08)'],
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverOffset: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gaugeOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: '82%',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const HISTORY_WINDOWS = [
|
||||||
|
{ label: '1h', hours: 1 },
|
||||||
|
{ label: '6h', hours: 6 },
|
||||||
|
{ label: '24h', hours: 24 },
|
||||||
|
{ label: '4d', hours: 96 }
|
||||||
|
];
|
||||||
|
const HISTORY_STACK_BREAKPOINT = 1700;
|
||||||
|
|
||||||
|
const CPU_SERIES_COLOR = '#c43d2f';
|
||||||
|
const GPU_SERIES_COLOR = '#c9961a';
|
||||||
|
const RAM_SERIES_COLOR = '#2e7d4f';
|
||||||
|
|
||||||
|
const historyChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: 8,
|
||||||
|
color: '#6a4d38'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(111,57,34,0.08)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
color: '#6a4d38'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(111,57,34,0.08)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function toNumberOrNull(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTickLabel(timestampIso) {
|
||||||
|
const parsed = Date.parse(String(timestampIso || ''));
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const date = new Date(parsed);
|
||||||
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSeriesToggleButtonStyle(color, active) {
|
||||||
|
if (active) {
|
||||||
|
return {
|
||||||
|
background: color,
|
||||||
|
borderColor: color,
|
||||||
|
color: '#fffaf1'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
background: 'transparent',
|
||||||
|
borderColor: color,
|
||||||
|
color
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoryViewportBucket() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'wide';
|
||||||
|
}
|
||||||
|
return window.innerWidth < HISTORY_STACK_BREAKPOINT ? 'narrow' : 'wide';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartLegendRow({ items = [] }) {
|
||||||
|
return (
|
||||||
|
<div className="hardware-history-legend-row" aria-hidden="true">
|
||||||
|
{items.map((item) => (
|
||||||
|
<span key={item.label} className="hardware-history-legend-item">
|
||||||
|
<span className="hardware-history-legend-dot" style={{ background: item.color }} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GaugeBlock({ title, subtitle, icon, valuePercent, gaugeColor, bars = [], footer = null }) {
|
||||||
|
return (
|
||||||
|
<Card className="hardware-detail-card hardware-detail-card-top">
|
||||||
|
<div className="hardware-detail-card-head">
|
||||||
|
<div className="hardware-detail-card-icon"><i className={`pi ${icon}`} /></div>
|
||||||
|
<div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{subtitle ? <small>{subtitle}</small> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hardware-detail-gauge-layout">
|
||||||
|
<div className="hardware-detail-gauge-wrap">
|
||||||
|
<Chart type="doughnut" data={buildGaugeDataset(valuePercent, gaugeColor)} options={gaugeOptions} className="hardware-detail-gauge-chart" />
|
||||||
|
<div className="hardware-detail-gauge-center">{formatPercent(valuePercent)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-detail-bar-list">
|
||||||
|
{bars.map((bar) => (
|
||||||
|
<div key={bar.label} className="hardware-detail-bar-item">
|
||||||
|
<div className="hardware-detail-bar-head">
|
||||||
|
<span>{bar.label}</span>
|
||||||
|
<span>{bar.valueLabel}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={clampPercent(bar.valuePercent)} showValue={false} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HardwarePage({ hardwareMonitoring }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [historyHours, setHistoryHours] = useState(24);
|
||||||
|
const [historyViewportBucket, setHistoryViewportBucket] = useState(() => getHistoryViewportBucket());
|
||||||
|
const [usageSeriesVisible, setUsageSeriesVisible] = useState({
|
||||||
|
cpu: true,
|
||||||
|
ram: true,
|
||||||
|
gpu: true
|
||||||
|
});
|
||||||
|
const [tempSeriesVisible, setTempSeriesVisible] = useState({
|
||||||
|
cpu: true,
|
||||||
|
gpu: true
|
||||||
|
});
|
||||||
|
const [historyState, setHistoryState] = useState({
|
||||||
|
loading: false,
|
||||||
|
points: [],
|
||||||
|
totalPoints: 0,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
const monitoringState = useMemo(
|
||||||
|
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
|
||||||
|
[hardwareMonitoring]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sample = monitoringState.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 cpuPerCore = Array.isArray(cpu?.perCore) ? cpu.perCore : [];
|
||||||
|
const gpuDevices = Array.isArray(gpu?.devices) ? gpu.devices : [];
|
||||||
|
const primaryGpu = gpuDevices[0] || null;
|
||||||
|
|
||||||
|
const cpuTitle = String(cpu?.name || cpu?.model || cpu?.modelName || cpu?.brand || '').trim() || 'CPU';
|
||||||
|
const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher';
|
||||||
|
const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU';
|
||||||
|
const historyPoints = Array.isArray(historyState.points) ? historyState.points : [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!monitoringState.enabled) {
|
||||||
|
setHistoryState({
|
||||||
|
loading: false,
|
||||||
|
points: [],
|
||||||
|
totalPoints: 0,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const loadHistory = async (forceRefresh = false) => {
|
||||||
|
setHistoryState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: prev.points.length === 0
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const response = await api.getHardwareHistory({
|
||||||
|
hours: historyHours,
|
||||||
|
maxPoints: 900,
|
||||||
|
forceRefresh
|
||||||
|
});
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = response?.history && typeof response.history === 'object' ? response.history : {};
|
||||||
|
setHistoryState({
|
||||||
|
loading: false,
|
||||||
|
points: Array.isArray(payload.points) ? payload.points : [],
|
||||||
|
totalPoints: Number(payload.totalPoints || 0),
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHistoryState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: error?.message || 'Historische Hardware-Daten konnten nicht geladen werden.'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadHistory(true);
|
||||||
|
const refreshMs = Math.max(5000, Number(monitoringState.intervalMs || 5000) * 2);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
loadHistory(true);
|
||||||
|
}, refreshMs);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [historyHours, monitoringState.enabled, monitoringState.intervalMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let frame = null;
|
||||||
|
const handleResize = () => {
|
||||||
|
if (frame) {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = null;
|
||||||
|
setHistoryViewportBucket((prev) => {
|
||||||
|
const next = getHistoryViewportBucket();
|
||||||
|
return prev === next ? prev : next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => {
|
||||||
|
if (frame) {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const historyUsageChartData = useMemo(() => ({
|
||||||
|
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'CPU',
|
||||||
|
data: historyPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
|
||||||
|
borderColor: CPU_SERIES_COLOR,
|
||||||
|
backgroundColor: CPU_SERIES_COLOR,
|
||||||
|
borderWidth: 2,
|
||||||
|
hidden: !usageSeriesVisible.cpu,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'RAM',
|
||||||
|
data: historyPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
|
||||||
|
borderColor: RAM_SERIES_COLOR,
|
||||||
|
backgroundColor: RAM_SERIES_COLOR,
|
||||||
|
borderWidth: 2,
|
||||||
|
hidden: !usageSeriesVisible.ram,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GPU',
|
||||||
|
data: historyPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
|
||||||
|
borderColor: GPU_SERIES_COLOR,
|
||||||
|
backgroundColor: GPU_SERIES_COLOR,
|
||||||
|
borderWidth: 2,
|
||||||
|
hidden: !usageSeriesVisible.gpu,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.22
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}), [historyPoints, usageSeriesVisible]);
|
||||||
|
|
||||||
|
const historyTempChartData = useMemo(() => ({
|
||||||
|
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'CPU',
|
||||||
|
data: historyPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
|
||||||
|
borderColor: CPU_SERIES_COLOR,
|
||||||
|
backgroundColor: CPU_SERIES_COLOR,
|
||||||
|
borderWidth: 2,
|
||||||
|
hidden: !tempSeriesVisible.cpu,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GPU',
|
||||||
|
data: historyPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
|
||||||
|
borderColor: GPU_SERIES_COLOR,
|
||||||
|
backgroundColor: GPU_SERIES_COLOR,
|
||||||
|
borderWidth: 2,
|
||||||
|
hidden: !tempSeriesVisible.gpu,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.22
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}), [historyPoints, tempSeriesVisible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hardware-detail-page">
|
||||||
|
<Card className="hardware-detail-hero">
|
||||||
|
<div className="hardware-detail-hero-head">
|
||||||
|
<div>
|
||||||
|
<h2>Hardware Monitoring</h2>
|
||||||
|
<small>Ausführliche Live-Ansicht für CPU, RAM und GPU</small>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-detail-hero-tags">
|
||||||
|
<Tag value={monitoringState.enabled ? 'Aktiv' : 'Inaktiv'} severity={monitoringState.enabled ? 'success' : 'warning'} />
|
||||||
|
<Tag value={`Intervall: ${monitoringState.intervalMs > 0 ? `${monitoringState.intervalMs} ms` : '-'}`} severity="secondary" />
|
||||||
|
<Tag value={`Letztes Update: ${formatUpdatedAt(monitoringState.updatedAt)}`} severity="info" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!monitoringState.enabled ? (
|
||||||
|
<Card className="hardware-detail-empty">
|
||||||
|
<h3>Monitoring ist deaktiviert</h3>
|
||||||
|
<p>
|
||||||
|
Aktiviere <code>hardware_monitoring_enabled</code> in den Einstellungen,
|
||||||
|
um die Live-Ansicht auf dieser Seite zu nutzen.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
label="Zu den Einstellungen"
|
||||||
|
icon="pi pi-cog"
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{monitoringState.enabled && !sample ? (
|
||||||
|
<Card className="hardware-detail-empty">
|
||||||
|
<h3>Warte auf erste Messung ...</h3>
|
||||||
|
<p>Die Hardware-Metriken werden gerade aufgebaut.</p>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{monitoringState.enabled ? (
|
||||||
|
<Card className="hardware-detail-card hardware-history-card">
|
||||||
|
<div className="hardware-detail-card-head hardware-history-card-head">
|
||||||
|
<div>
|
||||||
|
<h3>Historie</h3>
|
||||||
|
<small>
|
||||||
|
Verlauf aus Backend-Log ({historyState.totalPoints} Messpunkte, Ansicht: letzte {historyHours}h)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-history-window-buttons">
|
||||||
|
{HISTORY_WINDOWS.map((window) => (
|
||||||
|
<Button
|
||||||
|
key={`history-window-${window.hours}`}
|
||||||
|
label={window.label}
|
||||||
|
size="small"
|
||||||
|
severity={historyHours === window.hours ? 'primary' : 'secondary'}
|
||||||
|
outlined={historyHours !== window.hours}
|
||||||
|
onClick={() => setHistoryHours(window.hours)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{historyState.error ? <small className="error-text">{historyState.error}</small> : null}
|
||||||
|
{historyState.loading && historyPoints.length === 0 ? (
|
||||||
|
<p>Lade Verlauf ...</p>
|
||||||
|
) : historyPoints.length === 0 ? (
|
||||||
|
<p>Noch keine Verlaufsdaten vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="hardware-history-grid">
|
||||||
|
<div className="hardware-history-chart-wrap">
|
||||||
|
<h4>Auslastung (%)</h4>
|
||||||
|
<div className="hardware-history-series-toggles">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
label="CPU"
|
||||||
|
className="hardware-series-toggle-btn"
|
||||||
|
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, usageSeriesVisible.cpu)}
|
||||||
|
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
label="RAM"
|
||||||
|
className="hardware-series-toggle-btn"
|
||||||
|
style={buildSeriesToggleButtonStyle(RAM_SERIES_COLOR, usageSeriesVisible.ram)}
|
||||||
|
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, ram: !prev.ram }))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
label="GPU"
|
||||||
|
className="hardware-series-toggle-btn"
|
||||||
|
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, usageSeriesVisible.gpu)}
|
||||||
|
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-history-chart">
|
||||||
|
<Chart
|
||||||
|
key={`usage-${historyViewportBucket}`}
|
||||||
|
className="hardware-history-chart-canvas"
|
||||||
|
type="line"
|
||||||
|
data={historyUsageChartData}
|
||||||
|
options={historyChartOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ChartLegendRow
|
||||||
|
items={[
|
||||||
|
{ label: 'CPU', color: CPU_SERIES_COLOR },
|
||||||
|
{ label: 'RAM', color: RAM_SERIES_COLOR },
|
||||||
|
{ label: 'GPU', color: GPU_SERIES_COLOR }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-history-chart-wrap">
|
||||||
|
<h4>Temperatur (°C)</h4>
|
||||||
|
<div className="hardware-history-series-toggles">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
label="CPU °C"
|
||||||
|
className="hardware-series-toggle-btn"
|
||||||
|
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, tempSeriesVisible.cpu)}
|
||||||
|
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
label="GPU °C"
|
||||||
|
className="hardware-series-toggle-btn"
|
||||||
|
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, tempSeriesVisible.gpu)}
|
||||||
|
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-history-chart">
|
||||||
|
<Chart
|
||||||
|
key={`temp-${historyViewportBucket}`}
|
||||||
|
className="hardware-history-chart-canvas"
|
||||||
|
type="line"
|
||||||
|
data={historyTempChartData}
|
||||||
|
options={historyChartOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ChartLegendRow
|
||||||
|
items={[
|
||||||
|
{ label: 'CPU', color: CPU_SERIES_COLOR },
|
||||||
|
{ label: 'GPU', color: GPU_SERIES_COLOR }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{monitoringState.enabled && sample ? (
|
||||||
|
<div className="hardware-detail-grid">
|
||||||
|
<GaugeBlock
|
||||||
|
title="CPU"
|
||||||
|
subtitle={cpuTitle}
|
||||||
|
icon="pi-microchip"
|
||||||
|
valuePercent={cpu?.overallUsagePercent}
|
||||||
|
gaugeColor="#b07a24"
|
||||||
|
bars={[
|
||||||
|
{ label: 'Gesamtauslastung', valuePercent: cpu?.overallUsagePercent, valueLabel: formatPercent(cpu?.overallUsagePercent) },
|
||||||
|
{ label: 'Load Avg 1m', valuePercent: clampPercent(Number(cpu?.loadAverage?.[0]) * 100 / 8), valueLabel: Array.isArray(cpu?.loadAverage) ? String(cpu.loadAverage[0] ?? '-') : '-' }
|
||||||
|
]}
|
||||||
|
footer={(
|
||||||
|
<div className="hardware-detail-core-grid">
|
||||||
|
{cpuPerCore.length === 0 ? <small>Keine Core-Metriken verfügbar.</small> : cpuPerCore.map((core, index) => (
|
||||||
|
<div key={`cpu-core-${core?.index ?? index}`} className="hardware-detail-core-item">
|
||||||
|
<div className="hardware-detail-bar-head">
|
||||||
|
<span>Core {core?.index ?? index}</span>
|
||||||
|
<span>{formatPercent(core?.usagePercent)}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={clampPercent(core?.usagePercent)} showValue={false} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GaugeBlock
|
||||||
|
title="RAM"
|
||||||
|
subtitle={ramTitle}
|
||||||
|
icon="pi-server"
|
||||||
|
valuePercent={memory?.usagePercent}
|
||||||
|
gaugeColor="#9f6b1d"
|
||||||
|
bars={[
|
||||||
|
{ label: 'Verwendet', valuePercent: memory?.usagePercent, valueLabel: formatPercent(memory?.usagePercent) },
|
||||||
|
{ label: 'Belegt', valuePercent: memory?.usagePercent, valueLabel: formatBytes(memory?.usedBytes) },
|
||||||
|
{ label: 'Frei', valuePercent: Math.max(0, 100 - clampPercent(memory?.usagePercent)), valueLabel: formatBytes(memory?.freeBytes) }
|
||||||
|
]}
|
||||||
|
footer={(
|
||||||
|
<div className="hardware-detail-meter-wrap">
|
||||||
|
<div className="hardware-detail-meter-head">
|
||||||
|
<span>RAM Nutzung</span>
|
||||||
|
<span>{formatBytes(memory?.usedBytes)} / {formatBytes(memory?.totalBytes)}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={clampPercent(memory?.usagePercent)} showValue={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GaugeBlock
|
||||||
|
title="GPU"
|
||||||
|
subtitle={gpuTitle}
|
||||||
|
icon="pi-desktop"
|
||||||
|
valuePercent={primaryGpu?.utilizationPercent}
|
||||||
|
gaugeColor="#996521"
|
||||||
|
bars={[
|
||||||
|
{ label: '3D / Compute', valuePercent: primaryGpu?.utilizationPercent, valueLabel: formatPercent(primaryGpu?.utilizationPercent) },
|
||||||
|
{ label: 'Speicher', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: formatPercent(primaryGpu?.memoryUtilizationPercent) },
|
||||||
|
{ label: 'VRAM', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: `${formatBytes(primaryGpu?.memoryUsedBytes)} / ${formatBytes(primaryGpu?.memoryTotalBytes)}` }
|
||||||
|
]}
|
||||||
|
footer={(
|
||||||
|
<div className="hardware-detail-meter-wrap">
|
||||||
|
<div className="hardware-detail-meter-head">
|
||||||
|
<span>GPU Nutzung</span>
|
||||||
|
<span>
|
||||||
|
GPU {primaryGpu?.index ?? 0}
|
||||||
|
{primaryGpu?.name ? ` | ${primaryGpu.name}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={clampPercent(primaryGpu?.utilizationPercent)} showValue={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ const CD_FORMAT_LABELS = {
|
|||||||
|
|
||||||
const MULTIPART_CONTAINER_LIVE_STATUSES = new Set([
|
const MULTIPART_CONTAINER_LIVE_STATUSES = new Set([
|
||||||
'ANALYZING',
|
'ANALYZING',
|
||||||
|
'METADATA_LOOKUP',
|
||||||
'METADATA_SELECTION',
|
'METADATA_SELECTION',
|
||||||
'WAITING_FOR_USER_DECISION',
|
'WAITING_FOR_USER_DECISION',
|
||||||
'READY_TO_START',
|
'READY_TO_START',
|
||||||
@@ -445,7 +446,7 @@ function getQueueActionResult(response) {
|
|||||||
|
|
||||||
function isPipelineMetadataFlowStatus(value) {
|
function isPipelineMetadataFlowStatus(value) {
|
||||||
const normalized = String(value || '').trim().toUpperCase();
|
const normalized = String(value || '').trim().toUpperCase();
|
||||||
return ['METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
|
return ['METADATA_LOOKUP', 'METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSortText(value) {
|
function normalizeSortText(value) {
|
||||||
@@ -944,7 +945,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
} else if (action === 'restart_encode') {
|
} else if (action === 'restart_encode') {
|
||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.restartEncodeWithLastSettings(job.id, options);
|
const response = await api.restartEncodeWithLastSettings(job.id, {
|
||||||
|
...(options || {}),
|
||||||
|
createNewJob: true
|
||||||
|
});
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
const replacementJobId = normalizeJobId(result?.jobId);
|
const replacementJobId = normalizeJobId(result?.jobId);
|
||||||
if (result.queued) {
|
if (result.queued) {
|
||||||
@@ -977,7 +981,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
}
|
}
|
||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.restartReviewFromRaw(job.id, options);
|
const response = await api.restartReviewFromRaw(job.id, {
|
||||||
|
...(options || {}),
|
||||||
|
createNewJob: true
|
||||||
|
});
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
const replacementJobId = normalizeJobId(result?.jobId);
|
const replacementJobId = normalizeJobId(result?.jobId);
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
@@ -1307,7 +1314,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
|
|
||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.retryJob(row.id);
|
const response = await api.retryJob(row.id, { createNewJob: true });
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
const replacementJobId = normalizeJobId(result?.jobId);
|
const replacementJobId = normalizeJobId(result?.jobId);
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
@@ -1435,19 +1442,34 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMetadataSearch = async (query, options = {}) => {
|
const handleMetadataSearch = async (query, options = {}) => {
|
||||||
const workflow = String(
|
|
||||||
options?.workflowKind
|
|
||||||
|| metadataDialogContext?.selectedMetadata?.workflowKind
|
|
||||||
|| ''
|
|
||||||
).trim().toLowerCase();
|
|
||||||
try {
|
try {
|
||||||
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
|
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
|
||||||
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
|
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
|
||||||
?? null;
|
?? null;
|
||||||
const response = workflow === 'series'
|
const filterRaw = String(options?.resultFilter || '').trim().toLowerCase();
|
||||||
? await api.searchTmdbSeries(query, tmdbSeasonHint)
|
const includeMovies = filterRaw !== 'series';
|
||||||
: await api.searchTmdbMovie(query);
|
const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie';
|
||||||
return response.results || [];
|
const [movieResponse, seriesResponse] = await Promise.all([
|
||||||
|
includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }),
|
||||||
|
includeSeries ? api.searchTmdbSeries(query, tmdbSeasonHint) : Promise.resolve({ results: [] })
|
||||||
|
]);
|
||||||
|
const movieRows = Array.isArray(movieResponse?.results)
|
||||||
|
? movieResponse.results.map((row) => ({
|
||||||
|
...row,
|
||||||
|
workflowKind: 'film',
|
||||||
|
metadataKind: 'movie',
|
||||||
|
resultType: 'movie'
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const seriesRows = Array.isArray(seriesResponse?.results)
|
||||||
|
? seriesResponse.results.map((row) => ({
|
||||||
|
...row,
|
||||||
|
workflowKind: 'series',
|
||||||
|
metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series',
|
||||||
|
resultType: 'series'
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
return [...movieRows, ...seriesRows];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
|
|||||||
+519
-227
@@ -24,10 +24,11 @@ import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/sta
|
|||||||
import { confirmModal } from '../utils/confirmModal';
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
import { isConverterJob, isSeriesVideoJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
|
import { isConverterJob, isSeriesVideoJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
|
||||||
|
|
||||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
const processingStates = ['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
||||||
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
|
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
|
||||||
const ripperStatuses = new Set([
|
const ripperStatuses = new Set([
|
||||||
'ANALYZING',
|
'ANALYZING',
|
||||||
|
'METADATA_LOOKUP',
|
||||||
'METADATA_SELECTION',
|
'METADATA_SELECTION',
|
||||||
'WAITING_FOR_USER_DECISION',
|
'WAITING_FOR_USER_DECISION',
|
||||||
'READY_TO_START',
|
'READY_TO_START',
|
||||||
@@ -46,6 +47,7 @@ const ripperStatuses = new Set([
|
|||||||
const hiddenCancelledReviewOrigins = new Set([
|
const hiddenCancelledReviewOrigins = new Set([
|
||||||
'READY_TO_START',
|
'READY_TO_START',
|
||||||
'READY_TO_ENCODE',
|
'READY_TO_ENCODE',
|
||||||
|
'METADATA_LOOKUP',
|
||||||
'METADATA_SELECTION',
|
'METADATA_SELECTION',
|
||||||
'WAITING_FOR_USER_DECISION',
|
'WAITING_FOR_USER_DECISION',
|
||||||
'CD_METADATA_SELECTION',
|
'CD_METADATA_SELECTION',
|
||||||
@@ -238,6 +240,10 @@ function isPlaylistTrackPreparationStatus(value) {
|
|||||||
&& normalized.includes('VORBEREIT');
|
&& normalized.includes('VORBEREIT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMediainfoCheckStatus(value) {
|
||||||
|
return String(value || '').trim().toUpperCase() === 'MEDIAINFO_CHECK';
|
||||||
|
}
|
||||||
|
|
||||||
function formatTemperature(value) {
|
function formatTemperature(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed)) {
|
if (!Number.isFinite(parsed)) {
|
||||||
@@ -265,6 +271,28 @@ function formatBytes(value) {
|
|||||||
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytesCompact(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
return 'n/a';
|
||||||
|
}
|
||||||
|
if (parsed === 0) {
|
||||||
|
return '0B';
|
||||||
|
}
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
let unitIndex = 0;
|
||||||
|
let current = parsed;
|
||||||
|
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
current /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
return `${Math.round(current)}${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsageCompact(usedValue, totalValue) {
|
||||||
|
return `${formatBytesCompact(usedValue)}/${formatBytesCompact(totalValue)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatUpdatedAt(value) {
|
function formatUpdatedAt(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '-';
|
return '-';
|
||||||
@@ -322,6 +350,8 @@ function normalizeRuntimeActivitiesPayload(rawPayload) {
|
|||||||
durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
|
durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
|
||||||
jobId: Number.isFinite(Number(source.jobId)) && Number(source.jobId) > 0 ? Math.trunc(Number(source.jobId)) : null,
|
jobId: Number.isFinite(Number(source.jobId)) && Number(source.jobId) > 0 ? Math.trunc(Number(source.jobId)) : null,
|
||||||
cronJobId: Number.isFinite(Number(source.cronJobId)) && Number(source.cronJobId) > 0 ? Math.trunc(Number(source.cronJobId)) : null,
|
cronJobId: Number.isFinite(Number(source.cronJobId)) && Number(source.cronJobId) > 0 ? Math.trunc(Number(source.cronJobId)) : null,
|
||||||
|
chainId: Number.isFinite(Number(source.chainId)) && Number(source.chainId) > 0 ? Math.trunc(Number(source.chainId)) : null,
|
||||||
|
scriptId: Number.isFinite(Number(source.scriptId)) && Number(source.scriptId) > 0 ? Math.trunc(Number(source.scriptId)) : null,
|
||||||
canCancel: Boolean(source.canCancel),
|
canCancel: Boolean(source.canCancel),
|
||||||
canNextStep: Boolean(source.canNextStep),
|
canNextStep: Boolean(source.canNextStep),
|
||||||
parentActivityId: Number.isFinite(Number(source.parentActivityId)) && Number(source.parentActivityId) > 0
|
parentActivityId: Number.isFinite(Number(source.parentActivityId)) && Number(source.parentActivityId) > 0
|
||||||
@@ -573,6 +603,118 @@ function queueEntryLabel(item) {
|
|||||||
return item.title || `Job #${item.jobId}`;
|
return item.title || `Job #${item.jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGenericChainQueueLabel(value, chainId = null) {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const compact = text.toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
if (/^kette\s*#?\s*\d+$/.test(compact)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const normalizedChainId = Number(chainId);
|
||||||
|
if (Number.isFinite(normalizedChainId) && normalizedChainId > 0) {
|
||||||
|
const idToken = String(Math.trunc(normalizedChainId));
|
||||||
|
if (compact === `kette ${idToken}` || compact === `kette #${idToken}`) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChainQueueSubtitle(item, chainNameById = new Map()) {
|
||||||
|
const chainIdRaw = Number(item?.chainId);
|
||||||
|
const chainId = Number.isFinite(chainIdRaw) && chainIdRaw > 0 ? Math.trunc(chainIdRaw) : null;
|
||||||
|
const explicitChainName = String(item?.chainName || '').trim();
|
||||||
|
if (explicitChainName) {
|
||||||
|
return explicitChainName;
|
||||||
|
}
|
||||||
|
const title = String(item?.title || '').trim();
|
||||||
|
if (title && !isGenericChainQueueLabel(title, chainId)) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
if (chainId && chainNameById instanceof Map) {
|
||||||
|
const mappedName = String(chainNameById.get(chainId) || '').trim();
|
||||||
|
if (mappedName) {
|
||||||
|
return mappedName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return title || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeriesQueueEpisodeText(value) {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const compactMatch = text.match(/\bS\d{1,3}E\d{1,3}(?:-\d{1,3})?\b/i);
|
||||||
|
const spacedMatch = text.match(/\bS\s*(\d{1,3})\s*E\s*(\d{1,3})(?:\s*-\s*(\d{1,3}))?\b/i);
|
||||||
|
const xStyleMatch = text.match(/\b(\d{1,3})\s*[xX]\s*(\d{1,3})(?:\s*-\s*(\d{1,3}))?\b/);
|
||||||
|
const codeMatch = compactMatch || spacedMatch || xStyleMatch;
|
||||||
|
if (!codeMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = '';
|
||||||
|
if (compactMatch) {
|
||||||
|
code = String(compactMatch[0] || '').trim().toUpperCase().replace(/\s+/g, '');
|
||||||
|
} else {
|
||||||
|
const season = Number(codeMatch[1]);
|
||||||
|
const episodeStart = Number(codeMatch[2]);
|
||||||
|
const episodeEnd = Number(codeMatch[3]);
|
||||||
|
const seasonToken = Number.isFinite(season) ? String(Math.trunc(season)).padStart(2, '0') : '??';
|
||||||
|
const episodeStartToken = Number.isFinite(episodeStart) ? String(Math.trunc(episodeStart)).padStart(2, '0') : '??';
|
||||||
|
const episodeEndToken = Number.isFinite(episodeEnd) ? String(Math.trunc(episodeEnd)).padStart(2, '0') : null;
|
||||||
|
code = `S${seasonToken}E${episodeStartToken}${episodeEndToken ? `-${episodeEndToken}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = Number(codeMatch.index || 0);
|
||||||
|
const prefix = text.slice(0, index).trim().replace(/[-|:–—]+\s*$/u, '').trim();
|
||||||
|
const matchedRaw = String(codeMatch[0] || '');
|
||||||
|
const trailing = text.slice(index + matchedRaw.length).trim();
|
||||||
|
const episodeTitle = trailing.replace(/^[-|:–—]+\s*/u, '').trim() || null;
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
prefix: prefix || null,
|
||||||
|
episodeTitle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSeriesQueueDisplay(item) {
|
||||||
|
const titleRaw = String(item?.title || '').trim();
|
||||||
|
const subtitleRaw = String(item?.subtitle || '').trim();
|
||||||
|
const titleParsed = parseSeriesQueueEpisodeText(titleRaw);
|
||||||
|
const subtitleParsed = parseSeriesQueueEpisodeText(subtitleRaw);
|
||||||
|
const parsed = subtitleParsed || titleParsed;
|
||||||
|
if (!parsed) {
|
||||||
|
return {
|
||||||
|
isSeriesEpisode: false,
|
||||||
|
title: titleRaw || `Job #${item?.jobId || '-'}`,
|
||||||
|
subtitle: subtitleRaw || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseTitle = titleRaw;
|
||||||
|
if (titleParsed?.prefix) {
|
||||||
|
baseTitle = titleParsed.prefix;
|
||||||
|
} else if (
|
||||||
|
subtitleParsed?.prefix
|
||||||
|
&& (!baseTitle || baseTitle === subtitleRaw || /\bS\d{1,3}E\d{1,3}/i.test(baseTitle))
|
||||||
|
) {
|
||||||
|
baseTitle = subtitleParsed.prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTitle = String(baseTitle || '').trim() || `Job #${item?.jobId || '-'}`;
|
||||||
|
const episodeSubtitle = parsed.episodeTitle
|
||||||
|
? `${parsed.code} - ${parsed.episodeTitle}`
|
||||||
|
: parsed.code;
|
||||||
|
return {
|
||||||
|
isSeriesEpisode: true,
|
||||||
|
title: resolvedTitle,
|
||||||
|
subtitle: episodeSubtitle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeQueueNameList(values) {
|
function normalizeQueueNameList(values) {
|
||||||
const list = Array.isArray(values) ? values : [];
|
const list = Array.isArray(values) ? values : [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -1426,12 +1568,13 @@ export default function RipperPage({
|
|||||||
const [activationBytesInput, setActivationBytesInput] = useState('');
|
const [activationBytesInput, setActivationBytesInput] = useState('');
|
||||||
const [activationBytesBusy, setActivationBytesBusy] = useState(false);
|
const [activationBytesBusy, setActivationBytesBusy] = useState(false);
|
||||||
const [pendingActivationJobIds, setPendingActivationJobIds] = useState(() => new Set());
|
const [pendingActivationJobIds, setPendingActivationJobIds] = useState(() => new Set());
|
||||||
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
|
||||||
const [knownDrives, setKnownDrives] = useState([]);
|
const [knownDrives, setKnownDrives] = useState([]);
|
||||||
const [driveRescanBusy, setDriveRescanBusy] = useState(() => new Set());
|
const [driveRescanBusy, setDriveRescanBusy] = useState(() => new Set());
|
||||||
const [driveAnalyzeBusy, setDriveAnalyzeBusy] = useState(() => new Set());
|
const [driveAnalyzeBusy, setDriveAnalyzeBusy] = useState(() => new Set());
|
||||||
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
||||||
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
||||||
|
const [quickAccessCatalog, setQuickAccessCatalog] = useState({ scripts: [], chains: [] });
|
||||||
|
const [quickAccessBusyKeys, setQuickAccessBusyKeys] = useState(() => new Set());
|
||||||
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
const dismissedCdMetadataJobIdsRef = useRef(new Set());
|
const dismissedCdMetadataJobIdsRef = useRef(new Set());
|
||||||
@@ -1440,6 +1583,21 @@ export default function RipperPage({
|
|||||||
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
||||||
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
|
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
|
||||||
const isProcessing = processingStates.includes(state);
|
const isProcessing = processingStates.includes(state);
|
||||||
|
const setQuickAccessBusy = (entryKey, isBusy) => {
|
||||||
|
const normalizedKey = String(entryKey || '').trim().toLowerCase();
|
||||||
|
if (!normalizedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuickAccessBusyKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (isBusy) {
|
||||||
|
next.add(normalizedKey);
|
||||||
|
} else {
|
||||||
|
next.delete(normalizedKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
const monitoringState = useMemo(
|
const monitoringState = useMemo(
|
||||||
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
|
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
|
||||||
[hardwareMonitoring]
|
[hardwareMonitoring]
|
||||||
@@ -1448,12 +1606,16 @@ export default function RipperPage({
|
|||||||
() => buildCdDriveLifecycleKey(pipeline?.cdDrives),
|
() => buildCdDriveLifecycleKey(pipeline?.cdDrives),
|
||||||
[pipeline?.cdDrives]
|
[pipeline?.cdDrives]
|
||||||
);
|
);
|
||||||
// Detects when a background job reaches a terminal interactive state via PIPELINE_PROGRESS
|
// Detects when a background job reaches an interactive review/selection state via
|
||||||
// (e.g. READY_TO_ENCODE or WAITING_FOR_USER_DECISION), triggering a ripper jobs reload.
|
// PIPELINE_PROGRESS (e.g. METADATA_LOOKUP, METADATA_SELECTION, READY_TO_ENCODE, WAITING_FOR_USER_DECISION),
|
||||||
|
// triggering a ripper jobs reload.
|
||||||
const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {})
|
const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {})
|
||||||
.filter(([, v]) => {
|
.filter(([, v]) => {
|
||||||
const s = String(v?.state || '').toUpperCase();
|
const s = String(v?.state || '').toUpperCase();
|
||||||
return s === 'READY_TO_ENCODE' || s === 'WAITING_FOR_USER_DECISION';
|
return s === 'METADATA_LOOKUP'
|
||||||
|
|| s === 'METADATA_SELECTION'
|
||||||
|
|| s === 'READY_TO_ENCODE'
|
||||||
|
|| s === 'WAITING_FOR_USER_DECISION';
|
||||||
})
|
})
|
||||||
.map(([id]) => id)
|
.map(([id]) => id)
|
||||||
.sort()
|
.sort()
|
||||||
@@ -1498,8 +1660,10 @@ export default function RipperPage({
|
|||||||
}
|
}
|
||||||
return merged;
|
return merged;
|
||||||
}, [storageMetrics]);
|
}, [storageMetrics]);
|
||||||
const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
|
|
||||||
const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
|
const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
|
||||||
|
const primaryGpuMetrics = gpuDevices[0] || null;
|
||||||
|
const gpuSummaryUsage = primaryGpuMetrics?.utilizationPercent;
|
||||||
|
const gpuSummaryTemp = primaryGpuMetrics?.temperatureC;
|
||||||
const isRipperMainRoute = location.pathname === '/' || location.pathname === '/ripper';
|
const isRipperMainRoute = location.pathname === '/' || location.pathname === '/ripper';
|
||||||
const isSubpageRoute = !isRipperMainRoute;
|
const isSubpageRoute = !isRipperMainRoute;
|
||||||
|
|
||||||
@@ -1633,6 +1797,25 @@ export default function RipperPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadQuickAccessFavorites = async () => {
|
||||||
|
try {
|
||||||
|
const [scriptsRes, chainsRes] = await Promise.allSettled([
|
||||||
|
api.getScripts({ forceRefresh: true }),
|
||||||
|
api.getScriptChains({ forceRefresh: true })
|
||||||
|
]);
|
||||||
|
setQuickAccessCatalog({
|
||||||
|
scripts: scriptsRes.status === 'fulfilled' && Array.isArray(scriptsRes.value?.scripts)
|
||||||
|
? scriptsRes.value.scripts
|
||||||
|
: [],
|
||||||
|
chains: chainsRes.status === 'fulfilled' && Array.isArray(chainsRes.value?.chains)
|
||||||
|
? chainsRes.value.chains
|
||||||
|
: []
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
setQuickAccessCatalog({ scripts: [], chains: [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!metadataDialogVisible) {
|
if (!metadataDialogVisible) {
|
||||||
return;
|
return;
|
||||||
@@ -1645,7 +1828,7 @@ export default function RipperPage({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pipeline?.state !== 'METADATA_SELECTION' && pipeline?.state !== 'WAITING_FOR_USER_DECISION') {
|
if (pipeline?.state !== 'METADATA_LOOKUP' && pipeline?.state !== 'METADATA_SELECTION' && pipeline?.state !== 'WAITING_FOR_USER_DECISION') {
|
||||||
setMetadataDialogVisible(false);
|
setMetadataDialogVisible(false);
|
||||||
}
|
}
|
||||||
}, [pipeline?.state, metadataDialogVisible, metadataDialogContext, cdMetadataDialogVisible]);
|
}, [pipeline?.state, metadataDialogVisible, metadataDialogContext, cdMetadataDialogVisible]);
|
||||||
@@ -1738,6 +1921,10 @@ export default function RipperPage({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadQuickAccessFavorites();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = async (silent = false) => {
|
const load = async (silent = false) => {
|
||||||
@@ -1769,11 +1956,15 @@ export default function RipperPage({
|
|||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
onMessage: (message) => {
|
onMessage: (message) => {
|
||||||
if (message?.type !== 'RUNTIME_ACTIVITY_CHANGED') {
|
if (message?.type === 'RUNTIME_ACTIVITY_CHANGED') {
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRuntimeActivities(normalizeRuntimeActivitiesPayload(message.payload));
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(message.payload));
|
||||||
setRuntimeLoading(false);
|
setRuntimeLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message?.type === 'SETTINGS_SCRIPTS_UPDATED' || message?.type === 'SETTINGS_SCRIPT_CHAINS_UPDATED') {
|
||||||
|
void loadQuickAccessFavorites();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1934,7 +2125,7 @@ export default function RipperPage({
|
|||||||
: null;
|
: null;
|
||||||
const currentContextJobId = normalizeJobId(currentContext?.jobId);
|
const currentContextJobId = normalizeJobId(currentContext?.jobId);
|
||||||
if (
|
if (
|
||||||
(currentState === 'METADATA_SELECTION' || currentState === 'WAITING_FOR_USER_DECISION')
|
(currentState === 'METADATA_LOOKUP' || currentState === 'METADATA_SELECTION' || currentState === 'WAITING_FOR_USER_DECISION')
|
||||||
&& currentContextJobId
|
&& currentContextJobId
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
@@ -1958,7 +2149,7 @@ export default function RipperPage({
|
|||||||
|
|
||||||
const pendingJob = ripperJobs.find((job) => {
|
const pendingJob = ripperJobs.find((job) => {
|
||||||
const normalized = normalizeStatus(job?.status);
|
const normalized = normalizeStatus(job?.status);
|
||||||
return normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION';
|
return normalized === 'METADATA_LOOKUP' || normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION';
|
||||||
});
|
});
|
||||||
if (!pendingJob) {
|
if (!pendingJob) {
|
||||||
return null;
|
return null;
|
||||||
@@ -2199,7 +2390,7 @@ export default function RipperPage({
|
|||||||
imdbId: null,
|
imdbId: null,
|
||||||
poster: null,
|
poster: null,
|
||||||
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
||||||
seasonNumber: response?.result?.seriesLookupHint?.seasonNumber || null
|
seasonNumber: null
|
||||||
},
|
},
|
||||||
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
||||||
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
|
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
|
||||||
@@ -2257,7 +2448,7 @@ export default function RipperPage({
|
|||||||
imdbId: null,
|
imdbId: null,
|
||||||
poster: null,
|
poster: null,
|
||||||
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
||||||
seasonNumber: response?.result?.seriesLookupHint?.seasonNumber || null
|
seasonNumber: null
|
||||||
},
|
},
|
||||||
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
||||||
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
|
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
|
||||||
@@ -3093,11 +3284,30 @@ export default function RipperPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMetadataSearch = async (query, options = {}) => {
|
const handleMetadataSearch = async (query, options = {}) => {
|
||||||
const workflow = String(options?.workflowKind || '').trim().toLowerCase();
|
const filterRaw = String(options?.resultFilter || '').trim().toLowerCase();
|
||||||
const response = workflow === 'series'
|
const includeMovies = filterRaw !== 'series';
|
||||||
? await api.searchTmdbSeries(query)
|
const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie';
|
||||||
: await api.searchTmdbMovie(query);
|
const [movieResponse, seriesResponse] = await Promise.all([
|
||||||
return response.results || [];
|
includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }),
|
||||||
|
includeSeries ? api.searchTmdbSeries(query) : Promise.resolve({ results: [] })
|
||||||
|
]);
|
||||||
|
const movieRows = Array.isArray(movieResponse?.results)
|
||||||
|
? movieResponse.results.map((row) => ({
|
||||||
|
...row,
|
||||||
|
workflowKind: 'film',
|
||||||
|
metadataKind: 'movie',
|
||||||
|
resultType: 'movie'
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const seriesRows = Array.isArray(seriesResponse?.results)
|
||||||
|
? seriesResponse.results.map((row) => ({
|
||||||
|
...row,
|
||||||
|
workflowKind: 'series',
|
||||||
|
metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series',
|
||||||
|
resultType: 'series'
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
return [...movieRows, ...seriesRows];
|
||||||
};
|
};
|
||||||
|
|
||||||
const doSelectMetadata = async (payload, options = {}) => {
|
const doSelectMetadata = async (payload, options = {}) => {
|
||||||
@@ -3336,6 +3546,19 @@ export default function RipperPage({
|
|||||||
const queueRunningJobsRaw = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
const queueRunningJobsRaw = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
||||||
const queueIdleJobsRaw = Array.isArray(queueState?.idleJobs) ? queueState.idleJobs : [];
|
const queueIdleJobsRaw = Array.isArray(queueState?.idleJobs) ? queueState.idleJobs : [];
|
||||||
const queuedJobsRaw = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
const queuedJobsRaw = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
||||||
|
const queueChainNameById = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
const chains = Array.isArray(queueCatalog?.chains) ? queueCatalog.chains : [];
|
||||||
|
for (const chain of chains) {
|
||||||
|
const chainId = Number(chain?.id);
|
||||||
|
const chainName = String(chain?.name || '').trim();
|
||||||
|
if (!Number.isFinite(chainId) || chainId <= 0 || !chainName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(Math.trunc(chainId), chainName);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [queueCatalog?.chains]);
|
||||||
const isVisibleQueueItem = (_item) => true;
|
const isVisibleQueueItem = (_item) => true;
|
||||||
const queueRunningJobs = queueRunningJobsRaw.filter((item) => isVisibleQueueItem(item));
|
const queueRunningJobs = queueRunningJobsRaw.filter((item) => isVisibleQueueItem(item));
|
||||||
const queueRunningJobsDisplay = queueRunningJobs;
|
const queueRunningJobsDisplay = queueRunningJobs;
|
||||||
@@ -3497,7 +3720,110 @@ export default function RipperPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const quickAccessItems = useMemo(() => {
|
||||||
|
const scripts = Array.isArray(quickAccessCatalog?.scripts) ? quickAccessCatalog.scripts : [];
|
||||||
|
const chains = Array.isArray(quickAccessCatalog?.chains) ? quickAccessCatalog.chains : [];
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
for (const script of scripts) {
|
||||||
|
const scriptId = Number(script?.id);
|
||||||
|
if (!Number.isFinite(scriptId) || scriptId <= 0 || script?.isFavorite !== true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
type: 'script',
|
||||||
|
id: Math.trunc(scriptId),
|
||||||
|
name: String(script?.name || '').trim() || `Skript #${scriptId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chain of chains) {
|
||||||
|
const chainId = Number(chain?.id);
|
||||||
|
if (!Number.isFinite(chainId) || chainId <= 0 || chain?.isFavorite !== true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
type: 'chain',
|
||||||
|
id: Math.trunc(chainId),
|
||||||
|
name: String(chain?.name || '').trim() || `Kette #${chainId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.sort((left, right) => {
|
||||||
|
if (left.id !== right.id) {
|
||||||
|
return left.id - right.id;
|
||||||
|
}
|
||||||
|
if (left.type === right.type) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return left.type === 'script' ? -1 : 1;
|
||||||
|
});
|
||||||
|
}, [quickAccessCatalog]);
|
||||||
|
|
||||||
|
const handleRunQuickAccessItem = async (item) => {
|
||||||
|
const type = String(item?.type || '').trim().toLowerCase();
|
||||||
|
const id = Number(item?.id);
|
||||||
|
if (!Number.isFinite(id) || id <= 0 || (type !== 'script' && type !== 'chain')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedId = Math.trunc(id);
|
||||||
|
const entryKey = `${type}:${normalizedId}`;
|
||||||
|
if (quickAccessRunningKeys.has(entryKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuickAccessBusy(entryKey, true);
|
||||||
|
try {
|
||||||
|
if (type === 'script') {
|
||||||
|
const response = await api.testScript(normalizedId);
|
||||||
|
const result = response?.result || null;
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: result?.success ? 'success' : 'warn',
|
||||||
|
summary: 'Favoriten',
|
||||||
|
detail: result?.success
|
||||||
|
? `Skript "${item?.name || normalizedId}" gestartet.`
|
||||||
|
: `Skript "${item?.name || normalizedId}" mit Fehler beendet (exit=${result?.exitCode ?? 'n/a'}).`,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const response = await api.testScriptChain(normalizedId);
|
||||||
|
const result = response?.result || null;
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: result?.aborted ? 'warn' : 'success',
|
||||||
|
summary: 'Favoriten',
|
||||||
|
detail: result?.aborted
|
||||||
|
? `Kette "${item?.name || normalizedId}" mit Abbruch beendet.`
|
||||||
|
: `Kette "${item?.name || normalizedId}" gestartet.`,
|
||||||
|
life: 3200
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const activities = await api.getRuntimeActivities();
|
||||||
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(activities));
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setQuickAccessBusy(entryKey, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const runtimeActiveItems = Array.isArray(runtimeActivities?.active) ? runtimeActivities.active : [];
|
const runtimeActiveItems = Array.isArray(runtimeActivities?.active) ? runtimeActivities.active : [];
|
||||||
|
const quickAccessRunningKeys = useMemo(() => {
|
||||||
|
const keys = new Set();
|
||||||
|
for (const activity of runtimeActiveItems) {
|
||||||
|
const type = String(activity?.type || '').trim().toLowerCase();
|
||||||
|
if (type === 'script') {
|
||||||
|
const scriptId = Number(activity?.scriptId);
|
||||||
|
if (Number.isFinite(scriptId) && scriptId > 0) {
|
||||||
|
keys.add(`script:${Math.trunc(scriptId)}`);
|
||||||
|
}
|
||||||
|
} else if (type === 'chain') {
|
||||||
|
const chainId = Number(activity?.chainId);
|
||||||
|
if (Number.isFinite(chainId) && chainId > 0) {
|
||||||
|
keys.add(`chain:${Math.trunc(chainId)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}, [runtimeActiveItems]);
|
||||||
const runtimeRecentItems = Array.isArray(runtimeActivities?.recent)
|
const runtimeRecentItems = Array.isArray(runtimeActivities?.recent)
|
||||||
? runtimeActivities.recent.slice(0, 8)
|
? runtimeActivities.recent.slice(0, 8)
|
||||||
: [];
|
: [];
|
||||||
@@ -3721,9 +4047,51 @@ export default function RipperPage({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Freier Speicher">
|
{quickAccessItems.length > 0 ? (
|
||||||
|
<Card title="Favoriten">
|
||||||
|
<div className="quick-access-list">
|
||||||
|
{quickAccessItems.map((item) => {
|
||||||
|
const entryKey = `${item.type}:${item.id}`;
|
||||||
|
const isBusy = quickAccessBusyKeys.has(entryKey);
|
||||||
|
const isRunning = quickAccessRunningKeys.has(entryKey);
|
||||||
|
return (
|
||||||
|
<div key={entryKey} className="quick-access-item">
|
||||||
|
<div className="quick-access-item-main">
|
||||||
|
<strong className="quick-access-item-title">
|
||||||
|
<i
|
||||||
|
className={`pi ${item.type === 'script' ? 'pi-code' : 'pi-link'} quick-access-item-title-icon`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-play"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
className="quick-access-run-btn"
|
||||||
|
loading={isBusy || isRunning}
|
||||||
|
disabled={isBusy || isRunning}
|
||||||
|
onClick={() => handleRunQuickAccessItem(item)}
|
||||||
|
title="Starten"
|
||||||
|
aria-label={`${item.type === 'script' ? 'Skript' : 'Kette'} starten`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card title="Hardware">
|
||||||
|
<div className="hardware-panel-section">
|
||||||
|
<h4>Freier Speicher</h4>
|
||||||
<div className="hardware-storage-list">
|
<div className="hardware-storage-list">
|
||||||
{storageGroups.map((group) => {
|
{storageGroups.length === 0 ? (
|
||||||
|
<small>Keine Speicherinformationen verfuegbar.</small>
|
||||||
|
) : storageGroups.map((group) => {
|
||||||
const rep = group.representative;
|
const rep = group.representative;
|
||||||
const tone = getStorageUsageTone(rep?.usagePercent);
|
const tone = getStorageUsageTone(rep?.usagePercent);
|
||||||
const usagePercent = Number(rep?.usagePercent);
|
const usagePercent = Number(rep?.usagePercent);
|
||||||
@@ -3761,6 +4129,81 @@ export default function RipperPage({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{monitoringState.enabled ? (
|
||||||
|
<>
|
||||||
|
<div className="hardware-panel-divider" />
|
||||||
|
<div className="hardware-panel-section">
|
||||||
|
<div className="hardware-panel-section-head">
|
||||||
|
<h4>Hardware-Monitor</h4>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
icon="pi pi-link"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
className="hardware-panel-link-btn"
|
||||||
|
title="Zur Hardware-Seite"
|
||||||
|
aria-label="Zur Hardware-Seite"
|
||||||
|
onClick={() => navigate('/hardware')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!monitoringSample ? (
|
||||||
|
<small>Warte auf erste Messung ...</small>
|
||||||
|
) : (
|
||||||
|
<div className="hardware-mini-monitor-list">
|
||||||
|
<div className="hardware-mini-monitor-item">
|
||||||
|
<strong>CPU</strong>
|
||||||
|
<small className="hardware-mini-monitor-line">
|
||||||
|
<span className="hardware-mini-monitor-metric" title="Gesamtauslastung">
|
||||||
|
<i className="pi pi-chart-line" />
|
||||||
|
<span>{formatPercent(cpuMetrics?.overallUsagePercent)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="hardware-mini-monitor-sep">|</span>
|
||||||
|
<span className="hardware-mini-monitor-metric" title="Temperatur">
|
||||||
|
<i className="pi pi-bolt" />
|
||||||
|
<span>{formatTemperature(cpuMetrics?.overallTemperatureC)}</span>
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-mini-monitor-item">
|
||||||
|
<strong>RAM</strong>
|
||||||
|
<small className="hardware-mini-monitor-line">
|
||||||
|
<span className="hardware-mini-monitor-metric" title="Gesamtauslastung">
|
||||||
|
<i className="pi pi-chart-line" />
|
||||||
|
<span>{formatPercent(memoryMetrics?.usagePercent)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="hardware-mini-monitor-sep">|</span>
|
||||||
|
<span className="hardware-mini-monitor-metric" title="Nutzung">
|
||||||
|
<i className="pi pi-database" />
|
||||||
|
<span>{formatUsageCompact(memoryMetrics?.usedBytes, memoryMetrics?.totalBytes)}</span>
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-mini-monitor-item">
|
||||||
|
<strong>GPU</strong>
|
||||||
|
<small className="hardware-mini-monitor-line">
|
||||||
|
<span className="hardware-mini-monitor-metric" title="Gesamtauslastung">
|
||||||
|
<i className="pi pi-chart-line" />
|
||||||
|
<span>{formatPercent(gpuSummaryUsage)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="hardware-mini-monitor-sep">|</span>
|
||||||
|
<span className="hardware-mini-monitor-metric" title="Temperatur">
|
||||||
|
<i className="pi pi-bolt" />
|
||||||
|
<span>{formatTemperature(gpuSummaryTemp)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="hardware-mini-monitor-sep">|</span>
|
||||||
|
<span className="hardware-mini-monitor-metric" title="VRAM Nutzung">
|
||||||
|
<i className="pi pi-database" />
|
||||||
|
<span>{formatUsageCompact(primaryGpuMetrics?.memoryUsedBytes, primaryGpuMetrics?.memoryTotalBytes)}</span>
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3914,8 +4357,15 @@ export default function RipperPage({
|
|||||||
? Math.max(0, Math.min(100, rawProgress))
|
? Math.max(0, Math.min(100, rawProgress))
|
||||||
: 0;
|
: 0;
|
||||||
const progressLabel = `${Math.trunc(clampedProgress)}%`;
|
const progressLabel = `${Math.trunc(clampedProgress)}%`;
|
||||||
const showIndeterminateProgress = !hasSeriesBatchProgress
|
const showMediainfoIndeterminateProgress = !hasSeriesBatchProgress
|
||||||
&& isPlaylistTrackPreparationStatus(pipelineStatusTextRaw);
|
&& isMediainfoCheckStatus(normalizedStatus);
|
||||||
|
const showIndeterminateProgress = showMediainfoIndeterminateProgress || (
|
||||||
|
!hasSeriesBatchProgress
|
||||||
|
&& isPlaylistTrackPreparationStatus(pipelineStatusTextRaw)
|
||||||
|
);
|
||||||
|
const indeterminateProgressLabel = showMediainfoIndeterminateProgress
|
||||||
|
? 'Mediainfo-Prüfung läuft …'
|
||||||
|
: 'Vorbereitung läuft …';
|
||||||
const etaLabel = isCancelledState ? '' : String(
|
const etaLabel = isCancelledState ? '' : String(
|
||||||
hasSeriesBatchProgress
|
hasSeriesBatchProgress
|
||||||
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
|
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
|
||||||
@@ -4413,14 +4863,14 @@ export default function RipperPage({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="ripper-job-row-progress"
|
className="ripper-job-row-progress"
|
||||||
aria-label={showIndeterminateProgress ? 'Job Fortschritt Vorbereitung läuft' : `Job Fortschritt ${progressLabel}`}
|
aria-label={showIndeterminateProgress ? 'Job Fortschritt läuft' : `Job Fortschritt ${progressLabel}`}
|
||||||
>
|
>
|
||||||
{showIndeterminateProgress ? (
|
{showIndeterminateProgress ? (
|
||||||
<ProgressBar mode="indeterminate" showValue={false} />
|
<ProgressBar mode="indeterminate" showValue={false} />
|
||||||
) : (
|
) : (
|
||||||
<ProgressBar value={clampedProgress} showValue={false} />
|
<ProgressBar value={clampedProgress} showValue={false} />
|
||||||
)}
|
)}
|
||||||
<small>{showIndeterminateProgress ? 'Vorbereitung läuft …' : (etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel)}</small>
|
<small>{showIndeterminateProgress ? indeterminateProgressLabel : (etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel)}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<i className="pi pi-angle-down" aria-hidden="true" />
|
<i className="pi pi-angle-down" aria-hidden="true" />
|
||||||
@@ -4453,84 +4903,35 @@ export default function RipperPage({
|
|||||||
<small>Keine laufenden Jobs.</small>
|
<small>Keine laufenden Jobs.</small>
|
||||||
) : (
|
) : (
|
||||||
queueRunningJobsDisplay.map((item) => {
|
queueRunningJobsDisplay.map((item) => {
|
||||||
const isVirtualUpload = Boolean(item?.virtualUpload);
|
const runningJobId = normalizeJobId(item?.jobId);
|
||||||
const hasScriptSummary = !isVirtualUpload && hasQueueScriptSummary(item);
|
const runningQueueDisplay = resolveSeriesQueueDisplay(item);
|
||||||
const detailKey = buildRunningQueueScriptKey(item?.jobId);
|
const runningTitleBase = runningQueueDisplay.title || item?.title || `Job #${item?.jobId || '-'}`;
|
||||||
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
const runningStatusLabel = getMergeAwareStatusLabel(item, item.status);
|
||||||
const jobProgressEntry = !isVirtualUpload && item?.jobId != null
|
const runningMetaLine = `#${runningJobId ?? '-'} | ${runningStatusLabel}`;
|
||||||
? (pipeline?.jobProgress?.[String(item.jobId)] || null)
|
const liveQueueProgressRaw = Number(
|
||||||
: null;
|
item?.progress
|
||||||
const seriesBatchContext = jobProgressEntry?.context?.seriesBatch && typeof jobProgressEntry.context.seriesBatch === 'object'
|
?? pipeline?.jobProgress?.[runningJobId]?.progress
|
||||||
? jobProgressEntry.context.seriesBatch
|
|
||||||
: null;
|
|
||||||
const seriesBatchChildren = Array.isArray(seriesBatchContext?.children) ? seriesBatchContext.children : [];
|
|
||||||
const seriesBatchTotalCountRaw = Number(seriesBatchContext?.totalCount || 0);
|
|
||||||
const seriesBatchTotalCount = Number.isFinite(seriesBatchTotalCountRaw) && seriesBatchTotalCountRaw > 0
|
|
||||||
? Math.trunc(seriesBatchTotalCountRaw)
|
|
||||||
: seriesBatchChildren.length;
|
|
||||||
const seriesBatchOverallProgress = seriesBatchTotalCount > 0
|
|
||||||
? (
|
|
||||||
seriesBatchChildren.reduce((sum, child) => {
|
|
||||||
const status = String(child?.status || '').trim().toUpperCase();
|
|
||||||
if (status === 'FINISHED') {
|
|
||||||
return sum + 100;
|
|
||||||
}
|
|
||||||
const childProgress = Number(child?.progress || 0);
|
|
||||||
return sum + (Number.isFinite(childProgress) ? Math.max(0, Math.min(100, childProgress)) : 0);
|
|
||||||
}, 0) / seriesBatchTotalCount
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const runningSeriesChild = seriesBatchChildren.find(
|
|
||||||
(child) => String(child?.status || '').trim().toUpperCase() === 'RUNNING'
|
|
||||||
) || null;
|
|
||||||
const runningChildProgress = Number(runningSeriesChild?.progress ?? NaN);
|
|
||||||
const rawQueueProgress = Number(
|
|
||||||
isVirtualUpload
|
|
||||||
? Number(item?.virtualUploadProgress || 0)
|
|
||||||
: Number.isFinite(runningChildProgress)
|
|
||||||
? runningChildProgress
|
|
||||||
: (jobProgressEntry?.progress ?? 0)
|
|
||||||
);
|
);
|
||||||
const clampedQueueProgress = Number.isFinite(rawQueueProgress) ? Math.max(0, Math.min(100, rawQueueProgress)) : 0;
|
const hasLiveQueueProgress = Number.isFinite(liveQueueProgressRaw);
|
||||||
const normalizedQueueStatus = String(item?.status || jobProgressEntry?.state || '').trim().toUpperCase();
|
const clampedQueueProgress = hasLiveQueueProgress
|
||||||
const isCdRunningQueueJob = normalizedQueueStatus.startsWith('CD_');
|
? Math.max(0, Math.min(100, liveQueueProgressRaw))
|
||||||
const queueStatusText = isVirtualUpload
|
: 0;
|
||||||
? String(item?.statusText || '').trim()
|
const showQueueIndeterminateProgress = isMediainfoCheckStatus(item?.status) || !hasLiveQueueProgress;
|
||||||
: String(jobProgressEntry?.statusText || '').trim();
|
|
||||||
const showIndeterminateQueueProgress = isPlaylistTrackPreparationStatus(queueStatusText);
|
|
||||||
const showQueueProgressBar = isVirtualUpload || showIndeterminateQueueProgress || clampedQueueProgress > 0 || isCdRunningQueueJob;
|
|
||||||
return (
|
return (
|
||||||
<div key={String(item?.entryKey || `running-${item.jobId}`)} className="pipeline-queue-entry-wrap">
|
<div key={String(item?.entryKey || `running-${item.jobId}`)} className="pipeline-queue-entry-wrap">
|
||||||
<div className="pipeline-queue-item running queue-job-item">
|
<div className="pipeline-queue-item running queue-job-item">
|
||||||
<div className="pipeline-queue-item-main">
|
<div className="pipeline-queue-item-main">
|
||||||
<strong>
|
<strong>{runningTitleBase}</strong>
|
||||||
{item.title || `Job #${item.jobId}`}
|
<small>{runningMetaLine}</small>
|
||||||
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
<div className="queue-running-progress" aria-label={`Fortschritt Job #${runningJobId ?? '-'}`}>
|
||||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
{showQueueIndeterminateProgress ? (
|
||||||
</strong>
|
<ProgressBar mode="indeterminate" showValue={false} />
|
||||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
) : (
|
||||||
{!isVirtualUpload ? <small>#{item.jobId || '-'} | {getMergeAwareStatusLabel(item, item.status)}</small> : null}
|
<ProgressBar value={clampedQueueProgress} showValue={false} />
|
||||||
{showQueueProgressBar ? (
|
)}
|
||||||
showIndeterminateQueueProgress
|
</div>
|
||||||
? <ProgressBar mode="indeterminate" style={{ height: '5px' }} showValue={false} />
|
|
||||||
: <ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="pipeline-queue-item-actions">
|
|
||||||
{hasScriptSummary ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="queue-job-expand-btn"
|
|
||||||
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
|
|
||||||
aria-expanded={detailsExpanded}
|
|
||||||
onClick={() => toggleQueueScriptDetails(detailKey)}
|
|
||||||
>
|
|
||||||
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -4545,15 +4946,18 @@ export default function RipperPage({
|
|||||||
const hasScriptSummary = hasQueueScriptSummary(item);
|
const hasScriptSummary = hasQueueScriptSummary(item);
|
||||||
const detailKey = buildIdleQueueScriptKey(item?.jobId);
|
const detailKey = buildIdleQueueScriptKey(item?.jobId);
|
||||||
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
||||||
|
const idleQueueDisplay = resolveSeriesQueueDisplay(item);
|
||||||
|
const idleTitleBase = idleQueueDisplay.title || `Job #${item?.jobId || '-'}`;
|
||||||
return (
|
return (
|
||||||
<div key={`idle-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
<div key={`idle-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
||||||
<div className="pipeline-queue-item idle queue-job-item">
|
<div className="pipeline-queue-item idle queue-job-item">
|
||||||
<div className="pipeline-queue-item-main">
|
<div className="pipeline-queue-item-main">
|
||||||
<strong>
|
<strong>
|
||||||
#{item.jobId} | {item.title || `Job #${item.jobId}`}
|
{`${idleTitleBase} | #${item.jobId || '-'}`}
|
||||||
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
||||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
</strong>
|
</strong>
|
||||||
|
{idleQueueDisplay.subtitle ? <small>{idleQueueDisplay.subtitle}</small> : null}
|
||||||
<small>{getMergeAwareStatusLabel(item, item.status)}</small>
|
<small>{getMergeAwareStatusLabel(item, item.status)}</small>
|
||||||
</div>
|
</div>
|
||||||
{hasScriptSummary ? (
|
{hasScriptSummary ? (
|
||||||
@@ -4594,10 +4998,18 @@ export default function RipperPage({
|
|||||||
{queuedJobs.map((item) => {
|
{queuedJobs.map((item) => {
|
||||||
const entryId = Number(item?.entryId);
|
const entryId = Number(item?.entryId);
|
||||||
const isNonJob = item.type && item.type !== 'job';
|
const isNonJob = item.type && item.type !== 'job';
|
||||||
|
const isScriptOrChainNonJob = isNonJob && (item.type === 'script' || item.type === 'chain');
|
||||||
const isDragging = Number(draggingQueueEntryId) === entryId;
|
const isDragging = Number(draggingQueueEntryId) === entryId;
|
||||||
const hasScriptSummary = !isNonJob && hasQueueScriptSummary(item);
|
const hasScriptSummary = !isNonJob && hasQueueScriptSummary(item);
|
||||||
|
const queuedQueueDisplay = !isNonJob ? resolveSeriesQueueDisplay(item) : null;
|
||||||
const detailKey = buildQueuedQueueScriptKey(entryId);
|
const detailKey = buildQueuedQueueScriptKey(entryId);
|
||||||
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
||||||
|
const chainSubtitle = item.type === 'chain'
|
||||||
|
? resolveChainQueueSubtitle(item, queueChainNameById)
|
||||||
|
: null;
|
||||||
|
const scriptOrChainTitle = item.type === 'chain'
|
||||||
|
? (chainSubtitle || `Kette #${item.chainId || '-'}`)
|
||||||
|
: (item.title || `Skript #${item.scriptId || '-'}`);
|
||||||
return (
|
return (
|
||||||
<div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
|
<div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
|
||||||
<div
|
<div
|
||||||
@@ -4618,26 +5030,28 @@ export default function RipperPage({
|
|||||||
<span className={`pipeline-queue-drag-handle${canReorderQueue ? '' : ' disabled'}`} title="Reihenfolge ändern">
|
<span className={`pipeline-queue-drag-handle${canReorderQueue ? '' : ' disabled'}`} title="Reihenfolge ändern">
|
||||||
<i className="pi pi-bars" />
|
<i className="pi pi-bars" />
|
||||||
</span>
|
</span>
|
||||||
|
{!isScriptOrChainNonJob ? (
|
||||||
<i className={`pipeline-queue-type-icon ${queueEntryIcon(item.type)}`} title={item.type || 'job'} />
|
<i className={`pipeline-queue-type-icon ${queueEntryIcon(item.type)}`} title={item.type || 'job'} />
|
||||||
|
) : null}
|
||||||
<div className="pipeline-queue-item-main">
|
<div className="pipeline-queue-item-main">
|
||||||
{isNonJob ? (
|
{isNonJob ? (
|
||||||
item.type === 'chain' ? (
|
isScriptOrChainNonJob ? (
|
||||||
<>
|
<small className="queue-nonjob-subtitle">
|
||||||
<strong>Kette {item.position || '-'}</strong>
|
<i className={queueEntryIcon(item.type)} aria-hidden="true" />
|
||||||
<small>{item.title || `Kette #${item.chainId || '-'}`}</small>
|
<span>{scriptOrChainTitle}</span>
|
||||||
</>
|
</small>
|
||||||
) : (
|
) : (
|
||||||
<strong>{item.position || '-'}. {queueEntryLabel(item)}</strong>
|
<strong>{item.position || '-'}. {queueEntryLabel(item)}</strong>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<strong>
|
<strong>
|
||||||
{item.title || `Job #${item.jobId}`}
|
{`${queuedQueueDisplay?.title || item.title || `Job #${item.jobId}`} | #${item.jobId || '-'}`}
|
||||||
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
||||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
</strong>
|
</strong>
|
||||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
{queuedQueueDisplay?.subtitle ? <small>{queuedQueueDisplay.subtitle}</small> : null}
|
||||||
<small>{item.position || '-'} | #{item.jobId} | {item.actionLabel || item.action || '-'} | {getMergeAwareStatusLabel(item, item.status)}</small>
|
<small>{queuedQueueDisplay?.isSeriesEpisode ? `${item.position || '-'} | ${item.actionLabel || item.action || '-'} | ${getMergeAwareStatusLabel(item, item.status)}` : `${item.position || '-'} | #${item.jobId} | ${item.actionLabel || item.action || '-'} | ${getMergeAwareStatusLabel(item, item.status)}`}</small>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -4877,131 +5291,9 @@ export default function RipperPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isSubpageRoute && monitoringState.enabled ? (
|
|
||||||
<Card title="Hardware Monitoring">
|
|
||||||
<div className="hardware-monitor-head">
|
|
||||||
{!monitoringState.enabled ? (
|
|
||||||
<small>Hardware-Monitoring ist deaktiviert.</small>
|
|
||||||
) : !monitoringSample ? (
|
|
||||||
<small>Warte auf erste Messung ...</small>
|
|
||||||
) : (
|
|
||||||
<div className="hardware-monitor-grid">
|
|
||||||
<section className="hardware-monitor-block">
|
|
||||||
<h4>CPU</h4>
|
|
||||||
<div className="hardware-cpu-summary">
|
|
||||||
<div className="hardware-cpu-chip" title="CPU Gesamtauslastung">
|
|
||||||
<i className="pi pi-chart-line" />
|
|
||||||
<span>{formatPercent(cpuMetrics?.overallUsagePercent)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hardware-cpu-chip" title="CPU Gesamttemperatur">
|
|
||||||
<i className="pi pi-bolt" />
|
|
||||||
<span>{formatTemperature(cpuMetrics?.overallTemperatureC)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hardware-cpu-load-group">
|
|
||||||
<div className="hardware-cpu-chip" title="CPU Load Average">
|
|
||||||
<i className="pi pi-chart-bar" />
|
|
||||||
<span>{Array.isArray(cpuMetrics?.loadAverage) ? cpuMetrics.loadAverage.join(' / ') : '-'}</span>
|
|
||||||
</div>
|
|
||||||
{cpuPerCoreMetrics.length > 0 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hardware-cpu-core-toggle-btn"
|
|
||||||
onClick={() => setCpuCoresExpanded((prev) => !prev)}
|
|
||||||
aria-label={cpuCoresExpanded ? 'CPU-Kerne ausblenden' : 'CPU-Kerne einblenden'}
|
|
||||||
aria-expanded={cpuCoresExpanded}
|
|
||||||
>
|
|
||||||
<i className={`pi ${cpuCoresExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{cpuPerCoreMetrics.length === 0 ? (
|
|
||||||
<small>Pro-Core-Daten sind noch nicht verfuegbar.</small>
|
|
||||||
) : null}
|
|
||||||
{cpuPerCoreMetrics.length > 0 && cpuCoresExpanded ? (
|
|
||||||
<div className="hardware-core-grid compact">
|
|
||||||
{cpuPerCoreMetrics.map((core) => (
|
|
||||||
<div key={`core-${core.index}`} className="hardware-core-item compact">
|
|
||||||
<div className="hardware-core-title">C{core.index}</div>
|
|
||||||
<div className="hardware-core-metric" title="Auslastung">
|
|
||||||
<i className="pi pi-chart-line" />
|
|
||||||
<small>{formatPercent(core.usagePercent)}</small>
|
|
||||||
</div>
|
|
||||||
<div className="hardware-core-metric" title="Temperatur">
|
|
||||||
<i className="pi pi-bolt" />
|
|
||||||
<small>{formatTemperature(core.temperatureC)}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="hardware-monitor-block">
|
|
||||||
<h4>RAM</h4>
|
|
||||||
<div className="hardware-cpu-summary">
|
|
||||||
<div className="hardware-cpu-chip" title="RAM Auslastung">
|
|
||||||
<i className="pi pi-chart-pie" />
|
|
||||||
<span>{formatPercent(memoryMetrics?.usagePercent)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hardware-cpu-chip" title="RAM Belegt">
|
|
||||||
<i className="pi pi-arrow-up" />
|
|
||||||
<span>{formatBytes(memoryMetrics?.usedBytes)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hardware-cpu-chip" title="RAM Frei">
|
|
||||||
<i className="pi pi-arrow-down" />
|
|
||||||
<span>{formatBytes(memoryMetrics?.freeBytes)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hardware-cpu-chip" title="RAM Gesamt">
|
|
||||||
<i className="pi pi-database" />
|
|
||||||
<span>{formatBytes(memoryMetrics?.totalBytes)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="hardware-monitor-block">
|
|
||||||
<h4>GPU</h4>
|
|
||||||
{!gpuMetrics?.available ? (
|
|
||||||
<small>{gpuMetrics?.message || 'Keine GPU-Metriken verfuegbar.'}</small>
|
|
||||||
) : (
|
|
||||||
<div className="hardware-gpu-list">
|
|
||||||
{gpuDevices.map((gpu, index) => (
|
|
||||||
<div key={`gpu-${gpu?.index ?? index}`} className="hardware-gpu-item">
|
|
||||||
<strong>
|
|
||||||
GPU {gpu?.index ?? index}
|
|
||||||
{gpu?.name ? ` | ${gpu.name}` : ''}
|
|
||||||
</strong>
|
|
||||||
<span className="hardware-gpu-chip" title="GPU Load">
|
|
||||||
<i className="pi pi-chart-line" />{formatPercent(gpu?.utilizationPercent)}
|
|
||||||
</span>
|
|
||||||
<span className="hardware-gpu-chip" title="Speicher-Load">
|
|
||||||
<i className="pi pi-chart-pie" />{formatPercent(gpu?.memoryUtilizationPercent)}
|
|
||||||
</span>
|
|
||||||
<span className="hardware-gpu-chip" title="Temperatur">
|
|
||||||
<i className="pi pi-bolt" />{formatTemperature(gpu?.temperatureC)}
|
|
||||||
</span>
|
|
||||||
<span className="hardware-gpu-chip" title="VRAM">
|
|
||||||
<i className="pi pi-database" />{formatBytes(gpu?.memoryUsedBytes)} / {formatBytes(gpu?.memoryTotalBytes)}
|
|
||||||
</span>
|
|
||||||
<span className="hardware-gpu-chip" title="Power">
|
|
||||||
<i className="pi pi-flash" />
|
|
||||||
{Number.isFinite(Number(gpu?.powerDrawW)) ? `${gpu.powerDrawW} W` : 'n/a'}
|
|
||||||
{' / '}
|
|
||||||
{Number.isFinite(Number(gpu?.powerLimitW)) ? `${gpu.powerLimitW} W` : 'n/a'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<MetadataSelectionDialog
|
<MetadataSelectionDialog
|
||||||
visible={metadataDialogShouldBeVisible}
|
visible={metadataDialogShouldBeVisible}
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ export default function SettingsPage() {
|
|||||||
const [chainActionBusyIds, setChainActionBusyIds] = useState(() => new Set());
|
const [chainActionBusyIds, setChainActionBusyIds] = useState(() => new Set());
|
||||||
const [runningChainIds, setRunningChainIds] = useState([]);
|
const [runningChainIds, setRunningChainIds] = useState([]);
|
||||||
const [lastChainTestResult, setLastChainTestResult] = 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: '', description: '', steps: [] });
|
||||||
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||||||
const [chainDragSource, setChainDragSource] = useState(null);
|
const [chainDragSource, setChainDragSource] = useState(null);
|
||||||
|
|
||||||
@@ -1019,6 +1019,32 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleScriptFavorite = async (script) => {
|
||||||
|
const scriptId = Number(script?.id);
|
||||||
|
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setScriptActionBusyId(scriptId);
|
||||||
|
try {
|
||||||
|
const nextFavorite = !(script?.isFavorite === true);
|
||||||
|
await api.setScriptFavorite(scriptId, nextFavorite);
|
||||||
|
await loadScripts({ silent: true });
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Scripte',
|
||||||
|
detail: nextFavorite ? 'Favorit aktiviert.' : 'Favorit entfernt.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Favorit konnte nicht geändert werden',
|
||||||
|
detail: error.message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setScriptActionBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleScriptListDragStart = (event, scriptId) => {
|
const handleScriptListDragStart = (event, scriptId) => {
|
||||||
if (scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId)) {
|
if (scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1120,18 +1146,51 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleChainFavorite = async (chain) => {
|
||||||
|
const chainId = Number(chain?.id);
|
||||||
|
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedChainId = Math.trunc(chainId);
|
||||||
|
setChainActionBusy(normalizedChainId, true);
|
||||||
|
try {
|
||||||
|
const nextFavorite = !(chain?.isFavorite === true);
|
||||||
|
await api.setScriptChainFavorite(normalizedChainId, nextFavorite);
|
||||||
|
await loadChains({ silent: true });
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Skriptketten',
|
||||||
|
detail: nextFavorite ? 'Favorit aktiviert.' : 'Favorit entfernt.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Favorit konnte nicht geändert werden',
|
||||||
|
detail: error.message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setChainActionBusy(normalizedChainId, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Chain editor handlers
|
// Chain editor handlers
|
||||||
const openChainEditor = (chain = null) => {
|
const openChainEditor = (chain = null) => {
|
||||||
if (chain) {
|
if (chain) {
|
||||||
setChainEditor({ open: true, id: chain.id, name: chain.name, steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` })) });
|
setChainEditor({
|
||||||
|
open: true,
|
||||||
|
id: chain.id,
|
||||||
|
name: chain.name,
|
||||||
|
description: chain.description || '',
|
||||||
|
steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` }))
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setChainEditor({ open: true, id: null, name: '', steps: [] });
|
setChainEditor({ open: true, id: null, name: '', description: '', steps: [] });
|
||||||
}
|
}
|
||||||
setChainEditorErrors({});
|
setChainEditorErrors({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeChainEditor = () => {
|
const closeChainEditor = () => {
|
||||||
setChainEditor({ open: false, id: null, name: '', steps: [] });
|
setChainEditor({ open: false, id: null, name: '', description: '', steps: [] });
|
||||||
setChainEditorErrors({});
|
setChainEditorErrors({});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1182,6 +1241,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
|
description: String(chainEditor.description || '').trim(),
|
||||||
steps: chainEditor.steps.map((s) => ({
|
steps: chainEditor.steps.map((s) => ({
|
||||||
stepType: s.stepType,
|
stepType: s.stepType,
|
||||||
scriptId: s.stepType === 'script' ? s.scriptId : null,
|
scriptId: s.stepType === 'script' ? s.scriptId : null,
|
||||||
@@ -1414,6 +1474,7 @@ export default function SettingsPage() {
|
|||||||
dirtyKeys={dirtyKeys}
|
dirtyKeys={dirtyKeys}
|
||||||
onChange={handleFieldChange}
|
onChange={handleFieldChange}
|
||||||
effectivePaths={effectivePaths}
|
effectivePaths={effectivePaths}
|
||||||
|
activationBytes={activationBytes}
|
||||||
onNotify={(msg) => toastRef.current?.show(msg)}
|
onNotify={(msg) => toastRef.current?.show(msg)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1506,6 +1567,7 @@ export default function SettingsPage() {
|
|||||||
<div className="script-order-list">
|
<div className="script-order-list">
|
||||||
{scripts.map((script, index) => {
|
{scripts.map((script, index) => {
|
||||||
const isDragging = Number(scriptListDragSourceId) === Number(script.id);
|
const isDragging = Number(scriptListDragSourceId) === Number(script.id);
|
||||||
|
const isFavorite = script?.isFavorite === true;
|
||||||
return (
|
return (
|
||||||
<div key={script.id} className="script-order-wrapper">
|
<div key={script.id} className="script-order-wrapper">
|
||||||
<div
|
<div
|
||||||
@@ -1530,6 +1592,15 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="script-list-actions">
|
<div className="script-list-actions">
|
||||||
|
<Button
|
||||||
|
icon={isFavorite ? 'pi pi-star-fill' : 'pi pi-star'}
|
||||||
|
label="Favorit"
|
||||||
|
severity={isFavorite ? 'warning' : 'secondary'}
|
||||||
|
outlined={!isFavorite}
|
||||||
|
onClick={() => handleToggleScriptFavorite(script)}
|
||||||
|
loading={scriptActionBusyId === script.id}
|
||||||
|
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-pencil"
|
icon="pi pi-pencil"
|
||||||
label="Bearbeiten"
|
label="Bearbeiten"
|
||||||
@@ -1678,6 +1749,7 @@ export default function SettingsPage() {
|
|||||||
const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null;
|
const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null;
|
||||||
const isChainRuntimeRunning = normalizedChainId !== null && runningChainIdSet.has(normalizedChainId);
|
const isChainRuntimeRunning = normalizedChainId !== null && runningChainIdSet.has(normalizedChainId);
|
||||||
const isChainActionBusy = normalizedChainId !== null && chainActionBusyIds.has(normalizedChainId);
|
const isChainActionBusy = normalizedChainId !== null && chainActionBusyIds.has(normalizedChainId);
|
||||||
|
const isFavorite = chain?.isFavorite === true;
|
||||||
return (
|
return (
|
||||||
<div key={chain.id} className="script-order-wrapper">
|
<div key={chain.id} className="script-order-wrapper">
|
||||||
<div
|
<div
|
||||||
@@ -1699,20 +1771,20 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</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 className="chain-description-line" title={String(chain.description || '').trim()}>
|
||||||
{chain.steps?.length ?? 0} Schritt(e):
|
{String(chain.description || '').trim() || '—'}
|
||||||
{' '}
|
|
||||||
{(chain.steps || []).map((s, i) => (
|
|
||||||
<span key={i}>
|
|
||||||
{i > 0 ? ' → ' : ''}
|
|
||||||
{s.stepType === 'wait'
|
|
||||||
? `⏱ ${s.waitSeconds}s`
|
|
||||||
: (s.scriptName || `Script #${s.scriptId}`)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="script-list-actions">
|
<div className="script-list-actions">
|
||||||
|
<Button
|
||||||
|
icon={isFavorite ? 'pi pi-star-fill' : 'pi pi-star'}
|
||||||
|
label="Favorit"
|
||||||
|
severity={isFavorite ? 'warning' : 'secondary'}
|
||||||
|
outlined={!isFavorite}
|
||||||
|
onClick={() => handleToggleChainFavorite(chain)}
|
||||||
|
loading={isChainActionBusy}
|
||||||
|
disabled={chainReordering || isChainActionBusy || isChainRuntimeRunning}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-pencil"
|
icon="pi pi-pencil"
|
||||||
label="Bearbeiten"
|
label="Bearbeiten"
|
||||||
@@ -1801,6 +1873,20 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
{chainEditorErrors.name ? <small className="error-text">{chainEditorErrors.name}</small> : null}
|
{chainEditorErrors.name ? <small className="error-text">{chainEditorErrors.name}</small> : null}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="chain-editor-name-row" style={{ marginTop: '0.7rem' }}>
|
||||||
|
<label htmlFor="chain-description">Kurzbeschreibung (max. 50 Zeichen)</label>
|
||||||
|
<InputText
|
||||||
|
id="chain-description"
|
||||||
|
value={chainEditor.description}
|
||||||
|
maxLength={50}
|
||||||
|
onChange={(e) => {
|
||||||
|
setChainEditor((prev) => ({ ...prev, description: e.target.value }));
|
||||||
|
setChainEditorErrors((prev) => ({ ...prev, description: null }));
|
||||||
|
}}
|
||||||
|
placeholder="Kurzer Hinweis zur Kette"
|
||||||
|
/>
|
||||||
|
{chainEditorErrors.description ? <small className="error-text">{chainEditorErrors.description}</small> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="chain-editor-body">
|
<div className="chain-editor-body">
|
||||||
{/* Palette */}
|
{/* Palette */}
|
||||||
@@ -2016,9 +2102,9 @@ export default function SettingsPage() {
|
|||||||
) : userPresets.length === 0 ? (
|
) : userPresets.length === 0 ? (
|
||||||
<p style={{ marginTop: '1rem' }}>Keine Presets vorhanden. Lege ein neues Preset an.</p>
|
<p style={{ marginTop: '1rem' }}>Keine Presets vorhanden. Lege ein neues Preset an.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="script-list script-list--reorderable" style={{ marginTop: '1rem' }}>
|
<div className="preset-list" style={{ marginTop: '1rem' }}>
|
||||||
{userPresets.map((preset) => (
|
{userPresets.map((preset) => (
|
||||||
<div key={preset.id} className="script-list-item">
|
<div key={preset.id} className="script-list-item preset-list-item">
|
||||||
<div className="script-list-main">
|
<div className="script-list-main">
|
||||||
<div className="script-title-line">
|
<div className="script-title-line">
|
||||||
<strong className="script-id-title">#{preset.id} - {preset.name}</strong>
|
<strong className="script-id-title">#{preset.id} - {preset.name}</strong>
|
||||||
@@ -2032,7 +2118,7 @@ export default function SettingsPage() {
|
|||||||
{preset.description || '-'}
|
{preset.description || '-'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="script-list-actions script-list-actions--two">
|
<div className="script-list-actions script-list-actions--two preset-list-actions">
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-pencil"
|
icon="pi pi-pencil"
|
||||||
label="Bearbeiten"
|
label="Bearbeiten"
|
||||||
@@ -2153,33 +2239,6 @@ export default function SettingsPage() {
|
|||||||
</TabView>
|
</TabView>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{expertModeEnabled && activationBytes.length > 0 && (
|
|
||||||
<Card
|
|
||||||
title="Activation Bytes Cache"
|
|
||||||
subTitle="Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt."
|
|
||||||
style={{ marginTop: '1.5rem' }}
|
|
||||||
>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '1px solid var(--surface-border)' }}>
|
|
||||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Checksum</th>
|
|
||||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Activation Bytes</th>
|
|
||||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Gespeichert am</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{activationBytes.map((entry) => (
|
|
||||||
<tr key={entry.checksum} style={{ borderBottom: '1px solid var(--surface-border)' }}>
|
|
||||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{entry.checksum}</td>
|
|
||||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: 'bold' }}>{entry.activation_bytes}</td>
|
|
||||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{new Date(entry.created_at).toLocaleString('de-DE')}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+654
-12
@@ -459,6 +459,7 @@ body {
|
|||||||
.hardware-monitor-head {
|
.hardware-monitor-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
margin-bottom: 0.7rem;
|
margin-bottom: 0.7rem;
|
||||||
}
|
}
|
||||||
@@ -466,13 +467,16 @@ body {
|
|||||||
.hardware-monitor-grid {
|
.hardware-monitor-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-monitor-grid > .hardware-monitor-block {
|
.hardware-monitor-grid > .hardware-monitor-block {
|
||||||
flex: 0 0 calc(33.333% - 0.5rem);
|
flex: 1 1 18rem;
|
||||||
|
max-width: 24rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +488,8 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-monitor-block h4 {
|
.hardware-monitor-block h4 {
|
||||||
@@ -498,9 +504,10 @@ body {
|
|||||||
|
|
||||||
.hardware-cpu-summary {
|
.hardware-cpu-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -597,9 +604,10 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.2rem 0.3rem;
|
gap: 0.2rem 0.3rem;
|
||||||
padding: 0.3rem 0.45rem;
|
padding: 0.3rem 0.45rem;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,14 +615,14 @@ body {
|
|||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--rip-brown-700);
|
color: var(--rip-brown-700);
|
||||||
min-width: 2.2rem;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-core-metric {
|
.hardware-core-metric {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.28rem;
|
gap: 0.28rem;
|
||||||
justify-self: start;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-core-metric .pi {
|
.hardware-core-metric .pi {
|
||||||
@@ -628,6 +636,73 @@ body {
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hardware-panel-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-panel-section h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-panel-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-panel-link-btn.p-button {
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
color: var(--rip-brown-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-panel-divider {
|
||||||
|
border-top: 1px solid var(--surface-border);
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-mini-monitor-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-mini-monitor-item {
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-mini-monitor-item strong {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-mini-monitor-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-mini-monitor-metric {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-mini-monitor-sep {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-mini-monitor-metric .pi {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--rip-brown-700);
|
||||||
|
}
|
||||||
|
|
||||||
.hardware-gpu-item,
|
.hardware-gpu-item,
|
||||||
.hardware-storage-item {
|
.hardware-storage-item {
|
||||||
border: 1px dashed var(--rip-border);
|
border: 1px dashed var(--rip-border);
|
||||||
@@ -642,12 +717,14 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.25rem 0.35rem;
|
gap: 0.25rem 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-gpu-item strong {
|
.hardware-gpu-item strong {
|
||||||
flex: 0 0 100%;
|
flex: 0 0 100%;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-gpu-chip {
|
.hardware-gpu-chip {
|
||||||
@@ -1213,6 +1290,33 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-running-progress {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-running-progress .p-progressbar {
|
||||||
|
height: 0.34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-nonjob-subtitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-nonjob-subtitle i {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-nonjob-subtitle span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pipeline-queue-drag-handle {
|
.pipeline-queue-drag-handle {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
color: var(--rip-muted);
|
color: var(--rip-muted);
|
||||||
@@ -1802,6 +1906,11 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-dialog-controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1809,7 +1918,6 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1817,6 +1925,13 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metadata-result-filter-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.metadata-filter-disc {
|
.metadata-filter-disc {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
@@ -2047,6 +2162,35 @@ body {
|
|||||||
color: var(--rip-muted);
|
color: var(--rip-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activation-bytes-cache-box p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activation-bytes-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activation-bytes-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activation-bytes-table th,
|
||||||
|
.activation-bytes-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activation-bytes-table th {
|
||||||
|
color: var(--rip-brown-800);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-row {
|
.setting-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -2222,6 +2366,56 @@ body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-access-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-empty {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-item-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-item-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.32rem;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-item-title-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-access-run-btn.p-button.p-button-icon-only {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.converter-scan-extensions-grid {
|
.converter-scan-extensions-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(7.2rem, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(7.2rem, 1fr));
|
||||||
@@ -2769,6 +2963,14 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chain-description-line {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.script-title-line {
|
.script-title-line {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
@@ -2791,7 +2993,7 @@ body {
|
|||||||
|
|
||||||
.script-list-actions {
|
.script-list-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2810,6 +3012,16 @@ body {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1750px) {
|
||||||
|
.script-list--reorderable .script-list-item:not(.script-list-item-editing) {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) minmax(14.5rem, auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list-actions {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.script-action-spacer {
|
.script-action-spacer {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -2840,6 +3052,31 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preset-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-list-item {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(19rem, 40%);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-list-actions .p-button {
|
||||||
|
min-height: 2.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.preset-list-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-list-actions {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.user-preset-default-grid {
|
.user-preset-default-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -4342,12 +4579,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.job-detail-dialog .p-dialog-content,
|
.job-detail-dialog .p-dialog-content,
|
||||||
.metadata-selection-dialog .p-dialog-content,
|
|
||||||
.disc-detected-dialog .p-dialog-content {
|
.disc-detected-dialog .p-dialog-content {
|
||||||
max-height: min(80vh, 56rem);
|
max-height: min(80vh, 56rem);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metadata-selection-dialog .p-dialog-content {
|
||||||
|
max-height: min(84vh, 60rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.job-detail-dialog,
|
.job-detail-dialog,
|
||||||
.metadata-selection-dialog,
|
.metadata-selection-dialog,
|
||||||
.disc-detected-dialog {
|
.disc-detected-dialog {
|
||||||
@@ -4479,9 +4720,9 @@ body {
|
|||||||
.hardware-core-item.compact {
|
.hardware-core-item.compact {
|
||||||
grid-template-columns: auto auto auto;
|
grid-template-columns: auto auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: start;
|
justify-content: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hardware-core-grid.compact {
|
.hardware-core-grid.compact {
|
||||||
@@ -4493,7 +4734,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hardware-core-metric {
|
.hardware-core-metric {
|
||||||
justify-self: start;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-dv-item-list {
|
.history-dv-item-list {
|
||||||
@@ -5531,8 +5772,409 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Hardware Detail Page ===== */
|
||||||
|
|
||||||
|
.hardware-detail-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-hero .p-card-content,
|
||||||
|
.hardware-detail-card .p-card-content,
|
||||||
|
.hardware-detail-empty .p-card-content {
|
||||||
|
padding-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-hero {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-hero-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-hero-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-hero-head small {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-hero-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-empty {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-empty h3 {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-empty p {
|
||||||
|
margin: 0 0 0.9rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-card {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-card small {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-card-icon {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #f5f1e7;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-card-icon .pi {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-gauge-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 170px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-gauge-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 170px;
|
||||||
|
height: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-gauge-chart {
|
||||||
|
width: 170px;
|
||||||
|
height: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-gauge-center {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-bar-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-bar-item,
|
||||||
|
.hardware-detail-temp-item,
|
||||||
|
.hardware-detail-list-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-bar-item .p-progressbar,
|
||||||
|
.hardware-detail-temp-item .p-progressbar,
|
||||||
|
.hardware-detail-list-item .p-progressbar {
|
||||||
|
height: 0.38rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-page .p-progressbar .p-progressbar-value {
|
||||||
|
background: #b07a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-bar-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-scroll-list {
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow: auto;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-core-grid {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.45rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-core-item {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 0.35rem 0.42rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-core-item .p-progressbar {
|
||||||
|
height: 0.26rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-scroll-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-meter-wrap {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
border-top: 1px solid var(--surface-border);
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-meter-wrap .p-progressbar {
|
||||||
|
height: 0.38rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-meter-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-list-item small {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-list-item.tone-warn .p-progressbar-value {
|
||||||
|
background: linear-gradient(90deg, #d5a242, #e0b25f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-list-item.tone-high .p-progressbar-value {
|
||||||
|
background: linear-gradient(90deg, #d9924d, #c97931);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-list-item.tone-critical .p-progressbar-value {
|
||||||
|
background: linear-gradient(90deg, #c64d37, #ad3a2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-card {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-card-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-window-buttons {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-window-buttons .p-button {
|
||||||
|
min-width: 3.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-chart-wrap {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 0.6rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-chart-wrap h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-series-toggles {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-series-toggles .p-button {
|
||||||
|
min-height: 1.9rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-series-toggles .p-button.hardware-series-toggle-btn {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-series-toggles .p-button.hardware-series-toggle-btn:focus {
|
||||||
|
box-shadow: 0 0 0 1px rgba(58, 29, 18, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-chart {
|
||||||
|
position: relative;
|
||||||
|
height: 320px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-chart canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-chart .hardware-history-chart-canvas,
|
||||||
|
.hardware-history-chart .hardware-history-chart-canvas canvas {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-chart .hardware-history-chart-canvas canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-legend-row {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-history-legend-dot {
|
||||||
|
width: 0.62rem;
|
||||||
|
height: 0.62rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1700px) {
|
||||||
|
.hardware-history-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1900px) {
|
||||||
|
.hardware-detail-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.hardware-detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.hardware-detail-grid,
|
||||||
|
.hardware-detail-gauge-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-core-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-gauge-wrap {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-detail-card h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Converter Page ===== */
|
/* ===== Converter Page ===== */
|
||||||
|
|
||||||
|
.converter-beta-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #f5d28a;
|
||||||
|
background: #fff8e7;
|
||||||
|
color: #7a4b00;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.converter-beta-notice .pi {
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
color: #d17d00;
|
||||||
|
}
|
||||||
|
|
||||||
.converter-card-header {
|
.converter-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const STATUS_LABELS = {
|
|||||||
IDLE: 'Bereit',
|
IDLE: 'Bereit',
|
||||||
DISC_DETECTED: 'Medium erkannt',
|
DISC_DETECTED: 'Medium erkannt',
|
||||||
ANALYZING: 'Analyse',
|
ANALYZING: 'Analyse',
|
||||||
|
METADATA_LOOKUP: 'TMDb-Suche',
|
||||||
METADATA_SELECTION: 'Metadatenauswahl',
|
METADATA_SELECTION: 'Metadatenauswahl',
|
||||||
WAITING_FOR_USER_DECISION: 'Warte auf Auswahl',
|
WAITING_FOR_USER_DECISION: 'Warte auf Auswahl',
|
||||||
READY_TO_START: 'Startbereit',
|
READY_TO_START: 'Startbereit',
|
||||||
@@ -46,6 +47,7 @@ const FALLBACK_TOKEN_LABELS = {
|
|||||||
ENCODE: 'encodieren',
|
ENCODE: 'encodieren',
|
||||||
ENCODING: 'encodieren',
|
ENCODING: 'encodieren',
|
||||||
ANALYZING: 'analyse',
|
ANALYZING: 'analyse',
|
||||||
|
LOOKUP: 'suche',
|
||||||
METADATA: 'metadaten',
|
METADATA: 'metadaten',
|
||||||
SELECTION: 'auswahl',
|
SELECTION: 'auswahl',
|
||||||
WAITING: 'warte',
|
WAITING: 'warte',
|
||||||
@@ -120,6 +122,7 @@ export function getStatusSeverity(status, options = {}) {
|
|||||||
normalized === 'RIPPING'
|
normalized === 'RIPPING'
|
||||||
|| normalized === 'ENCODING'
|
|| normalized === 'ENCODING'
|
||||||
|| normalized === 'ANALYZING'
|
|| normalized === 'ANALYZING'
|
||||||
|
|| normalized === 'METADATA_LOOKUP'
|
||||||
|| normalized === 'MEDIAINFO_CHECK'
|
|| normalized === 'MEDIAINFO_CHECK'
|
||||||
|| normalized === 'METADATA_SELECTION'
|
|| normalized === 'METADATA_SELECTION'
|
||||||
|| normalized === 'POST_ENCODE_SCRIPTS'
|
|| normalized === 'POST_ENCODE_SCRIPTS'
|
||||||
@@ -154,5 +157,6 @@ export const STATUS_FILTER_OPTIONS = [
|
|||||||
{ label: getStatusLabel('RIPPING'), value: 'RIPPING' },
|
{ label: getStatusLabel('RIPPING'), value: 'RIPPING' },
|
||||||
{ label: getStatusLabel('ENCODING'), value: 'ENCODING' },
|
{ label: getStatusLabel('ENCODING'), value: 'ENCODING' },
|
||||||
{ label: getStatusLabel('ANALYZING'), value: 'ANALYZING' },
|
{ label: getStatusLabel('ANALYZING'), value: 'ANALYZING' },
|
||||||
|
{ label: getStatusLabel('METADATA_LOOKUP'), value: 'METADATA_LOOKUP' },
|
||||||
{ label: getStatusLabel('METADATA_SELECTION'), value: 'METADATA_SELECTION' }
|
{ label: getStatusLabel('METADATA_SELECTION'), value: 'METADATA_SELECTION' }
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "1.0.0-rc1",
|
"version": "1.0.0-rc2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "1.0.0-rc1",
|
"version": "1.0.0-rc2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-rc1",
|
"version": "1.0.0-rc2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user