From d679eface41606ebb5470b75c6ec45539ba4a10b Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 29 Apr 2026 07:39:49 +0000 Subject: [PATCH] 0.16.0 Pre-Release Checks --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/db/database.js | 56 +- backend/src/index.js | 7 + backend/src/routes/historyRoutes.js | 56 +- backend/src/services/historyService.js | 440 ++++++- backend/src/services/logger.js | 89 +- backend/src/services/pipelineService.js | 1064 +++++++++++++---- backend/src/services/settingsService.js | 75 +- db/schema.sql | 33 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/client.js | 30 + .../src/components/DynamicSettingsForm.jsx | 1 + frontend/src/components/JobDetailDialog.jsx | 114 +- .../components/MetadataSelectionDialog.jsx | 122 +- .../src/components/PipelineStatusCard.jsx | 121 +- frontend/src/hooks/useWebSocket.js | 42 + frontend/src/pages/HistoryPage.jsx | 277 ++++- frontend/src/pages/RipperPage.jsx | 108 +- frontend/vite.config.js | 20 +- package-lock.json | 4 +- package.json | 2 +- 23 files changed, 2372 insertions(+), 301 deletions(-) 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} />