diff --git a/backend/package-lock.json b/backend/package-lock.json index 26730b0..282905d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "1.0.0-rc1", + "version": "1.0.0-rc2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "1.0.0-rc1", + "version": "1.0.0-rc2", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index fc906de..397446b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "1.0.0-rc1", + "version": "1.0.0-rc2", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 5623893..235cc15 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -1169,17 +1169,10 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`); - await db.run( - `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) - VALUES ('series_playall_tolerance_lower_pct', 'Tools', 'Serie: PlayAll Untergrenze (%)', 'number', 1, 'Untere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 234)` - ); - await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_lower_pct', '10')`); - - await db.run( - `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) - VALUES ('series_playall_tolerance_upper_pct', 'Tools', 'Serie: PlayAll Obergrenze (%)', 'number', 1, 'Obere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 235)` - ); - await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_upper_pct', '10')`); + await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_lower_pct'`); + await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_lower_pct'`); + await db.run(`DELETE FROM settings_values WHERE key = 'series_playall_tolerance_upper_pct'`); + await db.run(`DELETE FROM settings_schema WHERE key = 'series_playall_tolerance_upper_pct'`); await db.run(`DELETE FROM settings_values WHERE key = 'handbrake_pre_metadata_scan_enabled'`); await db.run(`DELETE FROM settings_schema WHERE key = 'handbrake_pre_metadata_scan_enabled'`); diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index abadc34..5bd9092 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -31,6 +31,68 @@ router.get( }) ); +router.get( + '/hardware/history', + asyncHandler(async (req, res) => { + const hoursRaw = Number(req.query?.hours); + const maxPointsRaw = Number(req.query?.maxPoints); + const hours = Number.isFinite(hoursRaw) ? Math.max(1, Math.min(96, Math.trunc(hoursRaw))) : 24; + const maxPoints = Number.isFinite(maxPointsRaw) ? Math.max(120, Math.min(5000, Math.trunc(maxPointsRaw))) : 720; + const sinceIso = new Date(Date.now() - (hours * 60 * 60 * 1000)).toISOString(); + const db = await getDb(); + const rows = await db.all( + ` + SELECT + captured_at, + cpu_usage_percent, + cpu_temperature_c, + ram_usage_percent, + ram_used_bytes, + ram_total_bytes, + gpu_usage_percent, + gpu_temperature_c, + vram_used_bytes, + vram_total_bytes + FROM hardware_metrics_history + WHERE captured_at >= ? + ORDER BY captured_at ASC + `, + [sinceIso] + ); + + const stride = rows.length > maxPoints ? Math.ceil(rows.length / maxPoints) : 1; + const sampled = []; + for (let index = 0; index < rows.length; index += stride) { + sampled.push(rows[index]); + } + if (rows.length > 0 && sampled[sampled.length - 1] !== rows[rows.length - 1]) { + sampled.push(rows[rows.length - 1]); + } + + const points = sampled.map((row) => ({ + capturedAt: row.captured_at, + cpuUsagePercent: row.cpu_usage_percent, + cpuTemperatureC: row.cpu_temperature_c, + ramUsagePercent: row.ram_usage_percent, + ramUsedBytes: row.ram_used_bytes, + ramTotalBytes: row.ram_total_bytes, + gpuUsagePercent: row.gpu_usage_percent, + gpuTemperatureC: row.gpu_temperature_c, + vramUsedBytes: row.vram_used_bytes, + vramTotalBytes: row.vram_total_bytes + })); + + res.json({ + history: { + hours, + maxPoints, + totalPoints: rows.length, + points + } + }); + }) +); + router.post( '/analyze', asyncHandler(async (req, res) => { @@ -539,8 +601,9 @@ router.post( '/retry/:jobId', asyncHandler(async (req, res) => { const jobId = Number(req.params.jobId); - logger.info('post:retry', { reqId: req.reqId, jobId }); - const result = await pipelineService.retry(jobId); + const createNewJob = Boolean(req.body?.createNewJob); + logger.info('post:retry', { reqId: req.reqId, jobId, createNewJob }); + const result = await pipelineService.retry(jobId, { createNewJob }); res.json({ result }); }) ); @@ -593,15 +656,24 @@ router.post( const jobId = Number(req.params.jobId); const keepBoth = Boolean(req.body?.keepBoth); const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; - const reuseCurrentJob = Boolean(req.body?.reuseCurrentJob); + const createNewJob = Boolean(req.body?.createNewJob); + const reuseCurrentJob = Object.prototype.hasOwnProperty.call(req.body || {}, 'reuseCurrentJob') + ? Boolean(req.body?.reuseCurrentJob) + : !createNewJob; logger.info('post:restart-review', { reqId: req.reqId, jobId, keepBoth, + createNewJob, reuseCurrentJob, deleteFolderCount: deleteFolders.length }); - const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders, reuseCurrentJob }); + const result = await pipelineService.restartReviewFromRaw(jobId, { + keepBoth, + deleteFolders, + reuseCurrentJob, + createNewJob + }); res.json({ result }); }) ); @@ -613,17 +685,20 @@ router.post( const keepBoth = Boolean(req.body?.keepBoth); const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all'; + const createNewJob = Boolean(req.body?.createNewJob); logger.info('post:restart-encode', { reqId: req.reqId, jobId, keepBoth, + createNewJob, restartMode, deleteFolderCount: deleteFolders.length }); const result = await pipelineService.restartEncodeWithLastSettings(jobId, { keepBoth, deleteFolders, - restartMode + restartMode, + createNewJob }); res.json({ result }); }) diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index baa12a9..4ae0dda 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -158,6 +158,26 @@ router.put( }) ); +router.put( + '/scripts/:id/favorite', + asyncHandler(async (req, res) => { + const scriptId = Number(req.params.id); + const isFavorite = req.body?.isFavorite === true; + logger.info('put:settings:scripts:favorite', { + reqId: req.reqId, + scriptId, + isFavorite + }); + const script = await scriptService.setScriptFavorite(scriptId, isFavorite); + wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { + action: 'favorite-updated', + id: script.id, + isFavorite: script.isFavorite === true + }); + res.json({ script }); + }) +); + router.delete( '/scripts/:id', asyncHandler(async (req, res) => { @@ -251,6 +271,22 @@ router.put( }) ); +router.put( + '/script-chains/:id/favorite', + asyncHandler(async (req, res) => { + const chainId = Number(req.params.id); + const isFavorite = req.body?.isFavorite === true; + logger.info('put:settings:script-chains:favorite', { reqId: req.reqId, chainId, isFavorite }); + const chain = await scriptChainService.setChainFavorite(chainId, isFavorite); + wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { + action: 'favorite-updated', + id: chain.id, + isFavorite: chain.isFavorite === true + }); + res.json({ chain }); + }) +); + router.delete( '/script-chains/:id', asyncHandler(async (req, res) => { diff --git a/backend/src/services/hardwareMonitorService.js b/backend/src/services/hardwareMonitorService.js index a058996..0d0d15b 100644 --- a/backend/src/services/hardwareMonitorService.js +++ b/backend/src/services/hardwareMonitorService.js @@ -7,12 +7,14 @@ const settingsService = require('./settingsService'); const wsService = require('./websocketService'); const logger = require('./logger').child('HWMON'); const { errorToMeta } = require('../utils/errorMeta'); +const { getDb } = require('../db/database'); const execFileAsync = promisify(execFile); const DEFAULT_INTERVAL_MS = 5000; const MIN_INTERVAL_MS = 1000; const MAX_INTERVAL_MS = 60000; +const HISTORY_RETENTION_PREVIOUS_DAYS = 3; const DF_TIMEOUT_MS = 1800; const SENSORS_TIMEOUT_MS = 1800; const NVIDIA_SMI_TIMEOUT_MS = 1800; @@ -34,6 +36,20 @@ function nowIso() { return new Date().toISOString(); } +function toLocalDayKey(inputDate = new Date()) { + const date = inputDate instanceof Date ? inputDate : new Date(inputDate); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function addLocalDays(inputDate, deltaDays) { + const date = inputDate instanceof Date ? new Date(inputDate.getTime()) : new Date(inputDate); + date.setDate(date.getDate() + Number(deltaDays || 0)); + return date; +} + function isAbortError(error) { if (!error || typeof error !== 'object') { return false; @@ -356,6 +372,7 @@ class HardwareMonitorService { sample: null, error: null }; + this.lastHistoryCleanupDayKey = null; } async init() { @@ -432,11 +449,19 @@ class HardwareMonitorService { if (!this.enabled) { this.stopPolling(); + let storage = []; + try { + storage = await this.collectStorageMetrics(); + } catch (error) { + logger.debug('storage:collect:disabled-mode:failed', { error: errorToMeta(error) }); + } this.lastSnapshot = { enabled: false, intervalMs: this.intervalMs, updatedAt: nowIso(), - sample: null, + sample: { + storage + }, error: null }; this.broadcastUpdate(); @@ -579,6 +604,11 @@ class HardwareMonitorService { this.activePollAbortController = pollAbortController; try { const sample = await this.collectSample(pollAbortController.signal); + try { + await this.persistHistorySample(sample); + } catch (historyError) { + logger.warn('history:write:failed', { error: errorToMeta(historyError) }); + } this.lastSnapshot = { enabled: true, intervalMs: this.intervalMs, @@ -612,6 +642,97 @@ class HardwareMonitorService { } } + buildHistoryRecord(sample = {}) { + const cpu = sample?.cpu && typeof sample.cpu === 'object' ? sample.cpu : {}; + const memory = sample?.memory && typeof sample.memory === 'object' ? sample.memory : {}; + const gpu = sample?.gpu && typeof sample.gpu === 'object' ? sample.gpu : {}; + const gpuDevices = Array.isArray(gpu.devices) ? gpu.devices : []; + const primaryGpu = gpuDevices[0] || null; + const capturedAt = new Date(); + + return { + capturedAtIso: capturedAt.toISOString(), + dayKey: toLocalDayKey(capturedAt), + cpuUsagePercent: roundNumber(cpu?.overallUsagePercent, 1), + cpuTemperatureC: roundNumber(cpu?.overallTemperatureC, 1), + ramUsagePercent: roundNumber(memory?.usagePercent, 1), + ramUsedBytes: Number.isFinite(Number(memory?.usedBytes)) ? Math.max(0, Math.round(Number(memory.usedBytes))) : null, + ramTotalBytes: Number.isFinite(Number(memory?.totalBytes)) ? Math.max(0, Math.round(Number(memory.totalBytes))) : null, + gpuUsagePercent: roundNumber(primaryGpu?.utilizationPercent, 1), + gpuTemperatureC: roundNumber(primaryGpu?.temperatureC, 1), + vramUsedBytes: Number.isFinite(Number(primaryGpu?.memoryUsedBytes)) ? Math.max(0, Math.round(Number(primaryGpu.memoryUsedBytes))) : null, + vramTotalBytes: Number.isFinite(Number(primaryGpu?.memoryTotalBytes)) ? Math.max(0, Math.round(Number(primaryGpu.memoryTotalBytes))) : null + }; + } + + async cleanupHistoryForDay(currentDayKey, referenceDate = new Date()) { + const dayKey = String(currentDayKey || '').trim(); + if (!dayKey || this.lastHistoryCleanupDayKey === dayKey) { + return; + } + + const cutoffDayKey = toLocalDayKey(addLocalDays(referenceDate, -HISTORY_RETENTION_PREVIOUS_DAYS)); + const db = await getDb(); + const result = await db.run( + ` + DELETE FROM hardware_metrics_history + WHERE day_key < ? + `, + [cutoffDayKey] + ); + this.lastHistoryCleanupDayKey = dayKey; + logger.info('history:cleanup', { + dayKey, + cutoffDayKey, + deletedRows: Number(result?.changes || 0) + }); + } + + async persistHistorySample(sample = {}) { + if (!this.enabled) { + return; + } + const record = this.buildHistoryRecord(sample); + if (!record.dayKey) { + return; + } + + await this.cleanupHistoryForDay(record.dayKey, new Date(record.capturedAtIso)); + + const db = await getDb(); + await db.run( + ` + INSERT INTO hardware_metrics_history ( + captured_at, + day_key, + cpu_usage_percent, + cpu_temperature_c, + ram_usage_percent, + ram_used_bytes, + ram_total_bytes, + gpu_usage_percent, + gpu_temperature_c, + vram_used_bytes, + vram_total_bytes + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + record.capturedAtIso, + record.dayKey, + record.cpuUsagePercent, + record.cpuTemperatureC, + record.ramUsagePercent, + record.ramUsedBytes, + record.ramTotalBytes, + record.gpuUsagePercent, + record.gpuTemperatureC, + record.vramUsedBytes, + record.vramTotalBytes + ] + ); + } + broadcastUpdate() { wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot()); } @@ -931,6 +1052,10 @@ class HardwareMonitorService { .filter(Boolean); if (devices.length === 0) { + const fallback = this.getLastKnownGpuMetrics(); + if (fallback) { + return fallback; + } return { source: 'nvidia-smi', available: false, @@ -954,17 +1079,35 @@ class HardwareMonitorService { this.nvidiaSmiAvailable = false; } logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) }); + const fallback = this.getLastKnownGpuMetrics(); + if (fallback) { + return fallback; + } return { source: 'nvidia-smi', available: false, devices: [], message: commandMissing ? 'nvidia-smi ist nicht verfuegbar.' - : (String(error?.stderr || error?.message || 'GPU-Abfrage fehlgeschlagen').trim().slice(0, 220)) + : 'GPU-Daten derzeit nicht verfuegbar.' }; } } + getLastKnownGpuMetrics() { + const previousGpu = this.lastSnapshot?.sample?.gpu; + if (!previousGpu || typeof previousGpu !== 'object') { + return null; + } + if (!Array.isArray(previousGpu.devices) || previousGpu.devices.length === 0) { + return null; + } + return { + ...previousGpu, + message: null + }; + } + async collectStorageMetrics(signal) { const list = []; for (const entry of this.monitoredPaths) { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index a01a7d4..b4bbb6f 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -4688,7 +4688,9 @@ class HistoryService { } } } - if (existingCountFromSeriesBatch > existingCount) { + // For History UI with FS checks enabled, never override real filesystem + // counts with stale series-batch metadata counters. + if (!includeFsChecks && existingCountFromSeriesBatch > existingCount) { existingCount = existingCountFromSeriesBatch; } if (!encodeSuccessAny && existingCount > 0) { @@ -5202,7 +5204,7 @@ class HistoryService { ` SELECT * FROM jobs - WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') + WHERE status IN ('ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') AND COALESCE(job_kind, '') != 'dvd_series_container' ORDER BY updated_at ASC, id ASC ` @@ -8354,6 +8356,195 @@ class HistoryService { return { deleted, failed }; } + async _cleanupEmptyDirectoriesForDeletedJobs(jobRows = [], options = {}) { + const rows = Array.isArray(jobRows) + ? jobRows.filter((row) => normalizeJobIdValue(row?.id)) + : []; + if (rows.length === 0) { + return { + attemptedCandidates: { raw: 0, movie: 0 }, + removed: { raw: 0, movie: 0, total: 0 }, + removedPaths: [], + failures: [] + }; + } + + const jobIds = Array.from(new Set( + rows + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + )); + + const settings = options?.settings && typeof options.settings === 'object' + ? options.settings + : await settingsService.getSettingsMap(); + const lineageArtifactsByJobId = options?.lineageArtifactsByJobId instanceof Map + ? options.lineageArtifactsByJobId + : await this.listJobLineageArtifactsByJobIds(jobIds).catch(() => new Map()); + const outputFoldersByJobId = options?.outputFoldersByJobId instanceof Map + ? options.outputFoldersByJobId + : await this.listJobOutputFoldersByJobIds(jobIds).catch(() => new Map()); + + const candidateSets = { + raw: new Set(), + movie: new Set() + }; + const protectedRootSets = { + raw: new Set(), + movie: new Set() + }; + for (const row of rows) { + const rowId = normalizeJobIdValue(row?.id); + if (!rowId) { + continue; + } + const lineageArtifacts = lineageArtifactsByJobId.get(rowId) || []; + const trackedOutputPaths = (outputFoldersByJobId.get(rowId) || []) + .map((folder) => String(folder?.output_path || '').trim()) + .filter(Boolean); + const collected = this._collectDeleteCandidatesForJob(row, settings, { + lineageArtifacts, + trackedOutputPaths + }); + for (const rootPath of (collected?.rawRoots || [])) { + const normalizedRoot = normalizeComparablePath(rootPath); + if (normalizedRoot && !isFilesystemRootPath(normalizedRoot)) { + protectedRootSets.raw.add(normalizedRoot); + } + } + for (const rootPath of (collected?.movieRoots || [])) { + const normalizedRoot = normalizeComparablePath(rootPath); + if (normalizedRoot && !isFilesystemRootPath(normalizedRoot)) { + protectedRootSets.movie.add(normalizedRoot); + } + } + for (const candidate of (collected?.rawCandidates || [])) { + const normalizedPath = normalizeComparablePath(candidate?.path); + if (normalizedPath && !isFilesystemRootPath(normalizedPath)) { + candidateSets.raw.add(normalizedPath); + } + } + for (const candidate of (collected?.movieCandidates || [])) { + const normalizedPath = normalizeComparablePath(candidate?.path); + if (normalizedPath && !isFilesystemRootPath(normalizedPath)) { + candidateSets.movie.add(normalizedPath); + } + } + } + + const removedPaths = []; + const removedPathSet = new Set(); + const failures = []; + const removedCountByTarget = { + raw: 0, + movie: 0 + }; + + const rememberRemovedPath = (target, candidatePath) => { + const normalizedPath = normalizeComparablePath(candidatePath); + if (!normalizedPath || removedPathSet.has(normalizedPath)) { + return; + } + removedPathSet.add(normalizedPath); + removedPaths.push(normalizedPath); + if (target === 'raw' || target === 'movie') { + removedCountByTarget[target] += 1; + } + }; + + const cleanupTarget = (target) => { + const protectedRoots = Array.from(protectedRootSets[target] || []); + const protectedRootSet = new Set(protectedRoots); + const candidates = Array.from(candidateSets[target] || []) + .filter(Boolean) + .sort((left, right) => String(right || '').length - String(left || '').length); + + for (const candidatePathRaw of candidates) { + const candidatePath = normalizeComparablePath(candidatePathRaw); + if (!candidatePath || isFilesystemRootPath(candidatePath)) { + continue; + } + + const inspection = inspectDeletionPath(candidatePath); + if (inspection.exists && inspection.isDirectory) { + if (protectedRootSet.has(inspection.path)) { + continue; + } + if (protectedRoots.length > 0 && !protectedRoots.some((rootPath) => isPathInside(rootPath, inspection.path))) { + continue; + } + try { + const entries = fs.readdirSync(inspection.path); + if (entries.length > 0) { + continue; + } + fs.rmdirSync(inspection.path); + rememberRemovedPath(target, inspection.path); + if (protectedRoots.length > 0) { + const removedParentDirs = removeEmptyParentDirectories(path.dirname(inspection.path), { + protectedRoots + }); + for (const removedPath of removedParentDirs) { + rememberRemovedPath(target, removedPath); + } + } + } catch (error) { + if (error?.code !== 'ENOENT') { + failures.push({ + target, + path: inspection.path, + error: error?.message || String(error) + }); + } + } + continue; + } + + const parentDir = normalizeComparablePath(path.dirname(candidatePath)); + if (!parentDir || isFilesystemRootPath(parentDir)) { + continue; + } + if (protectedRoots.length === 0) { + continue; + } + if (!protectedRoots.some((rootPath) => isPathInside(rootPath, parentDir))) { + continue; + } + try { + const removedParentDirs = removeEmptyParentDirectories(parentDir, { + protectedRoots + }); + for (const removedPath of removedParentDirs) { + rememberRemovedPath(target, removedPath); + } + } catch (error) { + failures.push({ + target, + path: parentDir, + error: error?.message || String(error) + }); + } + } + }; + + cleanupTarget('raw'); + cleanupTarget('movie'); + + return { + attemptedCandidates: { + raw: (candidateSets.raw || new Set()).size, + movie: (candidateSets.movie || new Set()).size + }, + removed: { + raw: removedCountByTarget.raw, + movie: removedCountByTarget.movie, + total: removedPaths.length + }, + removedPaths: [...removedPaths].sort((left, right) => left.localeCompare(right, 'de')), + failures + }; + } + async deleteJob(jobId, fileTarget = 'none', options = {}) { const allowedTargets = new Set(['none', 'raw', 'movie', 'both']); if (!allowedTargets.has(fileTarget)) { @@ -8406,6 +8597,46 @@ class HistoryService { .map((row) => String(row?.path || '').trim()) .filter(Boolean); }; + const collectIncompleteRawPathsFromPreview = (preview, selectedJobRows = []) => { + const rows = Array.isArray(selectedJobRows) ? selectedJobRows : []; + if (rows.length === 0) { + return []; + } + + const incompleteCancelledJobIds = new Set( + rows + .filter((row) => { + if (!row || typeof row !== 'object') { + return false; + } + if (isOrphanRawImportJobRow(row)) { + return false; + } + const status = String(row?.status || '').trim().toUpperCase(); + const lastState = String(row?.last_state || '').trim().toUpperCase(); + const isCancelled = status === 'CANCELLED' || lastState === 'CANCELLED'; + if (!isCancelled) { + return false; + } + return Number(row?.rip_successful || 0) !== 1; + }) + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + ); + if (incompleteCancelledJobIds.size === 0) { + return []; + } + + const rawRows = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : []; + return rawRows + .filter((row) => Boolean(row?.exists)) + .filter((row) => { + const jobIds = Array.isArray(row?.jobIds) ? row.jobIds : []; + return jobIds.some((jobId) => incompleteCancelledJobIds.has(normalizeJobIdValue(jobId))); + }) + .map((row) => String(row?.path || '').trim()) + .filter(Boolean); + }; const cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => { const normalizedOwnerJobIds = Array.from(new Set( (Array.isArray(ownerJobIds) ? ownerJobIds : []) @@ -8533,6 +8764,31 @@ class HistoryService { } } + const cleanupDeletedJobRows = [existing]; + let emptyDirectoryCleanupContext = { + settings: null, + lineageArtifactsByJobId: new Map(), + outputFoldersByJobId: new Map() + }; + try { + const [cleanupSettings, cleanupLineageArtifactsByJobId, cleanupOutputFoldersByJobId] = await Promise.all([ + settingsService.getSettingsMap(), + this.listJobLineageArtifactsByJobIds([normalizedJobId]), + this.listJobOutputFoldersByJobIds([normalizedJobId]) + ]); + emptyDirectoryCleanupContext = { + settings: cleanupSettings, + lineageArtifactsByJobId: cleanupLineageArtifactsByJobId, + outputFoldersByJobId: cleanupOutputFoldersByJobId + }; + } catch (error) { + logger.warn('job:delete:empty-dir-context-failed', { + jobId: normalizedJobId, + includeRelated: false, + error: error?.message || String(error) + }); + } + let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); let selectedRawPathsForDelete = normalizedSelectedRawPaths; let selectedMoviePathsForDelete = normalizedSelectedMoviePaths; @@ -8541,8 +8797,15 @@ class HistoryService { if (effectiveFileTarget === 'none') { preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false }); const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview); + const autoIncompleteRawPaths = collectIncompleteRawPathsFromPreview(preview, [existing]); + if (autoIncompleteRawPaths.length > 0) { + effectiveFileTarget = autoIncompleteMoviePaths.length > 0 ? 'both' : 'raw'; + if (!selectedRawPathsForDelete || selectedRawPathsForDelete.length === 0) { + selectedRawPathsForDelete = autoIncompleteRawPaths; + } + } if (autoIncompleteMoviePaths.length > 0) { - effectiveFileTarget = 'movie'; + effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie'; if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) { selectedMoviePathsForDelete = autoIncompleteMoviePaths; } @@ -8567,7 +8830,7 @@ class HistoryService { ); const isActivePipelineJob = Number(pipelineRow?.active_job_id || 0) === Number(normalizedJobId); - const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); + const runningStates = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); if (isActivePipelineJob && runningStates.has(String(pipelineRow?.state || ''))) { const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.'); @@ -8626,6 +8889,19 @@ class HistoryService { if (includeMovieCleanup) { incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs([existing], [normalizedJobId]); } + let emptyDirectoryCleanup = null; + try { + emptyDirectoryCleanup = await this._cleanupEmptyDirectoriesForDeletedJobs( + cleanupDeletedJobRows, + emptyDirectoryCleanupContext + ); + } catch (error) { + logger.warn('job:delete:empty-dir-cleanup-failed', { + jobId: normalizedJobId, + includeRelated: false, + error: error?.message || String(error) + }); + } logger.warn('job:deleted', { jobId: normalizedJobId, @@ -8634,6 +8910,7 @@ class HistoryService { includeRelated: false, pipelineStateReset: isActivePipelineJob, incompleteCleanup: incompleteCleanupSummary, + emptyDirectoryCleanup, filesDeleted: fileSummary ? { raw: fileSummary.raw?.filesDeleted ?? 0, @@ -8650,7 +8927,8 @@ class HistoryService { includeRelated: false, deletedJobIds: [Number(normalizedJobId)], fileSummary, - incompleteCleanup: incompleteCleanupSummary + incompleteCleanup: incompleteCleanupSummary, + emptyDirectoryCleanup }; } @@ -8715,20 +8993,58 @@ class HistoryService { const pipelineRow = await db.get('SELECT state, active_job_id FROM pipeline_state WHERE id = 1'); const activePipelineJobId = normalizeJobIdValue(pipelineRow?.active_job_id); const activeJobIncluded = Boolean(activePipelineJobId && deleteJobIds.includes(activePipelineJobId)); - const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); + const runningStates = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); if (activeJobIncluded && runningStates.has(String(pipelineRow?.state || ''))) { const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.'); error.statusCode = 409; throw error; } + const deletedJobRows = deleteJobIds + .map((id) => rowById.get(id)) + .filter(Boolean); + let emptyDirectoryCleanupContext = { + settings: null, + lineageArtifactsByJobId: new Map(), + outputFoldersByJobId: new Map() + }; + try { + const [cleanupSettings, cleanupLineageArtifactsByJobId, cleanupOutputFoldersByJobId] = await Promise.all([ + settingsService.getSettingsMap(), + this.listJobLineageArtifactsByJobIds(deleteJobIds), + this.listJobOutputFoldersByJobIds(deleteJobIds) + ]); + emptyDirectoryCleanupContext = { + settings: cleanupSettings, + lineageArtifactsByJobId: cleanupLineageArtifactsByJobId, + outputFoldersByJobId: cleanupOutputFoldersByJobId + }; + } catch (error) { + logger.warn('job:delete:empty-dir-context-failed', { + jobId: normalizedJobId, + includeRelated: true, + deleteJobIds, + error: error?.message || String(error) + }); + } + let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); let selectedRawPathsForDelete = normalizedSelectedRawPaths; let selectedMoviePathsForDelete = normalizedSelectedMoviePaths; if (effectiveFileTarget === 'none') { const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview); + const selectedDeleteRows = deleteJobIds + .map((id) => rowById.get(id)) + .filter(Boolean); + const autoIncompleteRawPaths = collectIncompleteRawPathsFromPreview(preview, selectedDeleteRows); + if (autoIncompleteRawPaths.length > 0) { + effectiveFileTarget = autoIncompleteMoviePaths.length > 0 ? 'both' : 'raw'; + if (!selectedRawPathsForDelete || selectedRawPathsForDelete.length === 0) { + selectedRawPathsForDelete = autoIncompleteRawPaths; + } + } if (autoIncompleteMoviePaths.length > 0) { - effectiveFileTarget = 'movie'; + effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie'; if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) { selectedMoviePathsForDelete = autoIncompleteMoviePaths; } @@ -8806,11 +9122,22 @@ class HistoryService { const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both'; let incompleteCleanupSummary = null; if (includeMovieCleanup) { - const deletedJobRows = deleteJobIds - .map((id) => rowById.get(id)) - .filter(Boolean); incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs(deletedJobRows, deleteJobIds); } + let emptyDirectoryCleanup = null; + try { + emptyDirectoryCleanup = await this._cleanupEmptyDirectoriesForDeletedJobs( + deletedJobRows, + emptyDirectoryCleanupContext + ); + } catch (error) { + logger.warn('job:delete:empty-dir-cleanup-failed', { + jobId: normalizedJobId, + includeRelated: true, + deleteJobIds, + error: error?.message || String(error) + }); + } const deletedJobIdSet = new Set(deleteJobIds); const deletedJobs = Array.isArray(preview?.relatedJobs) @@ -8826,6 +9153,7 @@ class HistoryService { deletedJobCount: deleteJobIds.length, pipelineStateReset: activeJobIncluded, incompleteCleanup: incompleteCleanupSummary, + emptyDirectoryCleanup, filesDeleted: fileSummary ? { raw: fileSummary.raw?.filesDeleted ?? 0, @@ -8843,7 +9171,8 @@ class HistoryService { deletedJobIds: deleteJobIds, deletedJobs, fileSummary, - incompleteCleanup: incompleteCleanupSummary + incompleteCleanup: incompleteCleanupSummary, + emptyDirectoryCleanup }; } } diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index d561454..3c37ca5 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -32,7 +32,7 @@ const activationBytesService = require('./activationBytesService'); const { toBoolean } = require('../utils/validators'); const { tempDir } = require('../config'); -const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); +const RUNNING_STATES = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); const REVIEW_REFRESH_SETTING_PREFIXES = [ 'handbrake_', 'mediainfo_', @@ -101,6 +101,7 @@ const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_ const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000; const tmdbSeriesSearchCache = new Map(); const PROCESS_LOG_RESET_TERMINAL_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']); +const PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS = 120; function nowIso() { return new Date().toISOString(); @@ -206,248 +207,6 @@ function normalizePlayAllTolerancePercent(rawValue, fallback = 10) { return parsed; } -function buildSeriesHeuristicOptionsFromSettings(settingsMap = {}) { - const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; - return { - playAllToleranceLowerPct: normalizePlayAllTolerancePercent( - source.series_playall_tolerance_lower_pct, - 10 - ), - playAllToleranceUpperPct: normalizePlayAllTolerancePercent( - source.series_playall_tolerance_upper_pct, - 10 - ) - }; -} - -function deriveSeriesDecisionFromSummary(summary = null, seriesLookupHint = null, options = {}) { - const normalizedSummary = summary && typeof summary === 'object' ? summary : {}; - const episodeCandidateCount = Number(normalizedSummary.episodeCandidateCount || 0) || 0; - const playAllCount = Number(normalizedSummary.playAllCount || 0) || 0; - const typicalEpisodeMinutes = Number(normalizedSummary.typicalEpisodeMinutes || 0) || 0; - const episodeSumSeconds = Number(normalizedSummary.episodeSumSeconds || 0) || ( - episodeCandidateCount > 0 && typicalEpisodeMinutes > 0 - ? Math.round(typicalEpisodeMinutes * 60 * episodeCandidateCount) - : 0 - ); - const lowerPct = normalizePlayAllTolerancePercent(options?.playAllToleranceLowerPct, 10); - const upperPct = normalizePlayAllTolerancePercent(options?.playAllToleranceUpperPct, 10); - const lowerBoundSeconds = episodeSumSeconds > 0 - ? Math.round(episodeSumSeconds * (1 - (lowerPct / 100))) - : 0; - const upperBoundSeconds = episodeSumSeconds > 0 - ? Math.round(episodeSumSeconds * (1 + (upperPct / 100))) - : 0; - const longestOutsideClusterSeconds = Number(normalizedSummary.longestOutsideClusterSeconds || 0) || 0; - const hasWeakLookupQuery = Boolean(String(seriesLookupHint?.query || '').trim()); - const hasStrongLookupHint = hasStrongSeriesLookupHint(seriesLookupHint); - const playAllWithinTolerance = Boolean(normalizedSummary.playAllWithinTolerance); - const plausiblePlayAll = playAllCount > 0 && ( - playAllWithinTolerance - || (episodeSumSeconds <= 0) - ); - const longTitleOutsideUpperBound = ( - episodeCandidateCount >= 2 - && episodeSumSeconds > 0 - && longestOutsideClusterSeconds > upperBoundSeconds - ); - const scoreContributions = []; - let seriesScore = 0; - let filmScore = 0; - const addSeriesScore = (points, reason) => { - const numericPoints = Number(points || 0); - if (!Number.isFinite(numericPoints) || numericPoints === 0) { - return; - } - seriesScore += numericPoints; - scoreContributions.push({ - side: 'series', - points: numericPoints, - reason: String(reason || '').trim() || 'series_signal' - }); - }; - const addFilmScore = (points, reason) => { - const numericPoints = Number(points || 0); - if (!Number.isFinite(numericPoints) || numericPoints === 0) { - return; - } - filmScore += numericPoints; - scoreContributions.push({ - side: 'film', - points: numericPoints, - reason: String(reason || '').trim() || 'film_signal' - }); - }; - - let classification = 'film'; - let recommendedWorkflowKind = 'film'; - let seriesLike = false; - let confidence = 'low'; - - if (episodeCandidateCount >= 3) { - addSeriesScore(70, `episode_candidates>=3 (${episodeCandidateCount})`); - classification = 'series'; - recommendedWorkflowKind = 'series'; - seriesLike = true; - confidence = (plausiblePlayAll || hasStrongLookupHint) ? 'high' : 'medium'; - } else if (episodeCandidateCount === 2) { - addSeriesScore(45, 'episode_candidates=2'); - addFilmScore(20, 'episode_candidates=2_is_borderline'); - if (plausiblePlayAll || hasStrongLookupHint) { - classification = 'series'; - recommendedWorkflowKind = 'series'; - seriesLike = true; - confidence = (plausiblePlayAll && hasStrongLookupHint) ? 'high' : 'medium'; - } else { - addFilmScore(15, 'two_episodes_without_playall_or_strong_hint'); - classification = 'ambiguous'; - recommendedWorkflowKind = 'film'; - seriesLike = false; - confidence = 'medium'; - } - } else if (episodeCandidateCount === 1 && hasStrongLookupHint) { - addFilmScore(35, 'single_episode_candidate'); - addSeriesScore(25, 'strong_series_hint_with_single_episode'); - classification = 'ambiguous'; - recommendedWorkflowKind = 'series'; - seriesLike = false; - confidence = 'low'; - } else if (episodeCandidateCount === 1) { - addFilmScore(40, 'single_episode_candidate_without_strong_hint'); - } else { - addFilmScore(50, `episode_candidates=${episodeCandidateCount}`); - } - - if (plausiblePlayAll) { - addSeriesScore(35, 'playall_within_tolerance'); - } - if (longTitleOutsideUpperBound) { - addFilmScore(30, 'long_title_outside_playall_upper_bound'); - } - if (hasStrongLookupHint) { - addSeriesScore(25, 'strong_lookup_hint_season_or_disc'); - } else if (hasWeakLookupQuery) { - addSeriesScore(5, 'weak_lookup_query_only'); - } - - if (classification === 'ambiguous' && longTitleOutsideUpperBound) { - recommendedWorkflowKind = 'film'; - confidence = 'medium'; - } - - return { - classification, - recommendedWorkflowKind, - manualOverrideRequired: classification === 'ambiguous', - seriesLike, - confidence, - strongLookupHint: hasStrongLookupHint, - weakLookupQuery: hasWeakLookupQuery, - playAllWithinTolerance: plausiblePlayAll, - longTitleOutsideUpperBound, - score: { - series: seriesScore, - film: filmScore, - delta: seriesScore - filmScore, - model: 'series_vs_film_v1', - contributions: scoreContributions - }, - tolerance: { - lowerPct, - upperPct, - episodeSumSeconds, - lowerBoundSeconds, - upperBoundSeconds - } - }; -} - -function formatSeriesHeuristicDuration(seconds) { - const numeric = Number(seconds || 0); - if (!Number.isFinite(numeric) || numeric <= 0) { - return '-'; - } - const clock = formatDurationClock(numeric) || '-'; - const minutes = (numeric / 60).toFixed(2); - return `${clock} (${minutes} min)`; -} - -function formatSeriesHeuristicBool(value) { - return value ? 'ja' : 'nein'; -} - -function normalizeSeriesDecisionForLogging(seriesDecision = null, seriesLookupHint = null, options = {}) { - if (seriesDecision && typeof seriesDecision === 'object') { - return seriesDecision; - } - return deriveSeriesDecisionFromSummary(null, seriesLookupHint, options); -} - -async function appendSeriesHeuristicDecisionLog({ - jobId, - mediaProfile, - seriesAnalysis = null, - seriesDecision = null, - seriesLookupHint = null, - seriesHeuristicOptions = {} -} = {}) { - const normalizedJobId = Number(jobId || 0); - if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { - return; - } - const summary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' - ? seriesAnalysis.summary - : {}; - const decision = normalizeSeriesDecisionForLogging(seriesDecision, seriesLookupHint, seriesHeuristicOptions); - const tolerance = decision?.tolerance && typeof decision.tolerance === 'object' - ? decision.tolerance - : {}; - const score = decision?.score && typeof decision.score === 'object' - ? decision.score - : {}; - const lookupQueryRaw = String(seriesLookupHint?.query || '').trim(); - const lookupQuery = lookupQueryRaw.length > 80 - ? `${lookupQueryRaw.slice(0, 80)}...` - : lookupQueryRaw; - const lowerPct = Number(tolerance?.lowerPct ?? seriesHeuristicOptions?.playAllToleranceLowerPct ?? 10) || 10; - const upperPct = Number(tolerance?.upperPct ?? seriesHeuristicOptions?.playAllToleranceUpperPct ?? 10) || 10; - const episodeCandidateCount = Number(summary?.episodeCandidateCount || 0) || 0; - const typicalEpisodeMinutes = Number(summary?.typicalEpisodeMinutes || 0) || 0; - const episodeSumSeconds = Number(summary?.episodeSumSeconds || tolerance?.episodeSumSeconds || 0) || 0; - const lowerBoundSeconds = Number(summary?.playAllLowerBoundSeconds || tolerance?.lowerBoundSeconds || 0) || 0; - const upperBoundSeconds = Number(summary?.playAllUpperBoundSeconds || tolerance?.upperBoundSeconds || 0) || 0; - const playAllCount = Number(summary?.playAllCount || 0) || 0; - const playAllCandidateSeconds = Number(summary?.playAllCandidateSeconds || 0) || 0; - const longestOutsideClusterSeconds = Number(summary?.longestOutsideClusterSeconds || 0) || 0; - const titleCount = Number(summary?.titleCount || 0) || 0; - const extraCount = Number(summary?.extraCount || 0) || 0; - const duplicateCount = Number(summary?.duplicateCount || 0) || 0; - const contributionsText = Array.isArray(score?.contributions) && score.contributions.length > 0 - ? score.contributions.map((entry) => { - const side = String(entry?.side || '').trim().toLowerCase() === 'series' ? 'SERIE' : 'FILM'; - const points = Number(entry?.points || 0) || 0; - const reason = String(entry?.reason || '').trim() || 'signal'; - return `${side}:${points}(${reason})`; - }).join(', ') - : 'keine'; - - const lines = [ - `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Auswertung Film/Serie (vor Metadaten).`, - `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Episodencluster=${episodeCandidateCount}, typische Episodenlänge=${typicalEpisodeMinutes > 0 ? `${typicalEpisodeMinutes.toFixed(2)} min` : '-'}, Episodensumme=${formatSeriesHeuristicDuration(episodeSumSeconds)}.`, - `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: PlayAll-Toleranz -${lowerPct}%/+${upperPct}% => Bereich ${formatSeriesHeuristicDuration(lowerBoundSeconds)} bis ${formatSeriesHeuristicDuration(upperBoundSeconds)}.`, - `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: PlayAll-Kandidaten=${playAllCount}, PlayAll-Länge=${formatSeriesHeuristicDuration(playAllCandidateSeconds)}, innerhalb Toleranz=${formatSeriesHeuristicBool(Boolean(decision?.playAllWithinTolerance))}.`, - `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Längster Titel außerhalb Cluster=${formatSeriesHeuristicDuration(longestOutsideClusterSeconds)}, oberhalb Obergrenze=${formatSeriesHeuristicBool(Boolean(decision?.longTitleOutsideUpperBound))}.`, - `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Titel gesamt=${titleCount}, Extras (lange Nicht-Cluster-Titel)=${extraCount}, Duplikate=${duplicateCount}.`, - `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Lookup-Hints stark(season/disc)=${formatSeriesHeuristicBool(Boolean(decision?.strongLookupHint))}, schwach(query)=${formatSeriesHeuristicBool(Boolean(decision?.weakLookupQuery))}${lookupQuery ? `, Query="${lookupQuery}"` : ''}.`, - `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Score FILM=${Number(score?.film || 0) || 0}, SERIE=${Number(score?.series || 0) || 0}, DELTA=${Number(score?.delta || 0) || 0}, Beiträge=[${contributionsText}].`, - `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Entscheidung=${String(decision?.classification || 'film').toUpperCase()}, confidence=${String(decision?.confidence || 'low').toLowerCase()}, workflow=${String(decision?.recommendedWorkflowKind || 'film').toLowerCase()}, manualOverride=${formatSeriesHeuristicBool(Boolean(decision?.manualOverrideRequired))}.` - ]; - - for (const line of lines) { - await historyService.appendLog(normalizedJobId, 'SYSTEM', line); - } -} - function resolveSeriesDiscLabel(mediaProfile) { const normalized = normalizeMediaProfile(mediaProfile); if (normalized === 'bluray') { @@ -565,6 +324,93 @@ function buildMetadataSearchQueryVariants(query) { return variants.slice(0, 6); } +function normalizeMetadataCandidateWorkflowKind(value) { + return normalizeDvdMetadataWorkflowKind(value); +} + +function normalizeMetadataCandidateMetadataKind(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (['movie', 'film', 'feature'].includes(raw)) { + return 'movie'; + } + if (['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) { + return raw === 'tv' ? 'series' : raw; + } + return raw; +} + +function normalizeMetadataCandidateType(row = null, fallbackType = null) { + const source = row && typeof row === 'object' ? row : {}; + const metadataKind = normalizeMetadataCandidateMetadataKind(source?.metadataKind); + const workflowKind = normalizeMetadataCandidateWorkflowKind( + source?.workflowKind + || source?.kind + || metadataKind + || null + ); + if (workflowKind === 'series') { + return 'series'; + } + if (workflowKind === 'film') { + return 'movie'; + } + if (metadataKind === 'series' || metadataKind === 'season') { + return 'series'; + } + if (metadataKind === 'movie') { + return 'movie'; + } + const normalizedFallback = String(fallbackType || '').trim().toLowerCase(); + if (normalizedFallback === 'series' || normalizedFallback === 'movie') { + return normalizedFallback; + } + if (Number(source?.seasonNumber || 0) > 0) { + return 'series'; + } + return 'movie'; +} + +function normalizeMetadataCandidateRow(row = null, fallbackType = null) { + const source = row && typeof row === 'object' ? row : {}; + const resultType = normalizeMetadataCandidateType(source, fallbackType); + const workflowKind = resultType === 'series' ? 'series' : 'film'; + const metadataKind = normalizeMetadataCandidateMetadataKind(source?.metadataKind) + || (resultType === 'series' ? 'series' : 'movie'); + return { + ...source, + workflowKind, + metadataKind, + resultType + }; +} + +function deriveWorkflowKindFromMetadataCandidates(metadataCandidates = []) { + const rows = Array.isArray(metadataCandidates) ? metadataCandidates : []; + let hasSeries = false; + let hasMovies = false; + for (const row of rows) { + const resultType = normalizeMetadataCandidateType(row, null); + if (resultType === 'series') { + hasSeries = true; + } else { + hasMovies = true; + } + if (hasSeries && hasMovies) { + return null; + } + } + if (hasSeries) { + return 'series'; + } + if (hasMovies) { + return 'film'; + } + return null; +} + function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = null) { const mkInfo = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo @@ -3447,6 +3293,123 @@ function formatDurationClock(seconds) { return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } +function parseRuntimeMinutesValue(rawValue) { + if (Number.isFinite(Number(rawValue)) && Number(rawValue) > 0) { + return Math.trunc(Number(rawValue)); + } + const text = String(rawValue || '').trim(); + if (!text) { + return null; + } + const normalized = text.toLowerCase(); + + const minMatch = normalized.match(/(\d+(?:[.,]\d+)?)\s*min\b/); + if (minMatch) { + const parsed = Number(String(minMatch[1]).replace(',', '.')); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.max(1, Math.round(parsed)); + } + } + + const hmsMatch = normalized.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); + if (hmsMatch) { + const hours = Number(hmsMatch[1] || 0); + const minutes = Number(hmsMatch[2] || 0); + const seconds = Number(hmsMatch[3] || 0); + if (Number.isFinite(hours) && Number.isFinite(minutes) && Number.isFinite(seconds)) { + const totalMinutes = Math.round(((hours * 3600) + (minutes * 60) + seconds) / 60); + return totalMinutes > 0 ? totalMinutes : null; + } + } + + const hourMatch = normalized.match(/(\d+)\s*h(?:our|hours)?\s*(\d{1,2})?\s*m?/); + if (hourMatch) { + const hours = Number(hourMatch[1] || 0); + const minutes = Number(hourMatch[2] || 0); + if (Number.isFinite(hours) && Number.isFinite(minutes)) { + const totalMinutes = (hours * 60) + minutes; + return totalMinutes > 0 ? totalMinutes : null; + } + } + + const numeric = Number(normalized.replace(',', '.')); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.max(1, Math.round(numeric)); + } + + return null; +} + +function extractMovieRuntimeMinutesFromMetadata(selectedMetadata = null) { + const metadata = selectedMetadata && typeof selectedMetadata === 'object' + ? selectedMetadata + : {}; + const runtimeSources = [ + metadata?.runtime, + metadata?.runtimeLabel, + metadata?.tmdbDetails?.runtime, + metadata?.tmdbDetails?.runtimeLabel + ]; + for (const source of runtimeSources) { + const parsed = parseRuntimeMinutesValue(source); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return null; +} + +function resolvePlaylistByRuntimeAutoMatch({ playlistCandidates = [], selectedMetadata = null } = {}) { + const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata); + if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) { + return { + playlistId: null, + runtimeMinutes: null, + reason: 'missing_runtime' + }; + } + const expectedSeconds = runtimeMinutes * 60; + const toleranceSeconds = PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS; + const rows = (Array.isArray(playlistCandidates) ? playlistCandidates : []) + .map((item) => ({ + playlistId: normalizePlaylistId(item?.playlistId), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => item.playlistId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + if (rows.length < 2) { + return { + playlistId: null, + runtimeMinutes, + reason: 'insufficient_candidates' + }; + } + const withinTolerance = rows + .map((item) => ({ + ...item, + deltaSeconds: Math.abs(Math.trunc(item.durationSeconds) - expectedSeconds) + })) + .filter((item) => item.deltaSeconds <= toleranceSeconds) + .sort((a, b) => a.deltaSeconds - b.deltaSeconds || a.playlistId.localeCompare(b.playlistId)); + + if (withinTolerance.length !== 1) { + return { + playlistId: null, + runtimeMinutes, + reason: withinTolerance.length === 0 ? 'no_match_in_tolerance' : 'ambiguous_match', + matchCount: withinTolerance.length + }; + } + + return { + playlistId: withinTolerance[0].playlistId, + runtimeMinutes, + expectedSeconds, + durationSeconds: withinTolerance[0].durationSeconds, + deltaSeconds: withinTolerance[0].deltaSeconds, + toleranceSeconds + }; +} + function normalizeTrackLanguage(raw) { const value = String(raw || '').trim(); if (!value) { @@ -5932,6 +5895,129 @@ function filterSeriesBackupReviewTitles(reviewPlan) { }; } +function resolveSeriesSinglePlayAllFallback(seriesCandidates = [], allTitles = [], selectedMetadata = null, options = {}) { + const episodeCountFromMetadata = normalizePositiveInteger( + selectedMetadata?.episodeCount + ?? (Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : null) + ) || 0; + if (episodeCountFromMetadata < 2) { + return { + applied: false, + reason: 'insufficient_episode_metadata' + }; + } + + const normalizedCandidates = (Array.isArray(seriesCandidates) ? seriesCandidates : []) + .map((item) => { + const handBrakeTitleId = normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id); + const durationSeconds = Number(item?.durationSeconds || 0); + return { + raw: item, + handBrakeTitleId, + durationSeconds + }; + }) + .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + if (normalizedCandidates.length < 2) { + return { + applied: false, + reason: 'insufficient_candidates' + }; + } + + const sortedByDuration = normalizedCandidates + .slice() + .sort((a, b) => b.durationSeconds - a.durationSeconds || a.handBrakeTitleId - b.handBrakeTitleId); + const longest = sortedByDuration[0]; + const second = sortedByDuration[1]; + if (!longest || !second) { + return { + applied: false, + reason: 'missing_duration_anchor' + }; + } + + const minLongestSeconds = Math.max(20 * 60, Math.round(Number(options?.minLongestSeconds) || (45 * 60))); + if (longest.durationSeconds < minLongestSeconds) { + return { + applied: false, + reason: 'longest_title_too_short' + }; + } + + const requiredLongestRatio = Math.max(1.8, Number(options?.minLongestVsSecondRatio) || 3.0); + if (longest.durationSeconds < second.durationSeconds * requiredLongestRatio) { + return { + applied: false, + reason: 'longest_not_dominant' + }; + } + + const expectedEpisodeSeconds = longest.durationSeconds / episodeCountFromMetadata; + if (!Number.isFinite(expectedEpisodeSeconds) || expectedEpisodeSeconds < (15 * 60) || expectedEpisodeSeconds > (95 * 60)) { + return { + applied: false, + reason: 'episode_runtime_implausible', + episodeCountFromMetadata, + expectedEpisodeSeconds + }; + } + + const shortThresholdSeconds = Math.max(10 * 60, Math.round(expectedEpisodeSeconds * 0.35)); + const restRows = sortedByDuration.slice(1); + if (restRows.length === 0) { + return { + applied: false, + reason: 'no_short_candidates' + }; + } + const hasOnlyShortRest = restRows.every((row) => row.durationSeconds <= shortThresholdSeconds); + if (!hasOnlyShortRest) { + return { + applied: false, + reason: 'rest_contains_non_short_title', + shortThresholdSeconds + }; + } + + const restDurationSum = restRows.reduce((sum, row) => sum + Number(row.durationSeconds || 0), 0); + if (restDurationSum >= (longest.durationSeconds * 0.55)) { + return { + applied: false, + reason: 'short_titles_sum_too_large', + restDurationSum + }; + } + + const allRows = (Array.isArray(allTitles) ? allTitles : []) + .map((item) => ({ + handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + const allRowsById = new Map(allRows.map((item) => [Number(item.handBrakeTitleId), item])); + + return { + applied: true, + reason: 'single_playall_plus_short_extras', + episodeCountFromMetadata, + expectedEpisodeSeconds, + shortThresholdSeconds, + keptTitleId: longest.handBrakeTitleId, + keptDurationSeconds: longest.durationSeconds, + removedTitleIds: restRows.map((row) => row.handBrakeTitleId), + removedCount: restRows.length, + removedDurationSumSeconds: restDurationSum, + longestVsSecondRatio: Number((longest.durationSeconds / Math.max(1, second.durationSeconds)).toFixed(2)), + longestChapterCount: normalizePositiveInteger( + allRowsById.get(Number(longest.handBrakeTitleId))?.chapterCount + ?? longest?.raw?.chapterCount + ?? null + ) || null, + candidates: [longest.raw] + }; +} + function resolveSeriesPlayAllDoubleEpisodeDecision(seriesCandidates = [], allTitles = [], options = {}) { const normalizedSeriesCandidates = (Array.isArray(seriesCandidates) ? seriesCandidates : []) .map((item) => ({ @@ -8150,6 +8236,14 @@ function normalizeSubtitleLanguageOrder(rawOrder = []) { return output; } +function normalizeSubtitleSelectionMode(value, fallback = 'variants') { + const mode = String(value || '').trim().toLowerCase(); + if (mode === 'track_ids' || mode === 'variants') { + return mode; + } + return fallback; +} + function normalizeSubtitlePhysicalTrack(track, originalIndex = 0) { const id = normalizeTrackIdList([track?.id ?? track?.sourceTrackId])[0] || null; if (!id) { @@ -8565,13 +8659,20 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { const requestedSubtitleTrackIdsOrdered = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false }) .filter((id) => validSubtitleTrackIds.has(id)); - const explicitSubtitleVariants = normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection); + const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds'); + const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection'); + const requestedSubtitleSelectionMode = normalizeSubtitleSelectionMode( + rawSelection.subtitleSelectionMode, + hasRawSubtitleVariantSelection ? 'variants' : (hasRawSubtitleTrackIds ? 'track_ids' : 'variants') + ); + const explicitSubtitleVariants = requestedSubtitleSelectionMode === 'variants' + ? normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection) + : { map: new Map(), order: [] }; const explicitSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(rawSelection.subtitleLanguageOrder); let effectiveSubtitleVariantSelection = explicitSubtitleVariants; let forceExplicitEmptySubtitleSelection = false; - const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds'); - const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection'); - if (effectiveSubtitleVariantSelection.map.size === 0 && hasRawSubtitleTrackIds) { + + if (requestedSubtitleSelectionMode === 'track_ids') { if (requestedSubtitleTrackIds.length > 0) { effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds( encodeTitle.subtitleTracks, @@ -8584,10 +8685,25 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { effectiveSubtitleVariantSelection = buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); forceExplicitEmptySubtitleSelection = true; } - } else if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && requestedSubtitleTrackIds.length > 0) { - effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds); + } else if (effectiveSubtitleVariantSelection.map.size === 0 && hasRawSubtitleTrackIds) { + if (requestedSubtitleTrackIds.length > 0) { + effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds( + encodeTitle.subtitleTracks, + requestedSubtitleTrackIds + ); + } else { + effectiveSubtitleVariantSelection = buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); + forceExplicitEmptySubtitleSelection = true; + } } + const hasExplicitSubtitleVariantSelection = ( + requestedSubtitleSelectionMode === 'track_ids' + || hasRawSubtitleVariantSelection + || forceExplicitEmptySubtitleSelection + || (hasRawSubtitleTrackIds && effectiveSubtitleVariantSelection.map.size > 0) + ); + const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ subtitleTracks: encodeTitle.subtitleTracks, subtitleVariantSelection: Object.fromEntries(effectiveSubtitleVariantSelection.map.entries()), @@ -8596,7 +8712,7 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { : effectiveSubtitleVariantSelection.order, fallbackSelectedSubtitleTrackIds: requestedSubtitleTrackIds, fallbackSelectedSubtitleTrackIdsOrdered: requestedSubtitleTrackIdsOrdered, - hasExplicitVariantSelection: hasRawSubtitleVariantSelection || forceExplicitEmptySubtitleSelection + hasExplicitVariantSelection: hasExplicitSubtitleVariantSelection }); const audioSelectionSet = new Set(requestedAudioTrackIds.map((id) => String(id))); @@ -8652,6 +8768,7 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { const currentManualSelection = { titleId: encodeInputTitleId, + subtitleSelectionMode: requestedSubtitleSelectionMode, audioTrackIds: requestedAudioTrackIds, subtitleTrackIds: resolvedSubtitleSelection.selectedPhysicalTrackIds, subtitleTrackIdsOrdered: resolvedSubtitleSelection.subtitleTrackIdsOrdered, @@ -8749,11 +8866,25 @@ function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) { const manualSubtitleVariantSelection = manualMatchesTitle && manualSelection?.subtitleVariantSelection ? manualSelection.subtitleVariantSelection : null; + const hasManualSubtitleTrackIds = Boolean( + manualMatchesTitle + && manualSelection + && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleTrackIds') + ); const hasManualSubtitleVariantSelection = Boolean( manualMatchesTitle && manualSelection && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleVariantSelection') ); + const manualSubtitleSelectionMode = normalizeSubtitleSelectionMode( + manualMatchesTitle ? manualSelection?.subtitleSelectionMode : null, + hasManualSubtitleVariantSelection ? 'variants' : (hasManualSubtitleTrackIds ? 'track_ids' : 'variants') + ); + const hasExplicitManualSubtitleSelection = Boolean( + manualSubtitleSelectionMode === 'track_ids' + || hasManualSubtitleVariantSelection + || hasManualSubtitleTrackIds + ); const manualSubtitleLanguageOrder = manualMatchesTitle && Array.isArray(manualSelection?.subtitleLanguageOrder) ? manualSelection.subtitleLanguageOrder : []; @@ -8764,12 +8895,12 @@ function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) { subtitleLanguageOrder: manualSubtitleLanguageOrder, fallbackSelectedSubtitleTrackIds: fallbackSubtitleTrackIds, fallbackSelectedSubtitleTrackIdsOrdered: fallbackSubtitleTrackIdsOrdered, - hasExplicitVariantSelection: hasManualSubtitleVariantSelection + hasExplicitVariantSelection: hasExplicitManualSubtitleSelection }); const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIdsOrdered.length > 0 ? resolvedSubtitleSelection.subtitleTrackIdsOrdered - : (hasManualSubtitleVariantSelection + : (hasExplicitManualSubtitleSelection ? [] : (fallbackSubtitleTrackIdsOrdered.length > 0 ? fallbackSubtitleTrackIdsOrdered @@ -8796,6 +8927,7 @@ function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) { return { titleId: Number(encodeTitle?.id) || null, + subtitleSelectionMode: manualSubtitleSelectionMode, audioTrackIds, subtitleTrackIds, subtitleForcedTrackIndexes, @@ -8996,6 +9128,10 @@ function extractManualSelectionPayloadFromPlan(encodePlan) { const normalizedTitleId = normalizeReviewTitleId(selection.titleId ?? encodePlan?.encodeInputTitleId); return { titleId: normalizedTitleId || null, + subtitleSelectionMode: normalizeSubtitleSelectionMode( + selection.subtitleSelectionMode, + selection.subtitleVariantSelection ? 'variants' : 'track_ids' + ), audioTrackIds: normalizeTrackIdList(selection.audioTrackIds), subtitleTrackIds: normalizeTrackIdList(selection.subtitleTrackIds), subtitleTrackIdsOrdered: normalizeTrackIdSequence(selection.subtitleTrackIds, { dedupe: false }), @@ -10806,7 +10942,7 @@ class PipelineService extends EventEmitter { SELECT id, status, last_state, raw_path, rip_successful, makemkv_info_json, encode_plan_json, media_type, job_kind FROM jobs WHERE COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container') - AND status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', + AND status IN ('ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') ORDER BY updated_at ASC, id ASC `); @@ -11346,6 +11482,7 @@ class PipelineService extends EventEmitter { } const PIPELINE_INTERACTIVE_STATES = new Set([ 'ANALYZING', + 'METADATA_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'MEDIAINFO_CHECK' @@ -11470,6 +11607,7 @@ class PipelineService extends EventEmitter { ); const replaceableStates = new Set([ 'ANALYZING', + 'METADATA_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'READY_TO_START', @@ -12153,7 +12291,7 @@ class PipelineService extends EventEmitter { } const hasState = (state) => stateCounts.has(state); - const runningPriority = ['RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'ANALYZING', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']; + const runningPriority = ['RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'ANALYZING', 'METADATA_LOOKUP', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']; let nextState = null; for (const candidate of runningPriority) { if (hasState(candidate)) { @@ -14092,7 +14230,7 @@ class PipelineService extends EventEmitter { if (rawStatus === 'CANCELLED') { return 'CANCELLED'; } - if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'].includes(rawStatus)) { + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'].includes(rawStatus)) { return 'RUNNING'; } return normalizeSeriesEpisodeStatus(fallbackStatus, 'QUEUED'); @@ -15378,6 +15516,32 @@ class PipelineService extends EventEmitter { ? await historyService.getJobsByIds(queuedJobIds) : []; const queuedById = new Map(queuedRows.map((row) => [Number(row.id), row])); + const queuedChainNameById = new Map(); + const queuedChainIdsNeedingName = this.normalizeQueueChainIdList( + this.queueEntries + .filter((entry) => String(entry?.type || '').trim().toLowerCase() === 'chain') + .filter((entry) => !String(entry?.chainName || '').trim()) + .map((entry) => entry?.chainId) + ); + if (queuedChainIdsNeedingName.length > 0) { + const scriptChainService = require('./scriptChainService'); + try { + const chains = await scriptChainService.getChainsByIds(queuedChainIdsNeedingName); + for (const chain of (Array.isArray(chains) ? chains : [])) { + const chainId = Number(chain?.id); + const chainName = String(chain?.name || '').trim(); + if (!Number.isFinite(chainId) || chainId <= 0 || !chainName) { + continue; + } + queuedChainNameById.set(Math.trunc(chainId), chainName); + } + } catch (error) { + logger.warn('queue:snapshot:chain-name-resolve-failed', { + chainIds: queuedChainIdsNeedingName, + error: errorToMeta(error) + }); + } + } const scriptMetaByJobId = new Map(); const resolveRunningSeriesEpisodeSubtitle = (job) => { if (!this.isSeriesBatchParentQueueAnchor(job)) { @@ -15501,13 +15665,19 @@ class PipelineService extends EventEmitter { }; } if (entryType === 'chain') { + const chainId = Number(entry.chainId); + const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null; + const resolvedChainName = String(entry.chainName || '').trim() + || (normalizedChainId ? queuedChainNameById.get(normalizedChainId) : null) + || null; return { ...base, - chainId: entry.chainId, + chainId: normalizedChainId, + chainName: resolvedChainName, sourceJobId: Number.isFinite(sourceJobId) && sourceJobId > 0 ? Math.trunc(sourceJobId) : null, sourcePhase: sourcePhaseLabel, subtitle: sourceSubtitle, - title: entry.chainName || `Kette #${entry.chainId}`, + title: resolvedChainName || `Kette #${normalizedChainId || '-'}`, status: 'QUEUED' }; } @@ -15630,7 +15800,9 @@ class PipelineService extends EventEmitter { } await this.emitQueueChanged(); - void this.pumpQueue(); + // Manual script/chain/wait entries must stay queued first so users can + // reorder/remove them before execution. They are picked up by later queue + // pump cycles (e.g. after running work finishes or another queue trigger). return { entryId: entry.id, type, position: this.queueEntries.indexOf(entry) + 1 }; } @@ -16062,7 +16234,9 @@ class PipelineService extends EventEmitter { runningJobIds, sourceJobTerminalCache }); - if (nonJobReady) { + // Non-job queue entries run only when there is no active processing. + // This keeps them properly queued instead of running in parallel. + if (nonJobReady && this.activeProcesses.size === 0 && totalRunning === 0) { entryIndex = i; break; } @@ -16294,7 +16468,7 @@ class PipelineService extends EventEmitter { shouldSuspendDrivePollingForState(state, context = null) { const normalizedState = String(state || '').trim().toUpperCase(); - const DRIVE_ACTIVE_STATES = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']); + const DRIVE_ACTIVE_STATES = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']); if (DRIVE_ACTIVE_STATES.has(normalizedState)) { return true; } @@ -16593,7 +16767,7 @@ class PipelineService extends EventEmitter { previousJobId }); - if (previousJobId && (previousState === 'METADATA_SELECTION' || previousState === 'READY_TO_START' || previousState === 'WAITING_FOR_USER_DECISION')) { + if (previousJobId && (previousState === 'METADATA_LOOKUP' || previousState === 'METADATA_SELECTION' || previousState === 'READY_TO_START' || previousState === 'WAITING_FOR_USER_DECISION')) { await historyService.updateJob(previousJobId, { status: 'ERROR', last_state: 'ERROR', @@ -17221,13 +17395,9 @@ class PipelineService extends EventEmitter { try { let effectiveDetectedTitle = detectedTitle; let metadataCandidates = []; - let metadataProvider = 'tmdb'; - let seriesAnalysis = null; - let seriesLookupHint = null; - let seriesDecision = null; + const metadataProvider = 'tmdb'; + let workflowKind = null; let pluginExecution = null; - const effectiveSeriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile).catch(() => ({})); - const seriesHeuristicOptions = buildSeriesHeuristicOptionsFromSettings(effectiveSeriesSettings); if (analyzePlugin) { const pluginContext = await this.buildPluginContext(analyzePlugin.id, { @@ -17258,161 +17428,27 @@ class PipelineService extends EventEmitter { } } - if ( - isSeriesDiscMediaProfile(mediaProfile) - && analyzePlugin - && ['dvd', 'bluray'].includes(analyzePlugin.id) - ) { - try { - const rawMedia = collectRawMediaCandidates(resolvedRawPath); - const seriesScanInputPath = mediaProfile === 'dvd' - ? (resolveDvdScanInputPathFromRaw(resolvedRawPath, rawMedia) || resolvedRawPath) - : resolvedRawPath; - const reviewResult = await this.runPluginReviewScan(analyzePlugin, sourceJob, { - jobId: normalizedJobId, - rawPath: seriesScanInputPath, - reviewMode: 'rip', - source: 'HANDBRAKE_SCAN', - silent: true - }); - pluginExecution = this.mergePluginExecutionState(pluginExecution, reviewResult?.pluginExecution || null); - - const scanText = Array.isArray(reviewResult?.scanAnalysisLines) && reviewResult.scanAnalysisLines.length > 0 - ? reviewResult.scanAnalysisLines.join('\n') - : (Array.isArray(reviewResult?.scanLines) ? reviewResult.scanLines.join('\n') : ''); - if (scanText) { - const { DVDSeriesPlugin } = require('../plugins/DVDSeriesPlugin'); - const dvdSeriesPlugin = new DVDSeriesPlugin(); - const dvdSeriesContext = await this.buildPluginContext(dvdSeriesPlugin.id, { - discInfo: { - ...deviceWithProfile, - path: seriesScanInputPath - }, - jobId: normalizedJobId, - scanText, - detectedTitle: effectiveDetectedTitle, - seriesOptions: seriesHeuristicOptions - }); - const seriesPluginResult = await dvdSeriesPlugin.analyze(seriesScanInputPath, sourceJob, dvdSeriesContext); - pluginExecution = this.mergePluginExecutionState( - pluginExecution, - this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) - ); - const scanJson = parseMediainfoJsonOutput( - Array.isArray(reviewResult?.scanLines) - ? reviewResult.scanLines.join('\n') - : '' - ); - const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, seriesHeuristicOptions); - seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( - seriesPluginResult?.seriesAnalysis || null, - structuredSeriesAnalysis - ); - if (shouldRunMakeMkvSeriesSecondChance( - seriesAnalysis, - scanJson, - reviewResult?.scanAnalysisLines || reviewResult?.scanLines || null - )) { - const secondChance = await this.runMakeMkvSeriesSecondChance({ - jobId: normalizedJobId, - mediaProfile, - rawPath: seriesScanInputPath, - seriesOptions: seriesHeuristicOptions, - silent: true - }); - if (secondChance?.structuredSeriesAnalysis) { - seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( - seriesAnalysis, - secondChance.structuredSeriesAnalysis - ); - logger.info('dvd-series:analyze:makemkv-second-chance', { - jobId: normalizedJobId, - source: 'raw_import', - titleCount: Number(secondChance?.structuredSeriesAnalysis?.summary?.titleCount || 0) || 0, - episodeCandidateCount: Number(secondChance?.structuredSeriesAnalysis?.summary?.episodeCandidateCount || 0) || 0, - seriesLike: Boolean(secondChance?.structuredSeriesAnalysis?.summary?.seriesLike) - }); - } - } - seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; - const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' - ? seriesAnalysis.summary - : null; - seriesDecision = deriveSeriesDecisionFromSummary(rawSeriesSummary, seriesLookupHint, seriesHeuristicOptions); - const seriesLike = Boolean(seriesDecision?.seriesLike); - const hasSeriesLookupHint = Boolean(seriesDecision?.strongLookupHint); - seriesAnalysis = rawSeriesSummary - ? { - source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', - generatedAt: seriesAnalysis?.generatedAt || nowIso(), - summary: { - seriesLike, - confidence: String(seriesDecision?.confidence || rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', - reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], - titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0, - episodeCandidateCount: Number(rawSeriesSummary?.episodeCandidateCount || 0) || 0, - playAllCount: Number(rawSeriesSummary?.playAllCount || 0) || 0, - playAllCandidateSeconds: Number(rawSeriesSummary?.playAllCandidateSeconds || 0) || 0, - playAllWithinTolerance: Boolean(rawSeriesSummary?.playAllWithinTolerance), - episodeSumSeconds: Number(rawSeriesSummary?.episodeSumSeconds || 0) || 0, - playAllLowerBoundSeconds: Number(rawSeriesSummary?.playAllLowerBoundSeconds || 0) || 0, - playAllUpperBoundSeconds: Number(rawSeriesSummary?.playAllUpperBoundSeconds || 0) || 0, - longestOutsideClusterSeconds: Number(rawSeriesSummary?.longestOutsideClusterSeconds || 0) || 0, - duplicateCount: Number(rawSeriesSummary?.duplicateCount || 0) || 0, - extraCount: Number(rawSeriesSummary?.extraCount || 0) || 0, - typicalEpisodeMinutes: Number(rawSeriesSummary?.typicalEpisodeMinutes || 0) || 0 - } - } - : null; - - if (seriesLike || hasSeriesLookupHint) { - metadataProvider = 'tmdb'; - metadataCandidates = []; - const fallbackQuery = String(seriesLookupHint?.query || effectiveDetectedTitle || '').trim() || null; - if (fallbackQuery) { - seriesLookupHint = { - ...(seriesLookupHint && typeof seriesLookupHint === 'object' ? seriesLookupHint : {}), - query: fallbackQuery, - seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery - }; - } - } - - logger.info('dvd-series:analyze:done', { - jobId: normalizedJobId, - seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike), - confidence: seriesAnalysis?.summary?.confidence || null, - seriesSource: seriesAnalysis?.source || null, - metadataProvider, - metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, - seriesLookupHint, - seriesDecision, - source: 'raw_import' - }); - } - } catch (error) { - logger.warn('dvd-series:analyze:raw-import:fallback-legacy', { - jobId: normalizedJobId, - error: errorToMeta(error) - }); - } - } if (isSeriesDiscMediaProfile(mediaProfile)) { - if (!seriesDecision) { - seriesDecision = deriveSeriesDecisionFromSummary( - seriesAnalysis?.summary || null, - seriesLookupHint, - seriesHeuristicOptions - ); - } - await appendSeriesHeuristicDecisionLog({ - jobId: normalizedJobId, - mediaProfile, - seriesAnalysis, - seriesDecision, - seriesLookupHint, - seriesHeuristicOptions + await historyService.updateJob(normalizedJobId, { + status: 'METADATA_LOOKUP', + last_state: 'METADATA_LOOKUP' }); + await this.setState('METADATA_LOOKUP', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: 'TMDb-Metadaten werden gesucht', + context: { + jobId: normalizedJobId, + detectedTitle: effectiveDetectedTitle, + mediaProfile, + rawPath: resolvedRawPath, + rawImport: true, + metadataProvider + } + }); + metadataCandidates = await this.searchTmdbMixedMetadata(effectiveDetectedTitle, {}); + workflowKind = deriveWorkflowKindFromMetadataCandidates(metadataCandidates); } const prepareInfo = this.withPluginExecutionMeta( @@ -17429,12 +17465,12 @@ class PipelineService extends EventEmitter { playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, - workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase(), + workflowKind: workflowKind || null, metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], - seriesAnalysis, - seriesLookupHint, - seriesDecision + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null } }, mediaProfile), pluginExecution @@ -17452,11 +17488,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( normalizedJobId, 'SYSTEM', - String(seriesDecision?.classification || '').trim().toLowerCase() === 'series' - ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).` - : (String(seriesDecision?.classification || '').trim().toLowerCase() === 'ambiguous' - ? `${resolveSeriesDiscLabel(mediaProfile)} uneindeutig erkannt (Serie/Film). Bitte Workflow auswählen und Metadaten zuordnen (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}").` - : `Film-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}").`) + `${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Film- und Serien-Suche abgeschlossen (Suchvorschlag: "${effectiveDetectedTitle}").` ); await this.setState('METADATA_SELECTION', { @@ -17471,10 +17503,10 @@ class PipelineService extends EventEmitter { metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], mediaProfile, - workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase(), - seriesAnalysis, - seriesLookupHint, - seriesDecision, + workflowKind: workflowKind || null, + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], @@ -17500,10 +17532,10 @@ class PipelineService extends EventEmitter { detectedTitle: effectiveDetectedTitle, metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], - seriesAnalysis, - seriesLookupHint, - seriesDecision, - workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase() + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null, + workflowKind: workflowKind || null }; } catch (error) { logger.error('metadata:prepare:raw-import:failed', { jobId: normalizedJobId, error: errorToMeta(error) }); @@ -17645,13 +17677,9 @@ class PipelineService extends EventEmitter { try { let effectiveDetectedTitle = detectedTitle; let metadataCandidates = []; - let metadataProvider = 'tmdb'; - let seriesAnalysis = null; - let seriesLookupHint = null; - let seriesDecision = null; + const metadataProvider = 'tmdb'; + let workflowKind = null; let pluginExecution = null; - const effectiveSeriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile).catch(() => ({})); - const seriesHeuristicOptions = buildSeriesHeuristicOptionsFromSettings(effectiveSeriesSettings); if (analyzePlugin) { const pluginContext = await this.buildPluginContext(analyzePlugin.id, { discInfo: deviceWithProfile, @@ -17679,153 +17707,26 @@ class PipelineService extends EventEmitter { }); } } - if ( - isSeriesDiscMediaProfile(mediaProfile) - && analyzePlugin - && ['dvd', 'bluray'].includes(analyzePlugin.id) - ) { - try { - const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, { - jobId: job.id, - deviceInfo: deviceWithProfile, - reviewMode: 'pre_rip', - source: 'HANDBRAKE_SCAN', - silent: true - }); - pluginExecution = this.mergePluginExecutionState(pluginExecution, reviewResult?.pluginExecution || null); - - const scanText = Array.isArray(reviewResult?.scanAnalysisLines) && reviewResult.scanAnalysisLines.length > 0 - ? reviewResult.scanAnalysisLines.join('\n') - : (Array.isArray(reviewResult?.scanLines) ? reviewResult.scanLines.join('\n') : ''); - if (scanText) { - const { DVDSeriesPlugin } = require('../plugins/DVDSeriesPlugin'); - const dvdSeriesPlugin = new DVDSeriesPlugin(); - const dvdSeriesContext = await this.buildPluginContext(dvdSeriesPlugin.id, { - discInfo: deviceWithProfile, - jobId: job.id, - scanText, - detectedTitle: effectiveDetectedTitle, - seriesOptions: seriesHeuristicOptions - }); - const seriesPluginResult = await dvdSeriesPlugin.analyze(device.path, job, dvdSeriesContext); - pluginExecution = this.mergePluginExecutionState( - pluginExecution, - this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) - ); - const scanJson = parseMediainfoJsonOutput( - Array.isArray(reviewResult?.scanLines) - ? reviewResult.scanLines.join('\n') - : '' - ); - const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, seriesHeuristicOptions); - seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( - seriesPluginResult?.seriesAnalysis || null, - structuredSeriesAnalysis - ); - if (shouldRunMakeMkvSeriesSecondChance( - seriesAnalysis, - scanJson, - reviewResult?.scanAnalysisLines || reviewResult?.scanLines || null - )) { - const secondChance = await this.runMakeMkvSeriesSecondChance({ - jobId: job.id, - mediaProfile, - deviceInfo: deviceWithProfile, - seriesOptions: seriesHeuristicOptions, - silent: true - }); - if (secondChance?.structuredSeriesAnalysis) { - seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( - seriesAnalysis, - secondChance.structuredSeriesAnalysis - ); - logger.info('dvd-series:analyze:makemkv-second-chance', { - jobId: job.id, - source: 'drive_analyze', - titleCount: Number(secondChance?.structuredSeriesAnalysis?.summary?.titleCount || 0) || 0, - episodeCandidateCount: Number(secondChance?.structuredSeriesAnalysis?.summary?.episodeCandidateCount || 0) || 0, - seriesLike: Boolean(secondChance?.structuredSeriesAnalysis?.summary?.seriesLike) - }); - } - } - seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; - const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' - ? seriesAnalysis.summary - : null; - seriesDecision = deriveSeriesDecisionFromSummary(rawSeriesSummary, seriesLookupHint, seriesHeuristicOptions); - const seriesLike = Boolean(seriesDecision?.seriesLike); - const hasSeriesLookupHint = Boolean(seriesDecision?.strongLookupHint); - seriesAnalysis = rawSeriesSummary - ? { - source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', - generatedAt: seriesAnalysis?.generatedAt || nowIso(), - summary: { - seriesLike, - confidence: String(seriesDecision?.confidence || rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', - reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], - titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0, - episodeCandidateCount: Number(rawSeriesSummary?.episodeCandidateCount || 0) || 0, - playAllCount: Number(rawSeriesSummary?.playAllCount || 0) || 0, - playAllCandidateSeconds: Number(rawSeriesSummary?.playAllCandidateSeconds || 0) || 0, - playAllWithinTolerance: Boolean(rawSeriesSummary?.playAllWithinTolerance), - episodeSumSeconds: Number(rawSeriesSummary?.episodeSumSeconds || 0) || 0, - playAllLowerBoundSeconds: Number(rawSeriesSummary?.playAllLowerBoundSeconds || 0) || 0, - playAllUpperBoundSeconds: Number(rawSeriesSummary?.playAllUpperBoundSeconds || 0) || 0, - longestOutsideClusterSeconds: Number(rawSeriesSummary?.longestOutsideClusterSeconds || 0) || 0, - duplicateCount: Number(rawSeriesSummary?.duplicateCount || 0) || 0, - extraCount: Number(rawSeriesSummary?.extraCount || 0) || 0, - typicalEpisodeMinutes: Number(rawSeriesSummary?.typicalEpisodeMinutes || 0) || 0 - } - } - : null; - - if (seriesLike || hasSeriesLookupHint) { - metadataProvider = 'tmdb'; - metadataCandidates = []; - const fallbackQuery = String(seriesLookupHint?.query || effectiveDetectedTitle || '').trim() || null; - if (fallbackQuery) { - seriesLookupHint = { - ...(seriesLookupHint && typeof seriesLookupHint === 'object' ? seriesLookupHint : {}), - query: fallbackQuery, - seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery - }; - } - } - - logger.info('dvd-series:analyze:done', { - jobId: job.id, - seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike), - confidence: seriesAnalysis?.summary?.confidence || null, - seriesSource: seriesAnalysis?.source || null, - metadataProvider, - metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, - seriesLookupHint, - seriesDecision - }); - } - } catch (error) { - logger.warn('dvd-series:analyze:fallback-legacy', { - jobId: job.id, - error: errorToMeta(error) - }); - } - } if (isSeriesDiscMediaProfile(mediaProfile)) { - if (!seriesDecision) { - seriesDecision = deriveSeriesDecisionFromSummary( - seriesAnalysis?.summary || null, - seriesLookupHint, - seriesHeuristicOptions - ); - } - await appendSeriesHeuristicDecisionLog({ - jobId: job.id, - mediaProfile, - seriesAnalysis, - seriesDecision, - seriesLookupHint, - seriesHeuristicOptions + await historyService.updateJob(job.id, { + status: 'METADATA_LOOKUP', + last_state: 'METADATA_LOOKUP' }); + await this.setState('METADATA_LOOKUP', { + activeJobId: job.id, + progress: 0, + eta: null, + statusText: 'TMDb-Metadaten werden gesucht', + context: { + jobId: job.id, + device: deviceWithProfile, + detectedTitle: effectiveDetectedTitle, + mediaProfile, + metadataProvider + } + }); + metadataCandidates = await this.searchTmdbMixedMetadata(effectiveDetectedTitle, {}); + workflowKind = deriveWorkflowKindFromMetadataCandidates(metadataCandidates); } logger.info('metadata:prepare:result', { jobId: job.id, @@ -17843,12 +17744,12 @@ class PipelineService extends EventEmitter { playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, - workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase(), + workflowKind: workflowKind || null, metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], - seriesAnalysis, - seriesLookupHint, - seriesDecision + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null } }, mediaProfile), pluginExecution @@ -17863,11 +17764,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( job.id, 'SYSTEM', - String(seriesDecision?.classification || '').trim().toLowerCase() === 'series' - ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).` - : (String(seriesDecision?.classification || '').trim().toLowerCase() === 'ambiguous' - ? `${resolveSeriesDiscLabel(mediaProfile)} uneindeutig erkannt (Serie/Film). Bitte Workflow auswählen und Metadaten zuordnen (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}").` - : `Film-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}").`) + `${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Film- und Serien-Suche abgeschlossen (Suchvorschlag: "${effectiveDetectedTitle}").` ); const keepCurrentPipelineSession = this._hasForeignInteractiveSession(job.id); @@ -17887,10 +17784,10 @@ class PipelineService extends EventEmitter { metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], mediaProfile, - workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase(), - seriesAnalysis, - seriesLookupHint, - seriesDecision, + workflowKind: workflowKind || null, + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], @@ -17918,10 +17815,10 @@ class PipelineService extends EventEmitter { detectedTitle: effectiveDetectedTitle, metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], - seriesAnalysis, - seriesLookupHint, - seriesDecision, - workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase() + seriesAnalysis: null, + seriesLookupHint: null, + seriesDecision: null, + workflowKind: workflowKind || null }; } catch (error) { logger.error('metadata:prepare:failed', { jobId: job.id, error: errorToMeta(error) }); @@ -18128,6 +18025,57 @@ class PipelineService extends EventEmitter { return []; } + async searchTmdbMixedMetadata(query, options = {}) { + const rawQuery = String(query || '').trim(); + if (!rawQuery) { + return []; + } + const normalizedSeasonNumber = normalizePositiveInteger(options?.seasonNumber); + const [movieResult, seriesResult] = await Promise.allSettled([ + this.searchTmdbMovies(rawQuery), + this.searchTmdbSeries(rawQuery, normalizedSeasonNumber || null) + ]); + + if (movieResult.status === 'rejected' && seriesResult.status === 'rejected') { + throw movieResult.reason || seriesResult.reason; + } + + const movieRows = movieResult.status === 'fulfilled' && Array.isArray(movieResult.value) + ? movieResult.value + : []; + const seriesRows = seriesResult.status === 'fulfilled' && Array.isArray(seriesResult.value) + ? seriesResult.value + : []; + + const mixed = [ + ...movieRows.map((row) => normalizeMetadataCandidateRow(row, 'movie')), + ...seriesRows.map((row) => normalizeMetadataCandidateRow(row, 'series')) + ]; + const deduped = []; + const seen = new Set(); + for (const row of mixed) { + const rowType = normalizeMetadataCandidateType(row, null); + const seasonNumberKey = rowType === 'series' ? (normalizePositiveInteger(row?.seasonNumber) || 'none') : 'none'; + const key = String( + row?.providerId + || `${rowType}:${String(row?.tmdbId || '').trim()}:${seasonNumberKey}:${String(row?.title || '').trim().toLowerCase()}` + ).trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(row); + } + + logger.info('tmdb:mixed-search:done', { + query: rawQuery, + movieCount: movieRows.length, + seriesCount: seriesRows.length, + totalCount: deduped.length + }); + return deduped; + } + async runDiscTrackReviewForJob(jobId, deviceInfo = null, options = {}) { this.ensureNotBusy('runDiscTrackReviewForJob', jobId); logger.info('disc-track-review:start', { jobId, deviceInfo, options }); @@ -18401,7 +18349,7 @@ class PipelineService extends EventEmitter { const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze) ? (options?.selectedPlaylist || null) : (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null); - const selectedPlaylistId = normalizePlaylistId( + let selectedPlaylistId = normalizePlaylistId( selectedPlaylistSource ); const selectedTitleSource = (forcePlaylistReselection || forceFreshAnalyze) @@ -18526,13 +18474,13 @@ class PipelineService extends EventEmitter { const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired); let playlistCandidates = buildPlaylistCandidates(playlistAnalysis); - const selectedTitleFromPlaylist = selectedPlaylistId + let selectedTitleFromPlaylist = selectedPlaylistId ? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId) : null; - const selectedTitleFromContext = (!selectedPlaylistId && !isCandidateTitleId(playlistAnalysis, selectedMakemkvTitleId)) + const selectedTitleFromContextFallback = (!selectedPlaylistId && !isCandidateTitleId(playlistAnalysis, selectedMakemkvTitleId)) ? null : selectedMakemkvTitleId; - const selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContext ?? null; + let selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContextFallback ?? null; if (selectedPlaylistId && playlistCandidates.length > 0) { const isKnownPlaylist = playlistCandidates.some((item) => item.playlistId === selectedPlaylistId); if (!isKnownPlaylist) { @@ -18622,6 +18570,39 @@ class PipelineService extends EventEmitter { } } + let selectedPlaylistByRuntimeAutoMatch = false; + const shouldTryRuntimeAutoMatch = Boolean( + playlistDecisionRequired + && !selectedPlaylistId + && !isSeriesBackupReview + && isSeriesDiscMediaProfile(mediaProfile) + && playlistCandidates.length > 1 + ); + if (shouldTryRuntimeAutoMatch) { + const runtimeAutoMatch = resolvePlaylistByRuntimeAutoMatch({ + playlistCandidates, + selectedMetadata + }); + if (runtimeAutoMatch?.playlistId) { + selectedPlaylistId = runtimeAutoMatch.playlistId; + selectedTitleFromPlaylist = pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId); + selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContextFallback ?? null; + selectedPlaylistByRuntimeAutoMatch = true; + await historyService.appendLog( + jobId, + 'SYSTEM', + `TMDb Runtime-Abgleich: ${runtimeAutoMatch.runtimeMinutes} min, Toleranz ±02:00.` + ); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Automatische Playlist-Auswahl via Runtime-Match: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}` + + ` (Dauer ${formatDurationClock(runtimeAutoMatch.durationSeconds) || `${runtimeAutoMatch.durationSeconds}s`},` + + ` Delta ${runtimeAutoMatch.deltaSeconds}s).` + ); + } + } + const updatedMakemkvInfoBase = { ...mkInfo, analyzeContext: { @@ -18766,6 +18747,24 @@ class PipelineService extends EventEmitter { ); } + let seriesSinglePlayAllFallback = null; + if (normalizedSeriesPreselection.length === 0) { + seriesSinglePlayAllFallback = resolveSeriesSinglePlayAllFallback( + seriesCandidates, + allSeriesTitles, + selectedMetadata + ); + if (seriesSinglePlayAllFallback?.applied && Array.isArray(seriesSinglePlayAllFallback.candidates)) { + seriesCandidates = seriesSinglePlayAllFallback.candidates; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Sonderfall erkannt (ein PlayAll-Titel + Kurzclips): -t ${seriesSinglePlayAllFallback.keptTitleId} ` + + `behalten, ${seriesSinglePlayAllFallback.removedCount || 0} Kurzclips ausgeblendet.` + ); + } + } + const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( seriesCandidates, allSeriesTitles @@ -18911,6 +18910,10 @@ class PipelineService extends EventEmitter { notes: [ ...(Array.isArray(review.notes) ? review.notes : []), 'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.', + ...(seriesSinglePlayAllFallback?.applied ? [ + `Serien-Sonderfall: Ein langer PlayAll-Titel wurde automatisch gewählt (Titel #${seriesSinglePlayAllFallback.keptTitleId}); ` + + `${seriesSinglePlayAllFallback.removedCount || 0} kurze Zusatz-Titel wurden ausgeblendet.` + ] : []), `HandBrake Serien-Heuristik aktiv: ${resolvedSelectedTitleIds.length} Episoden-Titel aus --scan -t 0 übernommen.` ] }; @@ -19179,8 +19182,10 @@ class PipelineService extends EventEmitter { if (selectedPlaylistId) { await historyService.appendLog( jobId, - 'USER_ACTION', - `Playlist-Auswahl übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.` + selectedPlaylistByRuntimeAutoMatch ? 'SYSTEM' : 'USER_ACTION', + selectedPlaylistByRuntimeAutoMatch + ? `Playlist-Auswahl automatisch übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.` + : `Playlist-Auswahl übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.` ); } @@ -22098,49 +22103,6 @@ class PipelineService extends EventEmitter { } await resetProcessLogIfLifecycleAllows(jobId, job); - try { - const startAnalyzeContext = jobMakemkvInfo?.analyzeContext && typeof jobMakemkvInfo.analyzeContext === 'object' - ? jobMakemkvInfo.analyzeContext - : {}; - if (isSeriesDiscMediaProfile(jobMediaProfile)) { - const effectiveSeriesSettings = await settingsService.getEffectiveSettingsMap(jobMediaProfile).catch(() => ({})); - const seriesHeuristicOptions = buildSeriesHeuristicOptionsFromSettings(effectiveSeriesSettings); - const startSeriesAnalysis = startAnalyzeContext?.seriesAnalysis && typeof startAnalyzeContext.seriesAnalysis === 'object' - ? startAnalyzeContext.seriesAnalysis - : null; - const startSeriesLookupHint = startAnalyzeContext?.seriesLookupHint && typeof startAnalyzeContext.seriesLookupHint === 'object' - ? startAnalyzeContext.seriesLookupHint - : null; - const startSeriesDecision = startAnalyzeContext?.seriesDecision && typeof startAnalyzeContext.seriesDecision === 'object' - ? startAnalyzeContext.seriesDecision - : deriveSeriesDecisionFromSummary( - startSeriesAnalysis?.summary || null, - startSeriesLookupHint, - seriesHeuristicOptions - ); - await appendSeriesHeuristicDecisionLog({ - jobId, - mediaProfile: jobMediaProfile, - seriesAnalysis: startSeriesAnalysis, - seriesDecision: startSeriesDecision, - seriesLookupHint: startSeriesLookupHint, - seriesHeuristicOptions - }); - } else { - await historyService.appendLog( - jobId, - 'SYSTEM', - `Heuristik Film/Serie: Für Profil "${String(jobMediaProfile || 'unbekannt')}" nicht anwendbar.` - ); - } - } catch (error) { - logger.warn('startPreparedJob:heuristic-log-failed', { - jobId, - mediaProfile: jobMediaProfile, - error: errorToMeta(error) - }); - } - if (this.isMultipartMovieMergeHistoryJob(job)) { return this.startMultipartMergeJob(jobId, { ...options, preloadedJob: job }); } @@ -22445,7 +22407,7 @@ class PipelineService extends EventEmitter { return { started: true, stage: 'ENCODING' }; } - if (statusUpper === 'METADATA_SELECTION') { + if (statusUpper === 'METADATA_LOOKUP' || statusUpper === 'METADATA_SELECTION') { await historyService.appendLog( jobId, 'SYSTEM', @@ -22625,6 +22587,10 @@ class PipelineService extends EventEmitter { || null; if (lockedEntry && typeof lockedEntry === 'object') { lockedTrackSelectionPayload[trackSelectionTitleId] = { + subtitleSelectionMode: normalizeSubtitleSelectionMode( + lockedEntry.subtitleSelectionMode, + lockedEntry.subtitleVariantSelection ? 'variants' : 'track_ids' + ), audioTrackIds: Array.isArray(lockedEntry.audioTrackIds) ? lockedEntry.audioTrackIds : [], subtitleTrackIds: Array.isArray(lockedEntry.subtitleTrackIds) ? lockedEntry.subtitleTrackIds : [], subtitleVariantSelection: lockedEntry.subtitleVariantSelection || {}, @@ -22638,6 +22604,10 @@ class PipelineService extends EventEmitter { }); if (fallbackSelection && typeof fallbackSelection === 'object') { lockedTrackSelectionPayload[trackSelectionTitleId] = { + subtitleSelectionMode: normalizeSubtitleSelectionMode( + fallbackSelection.subtitleSelectionMode, + fallbackSelection.subtitleVariantSelection ? 'variants' : 'track_ids' + ), audioTrackIds: Array.isArray(fallbackSelection.audioTrackIds) ? fallbackSelection.audioTrackIds : [], subtitleTrackIds: Array.isArray(fallbackSelection.subtitleTrackIds) ? fallbackSelection.subtitleTrackIds : [], subtitleVariantSelection: fallbackSelection.subtitleVariantSelection || {}, @@ -23014,7 +22984,7 @@ class PipelineService extends EventEmitter { throw error; } - if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(sourceJob.status)) { + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(sourceJob.status)) { const error = new Error('Re-Encode nicht möglich: Quelljob ist noch aktiv.'); error.statusCode = 409; throw error; @@ -23390,7 +23360,7 @@ class PipelineService extends EventEmitter { const sourceJob = resolvedCdReviewJob.job || await historyService.getJobById(jobId); const currentStatus = String(sourceJob.status || '').trim().toUpperCase(); - if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(currentStatus)) { + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(currentStatus)) { const error = new Error(`CD-Vorprüfung nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); error.statusCode = 409; throw error; @@ -27070,6 +27040,7 @@ class PipelineService extends EventEmitter { : (isAudiobookRetry ? 'READY_TO_START' : (isMultipartMergeRetry ? 'READY_TO_START' : 'RIPPING')); + const createNewJob = Boolean(options?.createNewJob); const retryParentJobId = isMultipartMergeRetry ? mergeContainerJobId : this.normalizeQueueJobId(jobId); @@ -27087,17 +27058,22 @@ class PipelineService extends EventEmitter { }); } - const retryJob = await historyService.createJob({ - discDevice: sourceJob.disc_device || null, - status: retryInitialStatus, - detectedTitle: sourceJob.detected_title || sourceJob.title || null, - jobKind: this.resolveJobKindForJob(sourceJob, { - encodePlan: sourceEncodePlan - }) - }); - const retryJobId = Number(retryJob?.id || 0); - if (!Number.isFinite(retryJobId) || retryJobId <= 0) { - throw new Error('Retry fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + let retryJobId = Number(jobId); + let replacedSourceJob = false; + if (createNewJob) { + const retryJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: retryInitialStatus, + detectedTitle: sourceJob.detected_title || sourceJob.title || null, + jobKind: this.resolveJobKindForJob(sourceJob, { + encodePlan: sourceEncodePlan + }) + }); + retryJobId = Number(retryJob?.id || 0); + if (!Number.isFinite(retryJobId) || retryJobId <= 0) { + throw new Error('Retry fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); + } + replacedSourceJob = true; } let retryRawPath = isAudiobookRetry ? (sourceJob.raw_path || null) : null; @@ -27109,7 +27085,7 @@ class PipelineService extends EventEmitter { || null ) : null; - if (retryRawPath) { + if (retryRawPath && replacedSourceJob) { const previousRetryRawPath = retryRawPath; retryRawPath = await this.alignRawFolderJobId(retryRawPath, retryJobId); if ( @@ -27126,7 +27102,6 @@ class PipelineService extends EventEmitter { } const retryUpdatePayload = { - parent_job_id: retryParentJobId ? Number(retryParentJobId) : null, media_type: preservedRetryMediaType, title: sourceJob.title || null, year: sourceJob.year ?? null, @@ -27148,10 +27123,13 @@ class PipelineService extends EventEmitter { status: retryInitialStatus, last_state: retryInitialStatus }; + if (replacedSourceJob) { + retryUpdatePayload.parent_job_id = retryParentJobId ? Number(retryParentJobId) : null; + } await historyService.updateJob(retryJobId, retryUpdatePayload); // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist - if (thumbnailService.isLocalUrl(sourceJob.poster_url)) { + if (replacedSourceJob && thumbnailService.isLocalUrl(sourceJob.poster_url)) { const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), retryJobId); if (copiedUrl) { await historyService.updateJob(retryJobId, { poster_url: copiedUrl }).catch(() => {}); @@ -27161,17 +27139,19 @@ class PipelineService extends EventEmitter { await historyService.appendLog( retryJobId, 'USER_ACTION', - `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : (isMultipartMergeRetry ? 'Merge' : 'Disc'))}).` + `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : (isMultipartMergeRetry ? 'Merge' : 'Disc'))}).${replacedSourceJob ? ' Neuer Job wurde angelegt.' : ' Bestehender Job wird weiterverwendet.'}` ); if (preservedRetryMediaType && !explicitSourceMediaType) { await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {}); } - this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' }); - await historyService.retireJobInFavorOf(jobId, retryJobId, { - reason: isCdRetry - ? 'cd_retry' - : (isAudiobookRetry ? 'audiobook_retry' : (isMultipartMergeRetry ? 'merge_retry' : 'retry')) - }); + if (replacedSourceJob) { + this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' }); + await historyService.retireJobInFavorOf(jobId, retryJobId, { + reason: isCdRetry + ? 'cd_retry' + : (isAudiobookRetry ? 'audiobook_retry' : (isMultipartMergeRetry ? 'merge_retry' : 'retry')) + }); + } this.cancelRequestedByJob.delete(retryJobId); if (isCdRetry) { @@ -27186,7 +27166,7 @@ class PipelineService extends EventEmitter { return { jobId: retryJobId, sourceJobId: Number(jobId), - replacedSourceJob: true, + replacedSourceJob, started: false, queued: false, stage: 'READY_TO_START' @@ -27213,7 +27193,7 @@ class PipelineService extends EventEmitter { started: true, sourceJobId: Number(jobId), jobId: retryJobId, - replacedSourceJob: true + replacedSourceJob }; } @@ -27365,11 +27345,12 @@ class PipelineService extends EventEmitter { ]); const isAutoRecoveryTrigger = AUTO_RECOVERY_TRIGGER_REASONS.has(triggerReason); const preserveJobIdOnAutoRecovery = isAutoRecoveryTrigger; + const shouldCreateReplacementJob = !preserveJobIdOnAutoRecovery && Boolean(options?.createNewJob); const job = resolvedRestartEncodeJob.job || await historyService.getJobById(jobId); const currentStatus = String(job.status || '').trim().toUpperCase(); - if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK'].includes(currentStatus)) { + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK'].includes(currentStatus)) { const error = new Error(`Encode-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); error.statusCode = 409; throw error; @@ -27636,7 +27617,7 @@ class PipelineService extends EventEmitter { let replacementJobId = Number(jobId); let replacedSourceJob = false; - if (!preserveJobIdOnAutoRecovery) { + if (shouldCreateReplacementJob) { const replacementJob = await historyService.createJob({ discDevice: job.disc_device || null, status: 'READY_TO_ENCODE', @@ -27896,7 +27877,7 @@ class PipelineService extends EventEmitter { : existingEncodeInputPath; const currentStatus = String(sourceJob.status || '').trim().toUpperCase(); - if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(currentStatus)) { + if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(currentStatus)) { const error = new Error(`Review-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); error.statusCode = 409; throw error; @@ -27964,7 +27945,9 @@ class PipelineService extends EventEmitter { raw_path: resolvedReviewRawPath }; - const reuseCurrentJob = Boolean(options?.reuseCurrentJob); + const reuseCurrentJob = Object.prototype.hasOwnProperty.call(options || {}, 'reuseCurrentJob') + ? Boolean(options?.reuseCurrentJob) + : !Boolean(options?.createNewJob); const inheritedSourceJobId = this.normalizeQueueJobId(previousEncodePlan?.sourceJobId) || this.normalizeQueueJobId(sourceJob.parent_job_id) || null; @@ -28224,6 +28207,7 @@ class PipelineService extends EventEmitter { const softCancelableChildStates = new Set([ 'READY_TO_START', 'READY_TO_ENCODE', + 'METADATA_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION' ]); @@ -28458,6 +28442,7 @@ class PipelineService extends EventEmitter { const SOFT_CANCELABLE_STATES = new Set([ 'READY_TO_START', 'READY_TO_ENCODE', + 'METADATA_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', @@ -30970,7 +30955,8 @@ class PipelineService extends EventEmitter { const sourceHasSuccessfulRawRip = Number(sourceJob?.rip_successful || 0) === 1 && Boolean(sourceResolvedRawPath); const sourceStatus = String(sourceJob.status || sourceJob.last_state || '').trim().toUpperCase(); - const shouldReplaceSourceJob = sourceStatus === 'CANCELLED' || sourceStatus === 'ERROR'; + const shouldReplaceSourceJob = Boolean(ripConfig?.createNewJob) + && (sourceStatus === 'CANCELLED' || sourceStatus === 'ERROR'); if (shouldReplaceSourceJob) { const replacementJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, @@ -32788,7 +32774,7 @@ class PipelineService extends EventEmitter { throw error; } const statusUpper = String(job.status || '').trim().toUpperCase(); - const allowedDraftStates = new Set(['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION']); + const allowedDraftStates = new Set(['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION']); if (!allowedDraftStates.has(statusUpper)) { const error = new Error('Draft kann nur für nicht gestartete oder geprüfte Converter-Jobs gespeichert werden.'); error.statusCode = 409; diff --git a/backend/src/services/scriptChainService.js b/backend/src/services/scriptChainService.js index 6293b86..b7154b2 100644 --- a/backend/src/services/scriptChainService.js +++ b/backend/src/services/scriptChainService.js @@ -5,6 +5,7 @@ const { spawnTrackedProcess } = require('./processRunner'); const { errorToMeta } = require('../utils/errorMeta'); const CHAIN_NAME_MAX_LENGTH = 120; +const CHAIN_DESCRIPTION_MAX_LENGTH = 50; const STEP_TYPE_SCRIPT = 'script'; const STEP_TYPE_WAIT = 'wait'; const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]); @@ -26,6 +27,31 @@ function createValidationError(message, details = null) { return error; } +function normalizeFavoriteFlag(rawValue, fallback = 0) { + if (typeof rawValue === 'boolean') { + return rawValue ? 1 : 0; + } + const parsed = Number(rawValue); + if (Number.isFinite(parsed)) { + return parsed !== 0 ? 1 : 0; + } + const normalized = String(rawValue || '').trim().toLowerCase(); + if (!normalized) { + return fallback ? 1 : 0; + } + if (['true', 'yes', 'on', '1'].includes(normalized)) { + return 1; + } + if (['false', 'no', 'off', '0'].includes(normalized)) { + return 0; + } + return fallback ? 1 : 0; +} + +function normalizeChainDescription(rawValue) { + return String(rawValue || '').trim(); +} + function mapChainRow(row, steps = []) { if (!row) { return null; @@ -33,6 +59,8 @@ function mapChainRow(row, steps = []) { return { id: Number(row.id), name: String(row.name || ''), + description: normalizeChainDescription(row.description), + isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1, orderIndex: Number(row.order_index || 0), steps: steps.map(mapStepRow), createdAt: row.created_at, @@ -164,7 +192,7 @@ class ScriptChainService { const db = await getDb(); const rows = await db.all( ` - SELECT id, name, order_index, created_at, updated_at + SELECT id, name, description, is_favorite, order_index, created_at, updated_at FROM script_chains ORDER BY order_index ASC, id ASC ` @@ -213,7 +241,7 @@ class ScriptChainService { } const db = await getDb(); const row = await db.get( - `SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id = ?`, + `SELECT id, name, description, is_favorite, order_index, created_at, updated_at FROM script_chains WHERE id = ?`, [normalizedId] ); if (!row) { @@ -235,7 +263,7 @@ class ScriptChainService { const db = await getDb(); const placeholders = ids.map(() => '?').join(', '); const rows = await db.all( - `SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`, + `SELECT id, name, description, is_favorite, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`, ids ); const stepRows = await db.all( @@ -268,12 +296,16 @@ class ScriptChainService { async createChain(payload = {}) { const body = payload && typeof payload === 'object' ? payload : {}; const name = String(body.name || '').trim(); + const description = normalizeChainDescription(body.description); if (!name) { throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]); } if (name.length > CHAIN_NAME_MAX_LENGTH) { throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]); } + if (description.length > CHAIN_DESCRIPTION_MAX_LENGTH) { + throw createValidationError('Beschreibung zu lang.', [{ field: 'description', message: `Maximal ${CHAIN_DESCRIPTION_MAX_LENGTH} Zeichen.` }]); + } const steps = validateSteps(body.steps); const db = await getDb(); @@ -281,10 +313,10 @@ class ScriptChainService { const nextOrderIndex = await this._getNextOrderIndex(db); const result = await db.run( ` - INSERT INTO script_chains (name, order_index, created_at, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO script_chains (name, description, is_favorite, order_index, created_at, updated_at) + VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `, - [name, nextOrderIndex] + [name, description || null, nextOrderIndex] ); const chainId = result.lastID; await this._saveSteps(db, chainId, steps); @@ -304,12 +336,16 @@ class ScriptChainService { } const body = payload && typeof payload === 'object' ? payload : {}; const name = String(body.name || '').trim(); + const description = normalizeChainDescription(body.description); if (!name) { throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]); } if (name.length > CHAIN_NAME_MAX_LENGTH) { throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]); } + if (description.length > CHAIN_DESCRIPTION_MAX_LENGTH) { + throw createValidationError('Beschreibung zu lang.', [{ field: 'description', message: `Maximal ${CHAIN_DESCRIPTION_MAX_LENGTH} Zeichen.` }]); + } const steps = validateSteps(body.steps); await this.getChainById(normalizedId); @@ -317,8 +353,8 @@ class ScriptChainService { const db = await getDb(); try { await db.run( - `UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - [name, normalizedId] + `UPDATE script_chains SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [name, description || null, normalizedId] ); await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]); await this._saveSteps(db, normalizedId, steps); @@ -342,6 +378,25 @@ class ScriptChainService { return existing; } + async setChainFavorite(chainId, isFavorite) { + const normalizedId = normalizeChainId(chainId); + if (!normalizedId) { + throw createValidationError('Ungültige chainId.'); + } + const favoriteFlag = normalizeFavoriteFlag(isFavorite, 0); + const db = await getDb(); + await this.getChainById(normalizedId); + await db.run( + ` + UPDATE script_chains + SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [favoriteFlag, normalizedId] + ); + return this.getChainById(normalizedId); + } + async reorderChains(orderedIds = []) { const providedIds = Array.isArray(orderedIds) ? orderedIds.map(normalizeChainId).filter(Boolean) diff --git a/backend/src/services/scriptService.js b/backend/src/services/scriptService.js index 74aa523..b92de08 100644 --- a/backend/src/services/scriptService.js +++ b/backend/src/services/scriptService.js @@ -72,6 +72,27 @@ function normalizeScriptBody(rawValue) { return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } +function normalizeFavoriteFlag(rawValue, fallback = 0) { + if (typeof rawValue === 'boolean') { + return rawValue ? 1 : 0; + } + const parsed = Number(rawValue); + if (Number.isFinite(parsed)) { + return parsed !== 0 ? 1 : 0; + } + const normalized = String(rawValue || '').trim().toLowerCase(); + if (!normalized) { + return fallback ? 1 : 0; + } + if (['true', 'yes', 'on', '1'].includes(normalized)) { + return 1; + } + if (['false', 'no', 'off', '0'].includes(normalized)) { + return 0; + } + return fallback ? 1 : 0; +} + function createValidationError(message, details = null) { const error = new Error(message); error.statusCode = 400; @@ -125,6 +146,7 @@ function mapScriptRow(row) { id: Number(row.id), name: String(row.name || ''), scriptBody: String(row.script_body || ''), + isFavorite: normalizeFavoriteFlag(row.is_favorite, 0) === 1, orderIndex: Number(row.order_index || 0), createdAt: row.created_at, updatedAt: row.updated_at @@ -325,7 +347,7 @@ class ScriptService { const db = await getDb(); const rows = await db.all( ` - SELECT id, name, script_body, order_index, created_at, updated_at + SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at FROM scripts ORDER BY order_index ASC, id ASC ` @@ -341,7 +363,7 @@ class ScriptService { const db = await getDb(); const row = await db.get( ` - SELECT id, name, script_body, order_index, created_at, updated_at + SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at FROM scripts WHERE id = ? `, @@ -362,8 +384,8 @@ class ScriptService { const nextOrderIndex = await this._getNextOrderIndex(db); const result = await db.run( ` - INSERT INTO scripts (name, script_body, order_index, created_at, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO scripts (name, script_body, is_favorite, order_index, created_at, updated_at) + VALUES (?, ?, 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `, [normalized.name, normalized.scriptBody, nextOrderIndex] ); @@ -420,6 +442,25 @@ class ScriptService { return existing; } + async setScriptFavorite(scriptId, isFavorite) { + const normalizedId = normalizeScriptId(scriptId); + if (!normalizedId) { + throw createValidationError('Ungültige scriptId.'); + } + const favoriteFlag = normalizeFavoriteFlag(isFavorite, 0); + const db = await getDb(); + await this.getScriptById(normalizedId); + await db.run( + ` + UPDATE scripts + SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + [favoriteFlag, normalizedId] + ); + return this.getScriptById(normalizedId); + } + async getScriptsByIds(rawIds = []) { const ids = normalizeScriptIdList(rawIds); if (ids.length === 0) { @@ -429,7 +470,7 @@ class ScriptService { const placeholders = ids.map(() => '?').join(', '); const rows = await db.all( ` - SELECT id, name, script_body, order_index, created_at, updated_at + SELECT id, name, script_body, is_favorite, order_index, created_at, updated_at FROM scripts WHERE id IN (${placeholders}) `, diff --git a/db/schema.sql b/db/schema.sql index f043db0..a1b0265 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -99,6 +99,7 @@ CREATE TABLE scripts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, script_body TEXT NOT NULL, + is_favorite INTEGER NOT NULL DEFAULT 0, order_index INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -110,6 +111,8 @@ CREATE INDEX idx_scripts_order_index ON scripts(order_index, id); CREATE TABLE script_chains ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, + description TEXT, + is_favorite INTEGER NOT NULL DEFAULT 0, order_index INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -132,6 +135,25 @@ CREATE TABLE script_chain_steps ( CREATE INDEX idx_script_chain_steps_chain ON script_chain_steps(chain_id, position); +CREATE TABLE hardware_metrics_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at TEXT NOT NULL, + day_key TEXT NOT NULL, + cpu_usage_percent REAL, + cpu_temperature_c REAL, + ram_usage_percent REAL, + ram_used_bytes INTEGER, + ram_total_bytes INTEGER, + gpu_usage_percent REAL, + gpu_temperature_c REAL, + vram_used_bytes INTEGER, + vram_total_bytes INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_hardware_metrics_history_captured_at ON hardware_metrics_history(captured_at); +CREATE INDEX idx_hardware_metrics_history_day_key ON hardware_metrics_history(day_key); + CREATE TABLE pipeline_state ( id INTEGER PRIMARY KEY CHECK (id = 1), state TEXT NOT NULL, diff --git a/docs/configuration/settings-reference.md b/docs/configuration/settings-reference.md index c286c2e..31e05d3 100644 --- a/docs/configuration/settings-reference.md +++ b/docs/configuration/settings-reference.md @@ -1,191 +1,179 @@ # Einstellungsreferenz -Diese Seite dokumentiert den aktuellen Stand der Settings auf Basis von `settings_schema` und der aktiven GUI-Logik. +Diese Seite beschreibt **jede** aktuelle Einstellung aus der Settings-GUI: was technisch passiert und warum man sie setzt. -## Wichtiges Verhalten in der GUI +## Lesehilfe -- Speichern erfolgt gesammelt über `Änderungen speichern` (Bulk-Update). -- `ui_expert_mode` wird sofort einzeln gespeichert (nicht erst mit Bulk-Speichern). -- `*_owner`-Felder sind in der GUI erst aktiv, wenn der zugehörige Pfad gesetzt ist. -- Einige Keys sind absichtlich ausgeblendet und nur technisch (API/DB) nutzbar. -- Benachrichtigungs-Event-Toggles sind nur sichtbar, wenn `pushover_enabled=true`. +- `Sichtbarkeit`: `Standard`, `Expertenmodus` oder `Ausgeblendet (nur API/DB)`. +- `Default`: UI-/Schema-Default (sensible Werte werden als `leer` dargestellt). +- `Was passiert technisch?`: direkte Wirkung im Backend/Pipeline-Verhalten. +- `Warum/Wann setzen?`: Praxisgrund inkl. typischem Einsatzzweck. -## Fallbacks und Pfadauflösung - -- Leere profilspezifische Pfade fallen auf Installationsdefaults zurück. -- `download_dir` nutzt bei leerem Wert den Default-Downloadpfad. -- Die Konfigurationsseite zeigt im Block `Effektive Pfade` die tatsächlich verwendeten Laufzeitpfade. - -## Vollständige Feldliste +## Vollstaendige Feldliste ## Kategorie: Benachrichtigungen -| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System | -|---|---|---|---|---|---| -| `Expertenmodus` | `ui_expert_mode` | `boolean` | `false` | Standard | Schaltet erweiterte Einstellungen in der UI ein. | -| `PushOver aktiviert` | `pushover_enabled` | `boolean` | `false` | Standard | Master-Schalter für PushOver Versand. | -| `PushOver Token` | `pushover_token` | `string` | `leer` | Standard | Application Token für PushOver. | -| `PushOver User` | `pushover_user` | `string` | `leer` | Standard | User-Key für PushOver. | -| `PushOver Device (optional)` | `pushover_device` | `string` | `leer` | Expertenmodus | Optionales Ziel-Device in PushOver. | -| `PushOver Titel-Präfix` | `pushover_title_prefix` | `string` | `Ripster` | Standard | Prefix im PushOver Titel. | -| `PushOver Priority` | `pushover_priority` | `number` | `0` | Expertenmodus | Priorität -2 bis 2. | -| `PushOver Timeout (ms)` | `pushover_timeout_ms` | `number` | `7000` | Expertenmodus | HTTP Timeout für PushOver Requests. | -| `Bei manueller Auswahl senden` | `pushover_notify_metadata_ready` | `boolean` | `true` | Standard | Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist). | -| `Bei Rip-Start senden` | `pushover_notify_rip_started` | `boolean` | `true` | Standard | Sendet beim Start des MakeMKV-Rips. | -| `Bei Encode-Start senden` | `pushover_notify_encoding_started` | `boolean` | `true` | Standard | Sendet beim Start von HandBrake. | -| `Bei Erfolg senden` | `pushover_notify_job_finished` | `boolean` | `true` | Standard | Sendet bei erfolgreich abgeschlossenem Job. | -| `Bei Fehler senden` | `pushover_notify_job_error` | `boolean` | `true` | Standard | Sendet bei Fehlern in der Pipeline. | -| `Bei Abbruch senden` | `pushover_notify_job_cancelled` | `boolean` | `true` | Standard | Sendet wenn Job manuell abgebrochen wurde. | -| `Bei Re-Encode Start senden` | `pushover_notify_reencode_started` | `boolean` | `true` | Standard | Sendet beim Start von RAW Re-Encode. | -| `Bei Re-Encode Erfolg senden` | `pushover_notify_reencode_finished` | `boolean` | `true` | Standard | Sendet bei erfolgreichem RAW Re-Encode. | +| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? | +|---|---|---|---|---|---|---| +| `Expertenmodus` | `ui_expert_mode` | `boolean` | `false` | Standard | Schaltet erweiterte Einstellungen in der UI ein. Wird sofort einzeln gespeichert und beeinflusst Sichtbarkeit von Feldern/Kategorien. | Wenn du selten benoetigte technische Felder sehen willst (z.B. Logging- oder CLI-Details). | +| `PushOver aktiviert` | `pushover_enabled` | `boolean` | `false` | Standard | Master-Schalter für PushOver Versand. | Wenn du aktive Laufzeit-Benachrichtigungen auf Mobilgeraete erhalten willst. | +| `PushOver Token` | `pushover_token` | `string` | `leer` | Standard | Application Token für PushOver. | Wenn Ripster in deine PushOver-App senden soll (Token aus PushOver-App-Config). | +| `PushOver User` | `pushover_user` | `string` | `leer` | Standard | User-Key für PushOver. | Wenn Benachrichtigungen an einen bestimmten PushOver-Account gehen sollen. | +| `PushOver Device (optional)` | `pushover_device` | `string` | `leer` | Expertenmodus | Optionales Ziel-Device in PushOver. | Wenn Nachrichten nur auf ein bestimmtes Endgeraet zugestellt werden sollen. | +| `PushOver Titel-Präfix` | `pushover_title_prefix` | `string` | `Ripster` | Standard | Prefix im PushOver Titel. | Wenn du Meldungen mehrerer Instanzen sauber unterscheiden willst (z.B. "Ripper-Wohnzimmer"). | +| `PushOver Priority` | `pushover_priority` | `number` | `0` | Expertenmodus | Priorität -2 bis 2. | Wenn Meldungen bewusst leiser (niedrig) oder dringender (hoch) zugestellt werden sollen. | +| `PushOver Timeout (ms)` | `pushover_timeout_ms` | `number` | `7000` | Expertenmodus | HTTP Timeout für PushOver Requests. | Wenn PushOver bei schlechter Verbindung mehr oder weniger Zeit fuer den Request bekommen soll. | +| `Bei manueller Auswahl senden` | `pushover_notify_metadata_ready` | `boolean` | `true` | Standard | Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist). | Wenn 'Bei manueller Auswahl senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. | +| `Bei Rip-Start senden` | `pushover_notify_rip_started` | `boolean` | `true` | Standard | Sendet beim Start des MakeMKV-Rips. | Wenn 'Bei Rip-Start senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. | +| `Bei Encode-Start senden` | `pushover_notify_encoding_started` | `boolean` | `true` | Standard | Sendet beim Start von HandBrake. | Wenn 'Bei Encode-Start senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. | +| `Bei Erfolg senden` | `pushover_notify_job_finished` | `boolean` | `true` | Standard | Sendet bei erfolgreich abgeschlossenem Job. | Wenn 'Bei Erfolg senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. | +| `Bei Fehler senden` | `pushover_notify_job_error` | `boolean` | `true` | Standard | Sendet bei Fehlern in der Pipeline. | Wenn 'Bei Fehler senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. | +| `Bei Abbruch senden` | `pushover_notify_job_cancelled` | `boolean` | `true` | Standard | Sendet wenn Job manuell abgebrochen wurde. | Wenn 'Bei Abbruch senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. | +| `Bei Re-Encode Start senden` | `pushover_notify_reencode_started` | `boolean` | `true` | Standard | Sendet beim Start von RAW Re-Encode. | Wenn 'Bei Re-Encode Start senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. | +| `Bei Re-Encode Erfolg senden` | `pushover_notify_reencode_finished` | `boolean` | `true` | Standard | Sendet bei erfolgreichem RAW Re-Encode. | Wenn 'Bei Re-Encode Erfolg senden' bewusst ein- oder ausgeschaltet werden soll, um den Ablauf an deine Umgebung anzupassen. | ## Kategorie: Converter -| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System | -|---|---|---|---|---|---| -| `Erlaubte Datei-Endungen` | `converter_scan_extensions` | `string` | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus` | Standard | Komma-getrennte Liste erlaubter Dateiendungen für den Scan (ohne Punkt). | -| `Auto-Scan (Polling)` | `converter_polling_enabled` | `boolean` | `false` | Standard | Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen. | -| `Polling-Intervall (Sekunden)` | `converter_polling_interval` | `number` | `300` | Standard | Intervall für automatischen Scan des Converter-Ordners. | +| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? | +|---|---|---|---|---|---|---| +| `Erlaubte Datei-Endungen` | `converter_scan_extensions` | `string` | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus` | Standard | Komma-getrennte Liste erlaubter Dateiendungen für den Scan (ohne Punkt). Steuert sowohl Upload-Validierung als auch Converter-Scan-Filter. | Wenn 'Erlaubte Datei-Endungen' vom Default abweichen soll. | +| `Auto-Scan (Polling)` | `converter_polling_enabled` | `boolean` | `false` | Standard | Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen. | Wenn neue Dateien im Converter-Ordner automatisch erscheinen sollen, ohne manuellen Scan. | +| `Polling-Intervall (Sekunden)` | `converter_polling_interval` | `number` | `300` | Standard | Intervall für automatischen Scan des Converter-Ordners. | Wenn der Auto-Scan schneller (kleiner Wert) oder ressourcenschonender (groesserer Wert) laufen soll. | ## Kategorie: Laufwerk -| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System | -|---|---|---|---|---|---| -| `Laufwerksmodus` | `drive_mode` | `select` | `auto` | Standard | Auto-Discovery oder explizites Device. | -| `Explizite Laufwerke` | `drive_devices` | `path` | `[]` | Standard | Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{"path":"/dev/sr0","makemkvIndex":0},{"path":"/dev/sr1","makemkvIndex":1}]. Nur relevant wenn Modus = Explizites Device. | -| `Device Pfad` | `drive_device` | `path` | `/dev/sr0` | Ausgeblendet (nur API/DB) | Nur für expliziten Modus, z.B. /dev/sr0. | -| `MakeMKV Source Index` | `makemkv_source_index` | `number` | `0` | Ausgeblendet (nur API/DB) | Disc Index im Auto-Modus. | -| `Automatische Disk-Erkennung` | `disc_auto_detection_enabled` | `boolean` | `true` | Standard | Wenn deaktiviert, findet keine automatische Laufwerksprüfung statt. Neue Disks werden nur per "Laufwerk neu lesen" erkannt. | -| `Polling Intervall (ms)` | `disc_poll_interval_ms` | `number` | `4000` | Expertenmodus | Intervall für Disk-Erkennung. | -| `Laufwerk nach Rip auswerfen` | `auto_eject_after_rip` | `boolean` | `false` | Standard | Wenn aktiv, wird das Laufwerk nach erfolgreichem Abschluss eines Rip-Vorgangs automatisch ausgeworfen (eject). | +| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? | +|---|---|---|---|---|---|---| +| `Laufwerksmodus` | `drive_mode` | `select` | `auto` | Standard | Auto-Discovery oder explizites Device. | Wenn du zwischen automatischer Laufwerkserkennung und fixer Device-Liste umschalten willst. | +| `Explizite Laufwerke` | `drive_devices` | `path` | `[]` | Standard | Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{"path":"/dev/sr0","makemkvIndex":0},{"path":"/dev/sr1","makemkvIndex":1}]. Nur relevant wenn Modus = Explizites Device. Wird als JSON gespeichert und vom Laufwerksdienst fuer explizite Zuordnung genutzt. | Wenn auf dem Host mehrere Laufwerke stabil gemappt werden muessen (z.B. /dev/sr0=disc:0, /dev/sr1=disc:1). | +| `Device Pfad` | `drive_device` | `path` | `/dev/sr0` | Ausgeblendet (nur API/DB) | Nur für expliziten Modus, z.B. /dev/sr0. | Wenn 'Device Pfad' vom Default abweichen soll. | +| `MakeMKV Source Index` | `makemkv_source_index` | `number` | `0` | Ausgeblendet (nur API/DB) | Disc Index im Auto-Modus. | Wenn 'MakeMKV Source Index' fuer Performance, Last oder Genauigkeit feinjustiert werden soll. | +| `Automatische Disk-Erkennung` | `disc_auto_detection_enabled` | `boolean` | `true` | Standard | Wenn deaktiviert, findet keine automatische Laufwerksprüfung statt. Neue Disks werden nur per "Laufwerk neu lesen" erkannt. | Wenn Discs nur manuell statt automatisch erkannt werden sollen. | +| `Polling Intervall (ms)` | `disc_poll_interval_ms` | `number` | `4000` | Expertenmodus | Intervall für Disk-Erkennung. | Wenn die Erkennung schneller reagieren oder weniger oft pollen soll. | +| `Laufwerk nach Rip auswerfen` | `auto_eject_after_rip` | `boolean` | `false` | Standard | Wenn aktiv, wird das Laufwerk nach erfolgreichem Abschluss eines Rip-Vorgangs automatisch ausgeworfen (eject). | Wenn ein Laufwerk nach erfolgreichem Rip direkt freigegeben/ausgeworfen werden soll. | ## Kategorie: Logging -| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System | -|---|---|---|---|---|---| -| `Terminal-Ausgabe aktiv` | `server_console_log_output_enabled` | `boolean` | `true` | Expertenmodus | Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv. | -| `HTTP-Logs im Terminal` | `server_console_log_http_enabled` | `boolean` | `true` | Expertenmodus | Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert. | -| `DEBUG im Terminal` | `server_console_log_debug_enabled` | `boolean` | `true` | Expertenmodus | Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. | -| `INFO im Terminal` | `server_console_log_info_enabled` | `boolean` | `true` | Expertenmodus | Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. | -| `WARN im Terminal` | `server_console_log_warn_enabled` | `boolean` | `true` | Expertenmodus | Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. | -| `ERROR im Terminal` | `server_console_log_error_enabled` | `boolean` | `true` | Expertenmodus | Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. | +| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? | +|---|---|---|---|---|---|---| +| `Terminal-Ausgabe aktiv` | `server_console_log_output_enabled` | `boolean` | `true` | Expertenmodus | Master-Schalter für die Ausgabe der Server-Logs im Terminal (z.B. start.sh). Das Schreiben in Log-Dateien bleibt immer aktiv. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn Terminal-Logs bei Betrieb unter Service/SSH ein- oder ausgeschaltet werden sollen. | +| `HTTP-Logs im Terminal` | `server_console_log_http_enabled` | `boolean` | `true` | Expertenmodus | Steuert HTTP Request-Logs im Terminal (z.B. request:start). Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn HTTP-Request-Logs im Terminal fuer API-Debugging gebraucht werden. | +| `DEBUG im Terminal` | `server_console_log_debug_enabled` | `boolean` | `true` | Expertenmodus | Steuert DEBUG-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn detailreiche DEBUG-Logs im Terminal benoetigt werden (Fehlersuche). | +| `INFO im Terminal` | `server_console_log_info_enabled` | `boolean` | `true` | Expertenmodus | Steuert INFO-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn normale INFO-Laufzeitmeldungen im Terminal sichtbar sein sollen. | +| `WARN im Terminal` | `server_console_log_warn_enabled` | `boolean` | `true` | Expertenmodus | Steuert WARN-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn Warnungen im Terminal hervorgehoben bleiben sollen. | +| `ERROR im Terminal` | `server_console_log_error_enabled` | `boolean` | `true` | Expertenmodus | Steuert ERROR-Meldungen in der Terminal-Ausgabe. Dateilogs bleiben unverändert. Aenderung wirkt direkt auf die Terminal-Logausgabe, Dateilogging bleibt erhalten. | Wenn Fehler zwingend im Terminal sichtbar sein sollen. | ## Kategorie: Metadaten -| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System | -|---|---|---|---|---|---| -| `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Standard | Read Access Token für Serien-Metadaten über TheMovieDB (TMDb). | -| `Film-Sprache` | `dvd_series_language` | `string` | `de-DE` | Standard | Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US. | -| `Fallback Sprache` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Standard | Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR. | -| `Coverart-Nachladen aktiv` | `coverart_recovery_enabled` | `boolean` | `true` | Standard | Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen. | -| `Coverart-Prüfintervall (Stunden)` | `coverart_recovery_interval_hours` | `number` | `6` | Standard | Intervall für automatische Prüfung fehlender Coverarts in Stunden. | -| `NFO nach Encode erzeugen` | `generate_nfo_after_encode` | `boolean` | `false` | Standard | Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei. | +| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? | +|---|---|---|---|---|---|---| +| `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Standard | Read Access Token für Serien-Metadaten über TheMovieDB (TMDb). Wird von TMDb-Service und Serienzuordnung in Analyse/Metadaten-Dialog genutzt. | Wenn TMDb-Metadaten (Suche/Zuordnung/Serienepisoden) verlässlich funktionieren sollen. | +| `Film-Sprache` | `dvd_series_language` | `string` | `de-DE` | Standard | Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US. Wird von TMDb-Service und Serienzuordnung in Analyse/Metadaten-Dialog genutzt. | Wenn bevorzugt deutsche, englische oder andere lokalisierte Metadaten genutzt werden sollen. | +| `Fallback Sprache` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Standard | Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR. Wird von TMDb-Service und Serienzuordnung in Analyse/Metadaten-Dialog genutzt. | Wenn bei fehlender Primärsprache automatisch andere Sprachversionen ausprobiert werden sollen. | +| `Coverart-Nachladen aktiv` | `coverart_recovery_enabled` | `boolean` | `true` | Standard | Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen. Aenderung aktualisiert den Coverart-Recovery-Scheduler. | Wenn fehlende Cover später automatisch nachgezogen werden sollen. | +| `Coverart-Prüfintervall (Stunden)` | `coverart_recovery_interval_hours` | `number` | `6` | Standard | Intervall für automatische Prüfung fehlender Coverarts in Stunden. Aenderung aktualisiert den Coverart-Recovery-Scheduler. | Wenn der Nachlade-Job haeufiger oder seltener laufen soll. | +| `NFO nach Encode erzeugen` | `generate_nfo_after_encode` | `boolean` | `false` | Standard | Erstellt nach erfolgreichem Encode automatisch eine .nfo-Datei neben der Ausgabedatei. Wird nach erfolgreichem Encode in der Historie/Pipeline ausgewertet. | Wenn neben der Ausgabedatei automatisch eine .nfo fuer Media-Center erzeugt werden soll. | ## Kategorie: Monitoring -| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System | -|---|---|---|---|---|---| -| `Hardware Monitoring aktiviert` | `hardware_monitoring_enabled` | `boolean` | `true` | Standard | Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates). | -| `Hardware Monitoring Intervall (ms)` | `hardware_monitoring_interval_ms` | `number` | `5000` | Expertenmodus | Polling-Intervall für CPU/RAM/GPU/Storage-Metriken. | +| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? | +|---|---|---|---|---|---|---| +| `Hardware Monitoring aktiviert` | `hardware_monitoring_enabled` | `boolean` | `true` | Standard | Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates). Aenderung triggert Re-Konfiguration des Hardware-Monitoring-Dienstes. | Wenn CPU/RAM/GPU/Speicher live sichtbar sein sollen. | +| `Hardware Monitoring Intervall (ms)` | `hardware_monitoring_interval_ms` | `number` | `5000` | Expertenmodus | Polling-Intervall für CPU/RAM/GPU/Storage-Metriken. Aenderung triggert Re-Konfiguration des Hardware-Monitoring-Dienstes. | Wenn Monitoring häufiger aktualisiert oder Last reduziert werden soll. | ## Kategorie: Pfade -| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System | -|---|---|---|---|---|---| -| `Raw Ausgabeordner (Blu-ray)` | `raw_dir_bluray` | `path` | `leer` | Standard | Optionaler RAW-Zielpfad nur für Blu-ray. Leer = Fallback auf "Raw Ausgabeordner". | -| `Raw Ausgabeordner (DVD)` | `raw_dir_dvd` | `path` | `leer` | Standard | Optionaler RAW-Zielpfad nur für DVD. Leer = Fallback auf "Raw Ausgabeordner". | -| `CD RAW-Ordner` | `raw_dir_cd` | `path` | `leer` | Standard | Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd). | -| `Audiobook RAW-Ordner` | `raw_dir_audiobook` | `path` | `leer` | Standard | Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw). | -| `Serien-Ordner (Blu-ray)` | `series_dir_bluray` | `path` | `leer` | Standard | Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series). | -| `Film Ausgabeordner (Blu-ray)` | `movie_dir_bluray` | `path` | `leer` | Standard | Optionaler Encode-Zielpfad nur für Blu-ray. Leer = Fallback auf "Film Ausgabeordner". | -| `Film Ausgabeordner (DVD)` | `movie_dir_dvd` | `path` | `leer` | Standard | Optionaler Encode-Zielpfad nur für DVD. Leer = Fallback auf "Film Ausgabeordner". | -| `Serien-Ordner (DVD)` | `series_dir_dvd` | `path` | `leer` | Standard | Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series). | -| `CD Output-Ordner` | `movie_dir_cd` | `path` | `leer` | Standard | Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner. | -| `Audiobook Output-Ordner` | `movie_dir_audiobook` | `path` | `leer` | Standard | Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks). | -| `Download ZIP-Ordner` | `download_dir` | `path` | `leer` | Standard | Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads). | -| `Externe Speicher` | `external_storage_paths` | `path` | `[]` | Standard | Wird links unter "Freie Speicher" angezeigt. | -| `Log Ordner` | `log_dir` | `path` | `installationsabhängig` | Standard | Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend. | -| `CD Output Template` | `cd_output_template` | `string` | `{artist} - {album} ({year})/{trackNr} {artist} - {title}` | Standard | Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich. Die Endung wird über das gewählte Ausgabeformat gesetzt. | -| `Output Template (Blu-ray)` | `output_template_bluray` | `string` | `${title} (${year})/${title} (${year})` | Standard | Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt. | -| `Output Template (Blu-ray Serie, Episode)` | `output_template_bluray_series_episode` | `string` | `{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}` | Standard | Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. | -| `Output Template (Blu-ray Serie, Multi-Episode)` | `output_template_bluray_series_multi_episode` | `string` | `{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})` | Standard | Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. | -| `Output Template (DVD)` | `output_template_dvd` | `string` | `${title} (${year})/${title} (${year})` | Standard | Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt. | -| `Output Template (DVD Serie, Episode)` | `output_template_dvd_series_episode` | `string` | `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeNo} - {episodeTitle}` | Standard | Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. | -| `Output Template (DVD Serie, Multi-Episode)` | `output_template_dvd_series_multi_episode` | `string` | `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeRange}` | Standard | Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. | -| `Output Template (Audiobook)` | `output_template_audiobook` | `string` | `{author}/{author} - {title} ({year})` | Standard | Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich. | -| `Audiobook RAW Template` | `audiobook_raw_template` | `string` | `{author} - {title} ({year})` | Standard | Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}. | -| `Converter Raw-Ordner` | `converter_raw_dir` | `path` | `leer` | Standard | Eingangsordner für den Converter (Import-Scan und Datei-Uploads). Jeder Upload erhält einen Unterordner. Leer = Standardpfad (data/output/converter-raw). | -| `Converter Ausgabe (Video)` | `converter_movie_dir` | `path` | `leer` | Standard | Ausgabepfad für konvertierte Video-Dateien. Leer = Standardpfad (data/output/converted-movies). | -| `Converter Ausgabe (Audio)` | `converter_audio_dir` | `path` | `leer` | Standard | Ausgabepfad für konvertierte Audio-Dateien. Leer = Standardpfad (data/output/converted-audio). | -| `Output-Template (Video)` | `converter_output_template_video` | `string` | `{title}` | Standard | Dateiname-Template für konvertierte Video-Dateien (ohne Endung). Platzhalter: {title}, {year}. | -| `Output-Template (Audio)` | `converter_output_template_audio` | `string` | `{artist} - {title}` | Standard | Dateiname-Template für konvertierte Audio-Dateien (ohne Endung). Platzhalter: {artist}, {title}, {album}, {year}, {trackNr}. | -| `RAW-Ordner (Blu-ray Serie)` | `raw_dir_bluray_series` | `path` | `leer` | Standard | RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray). | -| `Eigentümer RAW-Ordner (Blu-ray Serie)` | `raw_dir_bluray_series_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray). | -| `Eigentümer Raw-Ordner (Blu-ray)` | `raw_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. | -| `RAW-Ordner (DVD Serie)` | `raw_dir_dvd_series` | `path` | `leer` | Standard | RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD). | -| `Eigentümer RAW-Ordner (DVD Serie)` | `raw_dir_dvd_series_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD). | -| `Eigentümer Raw-Ordner (DVD)` | `raw_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer CD RAW-Ordner` | `raw_dir_cd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Audiobook RAW-Ordner` | `raw_dir_audiobook_owner` | `string` | `leer` | Standard | Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Converter RAW-Ordner` | `converter_raw_dir_owner` | `string` | `leer` | Standard | Eigentümer der Converter-Eingabedateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Serien-Ordner (Blu-ray)` | `series_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Film-Ordner (Blu-ray)` | `movie_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Film-Ordner (DVD)` | `movie_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Serien-Ordner (DVD)` | `series_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer CD Output-Ordner` | `movie_dir_cd_owner` | `string` | `leer` | Standard | Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Audiobook Output-Ordner` | `movie_dir_audiobook_owner` | `string` | `leer` | Standard | Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Converter Video Output-Ordner` | `converter_movie_dir_owner` | `string` | `leer` | Standard | Eigentümer der konvertierten Video-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Converter Audio Output-Ordner` | `converter_audio_dir_owner` | `string` | `leer` | Standard | Eigentümer der konvertierten Audio-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. | -| `Eigentümer Download ZIP-Ordner` | `download_dir_owner` | `string` | `leer` | Standard | Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. | -| `Kapitel Template (Audiobook)` | `output_chapter_template_audiobook` | `string` | `{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}` | Standard | Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich. | +| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? | +|---|---|---|---|---|---|---| +| `Raw Ausgabeordner (Blu-ray)` | `raw_dir_bluray` | `path` | `leer` | Standard | Optionaler RAW-Zielpfad nur für Blu-ray. Leer = Fallback auf "Raw Ausgabeordner". | Wenn 'Raw Ausgabeordner (Blu-ray)' vom Default abweichen soll. | +| `Raw Ausgabeordner (DVD)` | `raw_dir_dvd` | `path` | `leer` | Standard | Optionaler RAW-Zielpfad nur für DVD. Leer = Fallback auf "Raw Ausgabeordner". | Wenn 'Raw Ausgabeordner (DVD)' vom Default abweichen soll. | +| `CD RAW-Ordner` | `raw_dir_cd` | `path` | `leer` | Standard | Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd). | Wenn 'CD RAW-Ordner' vom Default abweichen soll. | +| `Audiobook RAW-Ordner` | `raw_dir_audiobook` | `path` | `leer` | Standard | Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw). | Wenn 'Audiobook RAW-Ordner' vom Default abweichen soll. | +| `Serien-Ordner (Blu-ray)` | `series_dir_bluray` | `path` | `leer` | Standard | Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series). | Wenn 'Serien-Ordner (Blu-ray)' vom Default abweichen soll. | +| `Film Ausgabeordner (Blu-ray)` | `movie_dir_bluray` | `path` | `leer` | Standard | Optionaler Encode-Zielpfad nur für Blu-ray. Leer = Fallback auf "Film Ausgabeordner". | Wenn 'Film Ausgabeordner (Blu-ray)' vom Default abweichen soll. | +| `Film Ausgabeordner (DVD)` | `movie_dir_dvd` | `path` | `leer` | Standard | Optionaler Encode-Zielpfad nur für DVD. Leer = Fallback auf "Film Ausgabeordner". | Wenn 'Film Ausgabeordner (DVD)' vom Default abweichen soll. | +| `Serien-Ordner (DVD)` | `series_dir_dvd` | `path` | `leer` | Standard | Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series). | Wenn 'Serien-Ordner (DVD)' vom Default abweichen soll. | +| `CD Output-Ordner` | `movie_dir_cd` | `path` | `leer` | Standard | Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner. | Wenn 'CD Output-Ordner' vom Default abweichen soll. | +| `Audiobook Output-Ordner` | `movie_dir_audiobook` | `path` | `leer` | Standard | Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks). | Wenn 'Audiobook Output-Ordner' vom Default abweichen soll. | +| `Download ZIP-Ordner` | `download_dir` | `path` | `leer` | Standard | Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads). Leere Werte nutzen automatisch den Installations-Defaultpfad. | Wenn RAW/Output/Download auf andere Datentraeger verschoben werden sollen (z.B. grosse HDD/NAS). | +| `Externe Speicher` | `external_storage_paths` | `path` | `[]` | Standard | Wird links unter "Freie Speicher" angezeigt. | Wenn 'Externe Speicher' vom Default abweichen soll. | +| `Log Ordner` | `log_dir` | `path` | `installationsabhaengig` | Standard | Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend. Wird zur Laufzeit angewendet; bei ungueltigem Pfad faellt Ripster auf Fallback-Logpfad zurueck. | Wenn Logs auf ein anderes Medium sollen (z.B. persistentes NAS/SSD statt Standardpfad). | +| `CD Output Template` | `cd_output_template` | `string` | `{artist} - {album} ({year})/{trackNr} {artist} - {title}` | Standard | Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {title}, {trackNr}, {trackNo}. Unterordner sind über "/" möglich. Die Endung wird über das gewählte Ausgabeformat gesetzt. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Output Template (Blu-ray)` | `output_template_bluray` | `string` | `${title} (${year})/${title} (${year})` | Standard | Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Output Template (Blu-ray Serie, Episode)` | `output_template_bluray_series_episode` | `string` | `{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}` | Standard | Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Output Template (Blu-ray Serie, Multi-Episode)` | `output_template_bluray_series_multi_episode` | `string` | `{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})` | Standard | Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Output Template (DVD)` | `output_template_dvd` | `string` | `${title} (${year})/${title} (${year})` | Standard | Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Output Template (DVD Serie, Episode)` | `output_template_dvd_series_episode` | `string` | `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeNo} - {episodeTitle}` | Standard | Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Output Template (DVD Serie, Multi-Episode)` | `output_template_dvd_series_multi_episode` | `string` | `{seriesTitle}/Season {seasonNr}/{seriesTitle} - S{seasonNo}E{episodeRange}` | Standard | Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Output Template (Audiobook)` | `output_template_audiobook` | `string` | `{author}/{author} - {title} ({year})` | Standard | Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Audiobook RAW Template` | `audiobook_raw_template` | `string` | `{author} - {title} ({year})` | Standard | Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Converter Raw-Ordner` | `converter_raw_dir` | `path` | `leer` | Standard | Eingangsordner für den Converter (Import-Scan und Datei-Uploads). Jeder Upload erhält einen Unterordner. Leer = Standardpfad (data/output/converter-raw). Leere Werte nutzen automatisch den Installations-Defaultpfad. | Wenn RAW/Output/Download auf andere Datentraeger verschoben werden sollen (z.B. grosse HDD/NAS). | +| `Converter Ausgabe (Video)` | `converter_movie_dir` | `path` | `leer` | Standard | Ausgabepfad für konvertierte Video-Dateien. Leer = Standardpfad (data/output/converted-movies). Leere Werte nutzen automatisch den Installations-Defaultpfad. | Wenn RAW/Output/Download auf andere Datentraeger verschoben werden sollen (z.B. grosse HDD/NAS). | +| `Converter Ausgabe (Audio)` | `converter_audio_dir` | `path` | `leer` | Standard | Ausgabepfad für konvertierte Audio-Dateien. Leer = Standardpfad (data/output/converted-audio). Leere Werte nutzen automatisch den Installations-Defaultpfad. | Wenn RAW/Output/Download auf andere Datentraeger verschoben werden sollen (z.B. grosse HDD/NAS). | +| `Output-Template (Video)` | `converter_output_template_video` | `string` | `{title}` | Standard | Dateiname-Template für konvertierte Video-Dateien (ohne Endung). Platzhalter: {title}, {year}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `Output-Template (Audio)` | `converter_output_template_audio` | `string` | `{artist} - {title}` | Standard | Dateiname-Template für konvertierte Audio-Dateien (ohne Endung). Platzhalter: {artist}, {title}, {album}, {year}, {trackNr}. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | +| `RAW-Ordner (Blu-ray Serie)` | `raw_dir_bluray_series` | `path` | `leer` | Standard | RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray). | Wenn 'RAW-Ordner (Blu-ray Serie)' vom Default abweichen soll. | +| `Eigentümer RAW-Ordner (Blu-ray Serie)` | `raw_dir_bluray_series_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray). Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Raw-Ordner (Blu-ray)` | `raw_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `RAW-Ordner (DVD Serie)` | `raw_dir_dvd_series` | `path` | `leer` | Standard | RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD). | Wenn 'RAW-Ordner (DVD Serie)' vom Default abweichen soll. | +| `Eigentümer RAW-Ordner (DVD Serie)` | `raw_dir_dvd_series_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD). Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Raw-Ordner (DVD)` | `raw_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer CD RAW-Ordner` | `raw_dir_cd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Audiobook RAW-Ordner` | `raw_dir_audiobook_owner` | `string` | `leer` | Standard | Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Converter RAW-Ordner` | `converter_raw_dir_owner` | `string` | `leer` | Standard | Eigentümer der Converter-Eingabedateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Serien-Ordner (Blu-ray)` | `series_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Film-Ordner (Blu-ray)` | `movie_dir_bluray_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Film-Ordner (DVD)` | `movie_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein alternativer Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Serien-Ordner (DVD)` | `series_dir_dvd_owner` | `string` | `leer` | Standard | Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer CD Output-Ordner` | `movie_dir_cd_owner` | `string` | `leer` | Standard | Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Audiobook Output-Ordner` | `movie_dir_audiobook_owner` | `string` | `leer` | Standard | Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Converter Video Output-Ordner` | `converter_movie_dir_owner` | `string` | `leer` | Standard | Eigentümer der konvertierten Video-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Converter Audio Output-Ordner` | `converter_audio_dir_owner` | `string` | `leer` | Standard | Eigentümer der konvertierten Audio-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Eigentümer Download ZIP-Ordner` | `download_dir_owner` | `string` | `leer` | Standard | Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes. Wird beim Anlegen/Finalisieren per `chown` auf Zielpfade angewendet. Greift bei ZIP-Ordner, Archivdatei und Metadatei. | Wenn Dateien/Ordner nicht dem Service-User gehoeren sollen (z.B. NAS-Freigaben). Beispiel: `media:media`. | +| `Kapitel Template (Audiobook)` | `output_chapter_template_audiobook` | `string` | `{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}` | Standard | Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich. | Wenn Ausgabe-/Ordnernamen standardisiert werden sollen. Beispiel: Serien nach Staffel oder Kapitel in Unterordner strukturieren. | ## Kategorie: Tools -| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Wirkung im System | -|---|---|---|---|---|---| -| `MakeMKV Kommando` | `makemkv_command` | `string` | `makemkvcon` | Expertenmodus | Pfad oder Befehl für makemkvcon. | -| `MakeMKV Key` | `makemkv_registration_key` | `string` | `leer` | Standard | Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden. | -| `Mediainfo Kommando` | `mediainfo_command` | `string` | `mediainfo` | Expertenmodus | Pfad oder Befehl für mediainfo. | -| `Minimale Titellänge (Minuten)` | `makemkv_min_length_minutes` | `number` | `60` | Standard | Filtert kurze Titel beim Rip. | -| `HandBrake Kommando` | `handbrake_command` | `string` | `HandBrakeCLI` | Expertenmodus | Pfad oder Befehl für HandBrakeCLI. | -| `Encode-Neustart: unvollständige Ausgabe löschen` | `handbrake_restart_delete_incomplete_output` | `boolean` | `true` | Standard | Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt. | -| `Parallele Jobs` | `pipeline_max_parallel_jobs` | `number` | `1` | Standard | Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue. | -| `Max. parallele Audio CD Jobs` | `pipeline_max_parallel_cd_encodes` | `number` | `2` | Standard | Maximale Anzahl parallel laufender Audio CD Jobs (Rip + Encode als Einheit). Gilt zusätzlich zum Gesamtlimit. | -| `Script-Test Timeout (ms)` | `script_test_timeout_ms` | `number` | `0` | Expertenmodus | Timeout für Script-Tests in den Settings. 0 = kein Timeout. | -| `Max. Encodes gesamt (medienunabhängig)` | `pipeline_max_total_encodes` | `number` | `3` | Standard | Gesamtlimit für alle parallel laufenden Encode-Jobs (Film + Audio CD). Dieses Limit hat Vorrang vor den Einzellimits. | -| `Audio CDs: Queue-Reihenfolge überspringen` | `pipeline_cd_bypasses_queue` | `boolean` | `false` | Standard | Wenn aktiv, können Audio CD Jobs unabhängig von Film-Jobs starten (überspringen die Film-Queue-Reihenfolge). Einzellimits und Gesamtlimit gelten weiterhin. | -| `cdparanoia Kommando` | `cdparanoia_command` | `string` | `cdparanoia` | Expertenmodus | Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist. | -| `FFmpeg Kommando` | `ffmpeg_command` | `string` | `ffmpeg` | Standard | Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt. | -| `FFprobe Kommando` | `ffprobe_command` | `string` | `ffprobe` | Standard | Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt. | -| `Serie: PlayAll Untergrenze (%)` | `series_playall_tolerance_lower_pct` | `number` | `10` | Standard | Untere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray). | -| `Serie: PlayAll Obergrenze (%)` | `series_playall_tolerance_upper_pct` | `number` | `10` | Standard | Obere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray). | -| `Mediainfo Extra Args` | `mediainfo_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (Blu-ray). | -| `MakeMKV Rip Modus` | `makemkv_rip_mode_bluray` | `select` | `backup` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner. | -| `MakeMKV Analyze Extra Args` | `makemkv_analyze_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Analyze (Blu-ray). | -| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (Blu-ray). | -| `HandBrake Preset` | `handbrake_preset_bluray` | `string` | `H.264 MKV 1080p30` | Standard | Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet. | -| `HandBrake Extra Args` | `handbrake_extra_args_bluray` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --au...` | Standard | Zusätzliche CLI-Argumente (Blu-ray). | -| `Ausgabeformat` | `output_extension_bluray` | `select` | `mkv` | Standard | Dateiendung für finale Datei (Blu-ray). | -| `Mediainfo Extra Args` | `mediainfo_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (DVD). | -| `MakeMKV Rip Modus` | `makemkv_rip_mode_dvd` | `select` | `mkv` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner. | -| `MakeMKV Analyze Extra Args` | `makemkv_analyze_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Analyze (DVD). | -| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (DVD). | -| `HandBrake Preset` | `handbrake_preset_dvd` | `string` | `H.264 MKV 480p30` | Standard | Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet. | -| `HandBrake Extra Args` | `handbrake_extra_args_dvd` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --au...` | Standard | Zusätzliche CLI-Argumente (DVD). | -| `Ausgabeformat` | `output_extension_dvd` | `select` | `mkv` | Standard | Dateiendung für finale Datei (DVD). | -| `mkvmerge Kommando` | `mkvmerge_command` | `string` | `mkvmerge` | Standard | Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt. | +| GUI-Feld | Key | Typ | Default | Sichtbarkeit | Was passiert technisch? | Warum/Wann setzen? | +|---|---|---|---|---|---|---| +| `MakeMKV Kommando` | `makemkv_command` | `string` | `makemkvcon` | Expertenmodus | Pfad oder Befehl für makemkvcon. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). | +| `MakeMKV Key` | `makemkv_registration_key` | `string` | `leer` | Standard | Optionaler Registrierungsschlüssel. Wird beim Speichern nach ~/.MakeMKV/settings.conf synchronisiert. Darunter kann der aktuelle Betakey per API übernommen werden. | Wenn MakeMKV-Features ohne Trial-Limit genutzt werden sollen (inkl. Betakey-Usecase). | +| `Mediainfo Kommando` | `mediainfo_command` | `string` | `mediainfo` | Expertenmodus | Pfad oder Befehl für mediainfo. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). | +| `Minimale Titellänge (Minuten)` | `makemkv_min_length_minutes` | `number` | `60` | Standard | Filtert kurze Titel beim Rip. | Wenn kurze Bonus-/Trailer-Titel automatisch ausgefiltert werden sollen. | +| `HandBrake Kommando` | `handbrake_command` | `string` | `HandBrakeCLI` | Expertenmodus | Pfad oder Befehl für HandBrakeCLI. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). | +| `Encode-Neustart: unvollständige Ausgabe löschen` | `handbrake_restart_delete_incomplete_output` | `boolean` | `true` | Standard | Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt. | Wenn bei Encode-Neustart alte, unvollstaendige Dateien sicher entfernt werden sollen. | +| `Parallele Jobs` | `pipeline_max_parallel_jobs` | `number` | `1` | Standard | Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue. Aenderung beeinflusst Queue-Pump und Startentscheidungen fuer neue Jobs. | Wenn Durchsatz und CPU-Last fuer Videojobs ausbalanciert werden sollen. | +| `Max. parallele Audio CD Jobs` | `pipeline_max_parallel_cd_encodes` | `number` | `2` | Standard | Maximale Anzahl parallel laufender Audio CD Jobs (Rip + Encode als Einheit). Gilt zusätzlich zum Gesamtlimit. Aenderung beeinflusst Queue-Pump und Startentscheidungen fuer neue Jobs. | Wenn mehrere Audio-CD-Jobs parallel erlaubt/begrenzt werden sollen. | +| `Script-Test Timeout (ms)` | `script_test_timeout_ms` | `number` | `0` | Expertenmodus | Timeout fuer Script-Tests in den Settings. 0 = kein Timeout. Wird beim Testlauf von Scripts in Settings angewendet. | Wenn Script-Tests bei Haengern automatisch abgebrochen werden sollen. | +| `Max. Encodes gesamt (medienunabhängig)` | `pipeline_max_total_encodes` | `number` | `3` | Standard | Gesamtlimit für alle parallel laufenden Encode-Jobs (Film + Audio CD). Dieses Limit hat Vorrang vor den Einzellimits. Aenderung beeinflusst Queue-Pump und Startentscheidungen fuer neue Jobs. | Wenn ein harter Deckel fuer alle Encodes zusammen gelten soll. | +| `Audio CDs: Queue-Reihenfolge überspringen` | `pipeline_cd_bypasses_queue` | `boolean` | `false` | Standard | Wenn aktiv, können Audio CD Jobs unabhängig von Film-Jobs starten (überspringen die Film-Queue-Reihenfolge). Einzellimits und Gesamtlimit gelten weiterhin. Aenderung beeinflusst Queue-Pump und Startentscheidungen fuer neue Jobs. | Wenn Audio-CD-Jobs nicht hinter langen Video-Warteschlangen blockieren sollen. | +| `cdparanoia Kommando` | `cdparanoia_command` | `string` | `cdparanoia` | Expertenmodus | Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). | +| `FFmpeg Kommando` | `ffmpeg_command` | `string` | `ffmpeg` | Standard | Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). | +| `FFprobe Kommando` | `ffprobe_command` | `string` | `ffprobe` | Standard | Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). | +| `Mediainfo Extra Args` | `mediainfo_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). | +| `MakeMKV Rip Modus` | `makemkv_rip_mode_bluray` | `select` | `backup` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner. | Wenn fuer 'MakeMKV Rip Modus' ein anderes Standardverhalten benoetigt wird. | +| `MakeMKV Analyze Extra Args` | `makemkv_analyze_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Analyze (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). | +| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_bluray` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). | +| `HandBrake Preset` | `handbrake_preset_bluray` | `string` | `H.264 MKV 1080p30` | Standard | Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet. | Wenn ein bevorzugtes Standard-Preset fuer Blu-ray nicht jedes Mal manuell gewaehlt werden soll. | +| `HandBrake Extra Args` | `handbrake_extra_args_bluray` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-ma...` | Standard | Zusätzliche CLI-Argumente (Blu-ray). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). | +| `Ausgabeformat` | `output_extension_bluray` | `select` | `mkv` | Standard | Dateiendung für finale Datei (Blu-ray). | Wenn finale Blu-ray-Dateien standardmaessig als MKV oder MP4 ausgegeben werden sollen. | +| `Mediainfo Extra Args` | `mediainfo_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für mediainfo (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). | +| `MakeMKV Rip Modus` | `makemkv_rip_mode_dvd` | `select` | `mkv` | Ausgeblendet (nur API/DB) | mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner. | Wenn fuer 'MakeMKV Rip Modus' ein anderes Standardverhalten benoetigt wird. | +| `MakeMKV Analyze Extra Args` | `makemkv_analyze_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Analyze (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). | +| `MakeMKV Rip Extra Args` | `makemkv_rip_extra_args_dvd` | `string` | `leer` | Expertenmodus | Zusätzliche CLI-Parameter für Rip (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). | +| `HandBrake Preset` | `handbrake_preset_dvd` | `string` | `H.264 MKV 480p30` | Standard | Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet. | Wenn ein bevorzugtes Standard-Preset fuer DVD nicht jedes Mal manuell gewaehlt werden soll. | +| `HandBrake Extra Args` | `handbrake_extra_args_dvd` | `string` | `--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-ma...` | Standard | Zusätzliche CLI-Argumente (DVD). | Wenn du das CLI-Verhalten feinjustieren willst (Codec, Sprache, Untertitel, Analyse-Optionen). | +| `Ausgabeformat` | `output_extension_dvd` | `select` | `mkv` | Standard | Dateiendung für finale Datei (DVD). | Wenn finale DVD-Dateien standardmaessig als MKV oder MP4 ausgegeben werden sollen. | +| `mkvmerge Kommando` | `mkvmerge_command` | `string` | `mkvmerge` | Standard | Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt. | Wenn ein Tool nicht im PATH liegt oder eine feste Binary genutzt werden soll (voller Pfad). | -## Beispiele für häufige Settings +## Spezielle Werteformate -- `drive_devices`: `[{"path":"/dev/sr0","makemkvIndex":0},{"path":"/dev/sr1","makemkvIndex":1}]` -- `external_storage_paths`: `[{"name":"USB HDD","path":"/mnt/usb-hdd"}]` -- `converter_scan_extensions`: `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus` -- `output_template_bluray`: `${title} (${year})/${title} (${year})` -- `output_template_audiobook`: `{author}/{author} - {title} ({year})` -- `output_chapter_template_audiobook`: `{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}` -- `download_dir_owner`: `ripster:media` +- `Eigentümer (user:gruppe)`: z.B. `media:media` oder `1000:1000`. +- `drive_devices` (JSON): `[{"path":"/dev/sr0","makemkvIndex":0},{"path":"/dev/sr1","makemkvIndex":1}]` +- `external_storage_paths` (JSON): `[{"name":"USB HDD","path":"/mnt/usb-hdd"}]` +- `converter_scan_extensions`: kommagetrennt, ohne Punkt, z.B. `mkv,mp4,flac,mp3`. +- Template-Felder: erlauben Platzhalter (`{...}` / `${...}`) und `/` fuer Unterordner. -## Hinweise zu ausgeblendeten Keys +## Hinweis zu ausgeblendeten Feldern -Folgende Keys liegen im Schema, sind aber im normalen Settings-Formular absichtlich ausgeblendet: `drive_device`, `makemkv_source_index`, `makemkv_rip_mode_bluray`, `makemkv_rip_mode_dvd`. -Änderungen daran erfolgen nur über API/DB oder technische Migrationen. +Die Keys `drive_device`, `makemkv_source_index`, `makemkv_rip_mode_bluray`, `makemkv_rip_mode_dvd` liegen im Schema, werden aber bewusst nicht im normalen Formular angezeigt. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b306d96..78b2cc4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,14 @@ { "name": "ripster-frontend", - "version": "1.0.0-rc1", + "version": "1.0.0-rc2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "1.0.0-rc1", + "version": "1.0.0-rc2", "dependencies": { + "chart.js": "^4.5.1", "primeicons": "^7.0.0", "primereact": "^10.9.2", "react": "^18.3.1", @@ -703,6 +704,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1191,6 +1197,17 @@ } ] }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1612314..a02e1bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "1.0.0-rc1", + "version": "1.0.0-rc2", "private": true, "type": "module", "scripts": { @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "chart.js": "^4.5.1", "primeicons": "^7.0.0", "primereact": "^10.9.2", "react": "^18.3.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 255ad36..a6b6102 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import DownloadsPage from './pages/DownloadsPage'; import ConverterPage from './pages/ConverterPage'; import AudiobooksPage from './pages/AudiobooksPage'; import TmdbMigrationPage from './pages/TmdbMigrationPage'; +import HardwarePage from './pages/HardwarePage'; function normalizeJobId(value) { const parsed = Number(value); @@ -64,6 +65,35 @@ function isTerminalStage(value) { return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED'; } +function parseTimestamp(value) { + if (!value) { + return 0; + } + const ts = Date.parse(String(value)); + return Number.isFinite(ts) ? ts : 0; +} + +function mergePipelineStatePreservingNewestQueue(previousPipeline, incomingPipeline) { + const prev = previousPipeline && typeof previousPipeline === 'object' ? previousPipeline : {}; + const incoming = incomingPipeline && typeof incomingPipeline === 'object' ? incomingPipeline : {}; + const prevQueue = prev?.queue && typeof prev.queue === 'object' ? prev.queue : null; + const incomingQueue = incoming?.queue && typeof incoming.queue === 'object' ? incoming.queue : null; + if (!prevQueue || !incomingQueue) { + return { + ...prev, + ...incoming + }; + } + const prevQueueTs = parseTimestamp(prevQueue.updatedAt); + const incomingQueueTs = parseTimestamp(incomingQueue.updatedAt); + const queue = incomingQueueTs >= prevQueueTs ? incomingQueue : prevQueue; + return { + ...prev, + ...incoming, + queue + }; +} + function createInitialAudiobookUploadState() { return { phase: 'idle', @@ -363,7 +393,7 @@ function App() { } if (message.type === 'PIPELINE_STATE_CHANGED') { - setPipeline(message.payload); + setPipeline((prev) => mergePipelineStatePreservingNewestQueue(prev, message.payload)); } if (message.type === 'PIPELINE_PROGRESS') { @@ -705,6 +735,7 @@ function App() { } /> } /> } /> + } /> 0 ? JSON.stringify(body) : undefined }); afterMutationInvalidate(['/history', '/pipeline/queue']); return result; @@ -739,6 +773,7 @@ export const api = { if (options.keepBoth) body.keepBoth = true; if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; if (options.reuseCurrentJob) body.reuseCurrentJob = true; + if (options.createNewJob) body.createNewJob = true; const result = await request(`/pipeline/restart-review/${jobId}`, { method: 'POST', body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined @@ -751,6 +786,7 @@ export const api = { if (options.keepBoth) body.keepBoth = true; if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; if (options.restartMode) body.restartMode = options.restartMode; + if (options.createNewJob) body.createNewJob = true; const result = await request(`/pipeline/restart-encode/${jobId}`, { method: 'POST', body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined diff --git a/frontend/src/components/ConverterJobCard.jsx b/frontend/src/components/ConverterJobCard.jsx index f82ade6..d40d862 100644 --- a/frontend/src/components/ConverterJobCard.jsx +++ b/frontend/src/components/ConverterJobCard.jsx @@ -508,10 +508,32 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh }), [job.id, audioTracks, trackFields]); // ── Dialog handlers ───────────────────────────────────────────────────────── - const handleMetadataSearch = async (query) => { + const handleMetadataSearch = async (query, options = {}) => { try { - const response = await api.searchTmdbMovie(query); - return Array.isArray(response?.results) ? response.results : []; + const filterRaw = String(options?.resultFilter || '').trim().toLowerCase(); + const includeMovies = filterRaw !== 'series'; + const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie'; + const [movieResponse, seriesResponse] = await Promise.all([ + includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }), + includeSeries ? api.searchTmdbSeries(query) : Promise.resolve({ results: [] }) + ]); + const movieRows = Array.isArray(movieResponse?.results) + ? movieResponse.results.map((row) => ({ + ...row, + workflowKind: 'film', + metadataKind: 'movie', + resultType: 'movie' + })) + : []; + const seriesRows = Array.isArray(seriesResponse?.results) + ? seriesResponse.results.map((row) => ({ + ...row, + workflowKind: 'series', + metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series', + resultType: 'series' + })) + : []; + return [...movieRows, ...seriesRows]; } catch { return []; } }; diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index 9be3f90..8ece03a 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -57,8 +57,7 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([ 'makemkv_rip_mode', 'makemkv_rip_mode_bluray', 'makemkv_rip_mode_dvd', - 'makemkv_backup_mode', - 'handbrake_pre_metadata_scan_enabled' + 'makemkv_backup_mode' ]); const EXPERT_ONLY_SETTING_KEYS = new Set([ 'pushover_device', @@ -727,6 +726,42 @@ function MakeMKVBetaKeyHint({ onApply, disabled = false }) { ); } +function ActivationBytesCacheBox({ entries = [] }) { + const list = Array.isArray(entries) ? entries : []; + return ( +
+
+

Activation Bytes Cache

+ Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt. +
+ {list.length === 0 ? ( +

Keine Einträge vorhanden.

+ ) : ( +
+ + + + + + + + + + {list.map((entry, index) => ( + + + + + + ))} + +
ChecksumActivation BytesGespeichert am
{String(entry?.checksum || '-')}{String(entry?.activation_bytes || '-')}{entry?.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '-'}
+
+ )} +
+ ); +} + function SettingField({ setting, value, @@ -1124,6 +1159,7 @@ export default function DynamicSettingsForm({ dirtyKeys, onChange, effectivePaths, + activationBytes = [], onNotify }) { const safeCategories = Array.isArray(categories) ? categories : []; @@ -1217,6 +1253,7 @@ export default function DynamicSettingsForm({ const pushoverEnabled = toBoolean(values?.[PUSHOVER_ENABLED_SETTING_KEY]); const isDriveCategory = normalizeText(category?.category) === 'laufwerk'; + const isToolsCategory = normalizeText(category?.category) === 'tools'; return (
{isDriveCategory && } @@ -1345,6 +1382,7 @@ export default function DynamicSettingsForm({ })()} ))} + {isToolsCategory ? : null}
); })()} diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index 7f473e4..2f1612d 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -266,6 +266,14 @@ function normalizeSubtitleVariantSelectionMap(rawSelection) { return map; } +function normalizeSubtitleSelectionMode(value, fallback = 'variants') { + const mode = String(value || '').trim().toLowerCase(); + if (mode === 'track_ids' || mode === 'variants') { + return mode; + } + return fallback; +} + function normalizeSubtitleConfidenceValue(value) { const raw = String(value || '').trim().toLowerCase(); if (raw === 'high' || raw === 'medium' || raw === 'low') { @@ -338,10 +346,34 @@ function compareFullSubtitleCandidate(a, b) { return a.originalIndex - b.originalIndex; } +function buildSubtitleVariantSelectionForPreviewFromTrackIds(subtitleTracks, selectedTrackIds = []) { + const selectedSet = new Set(normalizeTrackIdList(selectedTrackIds).map((id) => String(id))); + const map = {}; + const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : []; + for (const track of tracks) { + if (!selectedSet.has(String(track?.id))) { + continue; + } + const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'); + if (!map[language]) { + map[language] = { full: false, forced: false }; + } + if (track.isForcedOnly) { + map[language].forced = true; + } else { + map[language].full = true; + } + } + return map; +} + function resolveDeterministicSubtitleCliSelectionForPreview({ subtitleTracks, subtitleVariantSelection, - subtitleLanguageOrder + subtitleLanguageOrder, + fallbackSelectedSubtitleTrackIds = [], + fallbackSelectedSubtitleTrackIdsOrdered = [], + hasExplicitVariantSelection = false }) { const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : []) .map((track, index) => { @@ -375,8 +407,17 @@ function resolveDeterministicSubtitleCliSelectionForPreview({ } const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(subtitleVariantSelection || {}); - const hasExplicitVariantSelection = Object.keys(normalizedVariantSelection).length > 0; const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder || []); + const fallbackSelectionFromTrackIds = normalizeSubtitleVariantSelectionMap( + buildSubtitleVariantSelectionForPreviewFromTrackIds(tracks, fallbackSelectedSubtitleTrackIds) + ); + const fallbackSelectionFromOrderedTrackIds = normalizeSubtitleVariantSelectionMap( + buildSubtitleVariantSelectionForPreviewFromTrackIds( + tracks, + normalizeTrackIdSequence(fallbackSelectedSubtitleTrackIdsOrdered, { dedupe: false }) + ) + ); + const fallbackSelectionSet = new Set(normalizeTrackIdList(fallbackSelectedSubtitleTrackIds).map((id) => String(id))); const sortedLanguages = Array.from(grouped.entries()) .map(([language, languageTracks]) => ({ language, @@ -395,6 +436,11 @@ function resolveDeterministicSubtitleCliSelectionForPreview({ order.push(language); }; normalizedLanguageOrder.forEach(pushLanguage); + if (!hasExplicitVariantSelection) { + Object.keys(normalizedVariantSelection).forEach(pushLanguage); + Object.keys(fallbackSelectionFromOrderedTrackIds).forEach(pushLanguage); + Object.keys(fallbackSelectionFromTrackIds).forEach(pushLanguage); + } sortedLanguages.forEach(pushLanguage); const subtitleTrackIds = []; @@ -406,15 +452,33 @@ function resolveDeterministicSubtitleCliSelectionForPreview({ const bestFull = fullTracks[0] || null; const bestFullHasForced = fullTracks.filter((track) => track.fullHasForced).sort(compareFullSubtitleCandidate)[0] || null; - const explicit = normalizedVariantSelection[language] || null; - const fallbackFull = languageTracks.some((track) => track.selectedForEncode && !track.isForcedOnly) || Boolean(bestFull); - const fallbackForced = languageTracks.some((track) => track.selectedForEncode && track.isForcedOnly) || Boolean(forcedOnly); - const requestedFull = explicit - ? Boolean(explicit.full) - : (hasExplicitVariantSelection ? false : fallbackFull); - const requestedForced = explicit - ? Boolean(explicit.forced) - : (hasExplicitVariantSelection ? false : fallbackForced); + const explicitEntry = normalizedVariantSelection[language] || null; + const fallbackEntryFromOrderedTrackIds = fallbackSelectionFromOrderedTrackIds[language] || null; + const fallbackEntryFromTrackIds = fallbackSelectionFromTrackIds[language] || null; + const hasAnyFallbackSelectedTrack = languageTracks.some((track) => ( + fallbackSelectionSet.has(String(track.id)) || track.selectedForEncode + )); + let requestedFull = false; + let requestedForced = false; + if (explicitEntry) { + requestedFull = Boolean(explicitEntry.full); + requestedForced = Boolean(explicitEntry.forced); + } else if (hasExplicitVariantSelection) { + requestedFull = false; + requestedForced = false; + } else if (fallbackEntryFromOrderedTrackIds) { + requestedFull = Boolean(fallbackEntryFromOrderedTrackIds.full); + requestedForced = Boolean(fallbackEntryFromOrderedTrackIds.forced); + } else if (fallbackEntryFromTrackIds) { + requestedFull = Boolean(fallbackEntryFromTrackIds.full); + requestedForced = Boolean(fallbackEntryFromTrackIds.forced); + } else if (hasAnyFallbackSelectedTrack) { + requestedFull = Boolean(bestFull); + requestedForced = Boolean(forcedOnly); + } else { + requestedFull = Boolean(bestFull); + requestedForced = Boolean(forcedOnly); + } if (!requestedFull && !requestedForced) { continue; } @@ -601,6 +665,7 @@ function buildHandBrakeCommandPreview({ title, selectedAudioTrackIds, selectedSubtitleTrackIds, + selectedSubtitleSelectionMode = 'variants', selectedSubtitleVariantSelection = {}, selectedSubtitleLanguageOrder = [], commandOutputPath = null, @@ -628,18 +693,31 @@ function buildHandBrakeCommandPreview({ ? Math.trunc(rawMappedTitleId) : null; - const resolvedSubtitleSelection = resolveDeterministicSubtitleCliSelectionForPreview({ - subtitleTracks: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [], - subtitleVariantSelection: selectedSubtitleVariantSelection, - subtitleLanguageOrder: selectedSubtitleLanguageOrder - }); const normalizedSelectedVariantSelection = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {}); const hasExplicitSelectedVariantSelection = Object.keys(normalizedSelectedVariantSelection).length > 0; + const normalizedSubtitleSelectionMode = normalizeSubtitleSelectionMode( + selectedSubtitleSelectionMode, + hasExplicitSelectedVariantSelection ? 'variants' : 'track_ids' + ); + const normalizedSelectedSubtitleTrackIds = normalizeTrackIdSequence(selectedSubtitleTrackIds, { dedupe: false }); + + const resolvedSubtitleSelection = resolveDeterministicSubtitleCliSelectionForPreview({ + subtitleTracks: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [], + subtitleVariantSelection: normalizedSubtitleSelectionMode === 'variants' + ? normalizedSelectedVariantSelection + : {}, + subtitleLanguageOrder: selectedSubtitleLanguageOrder, + fallbackSelectedSubtitleTrackIds: normalizedSelectedSubtitleTrackIds, + fallbackSelectedSubtitleTrackIdsOrdered: normalizedSelectedSubtitleTrackIds, + hasExplicitVariantSelection: normalizedSubtitleSelectionMode === 'track_ids' + ? true + : hasExplicitSelectedVariantSelection + }); const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0 ? resolvedSubtitleSelection.subtitleTrackIds : (hasExplicitSelectedVariantSelection ? [] - : normalizeTrackIdSequence(selectedSubtitleTrackIds, { dedupe: false })); + : normalizedSelectedSubtitleTrackIds); const selectedSubtitleSet = new Set(normalizeTrackIdList(subtitleTrackIds).map((id) => String(id))); const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => { const id = normalizeTrackId(track?.id); @@ -1480,6 +1558,7 @@ export default function MediaInfoReviewPanel({ commandOutputPathByTitle = {}, selectedEncodeTitleId = null, selectedEncodeTitleIds = [], + titleSelectionTouched = false, allowTitleSelection = false, compactTitleSelection = false, onSelectEncodeTitle = null, @@ -1546,13 +1625,16 @@ export default function MediaInfoReviewPanel({ .filter(Boolean) : null; const displayTitles = candidateTitles && candidateTitles.length > 0 ? candidateTitles : titles; - const selectedTitleIds = normalizeTrackIdList([ - ...normalizeTrackIdList(selectedEncodeTitleIds), + const explicitSelectedTitleIds = normalizeTrackIdList(selectedEncodeTitleIds); + const fallbackSelectedTitleIds = normalizeTrackIdList([ ...normalizeTrackIdList(review?.selectedTitleIds || []), ...displayTitles.filter((item) => Boolean(item?.selectedForEncode)).map((item) => item?.id) ]); + const selectedTitleIds = titleSelectionTouched + ? explicitSelectedTitleIds + : (explicitSelectedTitleIds.length > 0 ? explicitSelectedTitleIds : fallbackSelectedTitleIds); const currentSelectedId = normalizeTitleId(selectedEncodeTitleId) - || normalizeTitleId(review.encodeInputTitleId) + || (titleSelectionTouched ? null : normalizeTitleId(review.encodeInputTitleId)) || selectedTitleIds[0] || null; const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id))); @@ -1993,6 +2075,12 @@ export default function MediaInfoReviewPanel({ const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap( titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection ); + const selectedSubtitleSelectionMode = normalizeSubtitleSelectionMode( + titleSelectionEntry?.subtitleSelectionMode, + Object.keys(selectedSubtitleVariantSelection).length > 0 + ? 'variants' + : 'track_ids' + ); const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder( Array.isArray(titleSelectionEntry?.subtitleLanguageOrder) ? titleSelectionEntry.subtitleLanguageOrder @@ -2003,6 +2091,7 @@ export default function MediaInfoReviewPanel({ subtitleTracks, selectedAudioTrackIds, selectedSubtitleTrackIds, + selectedSubtitleSelectionMode, selectedSubtitleVariantSelection, selectedSubtitleLanguageOrder }; @@ -2621,6 +2710,12 @@ export default function MediaInfoReviewPanel({ const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap( titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection ); + const selectedSubtitleSelectionMode = normalizeSubtitleSelectionMode( + titleSelectionEntry?.subtitleSelectionMode, + Object.keys(selectedSubtitleVariantSelection).length > 0 + ? 'variants' + : 'track_ids' + ); const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder( Array.isArray(titleSelectionEntry?.subtitleLanguageOrder) ? titleSelectionEntry.subtitleLanguageOrder @@ -2803,6 +2898,7 @@ export default function MediaInfoReviewPanel({ title, selectedAudioTrackIds, selectedSubtitleTrackIds, + selectedSubtitleSelectionMode, selectedSubtitleVariantSelection, selectedSubtitleLanguageOrder, commandOutputPath: resolvedCommandOutputPath, diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx index 8407a34..55d5c88 100644 --- a/frontend/src/components/MetadataSelectionDialog.jsx +++ b/frontend/src/components/MetadataSelectionDialog.jsx @@ -5,6 +5,49 @@ import { DataTable } from 'primereact/datatable'; import { Column } from 'primereact/column'; import { InputText } from 'primereact/inputtext'; +function normalizeWorkflowKind(value) { + const raw = String(value || '').trim().toLowerCase(); + if (['film', 'movie', 'feature'].includes(raw)) { + return 'film'; + } + if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) { + return 'series'; + } + return null; +} + +function normalizeResultType(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'series') { + return 'series'; + } + if (raw === 'movie' || raw === 'film' || raw === 'movies') { + return 'movie'; + } + return null; +} + +function normalizeMetadataRow(row = {}) { + const workflowKind = normalizeWorkflowKind( + row?.workflowKind + || row?.kind + || row?.metadataKind + || null + ); + const metadataKindRaw = String(row?.metadataKind || '').trim().toLowerCase(); + const metadataKind = metadataKindRaw || (workflowKind === 'series' ? 'series' : 'movie'); + const resultType = normalizeResultType(row?.resultType) + || (workflowKind === 'series' ? 'series' : (workflowKind === 'film' ? 'movie' : null)) + || ((metadataKind === 'series' || metadataKind === 'season') ? 'series' : 'movie'); + const normalizedWorkflowKind = workflowKind || (resultType === 'series' ? 'series' : 'film'); + return { + ...row, + metadataKind, + resultType, + workflowKind: normalizedWorkflowKind + }; +} + export default function MetadataSelectionDialog({ visible, context, @@ -91,26 +134,6 @@ export default function MetadataSelectionDialog({ const preferred = stripped || separatorNormalized || raw; return toSearchTitleCase(preferred); }; - const normalizeWorkflowKind = (value) => { - const raw = String(value || '').trim().toLowerCase(); - if (!raw) { - return null; - } - if (['film', 'movie', 'feature'].includes(raw)) { - return 'film'; - } - if (['series', 'tv', 'season', 'episode'].includes(raw)) { - return 'series'; - } - return null; - }; - const normalizeSeriesClassification = (value) => { - const raw = String(value || '').trim().toLowerCase(); - if (raw === 'series' || raw === 'film' || raw === 'ambiguous') { - return raw; - } - return null; - }; const parseDiscNumber = (rawValue) => { const text = String(rawValue ?? '').trim(); if (!/^\d+$/.test(text)) { @@ -122,6 +145,7 @@ export default function MetadataSelectionDialog({ } return Math.trunc(parsed); }; + const [selected, setSelected] = useState(null); const [query, setQuery] = useState(''); const [manualTitle, setManualTitle] = useState(''); @@ -138,18 +162,13 @@ export default function MetadataSelectionDialog({ const [conflictNewDiscNumber, setConflictNewDiscNumber] = useState(''); const [conflictError, setConflictError] = useState(''); const [seasonFilter, setSeasonFilter] = useState(''); + const [resultFilter, setResultFilter] = useState('all'); const [discValidationError, setDiscValidationError] = useState(''); - const [manualWorkflowKind, setManualWorkflowKind] = useState(null); const activeSearchRequestRef = useRef(0); const autoSearchDoneRef = useRef(false); - const autoSearchWorkflowRef = useRef(null); const wasVisibleRef = useRef(false); const lastVisibleContextIdentityRef = useRef(null); - const contextWorkflowKind = normalizeWorkflowKind( - context?.selectedMetadata?.workflowKind - || context?.workflowKind - || null - ); + const mediaProfile = String( context?.mediaProfile || context?.selectedMetadata?.mediaProfile @@ -157,46 +176,7 @@ export default function MetadataSelectionDialog({ ).trim().toLowerCase(); const metadataProvider = 'tmdb'; const providerLabel = 'TMDb'; - const seriesDecisionClassification = normalizeSeriesClassification(context?.seriesDecision?.classification); - const hasSeriesSignals = useMemo(() => { - if (contextWorkflowKind === 'series') { - return true; - } - const selectedMetadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object' - ? context.selectedMetadata - : {}; - const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase(); - if (metadataKind === 'series' || metadataKind === 'season') { - return true; - } - if (Number(selectedMetadata?.seasonNumber || 0) > 0) { - return true; - } - if (Number(context?.seriesLookupHint?.seasonNumber || 0) > 0) { - return true; - } - if (Boolean(context?.seriesAnalysis?.summary?.seriesLike)) { - return true; - } - const contextCandidates = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : []; - return contextCandidates.some((row) => Number(row?.seasonNumber || 0) > 0); - }, [context, contextWorkflowKind]); - const recommendedWorkflowKind = normalizeWorkflowKind( - context?.seriesDecision?.recommendedWorkflowKind - || contextWorkflowKind - || null - ) || (hasSeriesSignals ? 'series' : 'film'); - const effectiveWorkflowKind = manualWorkflowKind || recommendedWorkflowKind; - const allowsWorkflowSwitch = ( - (mediaProfile === 'dvd' || mediaProfile === 'bluray') - && seriesDecisionClassification === 'ambiguous' - ); const supportsExternalIdInput = false; - const requiresDiscNumber = metadataProvider === 'tmdb' - && effectiveWorkflowKind === 'series' - && (mediaProfile === 'dvd' || mediaProfile === 'bluray'); - const effectiveDiscNumber = parseDiscNumber(manualDiscNumber); - const hasValidDiscNumber = effectiveDiscNumber !== null; const contextJobId = Number(context?.jobId || 0) || null; const contextIdentity = contextJobId ? `job:${contextJobId}` @@ -236,29 +216,19 @@ export default function MetadataSelectionDialog({ setConflictNewDiscNumber(''); setConflictError(''); setSeasonFilter(''); + setResultFilter('all'); setDiscValidationError(''); - setManualWorkflowKind(allowsWorkflowSwitch ? recommendedWorkflowKind : null); autoSearchDoneRef.current = false; - autoSearchWorkflowRef.current = null; activeSearchRequestRef.current += 1; wasVisibleRef.current = true; lastVisibleContextIdentityRef.current = contextIdentity; - }, [visible, contextIdentity, context, allowsWorkflowSwitch, recommendedWorkflowKind]); - - useEffect(() => { - if (!discValidationError) { - return; - } - if (!requiresDiscNumber || hasValidDiscNumber) { - setDiscValidationError(''); - } - }, [discValidationError, requiresDiscNumber, hasValidDiscNumber]); + }, [visible, contextIdentity, context]); useEffect(() => { if (submitError) { setSubmitError(''); } - }, [manualDiscNumber, seasonFilter, selected, metadataProvider, conflictExistingDiscNumber, conflictNewDiscNumber]); + }, [manualDiscNumber, seasonFilter, selected, resultFilter, conflictExistingDiscNumber, conflictNewDiscNumber]); const rows = useMemo(() => { const base = Array.isArray(context?.metadataCandidates) @@ -269,17 +239,17 @@ export default function MetadataSelectionDialog({ const map = new Map(); all.forEach((item) => { + const normalized = normalizeMetadataRow(item); + const rowType = normalizeResultType(normalized?.resultType) || 'movie'; const rowKey = String( - item?.providerId - || item?.imdbId - || item?.tmdbId - || `${item?.title || '-'}::${item?.year || '-'}::${item?.seasonNumber || '-'}` + normalized?.providerId + || `${rowType}:${normalized?.imdbId || normalized?.tmdbId || '-'}:${normalized?.seasonNumber || '-'}:${normalized?.title || '-'}:${normalized?.year || '-'}` ).trim(); if (!rowKey) { return; } map.set(rowKey, { - ...item, + ...normalized, _metadataKey: rowKey }); }); @@ -287,20 +257,56 @@ export default function MetadataSelectionDialog({ return Array.from(map.values()); }, [context, extraResults, hasSearchOverride]); + const seriesRowsAvailable = useMemo( + () => rows.some((row) => normalizeResultType(row?.resultType) === 'series'), + [rows] + ); + + const selectedResultType = normalizeResultType(selected?.resultType) + || (normalizeWorkflowKind(selected?.workflowKind) === 'series' ? 'series' : 'movie'); + const selectedIsSeries = Boolean(selected) && selectedResultType === 'series'; + const showSeasonFilter = resultFilter !== 'movies' && seriesRowsAvailable; + const filteredRows = useMemo(() => { - if (effectiveWorkflowKind !== 'series') { - return rows; + let output = [...rows]; + + if (resultFilter === 'movies') { + output = output.filter((row) => normalizeResultType(row?.resultType) !== 'series'); + } else if (resultFilter === 'series') { + output = output.filter((row) => normalizeResultType(row?.resultType) === 'series'); } - const normalizedFilter = String(seasonFilter || '').trim().toLowerCase(); - if (!normalizedFilter) { - return rows; + + if (showSeasonFilter) { + const normalizedFilter = String(seasonFilter || '').trim().toLowerCase(); + if (normalizedFilter) { + output = output.filter((row) => { + if (normalizeResultType(row?.resultType) !== 'series') { + return false; + } + const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase(); + const seasonName = String(row?.seasonName || '').trim().toLowerCase(); + return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter); + }); + } } - return rows.filter((row) => { - const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase(); - const seasonName = String(row?.seasonName || '').trim().toLowerCase(); - return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter); - }); - }, [metadataProvider, rows, seasonFilter]); + + return output; + }, [rows, resultFilter, seasonFilter, showSeasonFilter]); + + const effectiveDiscNumber = parseDiscNumber(manualDiscNumber); + const hasValidDiscNumber = effectiveDiscNumber !== null; + const requiresDiscNumber = metadataProvider === 'tmdb' + && selectedIsSeries + && (mediaProfile === 'dvd' || mediaProfile === 'bluray'); + + useEffect(() => { + if (!discValidationError) { + return; + } + if (!requiresDiscNumber || hasValidDiscNumber) { + setDiscValidationError(''); + } + }, [discValidationError, requiresDiscNumber, hasValidDiscNumber]); const titleWithPosterBody = (row) => (
@@ -341,8 +347,7 @@ export default function MetadataSelectionDialog({ let effectiveResults = []; for (const candidateQuery of queryVariants) { const results = await onSearch(candidateQuery, { - metadataProvider, - workflowKind: effectiveWorkflowKind + metadataProvider }); if (activeSearchRequestRef.current !== requestId) { return; @@ -371,21 +376,18 @@ export default function MetadataSelectionDialog({ } if (typeof onSearch !== 'function') { autoSearchDoneRef.current = true; - autoSearchWorkflowRef.current = effectiveWorkflowKind; return; } const trimmedQuery = String(query || '').trim(); if (!trimmedQuery) { return; } - const workflowChanged = autoSearchWorkflowRef.current !== effectiveWorkflowKind; - if (autoSearchDoneRef.current && !workflowChanged) { + if (autoSearchDoneRef.current) { return; } autoSearchDoneRef.current = true; - autoSearchWorkflowRef.current = effectiveWorkflowKind; void handleSearch(); - }, [visible, query, effectiveWorkflowKind, onSearch]); + }, [visible, query, onSearch]); const handleSubmitError = (error, pendingPayload = null) => { const details = Array.isArray(error?.details) ? error.details : []; @@ -435,6 +437,9 @@ export default function MetadataSelectionDialog({ return; } + const selectionWorkflowKind = selectedIsSeries ? 'series' : 'film'; + const manualWorkflowKind = resultFilter === 'series' ? 'series' : 'film'; + const payload = selected ? { jobId: context.jobId, @@ -443,10 +448,10 @@ export default function MetadataSelectionDialog({ imdbId: selected.imdbId, poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null, metadataProvider, - workflowKind: effectiveWorkflowKind, + workflowKind: selectionWorkflowKind, providerId: selected.providerId || null, tmdbId: selected.tmdbId || null, - metadataKind: selected.metadataKind || null, + metadataKind: selected.metadataKind || (selectedIsSeries ? 'series' : 'movie'), voteAverage: Number.isFinite(Number(selected?.voteAverage)) ? Number(Number(selected.voteAverage).toFixed(1)) : null, @@ -458,10 +463,10 @@ export default function MetadataSelectionDialog({ tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object' ? selected.tmdbDetails : null, - seasonNumber: selected.seasonNumber || null, - seasonName: selected.seasonName || null, - episodeCount: selected.episodeCount || 0, - episodes: Array.isArray(selected.episodes) ? selected.episodes : [], + seasonNumber: selectedIsSeries ? (selected.seasonNumber || null) : null, + seasonName: selectedIsSeries ? (selected.seasonName || null) : null, + episodeCount: selectedIsSeries ? (selected.episodeCount || 0) : 0, + episodes: selectedIsSeries && Array.isArray(selected.episodes) ? selected.episodes : [], ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) } : { @@ -471,7 +476,7 @@ export default function MetadataSelectionDialog({ imdbId: null, poster: null, metadataProvider, - workflowKind: effectiveWorkflowKind, + workflowKind: manualWorkflowKind, seasonNumber: null, ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) }; @@ -624,67 +629,77 @@ export default function MetadataSelectionDialog({ header="Metadaten auswählen" visible={visible} onHide={handleHideRequest} - style={{ width: '52rem', maxWidth: '95vw' }} + style={{ width: '58.25rem', maxWidth: '95vw', maxHeight: '950px' }} className="metadata-selection-dialog" breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }} modal > -
- setQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - void handleSearch(); - } - }} - placeholder="Titel suchen" - /> -
- {allowsWorkflowSwitch ? ( -
+
+
+ setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void handleSearch(); + } + }} + placeholder="Titel suchen" + /> +
+ +
+ {showSeasonFilter ? ( + setSeasonFilter(event.target.value)} + placeholder="Staffel-Filter (optional, z.B. 1 oder 5.2)" + disabled={searchBusy || busy} + /> + ) : null} +
+ setManualDiscNumber(event.target.value)} + placeholder={requiresDiscNumber ? 'Disc-Nr (Pflicht)' : 'Disc-Nr (optional)'} + inputMode="numeric" + disabled={searchBusy || busy} + /> + + {requiresDiscNumber + ? 'Pflichtfeld für Serien-DVD/Blu-ray. Ohne Disc-Nummer können die Metadaten nicht übernommen werden.' + : 'Optional für Mapping und Output-Templates. Gespeichert werden nur Werte > 0.'} + +
+
+ +
- ) : null} -
- {effectiveWorkflowKind === 'series' ? ( - setSeasonFilter(event.target.value)} - placeholder="Staffel-Filter (optional, z.B. 1 oder 5.2)" +
+ {searchError ? {searchError} : null} {discValidationError ? {discValidationError} : null} {submitError ? {submitError} : null} @@ -705,12 +720,17 @@ export default function MetadataSelectionDialog({ > + (normalizeResultType(row?.resultType) === 'series' ? 'Serie' : 'Film')} + style={{ width: '7rem' }} + /> (effectiveWorkflowKind === 'series' + body={(row) => (normalizeResultType(row?.resultType) === 'series' ? (row.seasonNumber ? `S${row.seasonNumber}` : '-') - : (row.imdbId || '-'))} - style={{ width: '10rem' }} + : '-')} + style={{ width: '8rem' }} />
diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index b78b91c..89dd739 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -858,6 +858,10 @@ function isPlaylistTrackPreparationStatus(value) { && normalized.includes('VORBEREIT'); } +function isMediainfoCheckStatus(value) { + return String(value || '').trim().toUpperCase() === 'MEDIAINFO_CHECK'; +} + function getSeriesBatchTagSeverity(status) { const normalized = normalizeSeriesBatchStatus(status); if (normalized === 'FINISHED') { @@ -1655,6 +1659,7 @@ export default function PipelineStatusCard({ const [liveJobLog, setLiveJobLog] = useState(''); const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null); const [selectedEncodeTitleIds, setSelectedEncodeTitleIds] = useState([]); + const [titleSelectionTouched, setTitleSelectionTouched] = useState(false); const [selectedPlaylistId, setSelectedPlaylistId] = useState(null); const [selectedHandBrakeTitleIdsState, setSelectedHandBrakeTitleIdsState] = useState([]); const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({}); @@ -1832,6 +1837,7 @@ export default function PipelineStatusCard({ const effectiveSelected = defaultAllTitles ? normalizeTitleIdList(reviewTitles.map((title) => title?.id)) : selectedFromReview; + setTitleSelectionTouched(false); setSelectedEncodeTitleIds(effectiveSelected); setSelectedEncodeTitleId(effectiveSelected[0] || fromReview || null); setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview)); @@ -1912,6 +1918,7 @@ export default function PipelineStatusCard({ if (mediaInfoReview) { return; } + setTitleSelectionTouched(false); setSelectedEncodeTitleIds([]); setSelectedEncodeTitleId(null); setTrackSelectionByTitle({}); @@ -1923,6 +1930,26 @@ export default function PipelineStatusCard({ setSelectedHandBrakePreset(''); }, [mediaInfoReview, retryJobId]); + const resolveEffectiveSelectedTitleIds = (reviewTitles = []) => { + const normalizedReviewTitles = Array.isArray(reviewTitles) ? reviewTitles : []; + const explicitSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds); + if (titleSelectionTouched) { + return explicitSelectedIds; + } + const fallbackSelectedIds = normalizeTitleIdList([ + ...explicitSelectedIds, + selectedEncodeTitleId, + mediaInfoReview?.encodeInputTitleId, + ...normalizedReviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id) + ]); + const selectedTitleIdsInReviewOrder = normalizedReviewTitles + .map((title) => normalizeTitleId(title?.id)) + .filter((id) => id !== null && fallbackSelectedIds.includes(id)); + return selectedTitleIdsInReviewOrder.length > 0 + ? selectedTitleIdsInReviewOrder + : fallbackSelectedIds; + }; + useEffect(() => { const normalizedSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds); const primaryTitleId = normalizeTitleId(selectedEncodeTitleId); @@ -1962,11 +1989,7 @@ export default function PipelineStatusCard({ const resolveEpisodeFillContext = () => { const episodeRows = Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : []; const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; - const selectedTitleIds = normalizeTitleIdList([ - ...normalizeTitleIdList(selectedEncodeTitleIds), - ...(Array.isArray(mediaInfoReview?.selectedTitleIds) ? mediaInfoReview.selectedTitleIds : []), - ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id) - ]); + const selectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles); const orderedTitles = reviewTitles .map((title) => ({ id: normalizeTitleId(title?.id), @@ -2268,18 +2291,7 @@ export default function PipelineStatusCard({ } const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; - const fallbackSelectedIds = normalizeTitleIdList([ - ...selectedEncodeTitleIds, - selectedEncodeTitleId, - mediaInfoReview?.encodeInputTitleId, - ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id) - ]); - const selectedTitleIdsInReviewOrder = reviewTitles - .map((title) => normalizeTitleId(title?.id)) - .filter((id) => id !== null && fallbackSelectedIds.includes(id)); - const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0 - ? selectedTitleIdsInReviewOrder - : fallbackSelectedIds; + const effectiveSelectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles); const hasEpisodeAssignmentPayload = (assignment) => { if (!assignment || typeof assignment !== 'object') { @@ -3113,8 +3125,12 @@ export default function PipelineStatusCard({ + `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}` ) : (pipeline?.statusText || 'Bereit'); - const showIndeterminatePrimaryProgress = !hasSeriesBatchProgress - && isPlaylistTrackPreparationStatus(pipeline?.statusText); + const showMediainfoIndeterminatePrimaryProgress = !hasSeriesBatchProgress + && isMediainfoCheckStatus(state); + const showIndeterminatePrimaryProgress = showMediainfoIndeterminatePrimaryProgress || ( + !hasSeriesBatchProgress + && isPlaylistTrackPreparationStatus(pipeline?.statusText) + ); const ripIssueEntries = useMemo( () => extractMakeMkvRipIssues(jobRow?.makemkvInfo), [jobRow?.makemkvInfo] @@ -3461,18 +3477,7 @@ export default function PipelineStatusCard({ .map((title) => [normalizeTitleId(title?.id), title]) .filter((entry) => entry[0] !== null) ); - const fallbackSelectedIds = normalizeTitleIdList([ - ...selectedEncodeTitleIds, - selectedEncodeTitleId, - mediaInfoReview?.encodeInputTitleId, - ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id) - ]); - const selectedTitleIdsInReviewOrder = reviewTitles - .map((title) => normalizeTitleId(title?.id)) - .filter((id) => id !== null && fallbackSelectedIds.includes(id)); - const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0 - ? selectedTitleIdsInReviewOrder - : fallbackSelectedIds; + const effectiveSelectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles); const preferredPrimaryTitleId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(mediaInfoReview?.encodeInputTitleId) || null; @@ -3657,7 +3662,7 @@ export default function PipelineStatusCard({ )} {showIndeterminatePrimaryProgress - ? 'Vorbereitung läuft …' + ? (showMediainfoIndeterminatePrimaryProgress ? 'Mediainfo-Prüfung läuft …' : 'Vorbereitung läuft …') : primaryEta ? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`) : 'ETA unbekannt'} @@ -4342,11 +4347,13 @@ export default function PipelineStatusCard({ )} selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)} selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)} + titleSelectionTouched={titleSelectionTouched} allowTitleSelection={allowTitleSelectionInReview} compactTitleSelection={isDiscTitleSelectionRequired} onSelectEncodeTitle={(titleId) => { const normalizedTitleId = normalizeTitleId(titleId); setSelectedEncodeTitleId(normalizedTitleId); + setTitleSelectionTouched(true); if (!normalizedTitleId) { return; } @@ -4357,6 +4364,7 @@ export default function PipelineStatusCard({ if (!normalizedTitleId) { return; } + setTitleSelectionTouched(true); setSelectedEncodeTitleIds((prev) => { const current = normalizeTitleIdList(prev); if (checked) { diff --git a/frontend/src/pages/ConverterPage.jsx b/frontend/src/pages/ConverterPage.jsx index ecdf5ee..04ce016 100644 --- a/frontend/src/pages/ConverterPage.jsx +++ b/frontend/src/pages/ConverterPage.jsx @@ -911,6 +911,13 @@ export default function ConverterPage() {
+
+