diff --git a/backend/package-lock.json b/backend/package-lock.json
index 60cd97d..cf7d084 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
- "version": "0.15.0-3",
+ "version": "0.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
- "version": "0.15.0-3",
+ "version": "0.16.0",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
diff --git a/backend/package.json b/backend/package.json
index 7a84b9a..af61a52 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
- "version": "0.15.0-3",
+ "version": "0.16.0",
"private": true,
"type": "commonjs",
"scripts": {
diff --git a/backend/src/db/database.js b/backend/src/db/database.js
index ddee180..ca910b5 100644
--- a/backend/src/db/database.js
+++ b/backend/src/db/database.js
@@ -883,7 +883,13 @@ const SETTINGS_CATEGORY_MOVES = [
{ key: 'converter_movie_dir', category: 'Pfade' },
{ key: 'converter_audio_dir', 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) {
@@ -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_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(
`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_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(`
CREATE TABLE IF NOT EXISTS user_prefs (
key TEXT PRIMARY KEY,
diff --git a/backend/src/index.js b/backend/src/index.js
index 99a6161..0faae4d 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -16,6 +16,7 @@ const runtimeRoutes = require('./routes/runtimeRoutes');
const converterRoutes = require('./routes/converterRoutes');
const wsService = require('./services/websocketService');
const pipelineService = require('./services/pipelineService');
+const settingsService = require('./services/settingsService');
const converterScanService = require('./services/converterScanService');
const cronService = require('./services/cronService');
const downloadService = require('./services/downloadService');
@@ -30,6 +31,12 @@ const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thum
async function start() {
logger.info('backend:start:init');
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 pipelineService.init();
await converterScanService.startPolling();
diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js
index baea647..8ce998d 100644
--- a/backend/src/routes/historyRoutes.js
+++ b/backend/src/routes/historyRoutes.js
@@ -6,6 +6,21 @@ const logger = require('../services/logger').child('HISTORY_ROUTE');
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(
'/',
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(
'/:id/delete-files',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const target = String(req.body?.target || 'both');
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)
? req.body.selectedRawPaths
.map((item) => String(item || '').trim())
@@ -169,12 +203,14 @@ router.post(
id,
target,
includeRelated,
+ selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
const result = await historyService.deleteJobFiles(id, target, {
includeRelated,
+ selectedJobIds,
selectedRawPaths,
selectedMoviePaths
});
@@ -187,14 +223,18 @@ router.get(
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
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', {
reqId: req.reqId,
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 });
})
);
@@ -205,6 +245,9 @@ router.post(
const id = Number(req.params.id);
const target = String(req.body?.target || 'none');
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 preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
@@ -226,6 +269,7 @@ router.post(
id,
target,
includeRelated,
+ selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
requestedResetDriveState,
preserveRawForImportJobs,
requestedKeepDetectedDevice,
@@ -233,10 +277,12 @@ router.post(
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
- const preview = await historyService.getJobDeletePreview(id, { includeRelated });
+ const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
const containsOrphanRawImportJob = Boolean(
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
? false
@@ -254,11 +300,13 @@ router.post(
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs
+ .filter((row) => Boolean(row?.selected))
.map((row) => String(row?.discDevice || '').trim())
.filter(Boolean)
: [];
const result = await historyService.deleteJob(id, target, {
includeRelated,
+ selectedJobIds,
selectedRawPaths,
selectedMoviePaths,
preserveRawForImportJobs
diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js
index 6db7e83..0f03e51 100644
--- a/backend/src/services/historyService.js
+++ b/backend/src/services/historyService.js
@@ -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, ''');
+}
+
+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 [
+ '',
+ ` ${title}`,
+ ` ${year}`,
+ ` ${imdbId}`,
+ '',
+ ''
+ ].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) {
if (!value) {
return fallback;
@@ -1044,6 +1225,13 @@ function enrichJobRow(job, settings = null, options = {}) {
? finishedWithOutput
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS')
);
+ const nfoStatus = buildNfoStatusForJob({
+ job,
+ outputPath: resolvedPaths.effectiveOutputPath,
+ outputStatus,
+ includeFsChecks,
+ settings
+ });
return {
...job,
@@ -1061,7 +1249,8 @@ function enrichJobRow(job, settings = null, options = {}) {
encodeSuccess,
rawStatus,
outputStatus,
- movieDirStatus
+ movieDirStatus,
+ nfoStatus
};
}
@@ -2727,6 +2916,48 @@ function parseSourceJobIdFromPlan(encodePlanRaw) {
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 = []) {
const jobIds = new Set();
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 = {}) {
const normalizedJobId = normalizeJobIdValue(jobId);
if (!normalizedJobId) {
@@ -6649,14 +6998,7 @@ class HistoryService {
continue;
}
if (!inspection.exists) {
- upsertMoviePath({
- path: row.path,
- exists: false,
- isDirectory: false,
- isFile: true,
- jobIds: Array.from(row.jobIds),
- sources
- });
+ // Output candidates must stay file-based only. Missing paths are not actionable.
continue;
}
if (inspection.isDirectory) {
@@ -6710,6 +7052,11 @@ class HistoryService {
async getJobDeletePreview(jobId, options = {}) {
const includeRelated = options?.includeRelated !== false;
+ const requestedSelectedJobIds = Array.isArray(options?.selectedJobIds)
+ ? options.selectedJobIds
+ .map((item) => normalizeJobIdValue(item))
+ .filter(Boolean)
+ : null;
const normalizedJobId = normalizeJobIdValue(jobId);
if (!normalizedJobId) {
const error = new Error('Ungültige Job-ID.');
@@ -6718,20 +7065,35 @@ class HistoryService {
}
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 relatedJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
- const [lineageArtifactsByJobId, outputFoldersByJobId] = await Promise.all([
- this.listJobLineageArtifactsByJobIds(relatedJobIds),
- this.listJobOutputFoldersByJobIds(relatedJobIds)
- ]);
+ const relatedJobIds = selectedJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
+ const [lineageArtifactsByJobId, outputFoldersByJobId] = relatedJobIds.length > 0
+ ? await Promise.all([
+ this.listJobLineageArtifactsByJobIds(relatedJobIds),
+ this.listJobOutputFoldersByJobIds(relatedJobIds)
+ ])
+ : [new Map(), new Map()];
const preview = this._buildDeletePreviewFromJobs(
- jobs,
+ selectedJobs,
settings,
lineageArtifactsByJobId,
outputFoldersByJobId
);
const relatedJobs = jobs.map((job) => {
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
+ const roleMeta = resolveDeletePreviewRole(job);
+ const id = normalizeJobIdValue(job?.id);
return {
id: Number(job.id),
parentJobId: normalizeJobIdValue(job.parent_job_id),
@@ -6739,17 +7101,23 @@ class HistoryService {
status: String(job.status || '').trim() || null,
discDevice: String(job.disc_device || '').trim() || null,
isPrimary: Number(job.id) === normalizedJobId,
+ selected: Boolean(id && effectiveSelectedJobIdSet.has(id)),
+ roleKey: roleMeta.roleKey,
+ roleLabel: roleMeta.roleLabel,
createdAt: String(job.created_at || '').trim() || null,
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 existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length;
return {
jobId: normalizedJobId,
includeRelated,
+ selectedJobIds: effectiveSelectedJobIds,
relatedJobs,
pathCandidates: preview.pathCandidates,
protectedRoots: preview.protectedRoots,
@@ -6758,6 +7126,7 @@ class HistoryService {
},
counts: {
relatedJobs: relatedJobs.length,
+ selectedJobs: effectiveSelectedJobIds.length,
orphanRawImportJobs,
rawCandidates: preview.pathCandidates.raw.length,
movieCandidates: preview.pathCandidates.movie.length,
@@ -6976,6 +7345,11 @@ class HistoryService {
}
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)
? options.selectedRawPaths
.map((item) => String(item || '').trim())
@@ -6987,13 +7361,19 @@ class HistoryService {
.filter(Boolean)
: 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 preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated });
+ const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated, selectedJobIds });
const summary = this._deletePathsFromPreview(preview, target, { selectedRawPaths, selectedMoviePaths });
const relatedJobIds = Array.from(new Set(
- (Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [])
- .map((row) => normalizeJobIdValue(row?.id))
+ (Array.isArray(preview?.selectedJobIds) ? preview.selectedJobIds : [])
+ .map((row) => normalizeJobIdValue(row))
.filter(Boolean)
));
@@ -7011,6 +7391,7 @@ class HistoryService {
logger.info('job:delete-files', {
jobId: normalizedJobId,
includeRelated,
+ selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0,
summary
@@ -7782,6 +8163,11 @@ class HistoryService {
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
+ const normalizedSelectedJobIds = Array.isArray(options?.selectedJobIds)
+ ? options.selectedJobIds
+ .map((item) => normalizeJobIdValue(item))
+ .filter(Boolean)
+ : null;
const includeRelated = Boolean(options?.includeRelated);
if (!includeRelated) {
@@ -7933,14 +8319,18 @@ class HistoryService {
const resolved = await this.getJobByIdOrReplacement(jobId);
const existing = resolved?.job || null;
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
- const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
- let deleteJobIds = Array.isArray(preview?.relatedJobs)
- ? preview.relatedJobs
- .map((row) => normalizeJobIdValue(row?.id))
+ const preview = await this.getJobDeletePreview(normalizedJobId, {
+ includeRelated: true,
+ selectedJobIds: normalizedSelectedJobIds
+ });
+ let deleteJobIds = Array.isArray(preview?.selectedJobIds)
+ ? preview.selectedJobIds
+ .map((row) => normalizeJobIdValue(row))
.filter(Boolean)
: [];
let rowById = new Map();
- if (normalizedJobId && !deleteJobIds.includes(normalizedJobId)) {
+ const hasExplicitSelectedJobFilter = Array.isArray(normalizedSelectedJobIds);
+ if (!hasExplicitSelectedJobFilter && normalizedJobId && !deleteJobIds.includes(normalizedJobId)) {
deleteJobIds = [...deleteJobIds, normalizedJobId];
}
if (deleteJobIds.length > 0) {
diff --git a/backend/src/services/logger.js b/backend/src/services/logger.js
index 4652114..c551576 100644
--- a/backend/src/services/logger.js
+++ b/backend/src/services/logger.js
@@ -11,6 +11,23 @@ const LEVELS = {
};
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) {
try {
@@ -118,6 +135,10 @@ function emit(level, scope, message, meta = null) {
const line = safeJson(payload);
writeLine(line);
+ if (!shouldEmitToConsole(normLevel, scope)) {
+ return;
+ }
+
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
if (normLevel === 'error') {
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) {
return {
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 = {
child,
- emit
+ emit,
+ setConsoleOutputEnabled,
+ isConsoleOutputEnabled,
+ configureConsoleOutput,
+ getConsoleOutputConfig
};
diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js
index 4c41ea1..2f6a30d 100644
--- a/backend/src/services/pipelineService.js
+++ b/backend/src/services/pipelineService.js
@@ -29,6 +29,7 @@ const { errorToMeta } = require('../utils/errorMeta');
const userPresetService = require('./userPresetService');
const thumbnailService = require('./thumbnailService');
const activationBytesService = require('./activationBytesService');
+const { toBoolean } = require('../utils/validators');
const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
const REVIEW_REFRESH_SETTING_PREFIXES = [
@@ -98,6 +99,7 @@ const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([
const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS);
const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000;
const tmdbSeriesSearchCache = new Map();
+const PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY = 'handbrake_pre_metadata_scan_enabled';
function nowIso() {
return new Date().toISOString();
@@ -197,6 +199,82 @@ function buildMovieMetadataFingerprint(mediaProfile, metadata = {}) {
return `${normalizedMediaProfile}|title|${title}`;
}
+function toMetadataSearchTitleCase(value) {
+ const source = String(value || '').trim();
+ if (!source) {
+ return '';
+ }
+ return source
+ .toLowerCase()
+ .replace(/\b([a-z\u00c0-\u024f])([a-z\u00c0-\u024f0-9']*)/g, (_match, first, rest) => (
+ `${first.toUpperCase()}${rest}`
+ ));
+}
+
+function pushUniqueMetadataSearchVariant(target, candidate) {
+ const normalized = String(candidate || '').replace(/\s+/g, ' ').trim();
+ if (!normalized) {
+ return;
+ }
+ if (!target.some((entry) => entry.toLowerCase() === normalized.toLowerCase())) {
+ target.push(normalized);
+ }
+}
+
+function stripTrailingDiscSearchHint(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;
+}
+
+function buildMetadataSearchQueryVariants(query) {
+ const rawQuery = String(query || '').trim();
+ if (!rawQuery) {
+ return [];
+ }
+
+ if (/^tt\d{6,12}$/i.test(rawQuery)) {
+ return [rawQuery.toLowerCase()];
+ }
+
+ const variants = [];
+ const separatorNormalized = rawQuery
+ .replace(/[_]+/g, ' ')
+ .replace(/[.]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ const punctuationReduced = separatorNormalized
+ .replace(/[|/\\]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ const stripped = stripTrailingDiscSearchHint(punctuationReduced);
+
+ pushUniqueMetadataSearchVariant(variants, rawQuery);
+ pushUniqueMetadataSearchVariant(variants, separatorNormalized);
+ pushUniqueMetadataSearchVariant(variants, punctuationReduced);
+ pushUniqueMetadataSearchVariant(variants, toMetadataSearchTitleCase(separatorNormalized));
+ pushUniqueMetadataSearchVariant(variants, stripped);
+ pushUniqueMetadataSearchVariant(variants, toMetadataSearchTitleCase(stripped));
+
+ return variants.slice(0, 6);
+}
+
function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = null) {
const mkInfo = makemkvInfo && typeof makemkvInfo === 'object'
? makemkvInfo
@@ -4448,7 +4526,31 @@ function parseHandBrakeTitleList(scanJson) {
const audioTrackCount = Array.isArray(title?.AudioList) ? title.AudioList.length : 0;
const subtitleTrackCount = Array.isArray(title?.SubtitleList) ? title.SubtitleList.length : 0;
const sizeBytes = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0;
- return { handBrakeTitleId, durationSeconds, audioTrackCount, subtitleTrackCount, sizeBytes };
+ const chapterList = Array.isArray(title?.ChapterList)
+ ? title.ChapterList
+ : [];
+ const chapterCountFromList = chapterList.length;
+ const chapterCountFromField = Number(title?.ChapterCount ?? title?.chapterCount ?? 0);
+ const chapterCount = chapterCountFromList > 0
+ ? chapterCountFromList
+ : (Number.isFinite(chapterCountFromField) && chapterCountFromField > 0 ? Math.trunc(chapterCountFromField) : 0);
+ const chapterDurations = chapterList
+ .map((chapter) => parseHandBrakeDurationSeconds(
+ chapter?.Duration ?? chapter?.duration ?? chapter?.Length ?? chapter?.length
+ ))
+ .filter((value) => Number.isFinite(value) && value > 0);
+ const chapterAverageSeconds = chapterDurations.length > 0
+ ? Number((chapterDurations.reduce((sum, value) => sum + value, 0) / chapterDurations.length).toFixed(2))
+ : 0;
+ return {
+ handBrakeTitleId,
+ durationSeconds,
+ audioTrackCount,
+ subtitleTrackCount,
+ sizeBytes,
+ chapterCount,
+ chapterAverageSeconds
+ };
});
}
@@ -4486,6 +4588,18 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options =
60,
Math.round(Number(options?.shortTitleMinutes || 5) * 60)
);
+ const minEpisodeChapterCount = Math.max(
+ 1,
+ Math.round(Number(options?.minEpisodeChapterCount || 4))
+ );
+ const chapterSimilarityRatio = Math.max(
+ 1.1,
+ Number(options?.chapterSimilarityRatio || 1.8)
+ );
+ const dominantFeatureDurationRatio = Math.max(
+ 1.2,
+ Number(options?.dominantFeatureDurationRatio || 2.2)
+ );
const titles = parseHandBrakeTitleList(scanJson);
if (!Array.isArray(titles) || titles.length === 0) {
@@ -4495,7 +4609,9 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options =
const titlesWithDuration = titles
.map((title) => ({
id: Number(title?.handBrakeTitleId || 0) || null,
- durationSeconds: Number(title?.durationSeconds || 0) || 0
+ durationSeconds: Number(title?.durationSeconds || 0) || 0,
+ chapterCount: Number(title?.chapterCount || 0) || 0,
+ chapterAverageSeconds: Number(title?.chapterAverageSeconds || 0) || 0
}))
.filter((title) => Number.isFinite(title.durationSeconds) && title.durationSeconds > 0);
@@ -4508,11 +4624,32 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options =
&& title.durationSeconds <= maxEpisodeSeconds
);
+ const areChapterProfilesCompatible = (left, right) => {
+ const leftCount = Number(left?.chapterCount || 0);
+ const rightCount = Number(right?.chapterCount || 0);
+ if (leftCount > 0 && rightCount > 0) {
+ const ratio = Math.max(leftCount, rightCount) / Math.max(1, Math.min(leftCount, rightCount));
+ if (ratio > chapterSimilarityRatio) {
+ return false;
+ }
+ }
+ const leftAvg = Number(left?.chapterAverageSeconds || 0);
+ const rightAvg = Number(right?.chapterAverageSeconds || 0);
+ if (leftAvg > 0 && rightAvg > 0) {
+ const ratio = Math.max(leftAvg, rightAvg) / Math.max(1, Math.min(leftAvg, rightAvg));
+ if (ratio > chapterSimilarityRatio) {
+ return false;
+ }
+ }
+ return true;
+ };
+
let bestCluster = [];
for (const anchor of episodeWindowCandidates) {
const toleranceSeconds = Math.max(90, Math.round(anchor.durationSeconds * 0.12));
const cluster = episodeWindowCandidates.filter((candidate) =>
Math.abs(candidate.durationSeconds - anchor.durationSeconds) <= toleranceSeconds
+ && areChapterProfilesCompatible(anchor, candidate)
);
if (cluster.length > bestCluster.length) {
bestCluster = cluster;
@@ -4529,6 +4666,11 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options =
const episodeCandidateCount = bestCluster.length;
const clusterMedianDuration = medianPositiveNumber(bestCluster.map((entry) => entry.durationSeconds));
+ const clusterMedianChapterCount = medianPositiveNumber(
+ bestCluster
+ .map((entry) => Number(entry?.chapterCount || 0))
+ .filter((value) => Number.isFinite(value) && value > 0)
+ );
const clusterIds = new Set(
bestCluster
.map((entry) => Number(entry?.id))
@@ -4551,7 +4693,31 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options =
!clusterIds.has(Number(title?.id))
&& title.durationSeconds > shortTitleSeconds
).length;
- const seriesLike = episodeCandidateCount >= 2;
+ let seriesLike = episodeCandidateCount >= 2;
+
+ const longestTitle = titlesWithDuration.reduce((best, current) => (
+ !best || current.durationSeconds > best.durationSeconds ? current : best
+ ), null);
+ const longestTitleDominates = Boolean(
+ longestTitle
+ && clusterMedianDuration > 0
+ && longestTitle.durationSeconds >= (clusterMedianDuration * dominantFeatureDurationRatio)
+ && Number(longestTitle?.chapterCount || 0) >= (minEpisodeChapterCount * 2)
+ );
+ const lowChapterEpisodeCount = bestCluster.filter((entry) => {
+ const chapterCount = Number(entry?.chapterCount || 0);
+ if (chapterCount <= 0) {
+ return longestTitleDominates;
+ }
+ return chapterCount < minEpisodeChapterCount;
+ }).length;
+ const allEpisodeCandidatesLowChapter = (
+ episodeCandidateCount > 0
+ && lowChapterEpisodeCount === episodeCandidateCount
+ );
+ if (seriesLike && longestTitleDominates && allEpisodeCandidatesLowChapter) {
+ seriesLike = false;
+ }
let confidence = 'low';
if (seriesLike) {
@@ -4577,6 +4743,12 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options =
if (shortCount > 0) {
reasons.push(`${shortCount} kurze Titel als Extras/Kurzsegmente erkannt.`);
}
+ if (!seriesLike && longestTitleDominates && allEpisodeCandidatesLowChapter) {
+ reasons.push(
+ 'Kurze Episodenkandidaten wurden wegen abweichender Kapitelstruktur als Bonusmaterial gewertet '
+ + `(Kapitelzahl Kandidaten < ${minEpisodeChapterCount}, Haupttitel kapitelreich).`
+ );
+ }
return {
source: 'handbrake_scan_json',
@@ -4587,6 +4759,9 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options =
reasons,
titleCount: titles.length,
episodeCandidateCount,
+ episodeChapterMedian: clusterMedianChapterCount > 0 ? Number(clusterMedianChapterCount.toFixed(2)) : 0,
+ lowChapterEpisodeCount,
+ longestTitleChapterCount: Number(longestTitle?.chapterCount || 0) || 0,
playAllCount,
duplicateCount: 0,
extraCount,
@@ -4605,11 +4780,50 @@ function buildStructuredSeriesAnalysisFromPlaylistAnalysis(playlistAnalysis, opt
return null;
}
+ const parseChapterCountFromText = (value) => {
+ const text = String(value || '').trim();
+ if (!text) {
+ return 0;
+ }
+ const chapterMatch = text.match(/(\d+)\s+chapter\(s\)/i);
+ if (!chapterMatch) {
+ return 0;
+ }
+ const parsed = Number(chapterMatch[1]);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ return 0;
+ }
+ return Math.trunc(parsed);
+ };
+
+ const normalizeChapterCountForSeriesHeuristics = (title) => {
+ const directChapterCount = Number(title?.chapterCount ?? title?.chapters ?? 0);
+ if (Number.isFinite(directChapterCount) && directChapterCount > 0) {
+ return Math.trunc(directChapterCount);
+ }
+
+ const fields = title?.fields && typeof title.fields === 'object'
+ ? title.fields
+ : null;
+ const fieldChapterCount = Number(fields?.[8] ?? fields?.[7] ?? 0);
+ if (Number.isFinite(fieldChapterCount) && fieldChapterCount > 0) {
+ return Math.trunc(fieldChapterCount);
+ }
+
+ const chapterInfoText = String(
+ title?.description
+ || fields?.[30]
+ || ''
+ ).trim();
+ return parseChapterCountFromText(chapterInfoText);
+ };
+
const syntheticScanJson = {
MainFeature: 0,
TitleList: sourceTitles.map((title, index) => ({
Index: normalizeScanTrackId(title?.titleId, index),
Duration: Number(title?.durationSeconds || 0) || String(title?.durationLabel || '').trim() || 0,
+ ChapterCount: normalizeChapterCountForSeriesHeuristics(title),
AudioList: Array.isArray(title?.audioTracks) ? title.audioTracks : [],
SubtitleList: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [],
Size: {
@@ -4737,6 +4951,18 @@ function mergeSeriesAnalysisWithStructuredFallback(seriesAnalysis = null, struct
Number(baseSummary?.episodeCandidateCount || 0) || 0,
Number(structuredSummary?.episodeCandidateCount || 0) || 0
),
+ episodeChapterMedian: Math.max(
+ Number(baseSummary?.episodeChapterMedian || 0) || 0,
+ Number(structuredSummary?.episodeChapterMedian || 0) || 0
+ ),
+ lowChapterEpisodeCount: Math.max(
+ Number(baseSummary?.lowChapterEpisodeCount || 0) || 0,
+ Number(structuredSummary?.lowChapterEpisodeCount || 0) || 0
+ ),
+ longestTitleChapterCount: Math.max(
+ Number(baseSummary?.longestTitleChapterCount || 0) || 0,
+ Number(structuredSummary?.longestTitleChapterCount || 0) || 0
+ ),
playAllCount: Math.max(
Number(baseSummary?.playAllCount || 0) || 0,
Number(structuredSummary?.playAllCount || 0) || 0
@@ -9051,6 +9277,25 @@ class PipelineService extends EventEmitter {
};
}
+ async isPreMetadataHandBrakeScanEnabled() {
+ try {
+ const settings = await settingsService.getSettingsMap();
+ if (!settings || typeof settings !== 'object') {
+ return true;
+ }
+ if (!Object.prototype.hasOwnProperty.call(settings, PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY)) {
+ return true;
+ }
+ return toBoolean(settings[PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY]);
+ } catch (error) {
+ logger.warn('metadata:pre-scan-setting:read-failed', {
+ settingKey: PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY,
+ error: errorToMeta(error)
+ });
+ return true;
+ }
+ }
+
async runMakeMkvSeriesSecondChance({
jobId,
mediaProfile,
@@ -13044,6 +13289,16 @@ class PipelineService extends EventEmitter {
output_path: finalizedOutputPath,
error_message: null
});
+ await historyService.generateJobNfo(normalizedJobId, {
+ mode: 'auto',
+ requireSettingEnabled: true
+ }).catch((nfoError) => {
+ logger.warn('job:nfo:auto-generate-failed', {
+ jobId: normalizedJobId,
+ mode: 'multipart_merge',
+ error: errorToMeta(nfoError)
+ });
+ });
if (mergeJob?.parent_job_id) {
await this.syncSeriesContainerStatusFromChildren(mergeJob.parent_job_id).catch((parentSyncError) => {
logger.warn('multipart-merge:parent-status-sync-failed', {
@@ -14135,6 +14390,127 @@ class PipelineService extends EventEmitter {
return output;
}
+ normalizeQueueAutomationItems(rawItems = []) {
+ const items = Array.isArray(rawItems) ? rawItems : [];
+ const seen = new Set();
+ const output = [];
+ for (const item of items) {
+ if (!item || typeof item !== 'object') {
+ continue;
+ }
+ const type = String(item.type || '').trim().toLowerCase();
+ if (type !== 'script' && type !== 'chain') {
+ continue;
+ }
+ const id = Number(item.id ?? item.scriptId ?? item.chainId);
+ if (!Number.isFinite(id) || id <= 0) {
+ continue;
+ }
+ const normalizedId = Math.trunc(id);
+ const key = `${type}:${normalizedId}`;
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ output.push({
+ type,
+ id: normalizedId,
+ name: String(item.name || '').trim() || null
+ });
+ }
+ return output;
+ }
+
+ extractDetachedQueueAutomationFromPlan(plan = null) {
+ const sourcePlan = plan && typeof plan === 'object' ? plan : {};
+ const buildNameMap = (items = []) => {
+ const map = new Map();
+ for (const item of (Array.isArray(items) ? items : [])) {
+ if (!item || typeof item !== 'object') {
+ continue;
+ }
+ const id = Number(item.id ?? item.scriptId ?? item.chainId);
+ const name = String(item.name || item.scriptName || item.chainName || '').trim();
+ if (!Number.isFinite(id) || id <= 0 || !name) {
+ continue;
+ }
+ map.set(Math.trunc(id), name);
+ }
+ return map;
+ };
+ const buildFallbackItems = (scriptIds, chainIds, scriptNameMap, chainNameMap) => {
+ const items = [];
+ for (const scriptId of normalizeScriptIdList(scriptIds || [])) {
+ items.push({
+ type: 'script',
+ id: scriptId,
+ name: scriptNameMap.get(scriptId) || null
+ });
+ }
+ for (const chainId of this.normalizeQueueChainIdList(chainIds || [])) {
+ items.push({
+ type: 'chain',
+ id: chainId,
+ name: chainNameMap.get(chainId) || null
+ });
+ }
+ return this.normalizeQueueAutomationItems(items);
+ };
+
+ const preFromItems = this.normalizeQueueAutomationItems(sourcePlan.preEncodeItems || []);
+ const postFromItems = this.normalizeQueueAutomationItems(sourcePlan.postEncodeItems || []);
+ const preScriptNameMap = buildNameMap(sourcePlan.preEncodeScripts || []);
+ const postScriptNameMap = buildNameMap(sourcePlan.postEncodeScripts || []);
+ const preChainNameMap = buildNameMap(sourcePlan.preEncodeChains || []);
+ const postChainNameMap = buildNameMap(sourcePlan.postEncodeChains || []);
+
+ const preItems = preFromItems.length > 0
+ ? preFromItems
+ : buildFallbackItems(
+ sourcePlan.preEncodeScriptIds || [],
+ sourcePlan.preEncodeChainIds || [],
+ preScriptNameMap,
+ preChainNameMap
+ );
+ const postItems = postFromItems.length > 0
+ ? postFromItems
+ : buildFallbackItems(
+ sourcePlan.postEncodeScriptIds || [],
+ sourcePlan.postEncodeChainIds || [],
+ postScriptNameMap,
+ postChainNameMap
+ );
+
+ return { preItems, postItems };
+ }
+
+ buildDetachedQueueEntryFromAutomationItem(item, options = {}) {
+ const source = item && typeof item === 'object' ? item : {};
+ const phase = String(options?.phase || '').trim().toLowerCase() === 'post' ? 'post' : 'pre';
+ const jobId = Number(options?.jobId);
+ const sourceJobId = Number.isFinite(jobId) && jobId > 0 ? Math.trunc(jobId) : null;
+ if (source.type === 'script') {
+ return {
+ id: this.queueEntrySeq++,
+ type: 'script',
+ scriptId: Number(source.id),
+ scriptName: String(source.name || '').trim() || null,
+ sourceJobId,
+ sourcePhase: phase === 'pre' ? 'pre_encode' : 'post_encode',
+ enqueuedAt: nowIso()
+ };
+ }
+ return {
+ id: this.queueEntrySeq++,
+ type: 'chain',
+ chainId: Number(source.id),
+ chainName: String(source.name || '').trim() || null,
+ sourceJobId,
+ sourcePhase: phase === 'pre' ? 'pre_encode' : 'post_encode',
+ enqueuedAt: nowIso()
+ };
+ }
+
extractQueueJobPlan(row) {
const source = row && typeof row === 'object' ? row : null;
if (!source) {
@@ -14352,13 +14728,7 @@ class PipelineService extends EventEmitter {
? await historyService.getJobsByIds(queuedJobIds)
: [];
const queuedById = new Map(queuedRows.map((row) => [Number(row.id), row]));
- const scriptMetaByJobId = await this.buildQueueJobScriptMeta(
- Array.from(
- new Map(
- [...visibleRunningJobs, ...queuedRows, ...idleJobs].map((row) => [Number(row?.id), row])
- ).values()
- )
- );
+ const scriptMetaByJobId = new Map();
const resolveRunningSeriesEpisodeSubtitle = (job) => {
if (!this.isSeriesBatchParentQueueAnchor(job)) {
return null;
@@ -14460,12 +14830,36 @@ class PipelineService extends EventEmitter {
type: entryType,
enqueuedAt: entry.enqueuedAt
};
+ const sourceJobId = Number(entry?.sourceJobId);
+ const sourcePhase = String(entry?.sourcePhase || '').trim().toLowerCase();
+ const sourcePhaseLabel = sourcePhase === 'post_encode'
+ ? 'Post-Encode'
+ : (sourcePhase === 'pre_encode' ? 'Pre-Encode' : null);
+ const sourceSubtitle = Number.isFinite(sourceJobId) && sourceJobId > 0
+ ? `${sourcePhaseLabel ? `${sourcePhaseLabel} ` : ''}aus Job #${Math.trunc(sourceJobId)}`
+ : null;
if (entryType === 'script') {
- return { ...base, scriptId: entry.scriptId, title: entry.scriptName || `Skript #${entry.scriptId}`, status: 'QUEUED' };
+ return {
+ ...base,
+ scriptId: entry.scriptId,
+ sourceJobId: Number.isFinite(sourceJobId) && sourceJobId > 0 ? Math.trunc(sourceJobId) : null,
+ sourcePhase: sourcePhaseLabel,
+ subtitle: sourceSubtitle,
+ title: entry.scriptName || `Skript #${entry.scriptId}`,
+ status: 'QUEUED'
+ };
}
if (entryType === 'chain') {
- return { ...base, chainId: entry.chainId, title: entry.chainName || `Kette #${entry.chainId}`, status: 'QUEUED' };
+ return {
+ ...base,
+ chainId: entry.chainId,
+ sourceJobId: Number.isFinite(sourceJobId) && sourceJobId > 0 ? Math.trunc(sourceJobId) : null,
+ sourcePhase: sourcePhaseLabel,
+ subtitle: sourceSubtitle,
+ title: entry.chainName || `Kette #${entry.chainId}`,
+ status: 'QUEUED'
+ };
}
if (entryType === 'wait') {
return { ...base, waitSeconds: entry.waitSeconds, title: `Warten ${entry.waitSeconds}s`, status: 'QUEUED' };
@@ -14626,10 +15020,38 @@ class PipelineService extends EventEmitter {
throw error;
}
+ const optionPreloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === normalizedJobId
+ ? options.preloadedJob
+ : null;
+ let resolvedQueueJob = optionPreloadedJob || null;
let poolType = this.normalizeQueuePoolType(options?.poolType);
if (!poolType) {
- const poolJob = await historyService.getJobById(normalizedJobId).catch(() => null);
- poolType = this.resolveQueuePoolTypeForJob(poolJob);
+ resolvedQueueJob = resolvedQueueJob || await historyService.getJobById(normalizedJobId).catch(() => null);
+ poolType = this.resolveQueuePoolTypeForJob(resolvedQueueJob);
+ }
+
+ let detachedPreItems = [];
+ let detachedPostItems = [];
+ if (options?.detachAttachedAutomation !== false) {
+ if (action === QUEUE_ACTIONS.START_PREPARED) {
+ resolvedQueueJob = resolvedQueueJob || await historyService.getJobById(normalizedJobId).catch(() => null);
+ const queueJobPlan = this.safeParseJson(resolvedQueueJob?.encode_plan_json);
+ const detachedItems = this.extractDetachedQueueAutomationFromPlan(queueJobPlan);
+ detachedPreItems = detachedItems.preItems;
+ detachedPostItems = detachedItems.postItems;
+ } else if (action === QUEUE_ACTIONS.START_CD) {
+ const ripConfig = options?.entryData?.ripConfig && typeof options.entryData.ripConfig === 'object'
+ ? options.entryData.ripConfig
+ : {};
+ detachedPreItems = this.normalizeQueueAutomationItems([
+ ...normalizeScriptIdList(ripConfig.selectedPreEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
+ ...this.normalizeQueueChainIdList(ripConfig.selectedPreEncodeChainIds || []).map((id) => ({ type: 'chain', id }))
+ ]);
+ detachedPostItems = this.normalizeQueueAutomationItems([
+ ...normalizeScriptIdList(ripConfig.selectedPostEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
+ ...this.normalizeQueueChainIdList(ripConfig.selectedPostEncodeChainIds || []).map((id) => ({ type: 'chain', id }))
+ ]);
+ }
}
const allowDuplicateJobEntries = Boolean(options?.allowDuplicateJobEntries);
@@ -14683,7 +15105,8 @@ class PipelineService extends EventEmitter {
const laneRunning = poolType === 'audio' ? audioRunning : filmRunning;
const laneCap = poolType === 'audio' ? maxCd : maxFilm;
const forceQueue = Boolean(options?.forceQueue);
- const shouldQueue = forceQueue
+ const hasDetachedAutomationEntries = detachedPreItems.length > 0 || detachedPostItems.length > 0;
+ const shouldQueue = forceQueue || hasDetachedAutomationEntries
? true
: (poolType === 'audio' && cdBypass
? (hasBlockingAudioQueueEntry || laneRunning >= laneCap || totalRunning >= maxTotal)
@@ -14700,7 +15123,15 @@ class PipelineService extends EventEmitter {
};
}
- this.queueEntries.push({
+ const preDetachedEntries = detachedPreItems.map((item) => this.buildDetachedQueueEntryFromAutomationItem(item, {
+ jobId: normalizedJobId,
+ phase: 'pre'
+ }));
+ const postDetachedEntries = detachedPostItems.map((item) => this.buildDetachedQueueEntryFromAutomationItem(item, {
+ jobId: normalizedJobId,
+ phase: 'post'
+ }));
+ const jobQueueEntry = {
id: this.queueEntrySeq++,
jobId: normalizedJobId,
action,
@@ -14708,11 +15139,17 @@ class PipelineService extends EventEmitter {
uniqueEntryKey,
...(options?.entryData && typeof options.entryData === 'object' ? options.entryData : {}),
enqueuedAt: nowIso()
- });
+ };
+ this.queueEntries.push(...preDetachedEntries, jobQueueEntry, ...postDetachedEntries);
+ const queuePosition = this.queueEntries.findIndex((entry) => entry.id === jobQueueEntry.id) + 1;
+ const detachedCount = preDetachedEntries.length + postDetachedEntries.length;
await historyService.appendLog(
normalizedJobId,
'USER_ACTION',
`In Queue aufgenommen: ${QUEUE_ACTION_LABELS[action] || action}`
+ + (detachedCount > 0
+ ? ` (Automationen als eigene Queue-Einträge: Pre=${preDetachedEntries.length}, Post=${postDetachedEntries.length}).`
+ : '')
);
await this.emitQueueChanged();
void this.pumpQueue();
@@ -14720,10 +15157,11 @@ class PipelineService extends EventEmitter {
return {
queued: true,
started: false,
- queuePosition: this.queueEntries.length,
+ queuePosition,
action,
poolType,
- queuedPoolTypeCount
+ queuedPoolTypeCount,
+ detachedAutomationQueued: detachedCount
};
}
@@ -14886,6 +15324,57 @@ class PipelineService extends EventEmitter {
}
}
+ async isNonJobQueueEntryReady(entry, options = {}) {
+ const entryType = String(entry?.type || '').trim().toLowerCase();
+ if (entryType !== 'script' && entryType !== 'chain') {
+ return true;
+ }
+
+ const sourceJobId = this.normalizeQueueJobId(entry?.sourceJobId);
+ if (!sourceJobId) {
+ return true;
+ }
+
+ const sourcePhase = String(entry?.sourcePhase || '').trim().toLowerCase();
+ if (sourcePhase !== 'post_encode') {
+ return true;
+ }
+
+ const runningJobIds = options?.runningJobIds instanceof Set
+ ? options.runningJobIds
+ : null;
+ if (runningJobIds && runningJobIds.has(sourceJobId)) {
+ return false;
+ }
+
+ const sourceJobTerminalCache = options?.sourceJobTerminalCache instanceof Map
+ ? options.sourceJobTerminalCache
+ : null;
+ if (sourceJobTerminalCache && sourceJobTerminalCache.has(sourceJobId)) {
+ return Boolean(sourceJobTerminalCache.get(sourceJobId));
+ }
+
+ const sourceJob = await historyService.getJobById(sourceJobId).catch(() => null);
+ if (!sourceJob) {
+ if (sourceJobTerminalCache) {
+ sourceJobTerminalCache.set(sourceJobId, true);
+ }
+ return true;
+ }
+
+ const stateCandidates = [
+ String(sourceJob?.status || '').trim().toUpperCase(),
+ String(sourceJob?.last_state || '').trim().toUpperCase()
+ ].filter(Boolean);
+ const isTerminal = stateCandidates.some((state) => (
+ state === 'FINISHED' || state === 'ERROR' || state === 'CANCELLED'
+ ));
+ if (sourceJobTerminalCache) {
+ sourceJobTerminalCache.set(sourceJobId, isTerminal);
+ }
+ return isTerminal;
+ }
+
async pumpQueue() {
if (this.queuePumpRunning) {
return;
@@ -14905,53 +15394,63 @@ class PipelineService extends EventEmitter {
const filmRunning = runningUsage.filmRunning;
const cdRunning = runningUsage.audioRunning;
const totalRunning = runningUsage.totalRunning;
+ const runningJobIds = new Set(
+ (Array.isArray(allRunningJobs) ? allRunningJobs : [])
+ .map((row) => this.normalizeQueueJobId(row?.id))
+ .filter(Boolean)
+ );
+ const sourceJobTerminalCache = new Map();
// Find next startable entry
let entryIndex = -1;
- const independentEntryIndex = this.queueEntries.findIndex((candidate) => {
- const type = String(candidate?.type || '').trim().toLowerCase();
- return type === 'script' || type === 'chain';
- });
- if (independentEntryIndex >= 0) {
- entryIndex = independentEntryIndex;
- } else {
- for (let i = 0; i < this.queueEntries.length; i++) {
- const candidate = this.queueEntries[i];
- const isWaitEntry = String(candidate?.type || '').trim().toLowerCase() === 'wait';
- if (isWaitEntry) {
- // Wait keeps queue order and blocks until no tracked job process is active.
- if (this.activeProcesses.size === 0) {
- entryIndex = i;
- }
+ for (let i = 0; i < this.queueEntries.length; i++) {
+ const candidate = this.queueEntries[i];
+ const candidateType = String(candidate?.type || 'job').trim().toLowerCase();
+ if (candidateType === 'script' || candidateType === 'chain') {
+ // Detached post-encode automations must only run after the source job finished.
+ const nonJobReady = await this.isNonJobQueueEntryReady(candidate, {
+ runningJobIds,
+ sourceJobTerminalCache
+ });
+ if (nonJobReady) {
+ entryIndex = i;
break;
}
-
- // Job entry: check hierarchical limits (caps apply only to encode jobs).
- if (totalRunning >= maxTotal) {
- // Total encode cap reached – nothing can start.
- break;
- }
-
- const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
- if (candidatePoolType === 'audio') {
- if (cdRunning < maxCd) {
- entryIndex = i;
- break;
- }
- // CD/audio encode cap reached
- if (!cdBypass) break; // Strict FIFO: stop scanning
- continue; // Bypass mode: skip this blocked CD entry
- } else {
- // Film/video encode job entry
- if (filmRunning < maxFilm) {
- entryIndex = i;
- break;
- }
- // Film encode cap reached
- if (!cdBypass) break; // Strict FIFO: stop scanning
- continue; // Bypass mode: skip this blocked film entry
- }
+ continue;
}
+ if (candidateType === 'wait') {
+ // Wait keeps queue order and blocks until no tracked job process is active.
+ if (this.activeProcesses.size === 0) {
+ entryIndex = i;
+ }
+ break;
+ }
+
+ // Job entry: check hierarchical limits (caps apply only to encode jobs).
+ if (totalRunning >= maxTotal) {
+ // Total encode cap reached – nothing can start.
+ break;
+ }
+
+ const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
+ if (candidatePoolType === 'audio') {
+ if (cdRunning < maxCd) {
+ entryIndex = i;
+ break;
+ }
+ // CD/audio encode cap reached
+ if (!cdBypass) break; // Strict FIFO: stop scanning
+ continue; // Bypass mode: skip this blocked CD entry
+ }
+
+ // Film/video encode job entry
+ if (filmRunning < maxFilm) {
+ entryIndex = i;
+ break;
+ }
+ // Film encode cap reached
+ if (!cdBypass) break; // Strict FIFO: stop scanning
+ // Bypass mode: skip this blocked film entry
}
if (entryIndex < 0) {
@@ -16102,7 +16601,13 @@ class PipelineService extends EventEmitter {
}
}
- if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) {
+ const preMetadataHandBrakeScanEnabled = await this.isPreMetadataHandBrakeScanEnabled();
+ if (
+ isSeriesDiscMediaProfile(mediaProfile)
+ && analyzePlugin
+ && ['dvd', 'bluray'].includes(analyzePlugin.id)
+ && preMetadataHandBrakeScanEnabled
+ ) {
try {
const rawMedia = collectRawMediaCandidates(resolvedRawPath);
const seriesScanInputPath = mediaProfile === 'dvd'
@@ -16229,6 +16734,23 @@ class PipelineService extends EventEmitter {
error: errorToMeta(error)
});
}
+ } else if (
+ isSeriesDiscMediaProfile(mediaProfile)
+ && analyzePlugin
+ && ['dvd', 'bluray'].includes(analyzePlugin.id)
+ && !preMetadataHandBrakeScanEnabled
+ ) {
+ await historyService.appendLog(
+ normalizedJobId,
+ 'SYSTEM',
+ `Vorab-HandBrake-Scan vor Metadaten wurde per Setting "${PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY}" übersprungen.`
+ );
+ logger.info('metadata:pre-scan:skipped', {
+ jobId: normalizedJobId,
+ mediaProfile,
+ source: 'raw_import',
+ settingKey: PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY
+ });
}
if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) {
@@ -16429,7 +16951,7 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({
discDevice: device.path,
- status: 'METADATA_SELECTION',
+ status: 'ANALYZING',
detectedTitle,
jobKind: resolveJobKindForMediaProfile(mediaProfile)
});
@@ -16493,7 +17015,13 @@ class PipelineService extends EventEmitter {
});
}
}
- if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) {
+ const preMetadataHandBrakeScanEnabled = await this.isPreMetadataHandBrakeScanEnabled();
+ if (
+ isSeriesDiscMediaProfile(mediaProfile)
+ && analyzePlugin
+ && ['dvd', 'bluray'].includes(analyzePlugin.id)
+ && preMetadataHandBrakeScanEnabled
+ ) {
try {
const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, {
jobId: job.id,
@@ -16612,6 +17140,23 @@ class PipelineService extends EventEmitter {
error: errorToMeta(error)
});
}
+ } else if (
+ isSeriesDiscMediaProfile(mediaProfile)
+ && analyzePlugin
+ && ['dvd', 'bluray'].includes(analyzePlugin.id)
+ && !preMetadataHandBrakeScanEnabled
+ ) {
+ await historyService.appendLog(
+ job.id,
+ 'SYSTEM',
+ `Vorab-HandBrake-Scan vor Metadaten wurde per Setting "${PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY}" übersprungen.`
+ );
+ logger.info('metadata:pre-scan:skipped', {
+ jobId: job.id,
+ mediaProfile,
+ source: 'drive_analyze',
+ settingKey: PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY
+ });
}
if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) {
omdbCandidates = [];
@@ -16722,15 +17267,39 @@ class PipelineService extends EventEmitter {
}
async searchOmdb(query) {
- logger.info('omdb:search', { query });
- const results = await omdbService.search(query);
- logger.info('omdb:search:done', { query, count: results.length });
- return results;
+ const rawQuery = String(query || '').trim();
+ const queryVariants = buildMetadataSearchQueryVariants(rawQuery);
+ if (queryVariants.length === 0) {
+ return [];
+ }
+
+ logger.info('omdb:search', {
+ query: rawQuery,
+ variants: queryVariants
+ });
+ for (const variant of queryVariants) {
+ const results = await omdbService.search(variant);
+ if (Array.isArray(results) && results.length > 0) {
+ logger.info('omdb:search:done', {
+ query: rawQuery,
+ matchedVariant: variant,
+ count: results.length
+ });
+ return results;
+ }
+ }
+ logger.info('omdb:search:done', {
+ query: rawQuery,
+ matchedVariant: null,
+ count: 0
+ });
+ return [];
}
async searchTmdbSeries(query, seasonNumber = null) {
- const normalizedQuery = String(query || '').trim();
- if (!normalizedQuery) {
+ const rawQuery = String(query || '').trim();
+ const queryVariants = buildMetadataSearchQueryVariants(rawQuery);
+ if (queryVariants.length === 0) {
const error = new Error('TMDb-Suche benötigt einen Titel.');
error.statusCode = 400;
throw error;
@@ -16758,10 +17327,7 @@ class PipelineService extends EventEmitter {
});
return tmdbService.attachFailureCode(filteredRows, failureCode);
};
- logger.info('tmdb:series-search', {
- query: normalizedQuery,
- seasonNumber: seasonFilter || null
- });
+ logger.info('tmdb:series-search', { query: rawQuery, seasonNumber: seasonFilter || null, variants: queryVariants });
let tmdbLanguage = null;
try {
@@ -16771,92 +17337,110 @@ class PipelineService extends EventEmitter {
tmdbLanguage = null;
}
- const cacheKey = [
- normalizedQuery.toLowerCase(),
- seasonFilter.toLowerCase(),
- String(tmdbLanguage || '').trim().toLowerCase()
- ].join('::');
- const cacheEntry = tmdbSeriesSearchCache.get(cacheKey);
- const nowTs = Date.now();
- if (cacheEntry && cacheEntry.expiresAt > nowTs && Array.isArray(cacheEntry.results)) {
- logger.info('tmdb:series-search:cache-hit', {
- query: normalizedQuery,
- seasonNumber: seasonFilter || null,
- count: cacheEntry.results.length
- });
- return cloneTmdbSeriesSearchResults(cacheEntry.results);
- }
- if (cacheEntry && cacheEntry.expiresAt <= nowTs) {
- tmdbSeriesSearchCache.delete(cacheKey);
- }
-
- let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
- language: tmdbLanguage
- });
- normalizedResults = applySeasonFilter(normalizedResults);
- const firstPassFailureCode = tmdbService.readFailureCode(normalizedResults);
-
- if (normalizedResults.length === 0) {
- if (firstPassFailureCode) {
- logger.warn('tmdb:series-search:empty:first-pass:skip-retry', {
- query: normalizedQuery,
+ const runTmdbSearchForVariant = async (variantQuery) => {
+ const normalizedQuery = String(variantQuery || '').trim();
+ const cacheKey = [
+ normalizedQuery.toLowerCase(),
+ seasonFilter.toLowerCase(),
+ String(tmdbLanguage || '').trim().toLowerCase()
+ ].join('::');
+ const cacheEntry = tmdbSeriesSearchCache.get(cacheKey);
+ const nowTs = Date.now();
+ if (cacheEntry && cacheEntry.expiresAt > nowTs && Array.isArray(cacheEntry.results)) {
+ logger.info('tmdb:series-search:cache-hit', {
+ query: rawQuery,
+ attemptedVariant: normalizedQuery,
seasonNumber: seasonFilter || null,
- failureCode: firstPassFailureCode
+ count: cacheEntry.results.length
});
- } else {
- logger.warn('tmdb:series-search:empty:first-pass', {
- query: normalizedQuery,
- seasonNumber: seasonFilter || null
- });
- let retryResults = [];
- try {
- retryResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
- language: tmdbLanguage
- });
- } catch (retryError) {
- logger.warn('tmdb:series-search:retry:failed', {
- query: normalizedQuery,
- seasonNumber: seasonFilter || null,
- error: errorToMeta(retryError)
- });
- }
- normalizedResults = applySeasonFilter(retryResults);
+ return cloneTmdbSeriesSearchResults(cacheEntry.results);
+ }
+ if (cacheEntry && cacheEntry.expiresAt <= nowTs) {
+ tmdbSeriesSearchCache.delete(cacheKey);
}
- }
- if (normalizedResults.length === 0) {
- const failureCode = tmdbService.readFailureCode(normalizedResults);
- logger.info('tmdb:series-search:done', {
- query: normalizedQuery,
- count: 0,
- empty: true,
- failureCode
+ let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
+ language: tmdbLanguage
});
- return [];
- }
- logger.info('tmdb:series-search:done', { query: normalizedQuery, count: normalizedResults.length });
+ normalizedResults = applySeasonFilter(normalizedResults);
+ const firstPassFailureCode = tmdbService.readFailureCode(normalizedResults);
- const cacheNowTs = Date.now();
- if (tmdbSeriesSearchCache.size >= 200) {
- for (const [key, entry] of tmdbSeriesSearchCache.entries()) {
- if (!entry || entry.expiresAt <= cacheNowTs) {
- tmdbSeriesSearchCache.delete(key);
+ if (normalizedResults.length === 0) {
+ if (firstPassFailureCode) {
+ logger.warn('tmdb:series-search:empty:first-pass:skip-retry', {
+ query: rawQuery,
+ attemptedVariant: normalizedQuery,
+ seasonNumber: seasonFilter || null,
+ failureCode: firstPassFailureCode
+ });
+ } else {
+ logger.warn('tmdb:series-search:empty:first-pass', {
+ query: rawQuery,
+ attemptedVariant: normalizedQuery,
+ seasonNumber: seasonFilter || null
+ });
+ let retryResults = [];
+ try {
+ retryResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
+ language: tmdbLanguage
+ });
+ } catch (retryError) {
+ logger.warn('tmdb:series-search:retry:failed', {
+ query: rawQuery,
+ attemptedVariant: normalizedQuery,
+ seasonNumber: seasonFilter || null,
+ error: errorToMeta(retryError)
+ });
+ }
+ normalizedResults = applySeasonFilter(retryResults);
}
}
+
+ if (normalizedResults.length === 0) {
+ return [];
+ }
+
+ const cacheNowTs = Date.now();
if (tmdbSeriesSearchCache.size >= 200) {
- const oldestKey = tmdbSeriesSearchCache.keys().next().value;
- if (oldestKey) {
- tmdbSeriesSearchCache.delete(oldestKey);
+ for (const [key, entry] of tmdbSeriesSearchCache.entries()) {
+ if (!entry || entry.expiresAt <= cacheNowTs) {
+ tmdbSeriesSearchCache.delete(key);
+ }
+ }
+ if (tmdbSeriesSearchCache.size >= 200) {
+ const oldestKey = tmdbSeriesSearchCache.keys().next().value;
+ if (oldestKey) {
+ tmdbSeriesSearchCache.delete(oldestKey);
+ }
}
}
+
+ tmdbSeriesSearchCache.set(cacheKey, {
+ results: cloneTmdbSeriesSearchResults(normalizedResults),
+ expiresAt: cacheNowTs + TMDB_SERIES_SEARCH_CACHE_TTL_MS
+ });
+
+ return normalizedResults;
+ };
+
+ for (const variant of queryVariants) {
+ const results = await runTmdbSearchForVariant(variant);
+ if (Array.isArray(results) && results.length > 0) {
+ logger.info('tmdb:series-search:done', {
+ query: rawQuery,
+ matchedVariant: variant,
+ count: results.length
+ });
+ return results;
+ }
}
-
- tmdbSeriesSearchCache.set(cacheKey, {
- results: cloneTmdbSeriesSearchResults(normalizedResults),
- expiresAt: cacheNowTs + TMDB_SERIES_SEARCH_CACHE_TTL_MS
+ logger.info('tmdb:series-search:done', {
+ query: rawQuery,
+ matchedVariant: null,
+ count: 0,
+ empty: true
});
-
- return normalizedResults;
+ return [];
}
async runDiscTrackReviewForJob(jobId, deviceInfo = null, options = {}) {
@@ -20661,7 +21245,8 @@ class PipelineService extends EventEmitter {
return this.enqueueOrStartAction(
QUEUE_ACTIONS.START_PREPARED,
jobId,
- () => this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob })
+ () => this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }),
+ { preloadedJob }
);
}
@@ -20707,7 +21292,8 @@ class PipelineService extends EventEmitter {
return this.enqueueOrStartAction(
QUEUE_ACTIONS.START_PREPARED,
jobId,
- () => this.startPreparedJob(jobId, { ...options, immediate: true })
+ () => this.startPreparedJob(jobId, { ...options, immediate: true }),
+ { preloadedJob }
);
}
@@ -21045,7 +21631,8 @@ class PipelineService extends EventEmitter {
return this.enqueueOrStartAction(
QUEUE_ACTIONS.START_PREPARED,
jobId,
- () => this.startPreparedConverterVideoJob(jobId, { ...options, immediate: true, preloadedJob: job })
+ () => this.startPreparedConverterVideoJob(jobId, { ...options, immediate: true, preloadedJob: job }),
+ { preloadedJob: job }
);
}
@@ -24304,14 +24891,6 @@ class PipelineService extends EventEmitter {
});
}
- const preEncodeContext = {
- mode,
- jobId,
- jobTitle: job.title || job.detected_title || null,
- inputPath,
- rawPath: activeRawPath,
- mediaProfile
- };
const preScriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []);
const preChainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [];
const postScriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
@@ -24322,30 +24901,30 @@ class PipelineService extends EventEmitter {
const normalizedPostChainIds = Array.isArray(postChainIds)
? postChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0)
: [];
+ const preAutomationCount = preScriptIds.length + normalizedPreChainIds.length;
+ const postAutomationCount = postScriptIds.length + normalizedPostChainIds.length;
const encodeScriptProgressTracker = createEncodeScriptProgressTracker({
jobId,
- preSteps: preScriptIds.length + normalizedPreChainIds.length,
- postSteps: postScriptIds.length + normalizedPostChainIds.length,
+ preSteps: 0,
+ postSteps: 0,
updateProgress: this.updateProgress.bind(this)
});
- let preEncodeScriptsSummary = { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] };
- if (preScriptIds.length > 0 || preChainIds.length > 0) {
- await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten werden ausgeführt...');
- try {
- preEncodeScriptsSummary = await this.runPreEncodeScripts(
- jobId,
- encodePlan,
- preEncodeContext,
- encodeScriptProgressTracker
- );
- } catch (preError) {
- if (preError.preEncodeFailed) {
- await this.failJob(jobId, 'ENCODING', preError);
- throw preError;
- }
- throw preError;
- }
- await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten abgeschlossen.');
+ const preEncodeScriptsSummary = {
+ configured: preAutomationCount,
+ attempted: 0,
+ succeeded: 0,
+ failed: 0,
+ skipped: 0,
+ queued: preAutomationCount > 0,
+ detachedQueue: preAutomationCount > 0,
+ results: []
+ };
+ if (preAutomationCount > 0) {
+ await historyService.appendLog(
+ jobId,
+ 'SYSTEM',
+ `Pre-Encode Automationen laufen als eigene Queue-Einträge (Pre=${preAutomationCount}).`
+ );
}
try {
@@ -24716,15 +25295,12 @@ class PipelineService extends EventEmitter {
historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => {
logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message });
});
- const postEncodeExecutionContext = {
- mode,
- jobTitle: job.title || job.detected_title || null,
- inputPath,
- outputPath: finalizedOutputPath,
- rawPath: activeRawPath,
- pipelineStage: 'ENCODING'
+ const postEncodeScriptsSummary = {
+ ...this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan),
+ pending: false,
+ queued: postAutomationCount > 0,
+ detachedQueue: postAutomationCount > 0
};
- const postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan);
let finalizedRawPath = activeRawPath || null;
const isMultipartDiscChildRun = this.isMultipartMovieDiscChildHistoryJob(job);
const deferRawFinalizeUntilSeriesBatchDone = (
@@ -24806,8 +25382,15 @@ class PipelineService extends EventEmitter {
output_path: finalizedOutputPath,
error_message: null
});
- this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
- phaseLabel: 'Post-Encode'
+ await historyService.generateJobNfo(jobId, {
+ mode: 'auto',
+ requireSettingEnabled: true
+ }).catch((nfoError) => {
+ logger.warn('job:nfo:auto-generate-failed', {
+ jobId,
+ mode: 'series_batch_episode',
+ error: errorToMeta(nfoError)
+ });
});
return {
outputPath: finalizedOutputPath,
@@ -24827,6 +25410,16 @@ class PipelineService extends EventEmitter {
output_path: finalizedOutputPath,
error_message: null
});
+ await historyService.generateJobNfo(jobId, {
+ mode: 'auto',
+ requireSettingEnabled: true
+ }).catch((nfoError) => {
+ logger.warn('job:nfo:auto-generate-failed', {
+ jobId,
+ mode: 'encode',
+ error: errorToMeta(nfoError)
+ });
+ });
if (job?.parent_job_id) {
const parentJobId = this.normalizeQueueJobId(job.parent_job_id);
@@ -24883,9 +25476,13 @@ class PipelineService extends EventEmitter {
logger.info('encoding:finished:background', { jobId, mode, activeJobId: this.snapshot.activeJobId });
void this.pumpQueue();
}
- this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
- phaseLabel: 'Post-Encode'
- });
+ if (postAutomationCount > 0) {
+ await historyService.appendLog(
+ jobId,
+ 'SYSTEM',
+ `Post-Encode Automationen laufen als eigene Queue-Einträge (Post=${postAutomationCount}).`
+ );
+ }
if (mode === 'reencode') {
void this.notifyPushover('reencode_finished', {
@@ -28092,7 +28689,8 @@ class PipelineService extends EventEmitter {
return this.enqueueOrStartAction(
QUEUE_ACTIONS.START_PREPARED,
jobId,
- () => this.startAudiobookEncode(jobId, { ...options, immediate: true })
+ () => this.startAudiobookEncode(jobId, { ...options, immediate: true }),
+ { preloadedJob: options?.preloadedJob || null }
);
}
@@ -28485,6 +29083,16 @@ class PipelineService extends EventEmitter {
output_path: finalizedOutputPath,
error_message: null
});
+ await historyService.generateJobNfo(jobId, {
+ mode: 'auto',
+ requireSettingEnabled: true
+ }).catch((nfoError) => {
+ logger.warn('job:nfo:auto-generate-failed', {
+ jobId,
+ mode: 'audiobook',
+ error: errorToMeta(nfoError)
+ });
+ });
// Only switch the pipeline UI to FINISHED if this job is still the active one.
if (Number(this.snapshot.activeJobId) === Number(jobId)) {
@@ -28576,7 +29184,7 @@ class PipelineService extends EventEmitter {
QUEUE_ACTIONS.START_PREPARED,
jobId,
() => this.startConverterEncode(jobId, { ...options, immediate: true }),
- { poolType: queuePoolType }
+ { poolType: queuePoolType, preloadedJob: queuedJob }
);
}
@@ -28784,6 +29392,16 @@ class PipelineService extends EventEmitter {
output_path: finalOutputPath,
error_message: null
});
+ await historyService.generateJobNfo(jobId, {
+ mode: 'auto',
+ requireSettingEnabled: true
+ }).catch((nfoError) => {
+ logger.warn('job:nfo:auto-generate-failed', {
+ jobId,
+ mode: 'converter',
+ error: errorToMeta(nfoError)
+ });
+ });
// Datei-Zuweisungen im Explorer freigeben
try {
@@ -29886,20 +30504,28 @@ class PipelineService extends EventEmitter {
const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []);
const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []);
- let preEncodeScriptsSummary = {
- configured: 0,
+ const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []);
+ const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []);
+ const preAutomationCount = preScriptIds.length + preChainIds.length;
+ const postAutomationCount = postScriptIds.length + postChainIds.length;
+ const preEncodeScriptsSummary = {
+ configured: preAutomationCount,
attempted: 0,
succeeded: 0,
failed: 0,
skipped: 0,
+ queued: preAutomationCount > 0,
+ detachedQueue: preAutomationCount > 0,
results: []
};
let postEncodeScriptsSummary = {
- configured: 0,
+ configured: postAutomationCount,
attempted: 0,
succeeded: 0,
failed: 0,
skipped: 0,
+ queued: postAutomationCount > 0,
+ detachedQueue: postAutomationCount > 0,
results: []
};
const selectedTrackOrder = normalizeCdTrackPositionList(selectedTrackPositions);
@@ -29920,19 +30546,12 @@ class PipelineService extends EventEmitter {
encodeCompletedCount,
failedTrackPosition
});
- if (preScriptIds.length > 0 || preChainIds.length > 0) {
- await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten werden ausgeführt...');
- preEncodeScriptsSummary = await this.runPreEncodeScripts(jobId, normalizedEncodePlan, {
- mode: 'cd_rip',
+ if (preAutomationCount > 0) {
+ await historyService.appendLog(
jobId,
- jobTitle: selectedMeta?.title || `Job #${jobId}`,
- inputPath: devicePath || null,
- outputPath: outputDir || null,
- rawPath: rawWavDir || null,
- mediaProfile: 'cd',
- pipelineStage: 'CD_RIPPING'
- });
- await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten abgeschlossen.');
+ 'SYSTEM',
+ `Pre-Rip Automationen laufen als eigene Queue-Einträge (Pre=${preAutomationCount}).`
+ );
}
let encodeStateApplied = false;
let lastProgressPercent = 0;
@@ -30107,16 +30726,11 @@ class PipelineService extends EventEmitter {
}
const { encodeResults: cdEncodeResults = [] } = cdRipResult || {};
settleLifecycle();
- postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(normalizedEncodePlan);
- let postCdScriptContext = {
- mode: 'cd_rip',
- jobId,
- jobTitle: selectedMeta?.title || `Job #${jobId}`,
- inputPath: devicePath || null,
- outputPath: outputDir || null,
- rawPath: rawWavDir || null,
- mediaProfile: 'cd',
- pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING'
+ postEncodeScriptsSummary = {
+ ...this.buildPostEncodeScriptsSummaryPlaceholder(normalizedEncodePlan),
+ pending: false,
+ queued: postAutomationCount > 0,
+ detachedQueue: postAutomationCount > 0
};
// RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen
@@ -30134,12 +30748,6 @@ class PipelineService extends EventEmitter {
// ignore – raw dir bleibt unter bestehendem Namen zugänglich
}
}
- postCdScriptContext = {
- ...postCdScriptContext,
- outputPath: outputDir || null,
- rawPath: activeRawDir || rawWavDir || null
- };
-
const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso();
const persistedEncodePlan = {
...normalizedEncodePlan,
@@ -30302,9 +30910,13 @@ class PipelineService extends EventEmitter {
void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath));
}
}
- this.runPostEncodeScriptsDetached(jobId, normalizedEncodePlan, postCdScriptContext, {
- phaseLabel: 'Post-Rip'
- });
+ if (postAutomationCount > 0) {
+ await historyService.appendLog(
+ jobId,
+ 'SYSTEM',
+ `Post-Rip Automationen laufen als eigene Queue-Einträge (Post=${postAutomationCount}).`
+ );
+ }
} catch (error) {
settleLifecycle();
const failedCdLive = buildLiveContext(currentTrackPosition || null);
diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js
index 61dcf81..fbe6167 100644
--- a/backend/src/services/settingsService.js
+++ b/backend/src/services/settingsService.js
@@ -3,12 +3,14 @@ const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { getDb } = require('../db/database');
-const logger = require('./logger').child('SETTINGS');
+const loggerService = require('./logger');
+const logger = loggerService.child('SETTINGS');
const {
parseJson,
normalizeValueByType,
serializeValueByType,
- validateSetting
+ validateSetting,
+ toBoolean
} = require('../utils/validators');
const { splitArgs } = require('../utils/commandLine');
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 TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
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 PROFILED_SETTINGS = {
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) {
return IMMUTABLE_SETTING_KEYS.has(String(key || '').trim().toLowerCase());
}
@@ -814,6 +859,21 @@ class SettingsService {
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) {
return normalizeMediaProfileValue(value);
}
@@ -1014,6 +1074,10 @@ class SettingsService {
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
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]);
return {
@@ -1107,6 +1171,13 @@ class SettingsService {
if (logDirChange) {
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));
logger.info('settings:bulk-updated', { count: normalizedEntries.length });
diff --git a/db/schema.sql b/db/schema.sql
index cbdbd48..caa2f0c 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -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);
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
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);
@@ -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);
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)
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');
@@ -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);
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
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);
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 92aab59..75495f1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
- "version": "0.15.0-3",
+ "version": "0.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
- "version": "0.15.0-3",
+ "version": "0.16.0",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
diff --git a/frontend/package.json b/frontend/package.json
index c4a09d0..31b7d23 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
- "version": "0.15.0-3",
+ "version": "0.16.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index f79c53c..8308582 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -836,8 +836,21 @@ export const api = {
afterMutationInvalidate(['/history']);
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 = {}) {
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)
? options.selectedRawPaths
.map((item) => String(item || '').trim())
@@ -853,6 +866,7 @@ export const api = {
body: JSON.stringify({
target,
includeRelated,
+ ...(selectedJobIds ? { selectedJobIds } : {}),
...(selectedRawPaths ? { selectedRawPaths } : {}),
...(selectedMoviePaths ? { selectedMoviePaths } : {})
})
@@ -862,14 +876,29 @@ export const api = {
},
getJobDeletePreview(jobId, options = {}) {
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();
query.set('includeRelated', includeRelated ? '1' : '0');
+ if (selectedJobIds) {
+ query.set('selectedJobIds', selectedJobIds.join(','));
+ }
return request(`/history/${jobId}/delete-preview?${query.toString()}`);
},
async deleteJobEntry(jobId, target = 'none', options = {}) {
const includeRelated = Boolean(options?.includeRelated);
const resetDriveState = Boolean(options?.resetDriveState);
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)
? options.selectedRawPaths
.map((item) => String(item || '').trim())
@@ -891,6 +920,7 @@ export const api = {
includeRelated,
resetDriveState,
preserveRawForImportJobs,
+ ...(selectedJobIds ? { selectedJobIds } : {}),
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
...(selectedRawPaths ? { selectedRawPaths } : {}),
...(selectedMoviePaths ? { selectedMoviePaths } : {})
diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx
index 60d079f..59fcfc8 100644
--- a/frontend/src/components/DynamicSettingsForm.jsx
+++ b/frontend/src/components/DynamicSettingsForm.jsx
@@ -28,6 +28,7 @@ const GENERAL_TOOL_KEYS = new Set([
'mkvmerge_command',
'ffprobe_command',
'handbrake_restart_delete_incomplete_output',
+ 'handbrake_pre_metadata_scan_enabled',
'script_test_timeout_ms'
]);
diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx
index ef3c0a3..aae6cf8 100644
--- a/frontend/src/components/JobDetailDialog.jsx
+++ b/frontend/src/components/JobDetailDialog.jsx
@@ -1060,6 +1060,7 @@ export default function JobDetailDialog({
logLoadingMode = null,
onAssignOmdb,
onAssignCdMetadata,
+ onGenerateNfo,
onAcknowledgeError,
onResumeReady,
onRestartEncode,
@@ -1078,6 +1079,7 @@ export default function JobDetailDialog({
omdbAssignBusy = false,
cdMetadataAssignBusy = false,
acknowledgeErrorBusy = false,
+ generateNfoBusy = false,
actionBusy = false,
cancelBusy = false,
reencodeBusy = false,
@@ -1088,6 +1090,8 @@ export default function JobDetailDialog({
}) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
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 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';
@@ -1123,6 +1127,22 @@ export default function JobDetailDialog({
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
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 converterMediaTypeLabel = converterMediaType === 'audio'
? 'Audio'
@@ -1215,6 +1235,7 @@ export default function JobDetailDialog({
&& job?.rawStatus?.isEmpty !== true
&& !running
&& mediaType !== 'audiobook'
+ && !blockMetadataAndReviewUntilRetry
&& typeof onRestartReview === 'function'
);
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
@@ -1271,6 +1292,14 @@ export default function JobDetailDialog({
})()
: null;
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 logCount = Number(job?.log_count || 0);
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 cdDetails = isCd ? resolveCdDetails(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'
? (isDvdSeries ? 'Blu-ray Serie' : 'Blu-ray')
: mediaType === 'dvd'
@@ -1516,6 +1545,7 @@ export default function JobDetailDialog({
const resolveChildActionState = (child) => {
const childStatusUpper = String(child?.status || '').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 childCanResumeReady = Boolean(
(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 childHasRaw = Boolean(child?.rawStatus?.exists || child?.raw_path || child?.encode_input_path);
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(
child?.rawStatus?.exists
&& child?.rawStatus?.isEmpty !== true
&& !childRunning
&& childMediaType !== 'audiobook'
+ && !childRetryRipRequired
&& 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 childSeriesOutputExisting = Number(child?.seriesOutputSummary?.existing || 0);
const childHasAnyOutput = childOutputFolders.length > 0
@@ -1557,6 +1604,8 @@ export default function JobDetailDialog({
childCanRestartEncode,
childCanRestartReview,
childCanAssignMetadata,
+ childCanRetry,
+ childRetryRipRequired,
childHasRaw,
childHasAnyOutput
};
@@ -2544,7 +2593,24 @@ export default function JobDetailDialog({
) : null}
- {!isCd && !isAudiobook && !isConverter && typeof onAssignOmdb === 'function' ? (
+ {childActionState.childCanRetry ? (
+
+
Recovery
+
+
+
+ ) : null}
+
+ {!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignOmdb === 'function' ? (
Metadaten
@@ -2938,7 +3004,28 @@ export default function JobDetailDialog({
) : null
)}
- {!isCd && !isAudiobook && !isConverter && typeof onAssignOmdb === 'function' ? (
+ {canRetry ? (
+
+
Recovery
+
+
+
+ ) : null}
+
+ {!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignOmdb === 'function' ? (
Metadaten
@@ -2957,6 +3044,25 @@ export default function JobDetailDialog({
) : null}
+ {canGenerateNfo ? (
+
+
NFO
+
+
+
+ ) : null}
+
{typeof onDeleteFiles === 'function' ? (
Dateien löschen
diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx
index 5e2d869..972482b 100644
--- a/frontend/src/components/MetadataSelectionDialog.jsx
+++ b/frontend/src/components/MetadataSelectionDialog.jsx
@@ -14,6 +14,84 @@ export default function MetadataSelectionDialog({
onSearch,
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 raw = String(value || '').trim().toLowerCase();
if (!raw) {
@@ -56,6 +134,7 @@ export default function MetadataSelectionDialog({
const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState('');
const activeSearchRequestRef = useRef(0);
+ const autoSearchDoneRef = useRef(false);
const contextWorkflowKind = normalizeWorkflowKind(
context?.selectedMetadata?.workflowKind
|| context?.workflowKind
@@ -97,7 +176,8 @@ export default function MetadataSelectionDialog({
}
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 defaultImdb = selectedMetadata.imdbId || '';
@@ -119,6 +199,7 @@ export default function MetadataSelectionDialog({
setSeasonFilter('');
setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider);
+ autoSearchDoneRef.current = false;
activeSearchRequestRef.current += 1;
}, [visible, context]);
@@ -205,19 +286,30 @@ export default function MetadataSelectionDialog({
if (!trimmedQuery || typeof onSearch !== 'function') {
return;
}
+ const queryVariants = buildSearchVariants(trimmedQuery);
+ if (queryVariants.length === 0) {
+ return;
+ }
const requestId = activeSearchRequestRef.current + 1;
activeSearchRequestRef.current = requestId;
setHasSearchOverride(true);
setSearchBusy(true);
setSearchError('');
try {
- const results = await onSearch(trimmedQuery, {
- metadataProvider
- });
- if (activeSearchRequestRef.current !== requestId) {
- return;
+ let effectiveResults = [];
+ for (const candidateQuery of queryVariants) {
+ const results = await onSearch(candidateQuery, {
+ metadataProvider
+ });
+ if (activeSearchRequestRef.current !== requestId) {
+ return;
+ }
+ if (Array.isArray(results) && results.length > 0) {
+ effectiveResults = results;
+ break;
+ }
}
- setExtraResults(Array.isArray(results) ? results : []);
+ setExtraResults(Array.isArray(effectiveResults) ? effectiveResults : []);
} catch (error) {
if (activeSearchRequestRef.current !== requestId) {
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 details = Array.isArray(error?.details) ? error.details : [];
const duplicateDetail = details.find(
diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx
index 85bb626..fc7d5e5 100644
--- a/frontend/src/components/PipelineStatusCard.jsx
+++ b/frontend/src/components/PipelineStatusCard.jsx
@@ -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 = []) {
const reviewTitles = Array.isArray(titles) ? titles : [];
return reviewTitles.some((title) => {
@@ -1496,6 +1578,13 @@ export default function PipelineStatusCard({
).trim().toLowerCase();
const isOrphanImport = orphanSource === 'orphan_raw_import';
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(
Number(jobRow?.rip_successful || jobRow?.ripSuccessful || pipeline?.context?.ripSuccessful || 0) === 1
|| jobRow?.raw_path
@@ -1504,12 +1593,31 @@ export default function PipelineStatusCard({
|| String(jobRow?.makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
);
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 playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
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 isDiscTitleSelectionRequired = Boolean(
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
@@ -2241,6 +2349,7 @@ export default function PipelineStatusCard({
const canRestartReviewFromRaw = Boolean(
retryJobId
&& !running
+ && !blockMetadataAndReviewForRipRetry
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
);
const allowConverterConfigEdit = showConverterConfig && allowReviewSelectionEdit && state === 'READY_TO_ENCODE';
@@ -2640,6 +2749,14 @@ export default function PipelineStatusCard({
mediaInfoReview?.titles,
mediaInfoReview?.seriesBatchParent
]);
+ const discWorkflowKind = resolveDiscWorkflowKind({
+ mediaProfile: jobMediaProfile,
+ selectedMetadata,
+ analyzeContext,
+ pipelineContext: pipeline?.context || null,
+ fallbackIsSeries: isSeriesDvdReview
+ });
+ const discWorkflowBadgeLabel = getDiscWorkflowBadgeLabel(jobMediaProfile, discWorkflowKind);
useEffect(() => {
let cancelled = false;
@@ -3551,6 +3668,7 @@ export default function PipelineStatusCard({
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION' || isOrphanCancelled)
&& retryJobId
&& typeof onOpenMetadata === 'function'
+ && !blockMetadataAndReviewForRipRetry
&& !(jobMediaProfile === 'converter' && !selectedMetadata?.imdbId) ? (
{!deleteEntryIsMergeJob ? (
@@ -2517,7 +2751,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
- disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
+ disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
/>
) : (
<>
@@ -2528,7 +2762,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('raw')}
loading={deleteEntryTargetBusy === 'raw'}
- disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
+ disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
/>
- {mediaIndicator.isSeriesDvd ? : null}
+ {discWorkflowBadgeLabel ? : null}
{isMultipartMerge ? : null}
{!isMultipartMerge && isMultipartMovie ? : null}
{isCurrentSession ? : null}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 6d46048..bdacedf 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs';
-import { defineConfig } from 'vite';
+import { createLogger, defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
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({
plugins: [react()],
+ customLogger,
define: {
__APP_VERSION__: JSON.stringify(appPackage.version)
},
diff --git a/package-lock.json b/package-lock.json
index d1733c9..c09e9fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster",
- "version": "0.15.0-3",
+ "version": "0.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
- "version": "0.15.0-3",
+ "version": "0.16.0",
"devDependencies": {
"concurrently": "^9.1.2"
}
diff --git a/package.json b/package.json
index 1d6651f..f1a19b9 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
- "version": "0.15.0-3",
+ "version": "0.16.0",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",