0.16.0 Pre-Release Checks
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.15.0-3",
|
"version": "0.16.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.15.0-3",
|
"version": "0.16.0",
|
||||||
"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": "0.15.0-3",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -883,7 +883,13 @@ const SETTINGS_CATEGORY_MOVES = [
|
|||||||
{ key: 'converter_movie_dir', category: 'Pfade' },
|
{ key: 'converter_movie_dir', category: 'Pfade' },
|
||||||
{ key: 'converter_audio_dir', category: 'Pfade' },
|
{ key: 'converter_audio_dir', category: 'Pfade' },
|
||||||
{ key: 'converter_output_template_video', category: 'Pfade' },
|
{ key: 'converter_output_template_video', category: 'Pfade' },
|
||||||
{ key: 'converter_output_template_audio', category: 'Pfade' }
|
{ key: 'converter_output_template_audio', category: 'Pfade' },
|
||||||
|
{ key: 'server_console_log_output_enabled', category: 'Logging' },
|
||||||
|
{ key: 'server_console_log_http_enabled', category: 'Logging' },
|
||||||
|
{ key: 'server_console_log_debug_enabled', category: 'Logging' },
|
||||||
|
{ key: 'server_console_log_info_enabled', category: 'Logging' },
|
||||||
|
{ key: 'server_console_log_warn_enabled', category: 'Logging' },
|
||||||
|
{ key: 'server_console_log_error_enabled', category: 'Logging' }
|
||||||
];
|
];
|
||||||
|
|
||||||
async function migrateSettingsSchemaMetadata(db) {
|
async function migrateSettingsSchemaMetadata(db) {
|
||||||
@@ -1049,6 +1055,48 @@ 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(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true')`);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_output_enabled', 'Logging', 'Terminal-Ausgabe aktiv', 'boolean', 1, 'Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv.', 'true', '[]', '{}', 150)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_output_enabled', 'true')`);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_http_enabled', 'Logging', 'HTTP-Logs im Terminal', 'boolean', 1, 'Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert.', 'true', '[]', '{}', 151)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_http_enabled', 'true')`);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_debug_enabled', 'Logging', 'DEBUG im Terminal', 'boolean', 1, 'Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 152)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_debug_enabled', 'true')`);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_info_enabled', 'Logging', 'INFO im Terminal', 'boolean', 1, 'Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 153)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_info_enabled', 'true')`);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_warn_enabled', 'Logging', 'WARN im Terminal', 'boolean', 1, 'Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 154)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_warn_enabled', 'true')`);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_error_enabled', 'Logging', 'ERROR im Terminal', 'boolean', 1, 'Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 155)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_error_enabled', 'true')`);
|
||||||
|
|
||||||
|
|
||||||
await db.run(
|
await db.run(
|
||||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
@@ -1288,6 +1336,12 @@ async function migrateSettingsSchemaMetadata(db) {
|
|||||||
);
|
);
|
||||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`);
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('generate_nfo_after_encode', 'Metadaten', 'NFO nach Encode erzeugen', 'boolean', 1, 'Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei.', 'false', '[]', '{}', 432)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('generate_nfo_after_encode', 'false')`);
|
||||||
|
|
||||||
await db.run(`
|
await db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const runtimeRoutes = require('./routes/runtimeRoutes');
|
|||||||
const converterRoutes = require('./routes/converterRoutes');
|
const converterRoutes = require('./routes/converterRoutes');
|
||||||
const wsService = require('./services/websocketService');
|
const wsService = require('./services/websocketService');
|
||||||
const pipelineService = require('./services/pipelineService');
|
const pipelineService = require('./services/pipelineService');
|
||||||
|
const settingsService = require('./services/settingsService');
|
||||||
const converterScanService = require('./services/converterScanService');
|
const converterScanService = require('./services/converterScanService');
|
||||||
const cronService = require('./services/cronService');
|
const cronService = require('./services/cronService');
|
||||||
const downloadService = require('./services/downloadService');
|
const downloadService = require('./services/downloadService');
|
||||||
@@ -30,6 +31,12 @@ const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thum
|
|||||||
async function start() {
|
async function start() {
|
||||||
logger.info('backend:start:init');
|
logger.info('backend:start:init');
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
|
try {
|
||||||
|
const runtimeSettings = await settingsService.applyRuntimeSettings();
|
||||||
|
logger.info('backend:runtime-settings:applied', runtimeSettings);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) });
|
||||||
|
}
|
||||||
await tempCleanupService.init();
|
await tempCleanupService.init();
|
||||||
await pipelineService.init();
|
await pipelineService.init();
|
||||||
await converterScanService.startPolling();
|
await converterScanService.startPolling();
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ const logger = require('../services/logger').child('HISTORY_ROUTE');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
function parseSelectedJobIds(value, options = {}) {
|
||||||
|
const { hasExplicitValue = true } = options;
|
||||||
|
if (!hasExplicitValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sourceValues = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: String(value || '')
|
||||||
|
.split(',');
|
||||||
|
return sourceValues
|
||||||
|
.map((entry) => Number(entry))
|
||||||
|
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
||||||
|
.map((entry) => Math.trunc(entry));
|
||||||
|
}
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
@@ -147,12 +162,31 @@ router.post(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/nfo/generate',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
logger.info('post:job:nfo:generate', { reqId: req.reqId, id });
|
||||||
|
const result = await historyService.generateJobNfo(id, {
|
||||||
|
mode: 'manual',
|
||||||
|
requireSettingDisabled: true,
|
||||||
|
failIfExists: true,
|
||||||
|
failIfOutputMissing: true
|
||||||
|
});
|
||||||
|
const job = await historyService.getJobWithLogs(id, { includeFsChecks: true });
|
||||||
|
res.json({ result, job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/delete-files',
|
'/:id/delete-files',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const target = String(req.body?.target || 'both');
|
const target = String(req.body?.target || 'both');
|
||||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||||
|
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
|
||||||
|
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
|
||||||
|
});
|
||||||
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
|
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
|
||||||
? req.body.selectedRawPaths
|
? req.body.selectedRawPaths
|
||||||
.map((item) => String(item || '').trim())
|
.map((item) => String(item || '').trim())
|
||||||
@@ -169,12 +203,14 @@ router.post(
|
|||||||
id,
|
id,
|
||||||
target,
|
target,
|
||||||
includeRelated,
|
includeRelated,
|
||||||
|
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
|
||||||
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
||||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await historyService.deleteJobFiles(id, target, {
|
const result = await historyService.deleteJobFiles(id, target, {
|
||||||
includeRelated,
|
includeRelated,
|
||||||
|
selectedJobIds,
|
||||||
selectedRawPaths,
|
selectedRawPaths,
|
||||||
selectedMoviePaths
|
selectedMoviePaths
|
||||||
});
|
});
|
||||||
@@ -187,14 +223,18 @@ router.get(
|
|||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase());
|
const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase());
|
||||||
|
const selectedJobIds = parseSelectedJobIds(req.query.selectedJobIds, {
|
||||||
|
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.query || {}, 'selectedJobIds')
|
||||||
|
});
|
||||||
|
|
||||||
logger.info('get:delete-preview', {
|
logger.info('get:delete-preview', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
id,
|
id,
|
||||||
includeRelated
|
includeRelated,
|
||||||
|
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
|
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
|
||||||
res.json({ preview });
|
res.json({ preview });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -205,6 +245,9 @@ router.post(
|
|||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const target = String(req.body?.target || 'none');
|
const target = String(req.body?.target || 'none');
|
||||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||||
|
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
|
||||||
|
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
|
||||||
|
});
|
||||||
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
|
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
|
||||||
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
|
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
|
||||||
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
|
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
|
||||||
@@ -226,6 +269,7 @@ router.post(
|
|||||||
id,
|
id,
|
||||||
target,
|
target,
|
||||||
includeRelated,
|
includeRelated,
|
||||||
|
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
|
||||||
requestedResetDriveState,
|
requestedResetDriveState,
|
||||||
preserveRawForImportJobs,
|
preserveRawForImportJobs,
|
||||||
requestedKeepDetectedDevice,
|
requestedKeepDetectedDevice,
|
||||||
@@ -233,10 +277,12 @@ router.post(
|
|||||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
|
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
|
||||||
const containsOrphanRawImportJob = Boolean(
|
const containsOrphanRawImportJob = Boolean(
|
||||||
preview?.flags?.containsOrphanRawImportJob
|
preview?.flags?.containsOrphanRawImportJob
|
||||||
|| (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some((row) => Boolean(row?.orphanRawImport)))
|
|| (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some(
|
||||||
|
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
|
||||||
|
))
|
||||||
);
|
);
|
||||||
const resetDriveState = containsOrphanRawImportJob
|
const resetDriveState = containsOrphanRawImportJob
|
||||||
? false
|
? false
|
||||||
@@ -254,11 +300,13 @@ router.post(
|
|||||||
|
|
||||||
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
|
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
|
||||||
? preview.relatedJobs
|
? preview.relatedJobs
|
||||||
|
.filter((row) => Boolean(row?.selected))
|
||||||
.map((row) => String(row?.discDevice || '').trim())
|
.map((row) => String(row?.discDevice || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
const result = await historyService.deleteJob(id, target, {
|
const result = await historyService.deleteJob(id, target, {
|
||||||
includeRelated,
|
includeRelated,
|
||||||
|
selectedJobIds,
|
||||||
selectedRawPaths,
|
selectedRawPaths,
|
||||||
selectedMoviePaths,
|
selectedMoviePaths,
|
||||||
preserveRawForImportJobs
|
preserveRawForImportJobs
|
||||||
|
|||||||
@@ -123,6 +123,187 @@ function inspectOutputFile(filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBooleanValue(value, fallback = false) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNfoPathFromOutputPath(outputPath) {
|
||||||
|
const normalizedOutputPath = String(outputPath || '').trim();
|
||||||
|
if (!normalizedOutputPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = path.parse(normalizedOutputPath);
|
||||||
|
const extension = String(parsed.ext || '').trim();
|
||||||
|
const baseName = String(parsed.name || '').trim();
|
||||||
|
if (!extension || !baseName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.join(parsed.dir, `${baseName}.nfo`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectNfoFileStatus(nfoPath, options = {}) {
|
||||||
|
const includeFsChecks = options?.includeFsChecks !== false;
|
||||||
|
if (!nfoPath) {
|
||||||
|
return {
|
||||||
|
path: null,
|
||||||
|
exists: false,
|
||||||
|
isFile: false,
|
||||||
|
sizeBytes: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!includeFsChecks) {
|
||||||
|
return {
|
||||||
|
path: nfoPath,
|
||||||
|
exists: null,
|
||||||
|
isFile: null,
|
||||||
|
sizeBytes: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(nfoPath);
|
||||||
|
return {
|
||||||
|
path: nfoPath,
|
||||||
|
exists: true,
|
||||||
|
isFile: stat.isFile(),
|
||||||
|
sizeBytes: stat.size
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return {
|
||||||
|
path: nfoPath,
|
||||||
|
exists: false,
|
||||||
|
isFile: false,
|
||||||
|
sizeBytes: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNfoMetadataFromJob(job = {}) {
|
||||||
|
const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {});
|
||||||
|
const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo);
|
||||||
|
const encodePlan = parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {});
|
||||||
|
const planMetadata = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
|
||||||
|
? encodePlan.metadata
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const title = String(
|
||||||
|
job?.title
|
||||||
|
|| selectedMetadata?.title
|
||||||
|
|| selectedMetadata?.seriesTitle
|
||||||
|
|| planMetadata?.title
|
||||||
|
|| job?.detected_title
|
||||||
|
|| ''
|
||||||
|
).trim();
|
||||||
|
const rawYear = Number(
|
||||||
|
job?.year
|
||||||
|
?? selectedMetadata?.year
|
||||||
|
?? planMetadata?.year
|
||||||
|
?? null
|
||||||
|
);
|
||||||
|
const year = Number.isFinite(rawYear) && rawYear > 0
|
||||||
|
? Math.trunc(rawYear)
|
||||||
|
: null;
|
||||||
|
const imdbId = String(
|
||||||
|
job?.imdb_id
|
||||||
|
|| selectedMetadata?.imdbId
|
||||||
|
|| planMetadata?.imdbId
|
||||||
|
|| ''
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
imdbId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNfoXml(metadata = {}) {
|
||||||
|
const title = escapeXml(metadata?.title || '');
|
||||||
|
const year = metadata?.year !== null && metadata?.year !== undefined
|
||||||
|
? escapeXml(String(metadata.year))
|
||||||
|
: '';
|
||||||
|
const imdbId = escapeXml(metadata?.imdbId || '');
|
||||||
|
return [
|
||||||
|
'<movie>',
|
||||||
|
` <title>${title}</title>`,
|
||||||
|
` <year>${year}</year>`,
|
||||||
|
` <imdbid>${imdbId}</imdbid>`,
|
||||||
|
'</movie>',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNfoStatusForJob({
|
||||||
|
job = {},
|
||||||
|
outputPath = null,
|
||||||
|
outputStatus = null,
|
||||||
|
includeFsChecks = true,
|
||||||
|
settings = {}
|
||||||
|
} = {}) {
|
||||||
|
const normalizedOutputPath = String(outputPath || '').trim() || null;
|
||||||
|
const nfoPath = resolveNfoPathFromOutputPath(normalizedOutputPath);
|
||||||
|
const nfoStatus = inspectNfoFileStatus(nfoPath, { includeFsChecks });
|
||||||
|
const nfoSettingEnabled = parseBooleanValue(
|
||||||
|
settings?.generate_nfo_after_encode,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const outputExists = Boolean(outputStatus?.exists);
|
||||||
|
const outputIsFile = Boolean(outputStatus?.isFile);
|
||||||
|
const canGenerateManual = !nfoSettingEnabled
|
||||||
|
&& Boolean(nfoPath)
|
||||||
|
&& outputExists
|
||||||
|
&& outputIsFile
|
||||||
|
&& !Boolean(nfoStatus?.exists);
|
||||||
|
|
||||||
|
let reason = null;
|
||||||
|
if (nfoSettingEnabled) {
|
||||||
|
reason = 'setting_enabled';
|
||||||
|
} else if (!nfoPath) {
|
||||||
|
reason = 'output_not_file';
|
||||||
|
} else if (!outputExists || !outputIsFile) {
|
||||||
|
reason = 'output_missing';
|
||||||
|
} else if (Boolean(nfoStatus?.exists)) {
|
||||||
|
reason = 'nfo_exists';
|
||||||
|
} else {
|
||||||
|
reason = 'eligible';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
settingEnabled: nfoSettingEnabled,
|
||||||
|
outputPath: normalizedOutputPath,
|
||||||
|
nfoPath,
|
||||||
|
outputExists: includeFsChecks ? outputExists : null,
|
||||||
|
outputIsFile: includeFsChecks ? outputIsFile : null,
|
||||||
|
nfoExists: includeFsChecks ? Boolean(nfoStatus?.exists) : null,
|
||||||
|
canGenerateManual,
|
||||||
|
reason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseInfoFromValue(value, fallback = null) {
|
function parseInfoFromValue(value, fallback = null) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -1044,6 +1225,13 @@ function enrichJobRow(job, settings = null, options = {}) {
|
|||||||
? finishedWithOutput
|
? finishedWithOutput
|
||||||
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS')
|
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS')
|
||||||
);
|
);
|
||||||
|
const nfoStatus = buildNfoStatusForJob({
|
||||||
|
job,
|
||||||
|
outputPath: resolvedPaths.effectiveOutputPath,
|
||||||
|
outputStatus,
|
||||||
|
includeFsChecks,
|
||||||
|
settings
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
@@ -1061,7 +1249,8 @@ function enrichJobRow(job, settings = null, options = {}) {
|
|||||||
encodeSuccess,
|
encodeSuccess,
|
||||||
rawStatus,
|
rawStatus,
|
||||||
outputStatus,
|
outputStatus,
|
||||||
movieDirStatus
|
movieDirStatus,
|
||||||
|
nfoStatus
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2727,6 +2916,48 @@ function parseSourceJobIdFromPlan(encodePlanRaw) {
|
|||||||
return sourceJobId || null;
|
return sourceJobId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractDiscNumberFromDeleteContext(row) {
|
||||||
|
const values = [
|
||||||
|
row?.raw_path,
|
||||||
|
row?.title,
|
||||||
|
row?.detected_title
|
||||||
|
];
|
||||||
|
for (const value of values) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
const match = raw.match(/(?:^|\W)D(?:ISC)?\s*[-_ ]?(\d+)(?:\W|$)/i)
|
||||||
|
|| raw.match(/-\s*D(\d+)\s*-\s*RAW/i);
|
||||||
|
const discNumber = normalizeJobIdValue(match?.[1]);
|
||||||
|
if (discNumber) {
|
||||||
|
return discNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDeletePreviewRole(row) {
|
||||||
|
if (!row || typeof row !== 'object') {
|
||||||
|
return { roleKey: 'job', roleLabel: 'Job' };
|
||||||
|
}
|
||||||
|
if (isSeriesContainerRow(row) || isMultipartContainerRow(row)) {
|
||||||
|
return { roleKey: 'container', roleLabel: 'Container' };
|
||||||
|
}
|
||||||
|
if (isMultipartMergeRow(row)) {
|
||||||
|
return { roleKey: 'merge', roleLabel: 'Merge' };
|
||||||
|
}
|
||||||
|
if (isSeriesChildRow(row) || isMultipartChildRow(row)) {
|
||||||
|
const discNumber = extractDiscNumberFromDeleteContext(row);
|
||||||
|
return {
|
||||||
|
roleKey: 'disc',
|
||||||
|
roleLabel: discNumber ? `Disk ${discNumber}` : 'Disk/Child'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (normalizeJobIdValue(row?.parent_job_id)) {
|
||||||
|
return { roleKey: 'child', roleLabel: 'Child-Job' };
|
||||||
|
}
|
||||||
|
return { roleKey: 'job', roleLabel: 'Einzeljob' };
|
||||||
|
}
|
||||||
|
|
||||||
function parseRetryLinkedJobIdsFromLogLines(lines = []) {
|
function parseRetryLinkedJobIdsFromLogLines(lines = []) {
|
||||||
const jobIds = new Set();
|
const jobIds = new Set();
|
||||||
const list = Array.isArray(lines) ? lines : [];
|
const list = Array.isArray(lines) ? lines : [];
|
||||||
@@ -5104,6 +5335,124 @@ class HistoryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateJobNfo(jobId, options = {}) {
|
||||||
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||||
|
if (!normalizedJobId) {
|
||||||
|
const error = new Error('Ungültige Job-ID.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = String(options?.mode || 'manual').trim().toLowerCase() || 'manual';
|
||||||
|
const requireSettingEnabled = Boolean(options?.requireSettingEnabled);
|
||||||
|
const requireSettingDisabled = Boolean(options?.requireSettingDisabled);
|
||||||
|
const failIfExists = Boolean(options?.failIfExists);
|
||||||
|
const failIfOutputMissing = Boolean(options?.failIfOutputMissing);
|
||||||
|
|
||||||
|
const [job, settings] = await Promise.all([
|
||||||
|
this.getJobById(normalizedJobId),
|
||||||
|
settingsService.getSettingsMap()
|
||||||
|
]);
|
||||||
|
if (!job) {
|
||||||
|
const error = new Error('Job nicht gefunden.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched = enrichJobRow(job, settings, { includeFsChecks: true });
|
||||||
|
const nfoStatus = enriched?.nfoStatus && typeof enriched.nfoStatus === 'object'
|
||||||
|
? enriched.nfoStatus
|
||||||
|
: buildNfoStatusForJob({
|
||||||
|
job: enriched,
|
||||||
|
outputPath: enriched?.output_path || null,
|
||||||
|
outputStatus: enriched?.outputStatus || null,
|
||||||
|
includeFsChecks: true,
|
||||||
|
settings
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requireSettingEnabled && !nfoStatus.settingEnabled) {
|
||||||
|
return {
|
||||||
|
generated: false,
|
||||||
|
skipped: true,
|
||||||
|
reason: 'setting_disabled',
|
||||||
|
nfoStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireSettingDisabled && nfoStatus.settingEnabled) {
|
||||||
|
const error = new Error('NFO-Generierung ist in den Settings aktiv. Manuelle Generierung ist nur bei deaktivierter Auto-NFO erlaubt.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nfoStatus.nfoPath) {
|
||||||
|
const error = new Error('Für diesen Job kann keine NFO-Datei erzeugt werden (Output ist keine Datei).');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nfoStatus.outputExists || !nfoStatus.outputIsFile) {
|
||||||
|
if (failIfOutputMissing) {
|
||||||
|
const error = new Error('NFO kann nicht erzeugt werden: Output-Datei fehlt.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
generated: false,
|
||||||
|
skipped: true,
|
||||||
|
reason: 'output_missing',
|
||||||
|
nfoStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nfoStatus.nfoExists) {
|
||||||
|
if (failIfExists) {
|
||||||
|
const error = new Error('NFO-Datei existiert bereits.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
generated: false,
|
||||||
|
skipped: true,
|
||||||
|
reason: 'nfo_exists',
|
||||||
|
nfoStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = resolveNfoMetadataFromJob(enriched);
|
||||||
|
const xml = buildNfoXml(metadata);
|
||||||
|
fs.writeFileSync(nfoStatus.nfoPath, xml, 'utf8');
|
||||||
|
|
||||||
|
const refreshedNfoStatus = buildNfoStatusForJob({
|
||||||
|
job: enriched,
|
||||||
|
outputPath: enriched?.output_path || null,
|
||||||
|
outputStatus: enriched?.outputStatus || null,
|
||||||
|
includeFsChecks: true,
|
||||||
|
settings
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.appendLog(
|
||||||
|
normalizedJobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`NFO-Datei erstellt (${mode}): ${nfoStatus.nfoPath}`
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
logger.info('job:nfo:generated', {
|
||||||
|
jobId: normalizedJobId,
|
||||||
|
mode,
|
||||||
|
nfoPath: nfoStatus.nfoPath
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
generated: true,
|
||||||
|
skipped: false,
|
||||||
|
mode,
|
||||||
|
nfoPath: nfoStatus.nfoPath,
|
||||||
|
metadata,
|
||||||
|
nfoStatus: refreshedNfoStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getJobArchiveDescriptor(jobId, target, options = {}) {
|
async getJobArchiveDescriptor(jobId, target, options = {}) {
|
||||||
const normalizedJobId = normalizeJobIdValue(jobId);
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||||
if (!normalizedJobId) {
|
if (!normalizedJobId) {
|
||||||
@@ -6649,14 +6998,7 @@ class HistoryService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!inspection.exists) {
|
if (!inspection.exists) {
|
||||||
upsertMoviePath({
|
// Output candidates must stay file-based only. Missing paths are not actionable.
|
||||||
path: row.path,
|
|
||||||
exists: false,
|
|
||||||
isDirectory: false,
|
|
||||||
isFile: true,
|
|
||||||
jobIds: Array.from(row.jobIds),
|
|
||||||
sources
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (inspection.isDirectory) {
|
if (inspection.isDirectory) {
|
||||||
@@ -6710,6 +7052,11 @@ class HistoryService {
|
|||||||
|
|
||||||
async getJobDeletePreview(jobId, options = {}) {
|
async getJobDeletePreview(jobId, options = {}) {
|
||||||
const includeRelated = options?.includeRelated !== false;
|
const includeRelated = options?.includeRelated !== false;
|
||||||
|
const requestedSelectedJobIds = Array.isArray(options?.selectedJobIds)
|
||||||
|
? options.selectedJobIds
|
||||||
|
.map((item) => normalizeJobIdValue(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
const normalizedJobId = normalizeJobIdValue(jobId);
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||||
if (!normalizedJobId) {
|
if (!normalizedJobId) {
|
||||||
const error = new Error('Ungültige Job-ID.');
|
const error = new Error('Ungültige Job-ID.');
|
||||||
@@ -6718,20 +7065,35 @@ class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, { includeRelated });
|
const jobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, { includeRelated });
|
||||||
|
const allJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
|
||||||
|
const allJobIdSet = new Set(allJobIds);
|
||||||
|
const hasExplicitSelectedJobIds = Array.isArray(requestedSelectedJobIds);
|
||||||
|
const effectiveSelectedJobIds = includeRelated
|
||||||
|
? (hasExplicitSelectedJobIds
|
||||||
|
? requestedSelectedJobIds.filter((id) => allJobIdSet.has(id))
|
||||||
|
: allJobIds)
|
||||||
|
: [normalizedJobId];
|
||||||
|
const effectiveSelectedJobIdSet = new Set(effectiveSelectedJobIds);
|
||||||
|
const selectedJobs = jobs.filter((job) => effectiveSelectedJobIdSet.has(normalizeJobIdValue(job?.id)));
|
||||||
|
|
||||||
const settings = await settingsService.getSettingsMap();
|
const settings = await settingsService.getSettingsMap();
|
||||||
const relatedJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
|
const relatedJobIds = selectedJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
|
||||||
const [lineageArtifactsByJobId, outputFoldersByJobId] = await Promise.all([
|
const [lineageArtifactsByJobId, outputFoldersByJobId] = relatedJobIds.length > 0
|
||||||
|
? await Promise.all([
|
||||||
this.listJobLineageArtifactsByJobIds(relatedJobIds),
|
this.listJobLineageArtifactsByJobIds(relatedJobIds),
|
||||||
this.listJobOutputFoldersByJobIds(relatedJobIds)
|
this.listJobOutputFoldersByJobIds(relatedJobIds)
|
||||||
]);
|
])
|
||||||
|
: [new Map(), new Map()];
|
||||||
const preview = this._buildDeletePreviewFromJobs(
|
const preview = this._buildDeletePreviewFromJobs(
|
||||||
jobs,
|
selectedJobs,
|
||||||
settings,
|
settings,
|
||||||
lineageArtifactsByJobId,
|
lineageArtifactsByJobId,
|
||||||
outputFoldersByJobId
|
outputFoldersByJobId
|
||||||
);
|
);
|
||||||
const relatedJobs = jobs.map((job) => {
|
const relatedJobs = jobs.map((job) => {
|
||||||
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
|
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
|
||||||
|
const roleMeta = resolveDeletePreviewRole(job);
|
||||||
|
const id = normalizeJobIdValue(job?.id);
|
||||||
return {
|
return {
|
||||||
id: Number(job.id),
|
id: Number(job.id),
|
||||||
parentJobId: normalizeJobIdValue(job.parent_job_id),
|
parentJobId: normalizeJobIdValue(job.parent_job_id),
|
||||||
@@ -6739,17 +7101,23 @@ class HistoryService {
|
|||||||
status: String(job.status || '').trim() || null,
|
status: String(job.status || '').trim() || null,
|
||||||
discDevice: String(job.disc_device || '').trim() || null,
|
discDevice: String(job.disc_device || '').trim() || null,
|
||||||
isPrimary: Number(job.id) === normalizedJobId,
|
isPrimary: Number(job.id) === normalizedJobId,
|
||||||
|
selected: Boolean(id && effectiveSelectedJobIdSet.has(id)),
|
||||||
|
roleKey: roleMeta.roleKey,
|
||||||
|
roleLabel: roleMeta.roleLabel,
|
||||||
createdAt: String(job.created_at || '').trim() || null,
|
createdAt: String(job.created_at || '').trim() || null,
|
||||||
orphanRawImport: isOrphanRawImportMakemkvInfo(mkInfo)
|
orphanRawImport: isOrphanRawImportMakemkvInfo(mkInfo)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const orphanRawImportJobs = relatedJobs.filter((row) => Boolean(row?.orphanRawImport)).length;
|
const orphanRawImportJobs = relatedJobs.filter(
|
||||||
|
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
|
||||||
|
).length;
|
||||||
const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length;
|
const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length;
|
||||||
const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length;
|
const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobId: normalizedJobId,
|
jobId: normalizedJobId,
|
||||||
includeRelated,
|
includeRelated,
|
||||||
|
selectedJobIds: effectiveSelectedJobIds,
|
||||||
relatedJobs,
|
relatedJobs,
|
||||||
pathCandidates: preview.pathCandidates,
|
pathCandidates: preview.pathCandidates,
|
||||||
protectedRoots: preview.protectedRoots,
|
protectedRoots: preview.protectedRoots,
|
||||||
@@ -6758,6 +7126,7 @@ class HistoryService {
|
|||||||
},
|
},
|
||||||
counts: {
|
counts: {
|
||||||
relatedJobs: relatedJobs.length,
|
relatedJobs: relatedJobs.length,
|
||||||
|
selectedJobs: effectiveSelectedJobIds.length,
|
||||||
orphanRawImportJobs,
|
orphanRawImportJobs,
|
||||||
rawCandidates: preview.pathCandidates.raw.length,
|
rawCandidates: preview.pathCandidates.raw.length,
|
||||||
movieCandidates: preview.pathCandidates.movie.length,
|
movieCandidates: preview.pathCandidates.movie.length,
|
||||||
@@ -6976,6 +7345,11 @@ class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const includeRelated = Boolean(options?.includeRelated);
|
const includeRelated = Boolean(options?.includeRelated);
|
||||||
|
const selectedJobIds = Array.isArray(options?.selectedJobIds)
|
||||||
|
? options.selectedJobIds
|
||||||
|
.map((item) => normalizeJobIdValue(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
||||||
? options.selectedRawPaths
|
? options.selectedRawPaths
|
||||||
.map((item) => String(item || '').trim())
|
.map((item) => String(item || '').trim())
|
||||||
@@ -6987,13 +7361,19 @@ class HistoryService {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (includeRelated || (selectedRawPaths && selectedRawPaths.length > 0) || (selectedMoviePaths && selectedMoviePaths.length > 0)) {
|
const hasSelectedJobFilter = Array.isArray(options?.selectedJobIds);
|
||||||
|
if (
|
||||||
|
includeRelated
|
||||||
|
|| hasSelectedJobFilter
|
||||||
|
|| (selectedRawPaths && selectedRawPaths.length > 0)
|
||||||
|
|| (selectedMoviePaths && selectedMoviePaths.length > 0)
|
||||||
|
) {
|
||||||
const normalizedJobId = normalizeJobIdValue(jobId);
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||||
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated });
|
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated, selectedJobIds });
|
||||||
const summary = this._deletePathsFromPreview(preview, target, { selectedRawPaths, selectedMoviePaths });
|
const summary = this._deletePathsFromPreview(preview, target, { selectedRawPaths, selectedMoviePaths });
|
||||||
const relatedJobIds = Array.from(new Set(
|
const relatedJobIds = Array.from(new Set(
|
||||||
(Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [])
|
(Array.isArray(preview?.selectedJobIds) ? preview.selectedJobIds : [])
|
||||||
.map((row) => normalizeJobIdValue(row?.id))
|
.map((row) => normalizeJobIdValue(row))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -7011,6 +7391,7 @@ class HistoryService {
|
|||||||
logger.info('job:delete-files', {
|
logger.info('job:delete-files', {
|
||||||
jobId: normalizedJobId,
|
jobId: normalizedJobId,
|
||||||
includeRelated,
|
includeRelated,
|
||||||
|
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
|
||||||
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
||||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0,
|
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0,
|
||||||
summary
|
summary
|
||||||
@@ -7782,6 +8163,11 @@ class HistoryService {
|
|||||||
.map((item) => String(item || '').trim())
|
.map((item) => String(item || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
|
const normalizedSelectedJobIds = Array.isArray(options?.selectedJobIds)
|
||||||
|
? options.selectedJobIds
|
||||||
|
.map((item) => normalizeJobIdValue(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
const includeRelated = Boolean(options?.includeRelated);
|
const includeRelated = Boolean(options?.includeRelated);
|
||||||
if (!includeRelated) {
|
if (!includeRelated) {
|
||||||
@@ -7933,14 +8319,18 @@ class HistoryService {
|
|||||||
const resolved = await this.getJobByIdOrReplacement(jobId);
|
const resolved = await this.getJobByIdOrReplacement(jobId);
|
||||||
const existing = resolved?.job || null;
|
const existing = resolved?.job || null;
|
||||||
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
|
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
|
||||||
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
|
const preview = await this.getJobDeletePreview(normalizedJobId, {
|
||||||
let deleteJobIds = Array.isArray(preview?.relatedJobs)
|
includeRelated: true,
|
||||||
? preview.relatedJobs
|
selectedJobIds: normalizedSelectedJobIds
|
||||||
.map((row) => normalizeJobIdValue(row?.id))
|
});
|
||||||
|
let deleteJobIds = Array.isArray(preview?.selectedJobIds)
|
||||||
|
? preview.selectedJobIds
|
||||||
|
.map((row) => normalizeJobIdValue(row))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
let rowById = new Map();
|
let rowById = new Map();
|
||||||
if (normalizedJobId && !deleteJobIds.includes(normalizedJobId)) {
|
const hasExplicitSelectedJobFilter = Array.isArray(normalizedSelectedJobIds);
|
||||||
|
if (!hasExplicitSelectedJobFilter && normalizedJobId && !deleteJobIds.includes(normalizedJobId)) {
|
||||||
deleteJobIds = [...deleteJobIds, normalizedJobId];
|
deleteJobIds = [...deleteJobIds, normalizedJobId];
|
||||||
}
|
}
|
||||||
if (deleteJobIds.length > 0) {
|
if (deleteJobIds.length > 0) {
|
||||||
|
|||||||
@@ -11,6 +11,23 @@ const LEVELS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
|
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
|
||||||
|
let consoleConfig = {
|
||||||
|
enabled: true,
|
||||||
|
levels: {
|
||||||
|
debug: true,
|
||||||
|
info: true,
|
||||||
|
warn: true,
|
||||||
|
error: true
|
||||||
|
},
|
||||||
|
http: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBoolean(value, fallback = true) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureLogDir(logDirPath) {
|
function ensureLogDir(logDirPath) {
|
||||||
try {
|
try {
|
||||||
@@ -118,6 +135,10 @@ function emit(level, scope, message, meta = null) {
|
|||||||
const line = safeJson(payload);
|
const line = safeJson(payload);
|
||||||
writeLine(line);
|
writeLine(line);
|
||||||
|
|
||||||
|
if (!shouldEmitToConsole(normLevel, scope)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
|
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
|
||||||
if (normLevel === 'error') {
|
if (normLevel === 'error') {
|
||||||
console.error(print, payload.meta ? payload.meta : '');
|
console.error(print, payload.meta ? payload.meta : '');
|
||||||
@@ -128,6 +149,24 @@ function emit(level, scope, message, meta = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldEmitToConsole(level, scope) {
|
||||||
|
if (!consoleConfig.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLevel = String(level || 'info').toLowerCase();
|
||||||
|
if (consoleConfig.levels[normalizedLevel] === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedScope = String(scope || '').trim().toUpperCase();
|
||||||
|
if (normalizedScope === 'HTTP' && !consoleConfig.http) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function child(scope) {
|
function child(scope) {
|
||||||
return {
|
return {
|
||||||
debug(message, meta) {
|
debug(message, meta) {
|
||||||
@@ -145,7 +184,55 @@ function child(scope) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setConsoleOutputEnabled(enabled) {
|
||||||
|
consoleConfig.enabled = Boolean(enabled);
|
||||||
|
return consoleConfig.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConsoleOutputEnabled() {
|
||||||
|
return consoleConfig.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureConsoleOutput(options = {}) {
|
||||||
|
const opts = options && typeof options === 'object' ? options : {};
|
||||||
|
if (Object.prototype.hasOwnProperty.call(opts, 'enabled')) {
|
||||||
|
consoleConfig.enabled = normalizeBoolean(opts.enabled, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.levels && typeof opts.levels === 'object') {
|
||||||
|
const sourceLevels = opts.levels;
|
||||||
|
for (const level of Object.keys(LEVELS)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(sourceLevels, level)) {
|
||||||
|
consoleConfig.levels[level] = normalizeBoolean(sourceLevels[level], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(opts, 'http')) {
|
||||||
|
consoleConfig.http = normalizeBoolean(opts.http, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getConsoleOutputConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConsoleOutputConfig() {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(consoleConfig.enabled),
|
||||||
|
levels: {
|
||||||
|
debug: Boolean(consoleConfig.levels.debug),
|
||||||
|
info: Boolean(consoleConfig.levels.info),
|
||||||
|
warn: Boolean(consoleConfig.levels.warn),
|
||||||
|
error: Boolean(consoleConfig.levels.error)
|
||||||
|
},
|
||||||
|
http: Boolean(consoleConfig.http)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
child,
|
child,
|
||||||
emit
|
emit,
|
||||||
|
setConsoleOutputEnabled,
|
||||||
|
isConsoleOutputEnabled,
|
||||||
|
configureConsoleOutput,
|
||||||
|
getConsoleOutputConfig
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,14 @@ const os = require('os');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn, spawnSync } = require('child_process');
|
const { spawn, spawnSync } = require('child_process');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const logger = require('./logger').child('SETTINGS');
|
const loggerService = require('./logger');
|
||||||
|
const logger = loggerService.child('SETTINGS');
|
||||||
const {
|
const {
|
||||||
parseJson,
|
parseJson,
|
||||||
normalizeValueByType,
|
normalizeValueByType,
|
||||||
serializeValueByType,
|
serializeValueByType,
|
||||||
validateSetting
|
validateSetting,
|
||||||
|
toBoolean
|
||||||
} = require('../utils/validators');
|
} = require('../utils/validators');
|
||||||
const { splitArgs } = require('../utils/commandLine');
|
const { splitArgs } = require('../utils/commandLine');
|
||||||
const { setLogRootDir } = require('./logPathService');
|
const { setLogRootDir } = require('./logPathService');
|
||||||
@@ -59,6 +61,20 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
|
|||||||
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||||
|
const SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY = 'server_console_log_output_enabled';
|
||||||
|
const SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY = 'server_console_log_http_enabled';
|
||||||
|
const SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY = 'server_console_log_debug_enabled';
|
||||||
|
const SERVER_CONSOLE_LOG_INFO_ENABLED_KEY = 'server_console_log_info_enabled';
|
||||||
|
const SERVER_CONSOLE_LOG_WARN_ENABLED_KEY = 'server_console_log_warn_enabled';
|
||||||
|
const SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY = 'server_console_log_error_enabled';
|
||||||
|
const SERVER_CONSOLE_LOG_SETTING_KEYS = new Set([
|
||||||
|
SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY,
|
||||||
|
SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY,
|
||||||
|
SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY,
|
||||||
|
SERVER_CONSOLE_LOG_INFO_ENABLED_KEY,
|
||||||
|
SERVER_CONSOLE_LOG_WARN_ENABLED_KEY,
|
||||||
|
SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY
|
||||||
|
]);
|
||||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd', 'audiobook'];
|
const MEDIA_PROFILES = ['bluray', 'dvd', 'cd', 'audiobook'];
|
||||||
const PROFILED_SETTINGS = {
|
const PROFILED_SETTINGS = {
|
||||||
raw_dir: {
|
raw_dir: {
|
||||||
@@ -168,6 +184,35 @@ function applyRuntimeLogDirSetting(rawValue) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBooleanSetting(rawValue, fallback = true) {
|
||||||
|
const normalizedRaw = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
|
||||||
|
if (normalizedRaw === null || normalizedRaw === undefined || normalizedRaw === '') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return toBoolean(normalizedRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRuntimeServerConsoleLoggingSettingsFromMap(settingsMap = {}) {
|
||||||
|
const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||||
|
const enabled = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY], true);
|
||||||
|
const http = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_HTTP_ENABLED_KEY], true);
|
||||||
|
const debug = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_DEBUG_ENABLED_KEY], true);
|
||||||
|
const info = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_INFO_ENABLED_KEY], true);
|
||||||
|
const warn = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_WARN_ENABLED_KEY], true);
|
||||||
|
const error = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_ERROR_ENABLED_KEY], true);
|
||||||
|
const applied = loggerService.configureConsoleOutput({
|
||||||
|
enabled,
|
||||||
|
http,
|
||||||
|
levels: {
|
||||||
|
debug,
|
||||||
|
info,
|
||||||
|
warn,
|
||||||
|
error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
function isImmutableSettingKey(key) {
|
function isImmutableSettingKey(key) {
|
||||||
return IMMUTABLE_SETTING_KEYS.has(String(key || '').trim().toLowerCase());
|
return IMMUTABLE_SETTING_KEYS.has(String(key || '').trim().toLowerCase());
|
||||||
}
|
}
|
||||||
@@ -814,6 +859,21 @@ class SettingsService {
|
|||||||
return { ...(snapshot?.map || {}) };
|
return { ...(snapshot?.map || {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyRuntimeSettingsFromMap(settingsMap = {}) {
|
||||||
|
const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||||
|
const logDir = applyRuntimeLogDirSetting(source[LOG_DIR_SETTING_KEY]);
|
||||||
|
const consoleLogConfig = applyRuntimeServerConsoleLoggingSettingsFromMap(source);
|
||||||
|
return {
|
||||||
|
logDir,
|
||||||
|
serverConsoleLogConfig: consoleLogConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyRuntimeSettings() {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
return this.applyRuntimeSettingsFromMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
normalizeMediaProfile(value) {
|
normalizeMediaProfile(value) {
|
||||||
return normalizeMediaProfileValue(value);
|
return normalizeMediaProfileValue(value);
|
||||||
}
|
}
|
||||||
@@ -1014,6 +1074,10 @@ class SettingsService {
|
|||||||
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
|
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
|
||||||
applyRuntimeLogDirSetting(result.normalized);
|
applyRuntimeLogDirSetting(result.normalized);
|
||||||
}
|
}
|
||||||
|
if (SERVER_CONSOLE_LOG_SETTING_KEYS.has(String(key || '').trim().toLowerCase())) {
|
||||||
|
const map = await this.getSettingsMap({ forceRefresh: true });
|
||||||
|
applyRuntimeServerConsoleLoggingSettingsFromMap(map);
|
||||||
|
}
|
||||||
this.invalidateSettingsCache([key]);
|
this.invalidateSettingsCache([key]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1107,6 +1171,13 @@ class SettingsService {
|
|||||||
if (logDirChange) {
|
if (logDirChange) {
|
||||||
applyRuntimeLogDirSetting(logDirChange.value);
|
applyRuntimeLogDirSetting(logDirChange.value);
|
||||||
}
|
}
|
||||||
|
const consoleOutputChange = normalizedEntries.find(
|
||||||
|
(item) => SERVER_CONSOLE_LOG_SETTING_KEYS.has(String(item?.key || '').trim().toLowerCase())
|
||||||
|
);
|
||||||
|
if (consoleOutputChange) {
|
||||||
|
const map = await this.getSettingsMap({ forceRefresh: true });
|
||||||
|
applyRuntimeServerConsoleLoggingSettingsFromMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
this.invalidateSettingsCache(normalizedEntries.map((item) => item.key));
|
this.invalidateSettingsCache(normalizedEntries.map((item) => item.key));
|
||||||
logger.info('settings:bulk-updated', { count: normalizedEntries.length });
|
logger.info('settings:bulk-updated', { count: normalizedEntries.length });
|
||||||
|
|||||||
@@ -366,6 +366,31 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('hardware_monitoring_interval_ms', 'Monitoring', 'Hardware Monitoring Intervall (ms)', 'number', 1, 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.', '5000', '[]', '{"min":1000,"max":60000}', 140);
|
VALUES ('hardware_monitoring_interval_ms', 'Monitoring', 'Hardware Monitoring Intervall (ms)', 'number', 1, 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.', '5000', '[]', '{"min":1000,"max":60000}', 140);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_interval_ms', '5000');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_interval_ms', '5000');
|
||||||
|
|
||||||
|
-- Logging
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_output_enabled', 'Logging', 'Terminal-Ausgabe aktiv', 'boolean', 1, 'Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv.', 'true', '[]', '{}', 150);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_output_enabled', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_http_enabled', 'Logging', 'HTTP-Logs im Terminal', 'boolean', 1, 'Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert.', 'true', '[]', '{}', 151);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_http_enabled', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_debug_enabled', 'Logging', 'DEBUG im Terminal', 'boolean', 1, 'Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 152);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_debug_enabled', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_info_enabled', 'Logging', 'INFO im Terminal', 'boolean', 1, 'Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 153);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_info_enabled', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_warn_enabled', 'Logging', 'WARN im Terminal', 'boolean', 1, 'Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 154);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_warn_enabled', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('server_console_log_error_enabled', 'Logging', 'ERROR im Terminal', 'boolean', 1, 'Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert.', 'true', '[]', '{}', 155);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('server_console_log_error_enabled', 'true');
|
||||||
|
|
||||||
-- Tools
|
-- Tools
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('makemkv_command', 'Tools', 'MakeMKV Kommando', 'string', 1, 'Pfad oder Befehl für makemkvcon.', 'makemkvcon', '[]', '{"minLength":1}', 200);
|
VALUES ('makemkv_command', 'Tools', 'MakeMKV Kommando', 'string', 1, 'Pfad oder Befehl für makemkvcon.', 'makemkvcon', '[]', '{"minLength":1}', 200);
|
||||||
@@ -391,6 +416,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220);
|
VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225);
|
VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1');
|
||||||
@@ -613,6 +642,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431);
|
VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('generate_nfo_after_encode', 'Metadaten', 'NFO nach Encode erzeugen', 'boolean', 1, 'Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei.', 'false', '[]', '{}', 432);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('generate_nfo_after_encode', 'false');
|
||||||
|
|
||||||
-- Benachrichtigungen
|
-- Benachrichtigungen
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495);
|
VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.15.0-3",
|
"version": "0.16.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.15.0-3",
|
"version": "0.16.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.15.0-3",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -836,8 +836,21 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history']);
|
afterMutationInvalidate(['/history']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
async generateJobNfo(jobId) {
|
||||||
|
const result = await request(`/history/${jobId}/nfo/generate`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
async deleteJobFiles(jobId, target = 'both', options = {}) {
|
async deleteJobFiles(jobId, target = 'both', options = {}) {
|
||||||
const includeRelated = Boolean(options?.includeRelated);
|
const includeRelated = Boolean(options?.includeRelated);
|
||||||
|
const selectedJobIds = Array.isArray(options?.selectedJobIds)
|
||||||
|
? options.selectedJobIds
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
.map((value) => Math.trunc(value))
|
||||||
|
: null;
|
||||||
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
||||||
? options.selectedRawPaths
|
? options.selectedRawPaths
|
||||||
.map((item) => String(item || '').trim())
|
.map((item) => String(item || '').trim())
|
||||||
@@ -853,6 +866,7 @@ export const api = {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
target,
|
target,
|
||||||
includeRelated,
|
includeRelated,
|
||||||
|
...(selectedJobIds ? { selectedJobIds } : {}),
|
||||||
...(selectedRawPaths ? { selectedRawPaths } : {}),
|
...(selectedRawPaths ? { selectedRawPaths } : {}),
|
||||||
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
||||||
})
|
})
|
||||||
@@ -862,14 +876,29 @@ export const api = {
|
|||||||
},
|
},
|
||||||
getJobDeletePreview(jobId, options = {}) {
|
getJobDeletePreview(jobId, options = {}) {
|
||||||
const includeRelated = options?.includeRelated !== false;
|
const includeRelated = options?.includeRelated !== false;
|
||||||
|
const selectedJobIds = Array.isArray(options?.selectedJobIds)
|
||||||
|
? options.selectedJobIds
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
.map((value) => Math.trunc(value))
|
||||||
|
: null;
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
query.set('includeRelated', includeRelated ? '1' : '0');
|
query.set('includeRelated', includeRelated ? '1' : '0');
|
||||||
|
if (selectedJobIds) {
|
||||||
|
query.set('selectedJobIds', selectedJobIds.join(','));
|
||||||
|
}
|
||||||
return request(`/history/${jobId}/delete-preview?${query.toString()}`);
|
return request(`/history/${jobId}/delete-preview?${query.toString()}`);
|
||||||
},
|
},
|
||||||
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
||||||
const includeRelated = Boolean(options?.includeRelated);
|
const includeRelated = Boolean(options?.includeRelated);
|
||||||
const resetDriveState = Boolean(options?.resetDriveState);
|
const resetDriveState = Boolean(options?.resetDriveState);
|
||||||
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
|
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
|
||||||
|
const selectedJobIds = Array.isArray(options?.selectedJobIds)
|
||||||
|
? options.selectedJobIds
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
.map((value) => Math.trunc(value))
|
||||||
|
: null;
|
||||||
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
||||||
? options.selectedRawPaths
|
? options.selectedRawPaths
|
||||||
.map((item) => String(item || '').trim())
|
.map((item) => String(item || '').trim())
|
||||||
@@ -891,6 +920,7 @@ export const api = {
|
|||||||
includeRelated,
|
includeRelated,
|
||||||
resetDriveState,
|
resetDriveState,
|
||||||
preserveRawForImportJobs,
|
preserveRawForImportJobs,
|
||||||
|
...(selectedJobIds ? { selectedJobIds } : {}),
|
||||||
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
|
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
|
||||||
...(selectedRawPaths ? { selectedRawPaths } : {}),
|
...(selectedRawPaths ? { selectedRawPaths } : {}),
|
||||||
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const GENERAL_TOOL_KEYS = new Set([
|
|||||||
'mkvmerge_command',
|
'mkvmerge_command',
|
||||||
'ffprobe_command',
|
'ffprobe_command',
|
||||||
'handbrake_restart_delete_incomplete_output',
|
'handbrake_restart_delete_incomplete_output',
|
||||||
|
'handbrake_pre_metadata_scan_enabled',
|
||||||
'script_test_timeout_ms'
|
'script_test_timeout_ms'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1060,6 +1060,7 @@ export default function JobDetailDialog({
|
|||||||
logLoadingMode = null,
|
logLoadingMode = null,
|
||||||
onAssignOmdb,
|
onAssignOmdb,
|
||||||
onAssignCdMetadata,
|
onAssignCdMetadata,
|
||||||
|
onGenerateNfo,
|
||||||
onAcknowledgeError,
|
onAcknowledgeError,
|
||||||
onResumeReady,
|
onResumeReady,
|
||||||
onRestartEncode,
|
onRestartEncode,
|
||||||
@@ -1078,6 +1079,7 @@ export default function JobDetailDialog({
|
|||||||
omdbAssignBusy = false,
|
omdbAssignBusy = false,
|
||||||
cdMetadataAssignBusy = false,
|
cdMetadataAssignBusy = false,
|
||||||
acknowledgeErrorBusy = false,
|
acknowledgeErrorBusy = false,
|
||||||
|
generateNfoBusy = false,
|
||||||
actionBusy = false,
|
actionBusy = false,
|
||||||
cancelBusy = false,
|
cancelBusy = false,
|
||||||
reencodeBusy = false,
|
reencodeBusy = false,
|
||||||
@@ -1088,6 +1090,8 @@ export default function JobDetailDialog({
|
|||||||
}) {
|
}) {
|
||||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||||
const statusUpper = String(job?.status || '').trim().toUpperCase();
|
const statusUpper = String(job?.status || '').trim().toUpperCase();
|
||||||
|
const lastStateUpper = String(job?.last_state || '').trim().toUpperCase();
|
||||||
|
const errorMessageLower = String(job?.error_message || '').trim().toLowerCase();
|
||||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(statusUpper);
|
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(statusUpper);
|
||||||
const softCancelable = ['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP'].includes(statusUpper);
|
const softCancelable = ['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP'].includes(statusUpper);
|
||||||
const showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
|
const showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
|
||||||
@@ -1123,6 +1127,22 @@ export default function JobDetailDialog({
|
|||||||
const isCd = mediaType === 'cd';
|
const isCd = mediaType === 'cd';
|
||||||
const isAudiobook = mediaType === 'audiobook';
|
const isAudiobook = mediaType === 'audiobook';
|
||||||
const isConverter = mediaType === 'converter';
|
const isConverter = mediaType === 'converter';
|
||||||
|
const retryRipRecoveryReason = (
|
||||||
|
errorMessageLower.includes('server-neustart')
|
||||||
|
|| errorMessageLower.includes('rip ist unvollständig')
|
||||||
|
|| errorMessageLower.includes('rip-validierung fehlgeschlagen')
|
||||||
|
);
|
||||||
|
const retryRipRequired = Boolean(
|
||||||
|
!running
|
||||||
|
&& !isAudiobook
|
||||||
|
&& !isConverter
|
||||||
|
&& ['ERROR', 'CANCELLED'].includes(statusUpper)
|
||||||
|
&& (
|
||||||
|
['RIPPING', 'CD_RIPPING'].includes(lastStateUpper)
|
||||||
|
|| retryRipRecoveryReason
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const blockMetadataAndReviewUntilRetry = retryRipRequired;
|
||||||
const converterMediaType = isConverter ? (resolveConverterMediaType(job) || 'video') : null;
|
const converterMediaType = isConverter ? (resolveConverterMediaType(job) || 'video') : null;
|
||||||
const converterMediaTypeLabel = converterMediaType === 'audio'
|
const converterMediaTypeLabel = converterMediaType === 'audio'
|
||||||
? 'Audio'
|
? 'Audio'
|
||||||
@@ -1215,6 +1235,7 @@ export default function JobDetailDialog({
|
|||||||
&& job?.rawStatus?.isEmpty !== true
|
&& job?.rawStatus?.isEmpty !== true
|
||||||
&& !running
|
&& !running
|
||||||
&& mediaType !== 'audiobook'
|
&& mediaType !== 'audiobook'
|
||||||
|
&& !blockMetadataAndReviewUntilRetry
|
||||||
&& typeof onRestartReview === 'function'
|
&& typeof onRestartReview === 'function'
|
||||||
);
|
);
|
||||||
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
|
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
|
||||||
@@ -1271,6 +1292,14 @@ export default function JobDetailDialog({
|
|||||||
})()
|
})()
|
||||||
: null;
|
: null;
|
||||||
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
|
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
|
||||||
|
const nfoStatus = job?.nfoStatus && typeof job.nfoStatus === 'object'
|
||||||
|
? job.nfoStatus
|
||||||
|
: {};
|
||||||
|
const canGenerateNfo = Boolean(
|
||||||
|
!running
|
||||||
|
&& typeof onGenerateNfo === 'function'
|
||||||
|
&& nfoStatus?.canGenerateManual
|
||||||
|
);
|
||||||
const queueLocked = Boolean(isQueued && job?.id);
|
const queueLocked = Boolean(isQueued && job?.id);
|
||||||
const logCount = Number(job?.log_count || 0);
|
const logCount = Number(job?.log_count || 0);
|
||||||
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
||||||
@@ -1278,7 +1307,7 @@ export default function JobDetailDialog({
|
|||||||
const logTruncated = Boolean(logMeta?.truncated);
|
const logTruncated = Boolean(logMeta?.truncated);
|
||||||
const cdDetails = isCd ? resolveCdDetails(job) : null;
|
const cdDetails = isCd ? resolveCdDetails(job) : null;
|
||||||
const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null;
|
const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null;
|
||||||
const canRetry = isCd && !running && typeof onRetry === 'function';
|
const canRetry = !running && typeof onRetry === 'function' && (isCd || retryRipRequired);
|
||||||
const mediaTypeLabel = mediaType === 'bluray'
|
const mediaTypeLabel = mediaType === 'bluray'
|
||||||
? (isDvdSeries ? 'Blu-ray Serie' : 'Blu-ray')
|
? (isDvdSeries ? 'Blu-ray Serie' : 'Blu-ray')
|
||||||
: mediaType === 'dvd'
|
: mediaType === 'dvd'
|
||||||
@@ -1516,6 +1545,7 @@ export default function JobDetailDialog({
|
|||||||
const resolveChildActionState = (child) => {
|
const resolveChildActionState = (child) => {
|
||||||
const childStatusUpper = String(child?.status || '').trim().toUpperCase();
|
const childStatusUpper = String(child?.status || '').trim().toUpperCase();
|
||||||
const childLastStateUpper = String(child?.last_state || '').trim().toUpperCase();
|
const childLastStateUpper = String(child?.last_state || '').trim().toUpperCase();
|
||||||
|
const childErrorMessageLower = String(child?.error_message || '').trim().toLowerCase();
|
||||||
const childRunning = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(childStatusUpper);
|
const childRunning = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(childStatusUpper);
|
||||||
const childCanResumeReady = Boolean(
|
const childCanResumeReady = Boolean(
|
||||||
(childStatusUpper === 'READY_TO_ENCODE' || childLastStateUpper === 'READY_TO_ENCODE')
|
(childStatusUpper === 'READY_TO_ENCODE' || childLastStateUpper === 'READY_TO_ENCODE')
|
||||||
@@ -1537,14 +1567,31 @@ export default function JobDetailDialog({
|
|||||||
const childHasRestartInput = Boolean(child?.encode_input_path || child?.raw_path || child?.encodePlan?.encodeInputPath);
|
const childHasRestartInput = Boolean(child?.encode_input_path || child?.raw_path || child?.encodePlan?.encodeInputPath);
|
||||||
const childHasRaw = Boolean(child?.rawStatus?.exists || child?.raw_path || child?.encode_input_path);
|
const childHasRaw = Boolean(child?.rawStatus?.exists || child?.raw_path || child?.encode_input_path);
|
||||||
const childCanRestartEncode = Boolean(childHasConfirmedPlan && childHasRestartInput && !childRunning && childHasRaw);
|
const childCanRestartEncode = Boolean(childHasConfirmedPlan && childHasRestartInput && !childRunning && childHasRaw);
|
||||||
|
const childRetryRipRecoveryReason = (
|
||||||
|
childErrorMessageLower.includes('server-neustart')
|
||||||
|
|| childErrorMessageLower.includes('rip ist unvollständig')
|
||||||
|
|| childErrorMessageLower.includes('rip-validierung fehlgeschlagen')
|
||||||
|
);
|
||||||
|
const childRetryRipRequired = Boolean(
|
||||||
|
!childRunning
|
||||||
|
&& childMediaType !== 'audiobook'
|
||||||
|
&& childMediaType !== 'converter'
|
||||||
|
&& ['ERROR', 'CANCELLED'].includes(childStatusUpper)
|
||||||
|
&& (
|
||||||
|
['RIPPING', 'CD_RIPPING'].includes(childLastStateUpper)
|
||||||
|
|| childRetryRipRecoveryReason
|
||||||
|
)
|
||||||
|
);
|
||||||
const childCanRestartReview = Boolean(
|
const childCanRestartReview = Boolean(
|
||||||
child?.rawStatus?.exists
|
child?.rawStatus?.exists
|
||||||
&& child?.rawStatus?.isEmpty !== true
|
&& child?.rawStatus?.isEmpty !== true
|
||||||
&& !childRunning
|
&& !childRunning
|
||||||
&& childMediaType !== 'audiobook'
|
&& childMediaType !== 'audiobook'
|
||||||
|
&& !childRetryRipRequired
|
||||||
&& typeof onRestartReview === 'function'
|
&& typeof onRestartReview === 'function'
|
||||||
);
|
);
|
||||||
const childCanAssignMetadata = !childRunning;
|
const childCanAssignMetadata = !childRunning && !childRetryRipRequired;
|
||||||
|
const childCanRetry = !childRunning && typeof onRetry === 'function' && (childMediaType === 'cd' || childRetryRipRequired);
|
||||||
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
|
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
|
||||||
const childSeriesOutputExisting = Number(child?.seriesOutputSummary?.existing || 0);
|
const childSeriesOutputExisting = Number(child?.seriesOutputSummary?.existing || 0);
|
||||||
const childHasAnyOutput = childOutputFolders.length > 0
|
const childHasAnyOutput = childOutputFolders.length > 0
|
||||||
@@ -1557,6 +1604,8 @@ export default function JobDetailDialog({
|
|||||||
childCanRestartEncode,
|
childCanRestartEncode,
|
||||||
childCanRestartReview,
|
childCanRestartReview,
|
||||||
childCanAssignMetadata,
|
childCanAssignMetadata,
|
||||||
|
childCanRetry,
|
||||||
|
childRetryRipRequired,
|
||||||
childHasRaw,
|
childHasRaw,
|
||||||
childHasAnyOutput
|
childHasAnyOutput
|
||||||
};
|
};
|
||||||
@@ -2544,7 +2593,24 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isCd && !isAudiobook && !isConverter && typeof onAssignOmdb === 'function' ? (
|
{childActionState.childCanRetry ? (
|
||||||
|
<div className="actions-group">
|
||||||
|
<div className="actions-group-label"><i className="pi pi-refresh" /> Recovery</div>
|
||||||
|
<div className="action-item">
|
||||||
|
<Button
|
||||||
|
label="Retry Rip"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="warning"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onRetry?.(child)}
|
||||||
|
loading={actionBusy}
|
||||||
|
/>
|
||||||
|
<span className="action-desc">Löscht das alte RAW und startet den Rip erneut.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignOmdb === 'function' ? (
|
||||||
<div className="actions-group">
|
<div className="actions-group">
|
||||||
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||||
<div className="action-item">
|
<div className="action-item">
|
||||||
@@ -2938,7 +3004,28 @@ export default function JobDetailDialog({
|
|||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isCd && !isAudiobook && !isConverter && typeof onAssignOmdb === 'function' ? (
|
{canRetry ? (
|
||||||
|
<div className="actions-group">
|
||||||
|
<div className="actions-group-label"><i className="pi pi-refresh" /> Recovery</div>
|
||||||
|
<div className="action-item">
|
||||||
|
<Button
|
||||||
|
label={isCd ? 'CD-Rip neu starten' : 'Retry Rip'}
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="warning"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onRetry?.(job)}
|
||||||
|
loading={actionBusy}
|
||||||
|
/>
|
||||||
|
<span className="action-desc">
|
||||||
|
{isCd
|
||||||
|
? 'Startet den CD-Rip neu.'
|
||||||
|
: 'Löscht das alte RAW und startet den Rip erneut.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignOmdb === 'function' ? (
|
||||||
<div className="actions-group">
|
<div className="actions-group">
|
||||||
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||||
<div className="action-item">
|
<div className="action-item">
|
||||||
@@ -2957,6 +3044,25 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{canGenerateNfo ? (
|
||||||
|
<div className="actions-group">
|
||||||
|
<div className="actions-group-label"><i className="pi pi-file" /> NFO</div>
|
||||||
|
<div className="action-item">
|
||||||
|
<Button
|
||||||
|
label="NFO erzeugen"
|
||||||
|
icon="pi pi-file-edit"
|
||||||
|
severity="info"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onGenerateNfo?.(job)}
|
||||||
|
loading={generateNfoBusy}
|
||||||
|
disabled={actionBusy}
|
||||||
|
/>
|
||||||
|
<span className="action-desc">Erstellt eine .nfo-Datei neben der Output-Datei (manuell aus der Historie).</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{typeof onDeleteFiles === 'function' ? (
|
{typeof onDeleteFiles === 'function' ? (
|
||||||
<div className="actions-group">
|
<div className="actions-group">
|
||||||
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
|
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
|
||||||
|
|||||||
@@ -14,6 +14,84 @@ export default function MetadataSelectionDialog({
|
|||||||
onSearch,
|
onSearch,
|
||||||
busy
|
busy
|
||||||
}) {
|
}) {
|
||||||
|
const toSearchTitleCase = (value) => String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b([a-z\u00c0-\u024f])([a-z\u00c0-\u024f0-9']*)/g, (_match, first, rest) => (
|
||||||
|
`${first.toUpperCase()}${rest}`
|
||||||
|
));
|
||||||
|
const stripTrailingDiscHint = (value) => {
|
||||||
|
let working = String(value || '').trim();
|
||||||
|
if (!working) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const patterns = [
|
||||||
|
/\b(?:disc|disk|dvd|bd|br|cd|part|pt|vol|volume|season|staffel|episode|ep)\s*(?:\d{1,3}|[ivxlcdm]{1,8})\b$/i,
|
||||||
|
/\b(?:[a-z]{1,3}\d{1,4}|\d{1,4}[a-z]{1,3})\b$/i
|
||||||
|
];
|
||||||
|
for (let i = 0; i < 4; i += 1) {
|
||||||
|
const before = working;
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
working = working.replace(pattern, '').trim();
|
||||||
|
}
|
||||||
|
working = working.replace(/[-_.,;:]+$/g, '').trim();
|
||||||
|
if (working === before || !working) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return working;
|
||||||
|
};
|
||||||
|
const buildSearchVariants = (rawValue) => {
|
||||||
|
const raw = String(rawValue || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (/^tt\d{6,12}$/i.test(raw)) {
|
||||||
|
return [raw.toLowerCase()];
|
||||||
|
}
|
||||||
|
const variants = [];
|
||||||
|
const pushUnique = (candidate) => {
|
||||||
|
const normalized = String(candidate || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!variants.some((entry) => entry.toLowerCase() === normalized.toLowerCase())) {
|
||||||
|
variants.push(normalized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const separatorNormalized = raw
|
||||||
|
.replace(/[_]+/g, ' ')
|
||||||
|
.replace(/[.]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const punctuationReduced = separatorNormalized
|
||||||
|
.replace(/[|/\\]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const stripped = stripTrailingDiscHint(punctuationReduced);
|
||||||
|
|
||||||
|
pushUnique(raw);
|
||||||
|
pushUnique(separatorNormalized);
|
||||||
|
pushUnique(punctuationReduced);
|
||||||
|
pushUnique(toSearchTitleCase(separatorNormalized));
|
||||||
|
pushUnique(stripped);
|
||||||
|
pushUnique(toSearchTitleCase(stripped));
|
||||||
|
|
||||||
|
return variants.slice(0, 6);
|
||||||
|
};
|
||||||
|
const normalizeDetectedTitleForMetadataInput = (rawValue) => {
|
||||||
|
const raw = String(rawValue || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const separatorNormalized = raw
|
||||||
|
.replace(/[_]+/g, ' ')
|
||||||
|
.replace(/[.]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const stripped = stripTrailingDiscHint(separatorNormalized);
|
||||||
|
const preferred = stripped || separatorNormalized || raw;
|
||||||
|
return toSearchTitleCase(preferred);
|
||||||
|
};
|
||||||
const normalizeWorkflowKind = (value) => {
|
const normalizeWorkflowKind = (value) => {
|
||||||
const raw = String(value || '').trim().toLowerCase();
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -56,6 +134,7 @@ export default function MetadataSelectionDialog({
|
|||||||
const [seasonFilter, setSeasonFilter] = useState('');
|
const [seasonFilter, setSeasonFilter] = useState('');
|
||||||
const [discValidationError, setDiscValidationError] = useState('');
|
const [discValidationError, setDiscValidationError] = useState('');
|
||||||
const activeSearchRequestRef = useRef(0);
|
const activeSearchRequestRef = useRef(0);
|
||||||
|
const autoSearchDoneRef = useRef(false);
|
||||||
const contextWorkflowKind = normalizeWorkflowKind(
|
const contextWorkflowKind = normalizeWorkflowKind(
|
||||||
context?.selectedMetadata?.workflowKind
|
context?.selectedMetadata?.workflowKind
|
||||||
|| context?.workflowKind
|
|| context?.workflowKind
|
||||||
@@ -97,7 +176,8 @@ export default function MetadataSelectionDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedMetadata = context?.selectedMetadata || {};
|
const selectedMetadata = context?.selectedMetadata || {};
|
||||||
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
|
const rawDefaultTitle = selectedMetadata.title || context?.detectedTitle || '';
|
||||||
|
const defaultTitle = normalizeDetectedTitleForMetadataInput(rawDefaultTitle) || rawDefaultTitle;
|
||||||
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
|
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
|
||||||
const defaultImdb = selectedMetadata.imdbId || '';
|
const defaultImdb = selectedMetadata.imdbId || '';
|
||||||
|
|
||||||
@@ -119,6 +199,7 @@ export default function MetadataSelectionDialog({
|
|||||||
setSeasonFilter('');
|
setSeasonFilter('');
|
||||||
setDiscValidationError('');
|
setDiscValidationError('');
|
||||||
setSelectedMetadataProvider(contextMetadataProvider);
|
setSelectedMetadataProvider(contextMetadataProvider);
|
||||||
|
autoSearchDoneRef.current = false;
|
||||||
activeSearchRequestRef.current += 1;
|
activeSearchRequestRef.current += 1;
|
||||||
}, [visible, context]);
|
}, [visible, context]);
|
||||||
|
|
||||||
@@ -205,19 +286,30 @@ export default function MetadataSelectionDialog({
|
|||||||
if (!trimmedQuery || typeof onSearch !== 'function') {
|
if (!trimmedQuery || typeof onSearch !== 'function') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const queryVariants = buildSearchVariants(trimmedQuery);
|
||||||
|
if (queryVariants.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const requestId = activeSearchRequestRef.current + 1;
|
const requestId = activeSearchRequestRef.current + 1;
|
||||||
activeSearchRequestRef.current = requestId;
|
activeSearchRequestRef.current = requestId;
|
||||||
setHasSearchOverride(true);
|
setHasSearchOverride(true);
|
||||||
setSearchBusy(true);
|
setSearchBusy(true);
|
||||||
setSearchError('');
|
setSearchError('');
|
||||||
try {
|
try {
|
||||||
const results = await onSearch(trimmedQuery, {
|
let effectiveResults = [];
|
||||||
|
for (const candidateQuery of queryVariants) {
|
||||||
|
const results = await onSearch(candidateQuery, {
|
||||||
metadataProvider
|
metadataProvider
|
||||||
});
|
});
|
||||||
if (activeSearchRequestRef.current !== requestId) {
|
if (activeSearchRequestRef.current !== requestId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExtraResults(Array.isArray(results) ? results : []);
|
if (Array.isArray(results) && results.length > 0) {
|
||||||
|
effectiveResults = results;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExtraResults(Array.isArray(effectiveResults) ? effectiveResults : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (activeSearchRequestRef.current !== requestId) {
|
if (activeSearchRequestRef.current !== requestId) {
|
||||||
return;
|
return;
|
||||||
@@ -230,6 +322,22 @@ export default function MetadataSelectionDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || autoSearchDoneRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof onSearch !== 'function') {
|
||||||
|
autoSearchDoneRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedQuery = String(query || '').trim();
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
autoSearchDoneRef.current = true;
|
||||||
|
void handleSearch();
|
||||||
|
}, [visible, query, metadataProvider, 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 : [];
|
||||||
const duplicateDetail = details.find(
|
const duplicateDetail = details.find(
|
||||||
|
|||||||
@@ -581,6 +581,88 @@ function hasSeriesMetadataShape(source = null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDiscWorkflowKind(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) {
|
||||||
|
return 'series';
|
||||||
|
}
|
||||||
|
if (['film', 'movie', 'feature'].includes(raw)) {
|
||||||
|
return 'film';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDiscWorkflowKind({
|
||||||
|
mediaProfile = null,
|
||||||
|
selectedMetadata = null,
|
||||||
|
analyzeContext = null,
|
||||||
|
pipelineContext = null,
|
||||||
|
fallbackIsSeries = false
|
||||||
|
} = {}) {
|
||||||
|
const normalizedMediaProfile = String(mediaProfile || '').trim().toLowerCase();
|
||||||
|
if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const selected = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {};
|
||||||
|
const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {};
|
||||||
|
const pipelineCtx = pipelineContext && typeof pipelineContext === 'object' ? pipelineContext : {};
|
||||||
|
const workflowCandidates = [
|
||||||
|
selected?.workflowKind,
|
||||||
|
selected?.kind,
|
||||||
|
analyze?.workflowKind,
|
||||||
|
pipelineCtx?.workflowKind
|
||||||
|
];
|
||||||
|
for (const candidate of workflowCandidates) {
|
||||||
|
const normalized = normalizeDiscWorkflowKind(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const metadataKindCandidates = [
|
||||||
|
selected?.metadataKind,
|
||||||
|
analyze?.metadataKind
|
||||||
|
];
|
||||||
|
for (const candidate of metadataKindCandidates) {
|
||||||
|
const normalized = normalizeDiscWorkflowKind(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const providerCandidates = [
|
||||||
|
selected?.metadataProvider,
|
||||||
|
analyze?.metadataProvider,
|
||||||
|
pipelineCtx?.metadataProvider
|
||||||
|
];
|
||||||
|
for (const candidate of providerCandidates) {
|
||||||
|
const provider = String(candidate || '').trim().toLowerCase();
|
||||||
|
if (!provider) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (provider === 'tmdb' || provider === 'themoviedb') {
|
||||||
|
return 'series';
|
||||||
|
}
|
||||||
|
if (provider === 'omdb') {
|
||||||
|
return 'film';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackIsSeries ? 'series' : 'film';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
|
||||||
|
const normalizedMediaProfile = String(mediaProfile || '').trim().toLowerCase();
|
||||||
|
if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const kind = normalizeDiscWorkflowKind(workflowKind) || 'film';
|
||||||
|
if (normalizedMediaProfile === 'bluray') {
|
||||||
|
return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film';
|
||||||
|
}
|
||||||
|
return kind === 'series' ? 'DVD-Serie' : 'DVD-Film';
|
||||||
|
}
|
||||||
|
|
||||||
function hasSeriesEpisodeAssignmentHints(titles = []) {
|
function hasSeriesEpisodeAssignmentHints(titles = []) {
|
||||||
const reviewTitles = Array.isArray(titles) ? titles : [];
|
const reviewTitles = Array.isArray(titles) ? titles : [];
|
||||||
return reviewTitles.some((title) => {
|
return reviewTitles.some((title) => {
|
||||||
@@ -1496,6 +1578,13 @@ export default function PipelineStatusCard({
|
|||||||
).trim().toLowerCase();
|
).trim().toLowerCase();
|
||||||
const isOrphanImport = orphanSource === 'orphan_raw_import';
|
const isOrphanImport = orphanSource === 'orphan_raw_import';
|
||||||
const isOrphanCancelled = isOrphanImport && (state === 'ERROR' || state === 'CANCELLED');
|
const isOrphanCancelled = isOrphanImport && (state === 'ERROR' || state === 'CANCELLED');
|
||||||
|
const jobStatusUpper = String(jobRow?.status || state || '').trim().toUpperCase();
|
||||||
|
const jobLastStateUpper = String(jobRow?.last_state || '').trim().toUpperCase();
|
||||||
|
const jobErrorMessageLower = String(
|
||||||
|
jobRow?.error_message
|
||||||
|
|| pipeline?.context?.errorMessage
|
||||||
|
|| ''
|
||||||
|
).trim().toLowerCase();
|
||||||
const ripCompleted = Boolean(
|
const ripCompleted = Boolean(
|
||||||
Number(jobRow?.rip_successful || jobRow?.ripSuccessful || pipeline?.context?.ripSuccessful || 0) === 1
|
Number(jobRow?.rip_successful || jobRow?.ripSuccessful || pipeline?.context?.ripSuccessful || 0) === 1
|
||||||
|| jobRow?.raw_path
|
|| jobRow?.raw_path
|
||||||
@@ -1504,12 +1593,31 @@ export default function PipelineStatusCard({
|
|||||||
|| String(jobRow?.makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
|
|| String(jobRow?.makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
|
||||||
);
|
);
|
||||||
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
|
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
|
||||||
|
const analyzeContext = jobRow?.makemkvInfo?.analyzeContext && typeof jobRow.makemkvInfo.analyzeContext === 'object'
|
||||||
|
? jobRow.makemkvInfo.analyzeContext
|
||||||
|
: null;
|
||||||
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
||||||
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
|
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
|
||||||
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
|
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
|
||||||
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
||||||
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
||||||
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
|
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
|
||||||
|
const retryRipRecoveryRequired = Boolean(
|
||||||
|
!running
|
||||||
|
&& retryJobId
|
||||||
|
&& typeof onRetry === 'function'
|
||||||
|
&& !queueLocked
|
||||||
|
&& !isOrphanCancelled
|
||||||
|
&& !['converter', 'audiobook'].includes(String(jobMediaProfile || '').trim().toLowerCase())
|
||||||
|
&& ['ERROR', 'CANCELLED'].includes(jobStatusUpper)
|
||||||
|
&& (
|
||||||
|
['RIPPING', 'CD_RIPPING'].includes(jobLastStateUpper)
|
||||||
|
|| jobErrorMessageLower.includes('server-neustart')
|
||||||
|
|| jobErrorMessageLower.includes('rip ist unvollständig')
|
||||||
|
|| jobErrorMessageLower.includes('rip-validierung fehlgeschlagen')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const blockMetadataAndReviewForRipRetry = retryRipRecoveryRequired;
|
||||||
const discTypeLabel = jobMediaProfile === 'bluray' ? 'Blu-ray' : 'DVD';
|
const discTypeLabel = jobMediaProfile === 'bluray' ? 'Blu-ray' : 'DVD';
|
||||||
const isDiscTitleSelectionRequired = Boolean(
|
const isDiscTitleSelectionRequired = Boolean(
|
||||||
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
|
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
|
||||||
@@ -2241,6 +2349,7 @@ export default function PipelineStatusCard({
|
|||||||
const canRestartReviewFromRaw = Boolean(
|
const canRestartReviewFromRaw = Boolean(
|
||||||
retryJobId
|
retryJobId
|
||||||
&& !running
|
&& !running
|
||||||
|
&& !blockMetadataAndReviewForRipRetry
|
||||||
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
|
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
|
||||||
);
|
);
|
||||||
const allowConverterConfigEdit = showConverterConfig && allowReviewSelectionEdit && state === 'READY_TO_ENCODE';
|
const allowConverterConfigEdit = showConverterConfig && allowReviewSelectionEdit && state === 'READY_TO_ENCODE';
|
||||||
@@ -2640,6 +2749,14 @@ export default function PipelineStatusCard({
|
|||||||
mediaInfoReview?.titles,
|
mediaInfoReview?.titles,
|
||||||
mediaInfoReview?.seriesBatchParent
|
mediaInfoReview?.seriesBatchParent
|
||||||
]);
|
]);
|
||||||
|
const discWorkflowKind = resolveDiscWorkflowKind({
|
||||||
|
mediaProfile: jobMediaProfile,
|
||||||
|
selectedMetadata,
|
||||||
|
analyzeContext,
|
||||||
|
pipelineContext: pipeline?.context || null,
|
||||||
|
fallbackIsSeries: isSeriesDvdReview
|
||||||
|
});
|
||||||
|
const discWorkflowBadgeLabel = getDiscWorkflowBadgeLabel(jobMediaProfile, discWorkflowKind);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -3551,6 +3668,7 @@ export default function PipelineStatusCard({
|
|||||||
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION' || isOrphanCancelled)
|
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION' || isOrphanCancelled)
|
||||||
&& retryJobId
|
&& retryJobId
|
||||||
&& typeof onOpenMetadata === 'function'
|
&& typeof onOpenMetadata === 'function'
|
||||||
|
&& !blockMetadataAndReviewForRipRetry
|
||||||
&& !(jobMediaProfile === 'converter' && !selectedMetadata?.imdbId) ? (
|
&& !(jobMediaProfile === 'converter' && !selectedMetadata?.imdbId) ? (
|
||||||
<Button
|
<Button
|
||||||
label="Metadaten öffnen"
|
label="Metadaten öffnen"
|
||||||
@@ -3579,6 +3697,7 @@ export default function PipelineStatusCard({
|
|||||||
&& retryJobId
|
&& retryJobId
|
||||||
&& typeof onReassignOmdb === 'function'
|
&& typeof onReassignOmdb === 'function'
|
||||||
&& !isOrphanCancelled
|
&& !isOrphanCancelled
|
||||||
|
&& !blockMetadataAndReviewForRipRetry
|
||||||
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
|
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
|
||||||
<Button
|
<Button
|
||||||
label="Metadaten neu zuweisen"
|
label="Metadaten neu zuweisen"
|
||||||
@@ -3738,7 +3857,7 @@ export default function PipelineStatusCard({
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(state === 'ERROR' || state === 'CANCELLED') && retryJobId && !isOrphanCancelled && !ripCompleted && (
|
{(state === 'ERROR' || state === 'CANCELLED') && retryJobId && !isOrphanCancelled && (retryRipRecoveryRequired || !ripCompleted) && (
|
||||||
<Button
|
<Button
|
||||||
label="Retry Rippen"
|
label="Retry Rippen"
|
||||||
icon="pi pi-refresh"
|
icon="pi pi-refresh"
|
||||||
|
|||||||
@@ -16,8 +16,44 @@ export function useWebSocket({ onMessage }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let socket;
|
let socket;
|
||||||
let reconnectTimer;
|
let reconnectTimer;
|
||||||
|
let progressFlushTimer = null;
|
||||||
|
let progressFlushRaf = null;
|
||||||
|
let latestProgressMessage = null;
|
||||||
let isUnmounted = false;
|
let isUnmounted = false;
|
||||||
|
|
||||||
|
const clearProgressFlush = () => {
|
||||||
|
if (progressFlushRaf !== null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||||
|
window.cancelAnimationFrame(progressFlushRaf);
|
||||||
|
}
|
||||||
|
if (progressFlushTimer) {
|
||||||
|
clearTimeout(progressFlushTimer);
|
||||||
|
}
|
||||||
|
progressFlushRaf = null;
|
||||||
|
progressFlushTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushProgressMessage = () => {
|
||||||
|
progressFlushRaf = null;
|
||||||
|
progressFlushTimer = null;
|
||||||
|
const message = latestProgressMessage;
|
||||||
|
latestProgressMessage = null;
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onMessageRef.current?.(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleProgressFlush = () => {
|
||||||
|
if (progressFlushRaf !== null || progressFlushTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||||
|
progressFlushRaf = window.requestAnimationFrame(flushProgressMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
progressFlushTimer = setTimeout(flushProgressMessage, 16);
|
||||||
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (isUnmounted) {
|
if (isUnmounted) {
|
||||||
return;
|
return;
|
||||||
@@ -28,6 +64,11 @@ export function useWebSocket({ onMessage }) {
|
|||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
|
if (message?.type === 'PIPELINE_PROGRESS') {
|
||||||
|
latestProgressMessage = message;
|
||||||
|
scheduleProgressFlush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
onMessageRef.current?.(message);
|
onMessageRef.current?.(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// ignore invalid json
|
// ignore invalid json
|
||||||
@@ -51,6 +92,7 @@ export function useWebSocket({ onMessage }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isUnmounted = true;
|
isUnmounted = true;
|
||||||
|
clearProgressFlush();
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer);
|
clearTimeout(reconnectTimer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||||
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||||
const [deleteEntryIncludeRelated, setDeleteEntryIncludeRelated] = useState(true);
|
const [deleteEntryIncludeRelated, setDeleteEntryIncludeRelated] = useState(true);
|
||||||
|
const [deleteEntrySelectedJobIds, setDeleteEntrySelectedJobIds] = useState([]);
|
||||||
const [deleteEntrySelectedRawPaths, setDeleteEntrySelectedRawPaths] = useState([]);
|
const [deleteEntrySelectedRawPaths, setDeleteEntrySelectedRawPaths] = useState([]);
|
||||||
const [deleteEntrySelectedMoviePaths, setDeleteEntrySelectedMoviePaths] = useState([]);
|
const [deleteEntrySelectedMoviePaths, setDeleteEntrySelectedMoviePaths] = useState([]);
|
||||||
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
|
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
|
||||||
@@ -556,6 +557,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||||
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
||||||
|
const [generateNfoBusy, setGenerateNfoBusy] = useState(false);
|
||||||
const [acknowledgeErrorBusy, setAcknowledgeErrorBusy] = useState(false);
|
const [acknowledgeErrorBusy, setAcknowledgeErrorBusy] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||||
@@ -1190,11 +1192,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const handleRetry = async (row) => {
|
const handleRetry = async (row) => {
|
||||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||||
const mediaType = resolveMediaType(row);
|
const mediaType = resolveMediaType(row);
|
||||||
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
|
const diskLabel = mediaType === 'cd' ? 'CD' : 'Disk';
|
||||||
const confirmed = await confirmModal({
|
const confirmed = await confirmModal({
|
||||||
header: `${actionLabel} neu starten`,
|
header: 'Rip neu starten',
|
||||||
message: `${actionLabel} für "${title}" neu starten?`,
|
message: `Bitte lege die korrekte ${diskLabel} für "${title}" ein.\nSoll der Rip jetzt gestartet werden?`,
|
||||||
acceptLabel: 'Neu starten',
|
acceptLabel: 'Rip starten',
|
||||||
rejectLabel: 'Abbrechen'
|
rejectLabel: 'Abbrechen'
|
||||||
});
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
@@ -1234,6 +1236,39 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateNfo = async (row) => {
|
||||||
|
const jobId = Number(row?.id || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGenerateNfoBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await api.generateJobNfo(jobId);
|
||||||
|
const nfoPath = String(response?.result?.nfoPath || '').trim() || null;
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'NFO erzeugt',
|
||||||
|
detail: nfoPath ? `Datei erstellt: ${nfoPath}` : 'NFO-Datei wurde erstellt.',
|
||||||
|
life: 4000
|
||||||
|
});
|
||||||
|
if (response?.job) {
|
||||||
|
setSelectedJob(response.job);
|
||||||
|
} else {
|
||||||
|
await refreshDetailIfOpen(jobId);
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'NFO-Erzeugung fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 4500
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setGenerateNfoBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAssignOmdb = (row) => {
|
const handleAssignOmdb = (row) => {
|
||||||
const jobId = Number(row?.id || 0);
|
const jobId = Number(row?.id || 0);
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
@@ -1493,6 +1528,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
setDeleteEntryPreviewLoading(false);
|
setDeleteEntryPreviewLoading(false);
|
||||||
setDeleteEntryTargetBusy(null);
|
setDeleteEntryTargetBusy(null);
|
||||||
setDeleteEntryIncludeRelated(true);
|
setDeleteEntryIncludeRelated(true);
|
||||||
|
setDeleteEntrySelectedJobIds([]);
|
||||||
setDeleteEntrySelectedRawPaths([]);
|
setDeleteEntrySelectedRawPaths([]);
|
||||||
setDeleteEntrySelectedMoviePaths([]);
|
setDeleteEntrySelectedMoviePaths([]);
|
||||||
};
|
};
|
||||||
@@ -1509,6 +1545,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
setDeleteEntryDialogRow(row);
|
setDeleteEntryDialogRow(row);
|
||||||
setDeleteEntryPreview(null);
|
setDeleteEntryPreview(null);
|
||||||
setDeleteEntryIncludeRelated(includeRelated);
|
setDeleteEntryIncludeRelated(includeRelated);
|
||||||
|
setDeleteEntrySelectedJobIds([]);
|
||||||
setDeleteEntrySelectedRawPaths([]);
|
setDeleteEntrySelectedRawPaths([]);
|
||||||
setDeleteEntrySelectedMoviePaths([]);
|
setDeleteEntrySelectedMoviePaths([]);
|
||||||
setDeleteEntryDialogVisible(true);
|
setDeleteEntryDialogVisible(true);
|
||||||
@@ -1517,7 +1554,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
try {
|
try {
|
||||||
const response = await api.getJobDeletePreview(jobId, { includeRelated });
|
const response = await api.getJobDeletePreview(jobId, { includeRelated });
|
||||||
const preview = response?.preview || null;
|
const preview = response?.preview || null;
|
||||||
|
const relatedJobs = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [];
|
||||||
|
const selectedJobIds = Array.isArray(preview?.selectedJobIds)
|
||||||
|
? preview.selectedJobIds.map((id) => normalizeJobId(id)).filter(Boolean)
|
||||||
|
: relatedJobs
|
||||||
|
.map((item) => normalizeJobId(item?.id))
|
||||||
|
.filter(Boolean);
|
||||||
setDeleteEntryPreview(preview);
|
setDeleteEntryPreview(preview);
|
||||||
|
setDeleteEntrySelectedJobIds(selectedJobIds);
|
||||||
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
|
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
|
||||||
const defaultSelectedRawPaths = rawCandidates
|
const defaultSelectedRawPaths = rawCandidates
|
||||||
.filter((item) => Boolean(item?.exists))
|
.filter((item) => Boolean(item?.exists))
|
||||||
@@ -1541,13 +1585,80 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
setDeleteEntryDialogRow(null);
|
setDeleteEntryDialogRow(null);
|
||||||
setDeleteEntryPreview(null);
|
setDeleteEntryPreview(null);
|
||||||
setDeleteEntryIncludeRelated(true);
|
setDeleteEntryIncludeRelated(true);
|
||||||
|
setDeleteEntrySelectedJobIds([]);
|
||||||
setDeleteEntrySelectedRawPaths([]);
|
setDeleteEntrySelectedRawPaths([]);
|
||||||
|
setDeleteEntrySelectedMoviePaths([]);
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteEntryPreviewLoading(false);
|
setDeleteEntryPreviewLoading(false);
|
||||||
setDeleteEntryBusy(false);
|
setDeleteEntryBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshDeleteEntryPreview = async (jobId, selectedJobIds) => {
|
||||||
|
setDeleteEntryPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.getJobDeletePreview(jobId, {
|
||||||
|
includeRelated: deleteEntryIncludeRelated,
|
||||||
|
selectedJobIds
|
||||||
|
});
|
||||||
|
const preview = response?.preview || null;
|
||||||
|
const relatedJobs = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [];
|
||||||
|
const nextSelectedJobIds = Array.isArray(preview?.selectedJobIds)
|
||||||
|
? preview.selectedJobIds.map((id) => normalizeJobId(id)).filter(Boolean)
|
||||||
|
: relatedJobs
|
||||||
|
.map((item) => normalizeJobId(item?.id))
|
||||||
|
.filter(Boolean);
|
||||||
|
setDeleteEntryPreview(preview);
|
||||||
|
setDeleteEntrySelectedJobIds(nextSelectedJobIds);
|
||||||
|
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
|
||||||
|
const defaultSelectedRawPaths = rawCandidates
|
||||||
|
.filter((item) => Boolean(item?.exists))
|
||||||
|
.map((item) => String(item?.path || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
setDeleteEntrySelectedRawPaths(defaultSelectedRawPaths);
|
||||||
|
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
|
||||||
|
const defaultSelectedMoviePaths = movieCandidates
|
||||||
|
.filter((item) => Boolean(item?.exists))
|
||||||
|
.map((item) => String(item?.path || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Löschvorschau fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 4500
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteEntryPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDeleteRelatedJobSelection = async (relatedJobId, checked) => {
|
||||||
|
const normalizedRelatedJobId = normalizeJobId(relatedJobId);
|
||||||
|
const dialogJobId = normalizeJobId(deleteEntryDialogRow?.id);
|
||||||
|
if (!normalizedRelatedJobId || !dialogJobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||||
|
const knownJobIds = relatedJobs
|
||||||
|
.map((item) => normalizeJobId(item?.id))
|
||||||
|
.filter(Boolean);
|
||||||
|
const currentSelected = new Set(
|
||||||
|
deleteEntrySelectedJobIds
|
||||||
|
.map((item) => normalizeJobId(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
if (checked) {
|
||||||
|
currentSelected.add(normalizedRelatedJobId);
|
||||||
|
} else {
|
||||||
|
currentSelected.delete(normalizedRelatedJobId);
|
||||||
|
}
|
||||||
|
const orderedSelected = knownJobIds.filter((jobId) => currentSelected.has(jobId));
|
||||||
|
setDeleteEntrySelectedJobIds(orderedSelected);
|
||||||
|
await refreshDeleteEntryPreview(dialogJobId, orderedSelected);
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDeleteEntry = async (target) => {
|
const confirmDeleteEntry = async (target) => {
|
||||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||||
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
|
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
|
||||||
@@ -1561,6 +1672,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||||
|
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Historie-Auswahl erforderlich',
|
||||||
|
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(normalizedTarget === 'raw' || normalizedTarget === 'both')
|
(normalizedTarget === 'raw' || normalizedTarget === 'both')
|
||||||
&& rawDeleteSelectionEnabled
|
&& rawDeleteSelectionEnabled
|
||||||
@@ -1593,6 +1714,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
try {
|
try {
|
||||||
const response = await api.deleteJobEntry(jobId, normalizedTarget, {
|
const response = await api.deleteJobEntry(jobId, normalizedTarget, {
|
||||||
includeRelated: deleteEntryIncludeRelated,
|
includeRelated: deleteEntryIncludeRelated,
|
||||||
|
selectedJobIds: deleteEntrySelectedJobIds,
|
||||||
selectedRawPaths: deleteEntrySelectedRawPaths,
|
selectedRawPaths: deleteEntrySelectedRawPaths,
|
||||||
selectedMoviePaths: deleteEntrySelectedMoviePaths
|
selectedMoviePaths: deleteEntrySelectedMoviePaths
|
||||||
});
|
});
|
||||||
@@ -1630,6 +1752,88 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDeleteFilesOnly = async (target = 'both') => {
|
||||||
|
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||||
|
if (!['raw', 'movie', 'both'].includes(normalizedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const jobId = Number(deleteEntryDialogRow?.id || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||||
|
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Historie-Auswahl erforderlich',
|
||||||
|
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(normalizedTarget === 'raw' || normalizedTarget === 'both')
|
||||||
|
&& rawDeleteSelectionEnabled
|
||||||
|
&& deleteEntrySelectedRawPaths.length === 0
|
||||||
|
) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'RAW-Auswahl erforderlich',
|
||||||
|
detail: 'Bitte mindestens einen RAW-Pfad zum Löschen auswählen.',
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(normalizedTarget === 'movie' || normalizedTarget === 'both')
|
||||||
|
&& previewMovieExisting.length > 0
|
||||||
|
&& deleteEntrySelectedMoviePaths.length === 0
|
||||||
|
) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: `${deleteEntryOutputShortLabel}-Dateien auswählen`,
|
||||||
|
detail: `Bitte mindestens eine ${deleteEntryOutputShortLabel}-Datei zum Löschen auswählen.`,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteEntryBusy(true);
|
||||||
|
setDeleteEntryTargetBusy(`files-${normalizedTarget}`);
|
||||||
|
try {
|
||||||
|
const response = await api.deleteJobFiles(jobId, normalizedTarget, {
|
||||||
|
includeRelated: deleteEntryIncludeRelated,
|
||||||
|
selectedJobIds: deleteEntrySelectedJobIds,
|
||||||
|
selectedRawPaths: deleteEntrySelectedRawPaths,
|
||||||
|
selectedMoviePaths: deleteEntrySelectedMoviePaths
|
||||||
|
});
|
||||||
|
const summary = response?.summary || {};
|
||||||
|
const rawFiles = Number(summary?.raw?.filesDeleted || 0);
|
||||||
|
const movieFiles = Number(summary?.movie?.filesDeleted || 0);
|
||||||
|
const rawDirs = Number(summary?.raw?.dirsRemoved || 0);
|
||||||
|
const movieDirs = Number(summary?.movie?.dirsRemoved || 0);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Dateien gelöscht',
|
||||||
|
detail: `Einträge bleiben bestehen | RAW: ${rawFiles} Dateien, ${rawDirs} Ordner | ${deleteEntryOutputShortLabel}: ${movieFiles} Dateien, ${movieDirs} Ordner`,
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
closeDeleteEntryDialog();
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Datei-Löschen fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteEntryTargetBusy(null);
|
||||||
|
setDeleteEntryBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleDeleteMoviePathSelection = (moviePath, checked) => {
|
const toggleDeleteMoviePathSelection = (moviePath, checked) => {
|
||||||
const normalizedPath = String(moviePath || '').trim();
|
const normalizedPath = String(moviePath || '').trim();
|
||||||
if (!normalizedPath) {
|
if (!normalizedPath) {
|
||||||
@@ -2251,24 +2455,36 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||||
|
const previewSelectedJobIds = Array.isArray(deleteEntryPreview?.selectedJobIds)
|
||||||
|
? deleteEntryPreview.selectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)
|
||||||
|
: previewRelatedJobs
|
||||||
|
.filter((item) => Boolean(item?.selected))
|
||||||
|
.map((item) => normalizeJobId(item?.id))
|
||||||
|
.filter(Boolean);
|
||||||
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
||||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||||
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
|
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
|
||||||
|
const deleteEntrySupportsFilesOnly = isContainerHistoryRow(deleteEntryDialogRow);
|
||||||
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
||||||
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
|
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
|
||||||
const rawDeleteSelectionEnabled = !deleteEntryIsMergeJob && previewRawExisting.length > 2;
|
const rawDeleteSelectionEnabled = !deleteEntryIsMergeJob && previewRawExisting.length > 0;
|
||||||
const previewRawDisplay = rawDeleteSelectionEnabled
|
const previewRawDisplay = previewRawPaths;
|
||||||
? previewRawPaths
|
const previewMovieDisplay = previewMoviePaths;
|
||||||
: (previewRawExisting.length > 0 ? previewRawExisting : previewRawPaths.slice(0, 1));
|
|
||||||
const previewMovieDisplay = previewMovieExisting.length > 0 ? previewMovieExisting : previewMoviePaths.slice(0, 1);
|
|
||||||
const selectedDeleteRawPathSet = useMemo(
|
const selectedDeleteRawPathSet = useMemo(
|
||||||
() => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
() => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||||
[deleteEntrySelectedRawPaths]
|
[deleteEntrySelectedRawPaths]
|
||||||
);
|
);
|
||||||
|
const selectedDeleteRelatedJobSet = useMemo(
|
||||||
|
() => new Set(deleteEntrySelectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)),
|
||||||
|
[deleteEntrySelectedJobIds]
|
||||||
|
);
|
||||||
const selectedDeleteMoviePathSet = useMemo(
|
const selectedDeleteMoviePathSet = useMemo(
|
||||||
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||||
[deleteEntrySelectedMoviePaths]
|
[deleteEntrySelectedMoviePaths]
|
||||||
);
|
);
|
||||||
|
const relatedDeleteSelectionRequired = deleteEntryIncludeRelated
|
||||||
|
&& previewRelatedJobs.length > 0
|
||||||
|
&& deleteEntrySelectedJobIds.length === 0;
|
||||||
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
|
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
|
||||||
&& rawDeleteSelectionEnabled
|
&& rawDeleteSelectionEnabled
|
||||||
&& deleteEntrySelectedRawPaths.length === 0;
|
&& deleteEntrySelectedRawPaths.length === 0;
|
||||||
@@ -2357,6 +2573,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
|
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
|
||||||
onAssignOmdb={handleAssignOmdb}
|
onAssignOmdb={handleAssignOmdb}
|
||||||
onAssignCdMetadata={handleAssignCdMetadata}
|
onAssignCdMetadata={handleAssignCdMetadata}
|
||||||
|
onGenerateNfo={handleGenerateNfo}
|
||||||
onAcknowledgeError={handleAcknowledgeError}
|
onAcknowledgeError={handleAcknowledgeError}
|
||||||
onDeleteFiles={handleDeleteFiles}
|
onDeleteFiles={handleDeleteFiles}
|
||||||
onDeleteEntry={handleDeleteEntry}
|
onDeleteEntry={handleDeleteEntry}
|
||||||
@@ -2371,6 +2588,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
restoreMergeBusy={actionBusy}
|
restoreMergeBusy={actionBusy}
|
||||||
omdbAssignBusy={omdbAssignBusy}
|
omdbAssignBusy={omdbAssignBusy}
|
||||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||||
|
generateNfoBusy={generateNfoBusy}
|
||||||
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
||||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||||
deleteEntryBusy={deleteEntryBusy}
|
deleteEntryBusy={deleteEntryBusy}
|
||||||
@@ -2394,7 +2612,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
modal
|
modal
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
|
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge verknüpft. Ausgewählt: ${deleteEntrySelectedJobIds.length || previewSelectedJobIds.length || 0}.`}
|
||||||
</p>
|
</p>
|
||||||
<small className="history-dv-subtle">
|
<small className="history-dv-subtle">
|
||||||
{deleteEntryIsMergeJob
|
{deleteEntryIsMergeJob
|
||||||
@@ -2418,13 +2636,29 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
<ul className="history-delete-preview-list">
|
<ul className="history-delete-preview-list">
|
||||||
{previewRelatedJobs.map((item) => (
|
{previewRelatedJobs.map((item) => (
|
||||||
<li key={`delete-related-${item.id}`}>
|
<li key={`delete-related-${item.id}`}>
|
||||||
<strong>#{item.id}</strong> | {item.title || '-'} | {getStatusLabel(item.status)} {item.isPrimary ? '(aktuell)' : '(Alt-Eintrag)'}
|
<label className="history-delete-preview-checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedDeleteRelatedJobSet.has(normalizeJobId(item?.id))}
|
||||||
|
onChange={(event) => toggleDeleteRelatedJobSelection(item.id, Boolean(event.target.checked))}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>#{item.id}</strong>
|
||||||
|
{' '}| {item.title || '-'}
|
||||||
|
{' '}| {item.roleLabel || 'Job'}
|
||||||
|
{' '}| {getStatusLabel(item.status)}
|
||||||
|
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
|
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
|
||||||
)}
|
)}
|
||||||
|
{relatedDeleteSelectionRequired ? (
|
||||||
|
<small className="history-dv-subtle">Bitte mindestens einen Historien-Eintrag auswählen.</small>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!deleteEntryIsMergeJob ? (
|
{!deleteEntryIsMergeJob ? (
|
||||||
@@ -2517,7 +2751,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
outlined
|
outlined
|
||||||
onClick={() => confirmDeleteEntry('movie')}
|
onClick={() => confirmDeleteEntry('movie')}
|
||||||
loading={deleteEntryTargetBusy === 'movie'}
|
loading={deleteEntryTargetBusy === 'movie'}
|
||||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -2528,7 +2762,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
outlined
|
outlined
|
||||||
onClick={() => confirmDeleteEntry('raw')}
|
onClick={() => confirmDeleteEntry('raw')}
|
||||||
loading={deleteEntryTargetBusy === 'raw'}
|
loading={deleteEntryTargetBusy === 'raw'}
|
||||||
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
|
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
|
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
|
||||||
@@ -2537,7 +2771,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
outlined
|
outlined
|
||||||
onClick={() => confirmDeleteEntry('movie')}
|
onClick={() => confirmDeleteEntry('movie')}
|
||||||
loading={deleteEntryTargetBusy === 'movie'}
|
loading={deleteEntryTargetBusy === 'movie'}
|
||||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
|
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
|
||||||
@@ -2545,10 +2779,21 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
severity="danger"
|
severity="danger"
|
||||||
onClick={() => confirmDeleteEntry('both')}
|
onClick={() => confirmDeleteEntry('both')}
|
||||||
loading={deleteEntryTargetBusy === 'both'}
|
loading={deleteEntryTargetBusy === 'both'}
|
||||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
|
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{deleteEntrySupportsFilesOnly ? (
|
||||||
|
<Button
|
||||||
|
label="Nur ausgewählte Dateien löschen"
|
||||||
|
icon="pi pi-folder-minus"
|
||||||
|
severity="help"
|
||||||
|
outlined
|
||||||
|
onClick={() => confirmDeleteFilesOnly('both')}
|
||||||
|
loading={deleteEntryTargetBusy === 'files-both'}
|
||||||
|
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
label="Nur Eintrag löschen"
|
label="Nur Eintrag löschen"
|
||||||
icon="pi pi-database"
|
icon="pi pi-database"
|
||||||
@@ -2556,7 +2801,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
outlined
|
outlined
|
||||||
onClick={() => confirmDeleteEntry('none')}
|
onClick={() => confirmDeleteEntry('none')}
|
||||||
loading={deleteEntryTargetBusy === 'none'}
|
loading={deleteEntryTargetBusy === 'none'}
|
||||||
disabled={deleteTargetActionsDisabled}
|
disabled={deleteTargetActionsDisabled || relatedDeleteSelectionRequired}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Abbrechen"
|
label="Abbrechen"
|
||||||
|
|||||||
@@ -771,6 +771,86 @@ function mediaIndicatorMeta(job) {
|
|||||||
return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
|
return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDiscWorkflowKind(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) {
|
||||||
|
return 'series';
|
||||||
|
}
|
||||||
|
if (['film', 'movie', 'feature'].includes(raw)) {
|
||||||
|
return 'film';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDiscWorkflowKind({
|
||||||
|
mediaType,
|
||||||
|
selectedMetadata = null,
|
||||||
|
analyzeContext = null,
|
||||||
|
pipelineContext = null,
|
||||||
|
fallbackIsSeries = false
|
||||||
|
} = {}) {
|
||||||
|
if (mediaType !== 'dvd' && mediaType !== 'bluray') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const selected = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {};
|
||||||
|
const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {};
|
||||||
|
const pipelineCtx = pipelineContext && typeof pipelineContext === 'object' ? pipelineContext : {};
|
||||||
|
const workflowCandidates = [
|
||||||
|
selected?.workflowKind,
|
||||||
|
selected?.kind,
|
||||||
|
analyze?.workflowKind,
|
||||||
|
pipelineCtx?.workflowKind
|
||||||
|
];
|
||||||
|
for (const candidate of workflowCandidates) {
|
||||||
|
const normalized = normalizeDiscWorkflowKind(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const metadataKindCandidates = [
|
||||||
|
selected?.metadataKind,
|
||||||
|
analyze?.metadataKind
|
||||||
|
];
|
||||||
|
for (const candidate of metadataKindCandidates) {
|
||||||
|
const normalized = normalizeDiscWorkflowKind(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const providerCandidates = [
|
||||||
|
selected?.metadataProvider,
|
||||||
|
analyze?.metadataProvider,
|
||||||
|
pipelineCtx?.metadataProvider
|
||||||
|
];
|
||||||
|
for (const candidate of providerCandidates) {
|
||||||
|
const provider = String(candidate || '').trim().toLowerCase();
|
||||||
|
if (!provider) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (provider === 'tmdb' || provider === 'themoviedb') {
|
||||||
|
return 'series';
|
||||||
|
}
|
||||||
|
if (provider === 'omdb') {
|
||||||
|
return 'film';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackIsSeries ? 'series' : 'film';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscWorkflowBadgeLabel(mediaType, workflowKind) {
|
||||||
|
if (mediaType !== 'dvd' && mediaType !== 'bluray') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const kind = normalizeDiscWorkflowKind(workflowKind) || 'film';
|
||||||
|
if (mediaType === 'bluray') {
|
||||||
|
return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film';
|
||||||
|
}
|
||||||
|
return kind === 'series' ? 'DVD-Serie' : 'DVD-Film';
|
||||||
|
}
|
||||||
|
|
||||||
function JobStepChecks({ backupSuccess, encodeSuccess }) {
|
function JobStepChecks({ backupSuccess, encodeSuccess }) {
|
||||||
const hasAny = Boolean(backupSuccess || encodeSuccess);
|
const hasAny = Boolean(backupSuccess || encodeSuccess);
|
||||||
if (!hasAny) {
|
if (!hasAny) {
|
||||||
@@ -2308,6 +2388,20 @@ export default function RipperPage({
|
|||||||
|
|
||||||
const handleRetry = async (jobId) => {
|
const handleRetry = async (jobId) => {
|
||||||
const normalizedJobId = normalizeJobId(jobId);
|
const normalizedJobId = normalizeJobId(jobId);
|
||||||
|
const retryJobRow = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||||
|
const title = retryJobRow?.title || retryJobRow?.detected_title || `Job #${normalizedJobId || jobId}`;
|
||||||
|
const mediaType = retryJobRow ? resolveMediaType(retryJobRow) : null;
|
||||||
|
const diskLabel = mediaType === 'cd' ? 'CD' : 'Disk';
|
||||||
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Rip neu starten',
|
||||||
|
message: `Bitte lege die korrekte ${diskLabel} für "${title}" ein.\nSoll der Rip jetzt gestartet werden?`,
|
||||||
|
acceptLabel: 'Rip starten',
|
||||||
|
rejectLabel: 'Abbrechen'
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||||
try {
|
try {
|
||||||
const response = await api.retryJob(jobId);
|
const response = await api.retryJob(jobId);
|
||||||
@@ -3304,7 +3398,6 @@ export default function RipperPage({
|
|||||||
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
||||||
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||||
const mediaIndicator = mediaIndicatorMeta(job);
|
const mediaIndicator = mediaIndicatorMeta(job);
|
||||||
const seriesTagLabel = mediaIndicator.mediaType === 'bluray' ? 'Blu-ray Serie' : 'DVD Serie';
|
|
||||||
const isResumable = (
|
const isResumable = (
|
||||||
normalizedStatus === 'READY_TO_ENCODE'
|
normalizedStatus === 'READY_TO_ENCODE'
|
||||||
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
|
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
|
||||||
@@ -3345,6 +3438,14 @@ export default function RipperPage({
|
|||||||
const seriesTitleSuffix = mediaIndicator.isSeriesDvd
|
const seriesTitleSuffix = mediaIndicator.isSeriesDvd
|
||||||
? `${seriesSeasonLabel}${seriesDiscLabel}${seriesContainerLabel}`
|
? `${seriesSeasonLabel}${seriesDiscLabel}${seriesContainerLabel}`
|
||||||
: '';
|
: '';
|
||||||
|
const discWorkflowKind = resolveDiscWorkflowKind({
|
||||||
|
mediaType: mediaIndicator.mediaType,
|
||||||
|
selectedMetadata,
|
||||||
|
analyzeContext,
|
||||||
|
pipelineContext: pipelineForJob?.context || null,
|
||||||
|
fallbackIsSeries: Boolean(mediaIndicator.isSeriesVideo)
|
||||||
|
});
|
||||||
|
const discWorkflowBadgeLabel = getDiscWorkflowBadgeLabel(mediaIndicator.mediaType, discWorkflowKind);
|
||||||
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
||||||
const isMultipartMovie = Number(job?.is_multipart_movie || 0) === 1
|
const isMultipartMovie = Number(job?.is_multipart_movie || 0) === 1
|
||||||
|| jobKindRaw === 'multipart_movie_child'
|
|| jobKindRaw === 'multipart_movie_child'
|
||||||
@@ -3478,7 +3579,7 @@ export default function RipperPage({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="ripper-job-badges">
|
<div className="ripper-job-badges">
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
{mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} severity="secondary" /> : null}
|
{discWorkflowBadgeLabel ? <Tag value={discWorkflowBadgeLabel} severity="secondary" /> : null}
|
||||||
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
||||||
{!isMultipartMerge && isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
|
{!isMultipartMerge && isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
|
||||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
@@ -3486,7 +3587,6 @@ export default function RipperPage({
|
|||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
||||||
: null}
|
: null}
|
||||||
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -3872,7 +3972,7 @@ export default function RipperPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="ripper-job-badges">
|
<div className="ripper-job-badges">
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
{mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} severity="secondary" /> : null}
|
{discWorkflowBadgeLabel ? <Tag value={discWorkflowBadgeLabel} severity="secondary" /> : null}
|
||||||
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
||||||
{!isMultipartMerge && isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
|
{!isMultipartMerge && isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
|
||||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
|
|||||||
+19
-1
@@ -1,5 +1,5 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { defineConfig } from 'vite';
|
import { createLogger, defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
const appPackage = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
const appPackage = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||||
@@ -24,8 +24,26 @@ if (publicOrigin) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viteLogger = createLogger();
|
||||||
|
const customLogger = {
|
||||||
|
...viteLogger,
|
||||||
|
error(message, options) {
|
||||||
|
const text = String(message || '');
|
||||||
|
const stack = String(options?.error?.stack || '');
|
||||||
|
const isWsProxyMessage = text.includes('ws proxy socket error');
|
||||||
|
const isEpipe = text.includes('EPIPE') || stack.includes('EPIPE');
|
||||||
|
|
||||||
|
if (isWsProxyMessage && isEpipe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viteLogger.error(message, options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
customLogger,
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(appPackage.version)
|
__APP_VERSION__: JSON.stringify(appPackage.version)
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.15.0-3",
|
"version": "0.16.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.15.0-3",
|
"version": "0.16.0",
|
||||||
"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": "0.15.0-3",
|
"version": "0.16.0",
|
||||||
"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