diff --git a/backend/package-lock.json b/backend/package-lock.json index bb224a7..9f9f19a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.13.1-12", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.13.1-12", + "version": "0.14.0", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 300479b..05c666d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.13.1-12", + "version": "0.14.0", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 1a9a9e6..d384f7a 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -841,6 +841,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [ description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.', validation_json: '{}' }, + { + key: 'output_template_bluray_series_episode', + required: 1, + description: '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}.', + validation_json: '{"minLength":1}' + }, + { + key: 'output_template_bluray_series_multi_episode', + required: 1, + description: '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}.', + validation_json: '{"minLength":1}' + }, { key: 'output_template_dvd_series_episode', required: 1, @@ -859,6 +871,8 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [ const SETTINGS_CATEGORY_MOVES = [ { key: 'cd_output_template', category: 'Pfade' }, { key: 'output_template_bluray', category: 'Pfade' }, + { key: 'output_template_bluray_series_episode', category: 'Pfade' }, + { key: 'output_template_bluray_series_multi_episode', category: 'Pfade' }, { key: 'output_template_dvd', category: 'Pfade' }, { key: 'output_template_audiobook', category: 'Pfade' }, { key: 'output_chapter_template_audiobook', category: 'Pfade' }, @@ -1050,6 +1064,18 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`); + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_bluray_series', 'Pfade', 'RAW-Ordner (Blu-ray Serie)', 'path', 0, 'RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray).', NULL, '[]', '{}', 1011)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_bluray_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (Blu-ray Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray).', NULL, '[]', '{}', 1012)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_owner', NULL)`); + await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021)` @@ -1062,6 +1088,18 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL)`); + 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_dir_bluray', 'Pfade', 'Serien-Ordner (Blu-ray)', 'path', 0, 'Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 110)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray', NULL)`); + + 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_dir_bluray_owner', 'Pfade', 'Eigentümer Serien-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1105)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray_owner', NULL)`); + 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_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)` @@ -1074,6 +1112,18 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL)`); + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_template_bluray_series_episode', 'Pfade', 'Output Template (Blu-ray Serie, Episode)', 'string', 1, '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}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 336)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('output_template_bluray_series_multi_episode', 'Pfade', 'Output Template (Blu-ray Serie, Multi-Episode)', 'string', 1, '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}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 337)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`); + await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, '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}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)` diff --git a/backend/src/plugins/DVDSeriesPlugin.js b/backend/src/plugins/DVDSeriesPlugin.js index 85f74a5..72ae297 100644 --- a/backend/src/plugins/DVDSeriesPlugin.js +++ b/backend/src/plugins/DVDSeriesPlugin.js @@ -31,11 +31,19 @@ class DVDSeriesPlugin extends SourcePlugin { pluginFile: 'DVDSeriesPlugin.js' }); + const fallbackSeriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([ + { + value: ctx.extra?.detectedTitle || '', + source: 'detected_title' + } + ]); + const scanText = String(ctx.extra?.scanText || '').trim(); if (!scanText) { return { parsedScan: null, seriesAnalysis: null, + seriesLookupHint: fallbackSeriesLookupHint, providerConfigured: await tmdbService.isConfigured() }; } @@ -50,7 +58,7 @@ class DVDSeriesPlugin extends SourcePlugin { value: ctx.extra?.detectedTitle || '', source: 'detected_title' } - ]); + ]) || fallbackSeriesLookupHint; return { parsedScan: result.parsed, seriesAnalysis: result.analysis, diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index 5640eea..f9563a9 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -58,11 +58,23 @@ router.post( logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath }); const importedJob = await historyService.importOrphanRawFolder(rawPath); const importedJobId = Number(importedJob?.id || 0); - const activation = (Number.isFinite(importedJobId) && importedJobId > 0) - ? await pipelineService.analyzeRawImportJob(importedJobId, { - rawPath: importedJob?.raw_path || rawPath - }) - : null; + let activation = null; + let activationError = null; + if (Number.isFinite(importedJobId) && importedJobId > 0) { + try { + activation = await pipelineService.analyzeRawImportJob(importedJobId, { + rawPath: importedJob?.raw_path || rawPath + }); + } catch (error) { + activationError = error?.message || String(error); + logger.warn('post:orphan-raw:import:activation-failed', { + reqId: req.reqId, + jobId: importedJobId, + rawPath, + error: activationError + }); + } + } const refreshedJob = importedJobId > 0 ? await historyService.getJobById(importedJobId) @@ -70,7 +82,8 @@ router.post( res.json({ job: refreshedJob || importedJob, - activation + activation, + ...(activationError ? { activationError } : {}) }); }) ); @@ -139,14 +152,25 @@ router.post( asyncHandler(async (req, res) => { const id = Number(req.params.id); const target = String(req.body?.target || 'both'); + const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase()); + const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths) + ? req.body.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; logger.warn('post:delete-files', { reqId: req.reqId, id, - target + target, + includeRelated, + selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0 }); - const result = await historyService.deleteJobFiles(id, target); + const result = await historyService.deleteJobFiles(id, target, { + includeRelated, + selectedMoviePaths + }); res.json(result); }) ); diff --git a/backend/src/services/dvdSeriesScanService.js b/backend/src/services/dvdSeriesScanService.js index c268aa8..22cf0cf 100644 --- a/backend/src/services/dvdSeriesScanService.js +++ b/backend/src/services/dvdSeriesScanService.js @@ -69,6 +69,8 @@ function normalizeLanguageCode(rawCode, fallbackLabel = null) { function normalizeSeriesLookupTitle(rawValue) { return String(rawValue || '') .replace(/[_./]+/g, ' ') + .replace(/\bs\d{1,2}\s*d\d{1,2}\b/gi, ' ') + .replace(/\bs(?:taffel|eason)?\s*\d{1,2}\s*(?:disc|dvd|cd|d)\s*\d{1,2}\b/gi, ' ') .replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ') .replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ') .replace(/\b\d{1,2}x\b/gi, ' ') @@ -99,7 +101,12 @@ function deriveSeriesLookupHint(inputs = []) { continue; } - const seasonMatch = compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i) + const compactSeasonDiscMatch = compactValue.match( + /(?:^|\s)s(?:taffel|eason)?\s*0?(\d{1,2})\s*(?:d|disc|dvd|cd)\s*0?(\d{1,2})(?=\s|$)/i + ); + const seasonMatch = compactSeasonDiscMatch + || compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i) + || compactValue.match(/(?:^|\s)s\s*0?(\d{1,2})(?=\s|$)/i) || compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i); if (!seasonMatch) { continue; @@ -110,9 +117,11 @@ function deriveSeriesLookupHint(inputs = []) { continue; } - const discMatch = compactValue.match(/(?:^|\s)(?:disc|dvd|cd)\s*(\d{1,2})(?=\s|$)/i); + const discMatch = compactSeasonDiscMatch + || compactValue.match(/(?:^|\s)(?:disc|dvd|cd|d)\s*0?(\d{1,2})(?=\s|$)/i); + const compactDiscToken = compactSeasonDiscMatch ? compactSeasonDiscMatch[2] : null; const discNumber = discMatch - ? Number(discMatch[1] || 0) || null + ? Number(compactDiscToken || discMatch[1] || 0) || null : null; const baseTitle = normalizeSeriesLookupTitle(compactValue); @@ -385,12 +394,22 @@ function parseHandBrakeScanText(rawOutput) { parsed.titleCount = Number(match[1] || 0); continue; } + match = line.match(/scan:\s+(?:BD|Blu-?ray)\s+has\s+(\d+)\s+title/i); + if (match) { + parsed.titleCount = Number(match[1] || 0); + continue; + } match = line.match(/scan:\s+scanning title\s+(\d+)/i); if (match) { ensureCurrentTitle(match[1]); continue; } + match = line.match(/^\s*\+\s+title\s+(\d+):/i); + if (match) { + ensureCurrentTitle(match[1]); + continue; + } match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i); if (match && currentTitle) { @@ -398,6 +417,12 @@ function parseHandBrakeScanText(rawOutput) { currentTitle.durationSeconds = parseDurationToSeconds(match[1]); continue; } + match = line.match(/^\s*\+\s+duration:\s+(\d{1,2}:\d{2}:\d{2})/i); + if (match && currentTitle) { + currentTitle.durationLabel = match[1]; + currentTitle.durationSeconds = parseDurationToSeconds(match[1]); + continue; + } match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i); if (match) { @@ -407,6 +432,11 @@ function parseHandBrakeScanText(rawOutput) { } continue; } + match = line.match(/^\s*\+\s+chapters:\s+(\d+)/i); + if (match && currentTitle) { + currentTitle.chapterCount = Number(match[1] || 0); + continue; + } match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i); if (match && currentTitle) { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 2646473..93f5875 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -685,8 +685,11 @@ function getSeriesRawPathCandidates(settings = {}) { for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) { pushPath(candidate); } + pushPath(source.raw_dir_bluray_series); pushPath(source.raw_dir_dvd_series); + const blurayEffective = settingsService.resolveEffectiveToolSettings(source, 'bluray') || {}; + pushPath(blurayEffective.series_raw_dir); const dvdEffective = settingsService.resolveEffectiveToolSettings(source, 'dvd') || {}; pushPath(dvdEffective.series_raw_dir); @@ -705,7 +708,7 @@ function isLikelySeriesRawPath(rawPath, settings = {}) { } // Fallback for common default layouts when series raw dir is not explicitly configured. - return /(^|\/)raw\/dvd\/series(\/|$)/i.test(normalizedRawPath); + return /(^|\/)raw\/(?:dvd|bluray)\/series(\/|$)/i.test(normalizedRawPath); } function getOrphanRawScanPathList(settings = {}) { @@ -732,6 +735,7 @@ function getOrphanRawScanPathList(settings = {}) { for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) { pushPath(candidate); } + pushPath(source.raw_dir_bluray_series); pushPath(source.raw_dir_dvd_series); pushPath(source.converter_raw_dir); @@ -739,7 +743,7 @@ function getOrphanRawScanPathList(settings = {}) { for (const profile of ['bluray', 'dvd', 'cd', 'audiobook']) { const effective = settingsService.resolveEffectiveToolSettings(source, profile) || {}; pushPath(effective.raw_dir); - if (profile === 'dvd') { + if (profile === 'dvd' || profile === 'bluray') { pushPath(effective.series_raw_dir); } } @@ -791,7 +795,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = || Number(selectedMetadata?.episodeCount || 0) > 0 || ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind) ); - const isSeriesDvd = mediaType === 'dvd' && ( + const isSeriesDisc = (mediaType === 'dvd' || mediaType === 'bluray') && ( seriesSignals || hasSeriesMetadataHint || hasSeriesJobKind @@ -825,8 +829,8 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = const seriesRawDir = String(effectiveSettings?.series_raw_dir || '').trim(); const seriesMovieDir = String(effectiveSettings?.series_dir || '').trim(); const defaultRawDir = String(effectiveSettings?.raw_dir || '').trim(); - let rawDir = isSeriesDvd ? (seriesRawDir || defaultRawDir) : defaultRawDir; - if (isSeriesDvd) { + let rawDir = isSeriesDisc ? (seriesRawDir || defaultRawDir) : defaultRawDir; + if (isSeriesDisc) { const normalizedStoredRawPath = normalizeComparablePath(job?.raw_path); if (normalizedStoredRawPath) { const seriesRawCandidates = getSeriesRawPathCandidates(settings || {}); @@ -836,11 +840,11 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = } } } - const configuredMovieDir = isSeriesDvd + const configuredMovieDir = isSeriesDisc ? (seriesMovieDir || String(effectiveSettings?.movie_dir || '').trim()) : String(effectiveSettings?.movie_dir || '').trim(); const movieDir = configuredMovieDir || rawDir; - const rawLookupDirsBase = isSeriesDvd + const rawLookupDirsBase = isSeriesDisc ? getSeriesRawPathCandidates(settings || {}) : getConfiguredMediaPathList(settings || {}, 'raw_dir'); const rawLookupDirs = rawLookupDirsBase @@ -848,7 +852,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = const effectiveRawPath = job?.raw_path ? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs) : (job?.raw_path || null); - const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDvd) + const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDisc) ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) : (job?.output_path || null); @@ -1062,6 +1066,97 @@ function inferSiblingOutputFolders(outputPath) { return candidates.map((item) => item.path); } +function resolveExistingOutputPathVariant(outputPath) { + const normalizedOutputPath = normalizeComparablePath(outputPath); + if (!normalizedOutputPath) { + return null; + } + try { + if (fs.existsSync(normalizedOutputPath)) { + return normalizedOutputPath; + } + } catch (_error) { + // Ignore and continue with fallback probing. + } + + const parsed = path.parse(normalizedOutputPath); + const parentDir = normalizeComparablePath(parsed.dir); + if (!parentDir) { + return null; + } + + const candidatePaths = []; + const candidateSet = new Set(); + const pushCandidate = (candidatePath) => { + const normalized = normalizeComparablePath(candidatePath); + if (!normalized || candidateSet.has(normalized)) { + return; + } + candidateSet.add(normalized); + candidatePaths.push(normalized); + }; + + // If parent directory is numbered (e.g. "Staffel 3_2"), prefer base folder first. + const parentName = path.basename(parentDir); + const parsedParentName = parseNumberedFolderName(parentName); + const parentRoot = path.dirname(parentDir); + const siblingBaseName = parsedParentName.baseName || parentName; + if (parsedParentName.numbered && parsedParentName.baseName) { + pushCandidate(path.join(parentRoot, parsedParentName.baseName, parsed.base)); + } + + // If filename is numbered (e.g. "Episode_2.mkv"), prefer base filename first. + const parsedFileName = parseNumberedFolderName(parsed.name); + if (parsedFileName.numbered && parsedFileName.baseName) { + pushCandidate(path.join(parentDir, `${parsedFileName.baseName}${parsed.ext}`)); + } + + // Probe sibling directories with same base name (base, _2, _3, ...). + if (parentRoot && siblingBaseName) { + try { + const siblingRegex = new RegExp(`^${escapeRegExp(siblingBaseName)}(?:_(\\d+))?$`); + const siblingDirs = fs.readdirSync(parentRoot, { withFileTypes: true }) + .filter((entry) => entry?.isDirectory?.()) + .map((entry) => { + const name = String(entry?.name || '').trim(); + const match = name.match(siblingRegex); + if (!match) { + return null; + } + const suffixNumber = match[1] ? Number(match[1]) : 0; + return { + dirPath: path.join(parentRoot, name), + suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0 + }; + }) + .filter(Boolean) + .sort((left, right) => { + if (left.suffixNumber !== right.suffixNumber) { + return left.suffixNumber - right.suffixNumber; + } + return String(left.dirPath || '').localeCompare(String(right.dirPath || ''), 'de-DE'); + }); + for (const sibling of siblingDirs) { + pushCandidate(path.join(sibling.dirPath, parsed.base)); + } + } catch (_error) { + // Best effort. + } + } + + for (const candidatePath of candidatePaths) { + try { + if (fs.existsSync(candidatePath)) { + return candidatePath; + } + } catch (_error) { + // Ignore and continue. + } + } + + return null; +} + function compareOutputFolderPaths(leftPath, rightPath) { const leftNormalized = normalizeComparablePath(leftPath); const rightNormalized = normalizeComparablePath(rightPath); @@ -3025,6 +3120,7 @@ class HistoryService { const mediaProfile = normalizeMediaTypeValue(options.mediaProfile) || 'other'; const importedAt = String(options.importedAt || new Date().toISOString()).trim() || new Date().toISOString(); const rawPath = String(options.rawPath || '').trim() || null; + const stripRecoveryMetadata = Boolean(options.stripRecoveryMetadata); const existingInfo = options.existingInfo && typeof options.existingInfo === 'object' ? options.existingInfo : {}; @@ -3109,7 +3205,7 @@ class HistoryService { if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) { nextInfo.tracks = recovery.tracks; } - if (recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') { + if (!stripRecoveryMetadata && recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') { const recoverySelectedMetadata = compactMetadataObject(recovery.selectedMetadata); nextInfo.selectedMetadata = { ...(nextInfo.selectedMetadata && typeof nextInfo.selectedMetadata === 'object' @@ -4895,19 +4991,7 @@ class HistoryService { const folderName = path.basename(absRawPath); const metadata = parseRawFolderMetadata(folderName); - let omdbById = null; - if (metadata.imdbId) { - try { - omdbById = await omdbService.fetchByImdbId(metadata.imdbId); - } catch (error) { - logger.warn('job:import-orphan-raw:omdb-fetch-failed', { - rawPath: absRawPath, - imdbId: metadata.imdbId, - message: error.message - }); - } - } - const effectiveTitle = omdbById?.title || metadata.title || folderName; + const effectiveTitle = metadata.title || folderName; const importedAt = new Date().toISOString(); const created = await this.createJob({ discDevice: null, @@ -4984,7 +5068,6 @@ class HistoryService { const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object' ? initialSourceJobContext.selectedMetadata : {}; - const orphanPosterUrl = omdbById?.poster || null; const cdRecovery = effectiveDetectedMediaType === 'cd' ? recoverCdJobArtifactsForImport({ currentRawPath: finalRawPath, @@ -5011,12 +5094,6 @@ class HistoryService { ], mediaProfile: effectiveDetectedMediaType }) || initialSourceJobContext; - const sourceJob = sourceJobContext?.job || null; - const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object' - ? sourceJobContext.selectedMetadata - : {}; - const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null); - const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null; const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({ importedAt, rawPath: finalRawPath, @@ -5025,27 +5102,12 @@ class HistoryService { mediaProfile: effectiveDetectedMediaType, // RAW-Import soll wie "Disk analysieren" starten: // keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan. - existingInfo: cdRecovery ? (sourceJobContext?.makemkvInfo || null) : null, + existingInfo: null, recovery: cdRecovery, selectedMetadata: null, - analyzeContextPatch: null + analyzeContextPatch: null, + stripRecoveryMetadata: true }); - const recoveredPosterUrl = orphanPosterUrl - || (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate) - || null; - const recoveredExternalId = String( - omdbById?.imdbId - || metadata.imdbId - || sourceSelectedMetadata?.mbId - || sourceSelectedMetadata?.musicBrainzId - || sourceSelectedMetadata?.musicbrainzId - || sourceSelectedMetadata?.musicbrainz_id - || sourceSelectedMetadata?.music_brainz_id - || sourceSelectedMetadata?.musicbrainz - || sourceSelectedMetadata?.mbid - || sourceJob?.imdb_id - || '' - ).trim() || null; await this.updateJob(created.id, { ...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), ...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}), @@ -5053,32 +5115,19 @@ class HistoryService { ...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}), status: 'FINISHED', last_state: 'FINISHED', - title: omdbById?.title - || sourceSelectedMetadata?.title - || sourceSelectedMetadata?.album - || sourceJob?.title - || metadata.title - || cdRecovery?.selectedMetadata?.title - || null, - year: Number.isFinite(Number(omdbById?.year)) - ? Number(omdbById.year) - : ( - sourceSelectedMetadata?.year - || sourceJob?.year - || metadata.year - || cdRecovery?.selectedMetadata?.year - || null - ), - imdb_id: recoveredExternalId, - poster_url: recoveredPosterUrl, - omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : (sourceJob?.omdb_json || null), - selected_from_omdb: omdbById ? 1 : Number(sourceJob?.selected_from_omdb || 0), + // /database-Import startet bewusst metadata-clean: keine Übernahme aus früheren Läufen. + title: null, + year: null, + imdb_id: null, + poster_url: null, + omdb_json: null, + selected_from_omdb: 0, rip_successful: 1, raw_path: finalRawPath, - output_path: cdRecovery?.outputPath || null, + output_path: null, handbrake_info_json: null, mediainfo_info_json: null, - encode_plan_json: recoveredCdEncodePlan ? JSON.stringify(recoveredCdEncodePlan) : null, + encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0, error_message: null, @@ -5092,11 +5141,6 @@ class HistoryService { }); } - // Bild direkt persistieren (kein Rip-Prozess, daher kein Cache-Zwischenschritt) - if (orphanPosterUrl || sourcePosterCandidate) { - this.restoreImportedPoster(created.id, sourceJobContext, orphanPosterUrl || sourcePosterCandidate).catch(() => {}); - } - if (!cdRecovery?.logSources?.length) { await this.appendLog( created.id, @@ -5112,22 +5156,11 @@ class HistoryService { ? `Historieneintrag aus RAW erstellt (Medientyp: ${effectiveDetectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}` : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${effectiveDetectedMediaType})` ); - if (metadata.imdbId) { - await this.appendLog( - created.id, - 'SYSTEM', - omdbById - ? `OMDb-Zuordnung via IMDb-ID übernommen: ${omdbById.imdbId} (${omdbById.title || '-'})` - : `OMDb-Zuordnung via IMDb-ID fehlgeschlagen: ${metadata.imdbId}` - ); - } - if (sourceJob) { - await this.appendLog( - created.id, - 'SYSTEM', - `Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.` - ); - } + await this.appendLog( + created.id, + 'SYSTEM', + 'Metadaten aus früheren Läufen wurden beim /database-Import verworfen (metadata-clean Start).' + ); logger.info('job:import-orphan-raw', { jobId: created.id, @@ -5268,8 +5301,9 @@ class HistoryService { job?.encode_plan_json, job?.handbrake_info_json ); - if (resolvedJobMediaType === 'dvd' && discNumber === null) { - const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).'); + if ((resolvedJobMediaType === 'dvd' || resolvedJobMediaType === 'bluray') && discNumber === null) { + const seriesDiscLabel = resolvedJobMediaType === 'bluray' ? 'Blu-ray' : 'DVD'; + const error = new Error(`Serien-${seriesDiscLabel} erkannt: Disk-Nummer ist Pflicht (Start bei 1).`); error.statusCode = 400; throw error; } @@ -6363,7 +6397,7 @@ class HistoryService { } } - async deleteJobFiles(jobId, target = 'both') { + async deleteJobFiles(jobId, target = 'both', options = {}) { const allowedTargets = new Set(['raw', 'movie', 'both']); if (!allowedTargets.has(target)) { const error = new Error(`Ungültiges target '${target}'. Erlaubt: raw, movie, both.`); @@ -6378,6 +6412,81 @@ class HistoryService { throw error; } + const includeRelated = Boolean(options?.includeRelated); + const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths) + ? options.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; + + if (includeRelated || (selectedMoviePaths && selectedMoviePaths.length > 0)) { + const normalizedJobId = normalizeJobIdValue(jobId); + const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated }); + const summary = this._deletePathsFromPreview(preview, target, { selectedMoviePaths }); + const relatedJobIds = Array.from(new Set( + (Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : []) + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + )); + + if (summary.movie?.deleted) { + const movieDeleteEntries = (Array.isArray(summary?.deletedPaths) ? summary.deletedPaths : []) + .filter((entry) => String(entry?.target || '').trim().toLowerCase() === 'movie') + .map((entry) => ({ + path: normalizeComparablePath(entry?.path), + type: String(entry?.type || '').trim().toLowerCase() + })) + .filter((entry) => Boolean(entry.path)); + const previewMoviePaths = (Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : []) + .map((candidate) => normalizeComparablePath(candidate?.path)) + .filter(Boolean); + const cleanupPaths = new Set(); + + for (const deletedEntry of movieDeleteEntries) { + cleanupPaths.add(deletedEntry.path); + for (const candidatePath of previewMoviePaths) { + if (deletedEntry.type === 'directory') { + if (isPathInside(deletedEntry.path, candidatePath)) { + cleanupPaths.add(candidatePath); + } + } else if (candidatePath === deletedEntry.path) { + cleanupPaths.add(candidatePath); + } + } + } + + for (const deletedPath of cleanupPaths) { + await this.removeJobOutputFolderFromJobs( + relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId], + deletedPath + ); + } + } + + await this.appendLog( + normalizedJobId, + 'USER_ACTION', + `Dateien gelöscht (${target}) - includeRelated=${includeRelated} - raw=${JSON.stringify(summary.raw)} movie=${JSON.stringify(summary.movie)}` + ); + logger.info('job:delete-files', { + jobId: normalizedJobId, + includeRelated, + selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0, + summary + }); + + const [updated, enrichSettings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + return { + summary, + includeRelated, + relatedJobIds, + job: enrichJobRow(updated, enrichSettings) + }; + } + const settings = await settingsService.getSettingsMap(); const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); const effectiveRawPath = resolvedPaths.effectiveRawPath; @@ -6717,20 +6826,36 @@ class HistoryService { const merged = []; const seen = new Set(); const addFolder = (rawFolder, defaults = {}) => { - const outputPath = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim(); - if (!outputPath) { + const outputPathRaw = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim(); + if (!outputPathRaw) { return; } - const normalized = normalizeComparablePath(outputPath); - if (!normalized || seen.has(normalized)) { + const normalizedRawPath = normalizeComparablePath(outputPathRaw); + if (!normalizedRawPath) { return; } let exists = false; + let resolvedOutputPath = normalizedRawPath; try { - exists = fs.existsSync(normalized); + exists = fs.existsSync(normalizedRawPath); } catch (_error) { exists = false; } + if (!exists) { + const repairedPath = resolveExistingOutputPathVariant(normalizedRawPath); + if (repairedPath) { + resolvedOutputPath = repairedPath; + try { + exists = fs.existsSync(repairedPath); + } catch (_error) { + exists = false; + } + } + } + const normalized = normalizeComparablePath(resolvedOutputPath); + if (!normalized || seen.has(normalized)) { + return; + } if (!includeMissing && !exists) { return; } @@ -6738,7 +6863,7 @@ class HistoryService { merged.push({ id: normalizeJobIdValue(rawFolder?.id), job_id: normalizeJobIdValue(rawFolder?.job_id ?? rawFolder?.jobId ?? defaults.job_id ?? normalizedJobId), - output_path: outputPath, + output_path: resolvedOutputPath, label: String(rawFolder?.label || defaults.label || '').trim() || null, created_at: String(rawFolder?.created_at || rawFolder?.createdAt || '').trim() || null, exists @@ -6940,6 +7065,104 @@ class HistoryService { } return normalizedRequested; }; + const collectIncompleteMoviePathsFromPreview = (preview) => { + const rows = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : []; + return rows + .filter((row) => Boolean(row?.exists)) + .map((row) => String(row?.path || '').trim()) + .filter((candidatePath) => Boolean(candidatePath)) + .filter((candidatePath) => /(^|[\\/])incomplete_job-\d+([\\/]|$)/i.test(candidatePath)); + }; + const cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => { + const normalizedOwnerJobIds = Array.from(new Set( + (Array.isArray(ownerJobIds) ? ownerJobIds : []) + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + )); + const rows = Array.isArray(jobRows) ? jobRows : []; + if (rows.length === 0) { + return { attempted: 0, deleted: 0, failed: 0, paths: [] }; + } + + const settings = await settingsService.getSettingsMap(); + const candidatePaths = new Set(); + const fallbackIncompletePattern = /^incomplete_job-(\d+)\s*$/i; + + for (const row of rows) { + const rowId = normalizeJobIdValue(row?.id); + if (!rowId) { + continue; + } + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, row); + const movieRoot = normalizeComparablePath(resolvedPaths?.movieDir); + if (!movieRoot || isFilesystemRootPath(movieRoot)) { + continue; + } + const canonicalPath = normalizeComparablePath(path.join(movieRoot, `Incomplete_job-${rowId}`)); + if (canonicalPath && isPathInside(movieRoot, canonicalPath)) { + candidatePaths.add(canonicalPath); + } + try { + if (!fs.existsSync(movieRoot) || !fs.lstatSync(movieRoot).isDirectory()) { + continue; + } + const entries = fs.readdirSync(movieRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry?.isDirectory?.()) { + continue; + } + const match = String(entry?.name || '').match(fallbackIncompletePattern); + if (normalizeJobIdValue(match?.[1]) !== rowId) { + continue; + } + const scannedPath = normalizeComparablePath(path.join(movieRoot, entry.name)); + if (scannedPath && isPathInside(movieRoot, scannedPath)) { + candidatePaths.add(scannedPath); + } + } + } catch (_error) { + // best-effort scan + } + } + + if (candidatePaths.size === 0) { + return { attempted: 0, deleted: 0, failed: 0, paths: [] }; + } + + const deletedPaths = []; + let failedCount = 0; + for (const candidatePath of candidatePaths) { + try { + const inspection = inspectDeletionPath(candidatePath); + if (!inspection.exists) { + await this.removeJobOutputFolderFromJobs(normalizedOwnerJobIds, candidatePath); + deletedPaths.push(candidatePath); + continue; + } + if (inspection.isDirectory) { + deleteFilesRecursively(inspection.path, false); + } else if (inspection.isFile) { + fs.unlinkSync(inspection.path); + } + await this.removeJobOutputFolderFromJobs(normalizedOwnerJobIds, candidatePath); + deletedPaths.push(candidatePath); + } catch (_error) { + failedCount += 1; + } + } + + return { + attempted: candidatePaths.size, + deleted: deletedPaths.length, + failed: failedCount, + paths: deletedPaths + }; + }; + const normalizedSelectedMoviePaths = Array.isArray(options?.selectedMoviePaths) + ? options.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; const includeRelated = Boolean(options?.includeRelated); if (!includeRelated) { @@ -6956,13 +7179,37 @@ class HistoryService { error.statusCode = 400; throw error; } + if (isSeriesContainerRow(existing)) { + const containerChildren = await this.listChildJobs(normalizedJobId); + if (Array.isArray(containerChildren) && containerChildren.length > 0) { + const error = new Error( + 'Serien-Container kann nicht einzeln gelöscht werden, solange noch Child-Jobs vorhanden sind.' + ); + error.statusCode = 409; + throw error; + } + } - const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); + let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); + let selectedMoviePathsForDelete = normalizedSelectedMoviePaths; + let preview = null; let fileSummary = null; + if (effectiveFileTarget === 'none') { + preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false }); + const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview); + if (autoIncompleteMoviePaths.length > 0) { + effectiveFileTarget = 'movie'; + if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) { + selectedMoviePathsForDelete = autoIncompleteMoviePaths; + } + } + } if (effectiveFileTarget !== 'none') { - const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false }); + if (!preview) { + preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false }); + } fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, { - selectedMoviePaths: options?.selectedMoviePaths + selectedMoviePaths: selectedMoviePathsForDelete }); } @@ -7011,6 +7258,12 @@ class HistoryService { } await db.run('DELETE FROM jobs WHERE id = ?', [normalizedJobId]); + const stillExists = await db.get('SELECT id FROM jobs WHERE id = ?', [normalizedJobId]); + if (stillExists) { + const error = new Error(`Job #${normalizedJobId} konnte nicht aus der Datenbank entfernt werden.`); + error.statusCode = 409; + throw error; + } await db.exec('COMMIT'); } catch (error) { await db.exec('ROLLBACK'); @@ -7020,6 +7273,11 @@ class HistoryService { await this.closeProcessLog(normalizedJobId); this._deleteProcessLogFile(normalizedJobId); thumbnailService.deleteThumbnail(normalizedJobId); + const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both'; + let incompleteCleanupSummary = null; + if (includeMovieCleanup) { + incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs([existing], [normalizedJobId]); + } logger.warn('job:deleted', { jobId: normalizedJobId, @@ -7027,6 +7285,7 @@ class HistoryService { requestedFileTarget: fileTarget, includeRelated: false, pipelineStateReset: isActivePipelineJob, + incompleteCleanup: incompleteCleanupSummary, filesDeleted: fileSummary ? { raw: fileSummary.raw?.filesDeleted ?? 0, @@ -7042,7 +7301,8 @@ class HistoryService { requestedFileTarget: fileTarget, includeRelated: false, deletedJobIds: [Number(normalizedJobId)], - fileSummary + fileSummary, + incompleteCleanup: incompleteCleanupSummary }; } @@ -7050,11 +7310,49 @@ class HistoryService { const existing = resolved?.job || null; const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId); const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true }); - const deleteJobIds = Array.isArray(preview?.relatedJobs) + let deleteJobIds = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs .map((row) => normalizeJobIdValue(row?.id)) .filter(Boolean) : []; + let rowById = new Map(); + if (normalizedJobId && !deleteJobIds.includes(normalizedJobId)) { + deleteJobIds = [...deleteJobIds, normalizedJobId]; + } + if (deleteJobIds.length > 0) { + const db = await getDb(); + const placeholders = deleteJobIds.map(() => '?').join(', '); + const rows = await db.all( + `SELECT * FROM jobs WHERE id IN (${placeholders})`, + deleteJobIds + ); + rowById = new Map( + (Array.isArray(rows) ? rows : []) + .map((row) => [normalizeJobIdValue(row?.id), row]) + .filter(([id]) => Boolean(id)) + ); + const deleteSet = new Set(deleteJobIds); + // Safety net: never delete a series container while it would still have + // other children afterwards. + for (const candidateId of [...deleteJobIds]) { + const row = rowById.get(candidateId); + if (!row || !isSeriesContainerRow(row)) { + continue; + } + const childRows = await db.all( + `SELECT id FROM jobs WHERE parent_job_id = ?`, + [candidateId] + ); + const hasRemainingChildOutsideDeleteSet = (Array.isArray(childRows) ? childRows : []).some((childRow) => { + const childId = normalizeJobIdValue(childRow?.id); + return Boolean(childId && !deleteSet.has(childId)); + }); + if (hasRemainingChildOutsideDeleteSet) { + deleteSet.delete(candidateId); + } + } + deleteJobIds = Array.from(deleteSet); + } if (deleteJobIds.length === 0) { const error = new Error('Keine löschbaren Historien-Einträge gefunden.'); error.statusCode = 404; @@ -7072,11 +7370,21 @@ class HistoryService { throw error; } - const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); + let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); + let selectedMoviePathsForDelete = normalizedSelectedMoviePaths; + if (effectiveFileTarget === 'none') { + const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview); + if (autoIncompleteMoviePaths.length > 0) { + effectiveFileTarget = 'movie'; + if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) { + selectedMoviePathsForDelete = autoIncompleteMoviePaths; + } + } + } let fileSummary = null; if (effectiveFileTarget !== 'none') { fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, { - selectedMoviePaths: options?.selectedMoviePaths + selectedMoviePaths: selectedMoviePathsForDelete }); } @@ -7113,6 +7421,20 @@ class HistoryService { const deletePlaceholders = deleteJobIds.map(() => '?').join(', '); await db.run(`DELETE FROM jobs WHERE id IN (${deletePlaceholders})`, deleteJobIds); + const deletedVerificationRows = await db.all( + `SELECT id FROM jobs WHERE id IN (${deletePlaceholders})`, + deleteJobIds + ); + if (Array.isArray(deletedVerificationRows) && deletedVerificationRows.length > 0) { + const remainingIds = deletedVerificationRows + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean); + const error = new Error( + `Folgende Jobs konnten nicht gelöscht werden: ${remainingIds.join(', ')}` + ); + error.statusCode = 409; + throw error; + } await db.exec('COMMIT'); } catch (error) { await db.exec('ROLLBACK'); @@ -7124,6 +7446,19 @@ class HistoryService { this._deleteProcessLogFile(deletedJobId); thumbnailService.deleteThumbnail(deletedJobId); } + 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); + } + + const deletedJobIdSet = new Set(deleteJobIds); + const deletedJobs = Array.isArray(preview?.relatedJobs) + ? preview.relatedJobs.filter((row) => deletedJobIdSet.has(normalizeJobIdValue(row?.id))) + : []; logger.warn('job:deleted', { jobId: normalizedJobId, @@ -7133,6 +7468,7 @@ class HistoryService { deletedJobIds: deleteJobIds, deletedJobCount: deleteJobIds.length, pipelineStateReset: activeJobIncluded, + incompleteCleanup: incompleteCleanupSummary, filesDeleted: fileSummary ? { raw: fileSummary.raw?.filesDeleted ?? 0, @@ -7148,8 +7484,9 @@ class HistoryService { requestedFileTarget: fileTarget, includeRelated: true, deletedJobIds: deleteJobIds, - deletedJobs: preview.relatedJobs, - fileSummary + deletedJobs, + fileSummary, + incompleteCleanup: incompleteCleanupSummary }; } } diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index fad7bf8..abdc07a 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -149,6 +149,22 @@ function normalizeDvdMetadataWorkflowKind(value) { return null; } +function isSeriesDiscMediaProfile(mediaProfile) { + const normalized = normalizeMediaProfile(mediaProfile); + return normalized === 'dvd' || normalized === 'bluray'; +} + +function resolveSeriesDiscLabel(mediaProfile) { + const normalized = normalizeMediaProfile(mediaProfile); + if (normalized === 'bluray') { + return 'Blu-ray'; + } + if (normalized === 'dvd') { + return 'DVD'; + } + return 'Disc'; +} + function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) { const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' ? analyzeContext.selectedMetadata @@ -184,7 +200,7 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active } function isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata = {}, analyzeContext = null) { - if (normalizeMediaProfile(mediaProfile) !== 'dvd') { + if (!isSeriesDiscMediaProfile(mediaProfile)) { return false; } @@ -255,13 +271,20 @@ function resolveSeriesAwareRawStorage(settings = {}, mediaProfile = null, select ).trim(); const fallbackRawOwner = String(settings?.raw_dir_owner || '').trim(); const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); + const normalizedSeriesMediaProfile = normalizeMediaProfile(mediaProfile); const seriesRawCandidate = String( settings?.series_raw_dir + || (normalizedSeriesMediaProfile === 'bluray' ? settings?.raw_dir_bluray_series : null) + || (normalizedSeriesMediaProfile === 'dvd' ? settings?.raw_dir_dvd_series : null) + || settings?.raw_dir_bluray_series || settings?.raw_dir_dvd_series || '' ).trim(); const seriesRawOwnerCandidate = String( settings?.series_raw_dir_owner + || (normalizedSeriesMediaProfile === 'bluray' ? settings?.raw_dir_bluray_series_owner : null) + || (normalizedSeriesMediaProfile === 'dvd' ? settings?.raw_dir_dvd_series_owner : null) + || settings?.raw_dir_bluray_series_owner || settings?.raw_dir_dvd_series_owner || '' ).trim(); @@ -1313,6 +1336,18 @@ function normalizeSeriesEpisodeTitleForOutput(value, episodeRangeInfo = null) { return stripped; } +function normalizeSeriesTitleForOutputPath(value, fallback = 'series') { + const fallbackTitle = normalizeCdTrackText(fallback) || 'series'; + const raw = normalizeCdTrackText(value); + if (!raw) { + return fallbackTitle; + } + const stripped = String(raw) + .replace(/\s*-\s*S\d{1,2}E\d{1,3}(?:-\d{1,3})?\b.*$/i, '') + .trim(); + return stripped || raw || fallbackTitle; +} + function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); const makemkvInfo = parseJsonObjectSafe(job?.makemkv_info_json); @@ -1339,7 +1374,7 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { || encodePlan?.seriesBatchChild || encodePlan?.seriesBatchParentJobId ); - const isSeries = mediaProfile === 'dvd' + const isSeries = isSeriesDiscMediaProfile(mediaProfile) && ( seriesByPlan || isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext) @@ -1403,19 +1438,28 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { ?? analyzeContext?.seriesLookupHint?.discNumber ?? null ); - const seriesTitle = normalizeCdTrackText( - selectedMetadata?.title - || selectedMetadata?.seriesTitle - || job?.title + const seriesTitle = normalizeSeriesTitleForOutputPath( + selectedMetadata?.seriesTitle + || selectedMetadata?.title || job?.detected_title - || (fallbackJobId ? `job-${fallbackJobId}` : 'series') - ) || (fallbackJobId ? `job-${fallbackJobId}` : 'series'); + || job?.title + || (fallbackJobId ? `job-${fallbackJobId}` : 'series'), + fallbackJobId ? `job-${fallbackJobId}` : 'series' + ); const singleTemplateRaw = String( - settings?.output_template_dvd_series_episode + (mediaProfile === 'bluray' + ? settings?.output_template_bluray_series_episode + : settings?.output_template_dvd_series_episode) + || settings?.output_template_dvd_series_episode + || settings?.output_template_bluray_series_episode || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE ).trim(); const multiTemplateRaw = String( - settings?.output_template_dvd_series_multi_episode + (mediaProfile === 'bluray' + ? settings?.output_template_bluray_series_multi_episode + : settings?.output_template_dvd_series_multi_episode) + || settings?.output_template_dvd_series_multi_episode + || settings?.output_template_bluray_series_multi_episode || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE ).trim(); const singleTemplate = singleTemplateRaw || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE; @@ -1554,7 +1598,7 @@ function ensureUniqueOutputPath(outputPath) { } const isDirectory = Boolean(stat?.isDirectory?.()); - // Use sequential numbering (_2, _3, ...) for directories and parent-folder of files + // Use sequential numbering (_2, _3, ...) for directories and files. if (isDirectory) { for (let i = 2; i < 200; i++) { const attempt = `${outputPath}_${i}`; @@ -1562,9 +1606,9 @@ function ensureUniqueOutputPath(outputPath) { } } else { const parentDir = path.dirname(outputPath); - const fileName = path.basename(outputPath); + const parsed = path.parse(outputPath); for (let i = 2; i < 200; i++) { - const attempt = path.join(`${parentDir}_${i}`, fileName); + const attempt = path.join(parentDir, `${parsed.name}_${i}${parsed.ext}`); if (!fs.existsSync(attempt)) return attempt; } } @@ -2330,7 +2374,284 @@ function normalizeTrackLanguage(raw) { if (!value) { return 'und'; } - return value.toLowerCase().slice(0, 3); + const normalized = value.toLowerCase().slice(0, 3); + if (!normalized || normalized === 'un' || normalized === 'unk' || normalized === 'und') { + return 'und'; + } + return normalized; +} + +function isUnknownTrackLanguage(raw) { + const value = String(raw || '').trim().toLowerCase(); + return !value || value === 'und' || value === 'unknown' || value === 'unk' || value === 'un'; +} + +function normalizeTrackLanguageLabel(raw, fallbackLanguage = 'und') { + const value = String(raw || '').trim(); + if (value) { + return value; + } + return String(fallbackLanguage || 'und').trim() || 'und'; +} + +function buildSeriesBackupLanguageEnrichmentCandidatesFromPlaylistCache(scanCache) { + const normalizedCache = normalizeHandBrakePlaylistScanCache(scanCache); + if (!normalizedCache || !normalizedCache.byPlaylist || typeof normalizedCache.byPlaylist !== 'object') { + return []; + } + return Object.values(normalizedCache.byPlaylist) + .map((entry) => { + const titleInfo = entry?.titleInfo && typeof entry.titleInfo === 'object' + ? entry.titleInfo + : null; + if (!titleInfo) { + return null; + } + const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; + const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []; + return { + handBrakeTitleId: Number.isFinite(Number(entry?.handBrakeTitleId)) + ? Math.trunc(Number(entry.handBrakeTitleId)) + : null, + fileName: String(titleInfo?.fileName || '').trim() || null, + durationSeconds: Number(titleInfo?.durationSeconds || 0) || 0, + sizeBytes: Number(titleInfo?.sizeBytes || 0) || 0, + audioTracks, + subtitleTracks + }; + }) + .filter((title) => + title + && Number.isFinite(title.durationSeconds) + && title.durationSeconds > 0 + && (title.audioTracks.length > 0 || title.subtitleTracks.length > 0) + ); +} + +function buildSeriesBackupLanguageEnrichmentCandidatesFromHandBrakeScan(scanJson) { + const titleList = parseHandBrakeTitleList(scanJson); + if (!Array.isArray(titleList) || titleList.length === 0) { + return []; + } + + return titleList + .map((title) => { + const handBrakeTitleId = Number.isFinite(Number(title?.handBrakeTitleId)) + ? Math.trunc(Number(title.handBrakeTitleId)) + : null; + if (!handBrakeTitleId) { + return null; + } + const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, { + handBrakeTitleId, + strictHandBrakeTitleId: true + }); + if (!titleInfo) { + return null; + } + const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; + const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []; + return { + handBrakeTitleId, + fileName: String(titleInfo?.fileName || '').trim() || null, + durationSeconds: Number(titleInfo?.durationSeconds || title?.durationSeconds || 0) || 0, + sizeBytes: Number(titleInfo?.sizeBytes || title?.sizeBytes || 0) || 0, + audioTracks, + subtitleTracks + }; + }) + .filter((title) => + title + && Number.isFinite(title.durationSeconds) + && title.durationSeconds > 0 + && (title.audioTracks.length > 0 || title.subtitleTracks.length > 0) + ); +} + +function mapSeriesBackupReviewTitlesToLanguageCandidates(reviewTitles, candidates) { + const normalizedTitles = Array.isArray(reviewTitles) ? reviewTitles : []; + const normalizedCandidates = Array.isArray(candidates) ? candidates : []; + const mapping = new Map(); + if (normalizedTitles.length === 0 || normalizedCandidates.length === 0) { + return mapping; + } + + const usedCandidateIndexes = new Set(); + const normalizeFileName = (value) => String(value || '').trim().toLowerCase(); + + // 1) Prefer deterministic filename mapping when possible. + for (const title of normalizedTitles) { + const titleId = Number.isFinite(Number(title?.id)) ? Math.trunc(Number(title.id)) : null; + if (!titleId || mapping.has(titleId)) { + continue; + } + const titleFileName = normalizeFileName(title?.fileName || title?.name || null); + if (!titleFileName) { + continue; + } + let matchedIndex = null; + for (let index = 0; index < normalizedCandidates.length; index += 1) { + if (usedCandidateIndexes.has(index)) { + continue; + } + const candidateFileName = normalizeFileName(normalizedCandidates[index]?.fileName || null); + if (!candidateFileName || candidateFileName !== titleFileName) { + continue; + } + matchedIndex = index; + break; + } + if (matchedIndex === null) { + continue; + } + usedCandidateIndexes.add(matchedIndex); + mapping.set(titleId, normalizedCandidates[matchedIndex]); + } + + // 2) Fallback: duration/size score mapping. + for (const title of normalizedTitles) { + const titleId = Number.isFinite(Number(title?.id)) ? Math.trunc(Number(title.id)) : null; + if (!titleId || mapping.has(titleId)) { + continue; + } + const durationSeconds = Number(title?.durationSeconds || 0) || 0; + const sizeBytes = Number(title?.sizeBytes || 0) || 0; + + let bestIndex = null; + let bestScore = Number.POSITIVE_INFINITY; + for (let index = 0; index < normalizedCandidates.length; index += 1) { + if (usedCandidateIndexes.has(index)) { + continue; + } + const candidate = normalizedCandidates[index]; + const durationDelta = Math.abs(Number(candidate?.durationSeconds || 0) - durationSeconds); + const sizeDelta = ( + sizeBytes > 0 && Number(candidate?.sizeBytes || 0) > 0 + ? Math.abs(Number(candidate.sizeBytes) - sizeBytes) + : 0 + ); + const score = (durationDelta * 1024 * 1024) + sizeDelta; + if (score < bestScore) { + bestScore = score; + bestIndex = index; + } + } + + if (bestIndex === null) { + continue; + } + usedCandidateIndexes.add(bestIndex); + mapping.set(titleId, normalizedCandidates[bestIndex]); + } + + return mapping; +} + +function enrichSeriesBackupReviewLanguagesFromCandidates(reviewPlan, candidates = []) { + if (!reviewPlan || !Array.isArray(reviewPlan?.titles) || reviewPlan.titles.length === 0) { + return { plan: reviewPlan, applied: false, matchedTitles: 0, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; + } + + const normalizedCandidates = Array.isArray(candidates) ? candidates : []; + if (normalizedCandidates.length === 0) { + return { plan: reviewPlan, applied: false, matchedTitles: 0, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; + } + + const mapping = mapSeriesBackupReviewTitlesToLanguageCandidates(reviewPlan.titles, normalizedCandidates); + if (mapping.size === 0) { + return { plan: reviewPlan, applied: false, matchedTitles: 0, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; + } + + let enrichedAudioTracks = 0; + let enrichedSubtitleTracks = 0; + + const enrichedTitles = reviewPlan.titles.map((title) => { + const titleId = Number.isFinite(Number(title?.id)) ? Math.trunc(Number(title.id)) : null; + if (!titleId || !mapping.has(titleId)) { + return title; + } + const candidate = mapping.get(titleId); + const candidateAudioTracks = Array.isArray(candidate?.audioTracks) ? candidate.audioTracks : []; + const candidateSubtitleTracks = Array.isArray(candidate?.subtitleTracks) ? candidate.subtitleTracks : []; + + const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track, index) => { + const sourceTrack = candidateAudioTracks[index] || null; + if (!sourceTrack) { + return track; + } + const currentLanguage = String(track?.language || '').trim().toLowerCase(); + if (!isUnknownTrackLanguage(currentLanguage)) { + return track; + } + const sourceLanguage = normalizeTrackLanguage(sourceTrack?.language || sourceTrack?.languageLabel || 'und'); + if (isUnknownTrackLanguage(sourceLanguage)) { + return track; + } + enrichedAudioTracks += 1; + return { + ...track, + language: sourceLanguage, + languageLabel: normalizeTrackLanguageLabel(sourceTrack?.languageLabel || sourceTrack?.language, sourceLanguage), + title: track?.title || sourceTrack?.title || sourceTrack?.description || null, + format: track?.format || sourceTrack?.format || null, + channels: track?.channels || sourceTrack?.channels || null + }; + }); + + const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track, index) => { + const sourceTrack = candidateSubtitleTracks[index] || null; + if (!sourceTrack) { + return track; + } + const currentLanguage = String(track?.language || '').trim().toLowerCase(); + if (!isUnknownTrackLanguage(currentLanguage)) { + return track; + } + const sourceLanguage = normalizeTrackLanguage(sourceTrack?.language || sourceTrack?.languageLabel || 'und'); + if (isUnknownTrackLanguage(sourceLanguage)) { + return track; + } + enrichedSubtitleTracks += 1; + return { + ...track, + language: sourceLanguage, + languageLabel: normalizeTrackLanguageLabel(sourceTrack?.languageLabel || sourceTrack?.language, sourceLanguage), + title: track?.title || sourceTrack?.title || sourceTrack?.description || null, + format: track?.format || sourceTrack?.format || null, + forcedFlag: Boolean(track?.forcedFlag ?? sourceTrack?.forcedFlag), + sdhFlag: Boolean(track?.sdhFlag ?? sourceTrack?.sdhFlag), + defaultFlag: Boolean(track?.defaultFlag ?? sourceTrack?.defaultFlag), + eventCount: Number.isFinite(Number(track?.eventCount)) + ? Math.trunc(Number(track.eventCount)) + : (Number.isFinite(Number(sourceTrack?.eventCount)) ? Math.trunc(Number(sourceTrack.eventCount)) : null), + streamSizeBytes: Number.isFinite(Number(track?.streamSizeBytes)) + ? Math.trunc(Number(track.streamSizeBytes)) + : (Number.isFinite(Number(sourceTrack?.streamSizeBytes)) ? Math.trunc(Number(sourceTrack.streamSizeBytes)) : null) + }; + }); + + return { + ...title, + audioTracks, + subtitleTracks + }; + }); + + const applied = enrichedAudioTracks > 0 || enrichedSubtitleTracks > 0; + if (!applied) { + return { plan: reviewPlan, applied: false, matchedTitles: mapping.size, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; + } + + return { + plan: { + ...reviewPlan, + titles: enrichedTitles + }, + applied: true, + matchedTitles: mapping.size, + enrichedAudioTracks, + enrichedSubtitleTracks + }; } function normalizePositiveTrackId(rawValue) { @@ -3787,6 +4108,144 @@ function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = nu }; } +function filterSeriesBackupReviewTitles(reviewPlan) { + const plan = reviewPlan && typeof reviewPlan === 'object' + ? reviewPlan + : null; + if (!plan) { + return { + plan: reviewPlan, + applied: false, + reason: 'invalid_plan', + sourceCount: 0, + resultCount: 0 + }; + } + + const titles = Array.isArray(plan.titles) ? plan.titles : []; + if (titles.length < 3) { + return { + plan, + applied: false, + reason: 'insufficient_titles', + sourceCount: titles.length, + resultCount: titles.length + }; + } + + const rows = titles + .map((title, index) => ({ + id: normalizeReviewTitleId(title?.id), + durationSeconds: Number(title?.durationSeconds || 0), + index + })) + .filter((row) => row.id && Number.isFinite(row.durationSeconds) && row.durationSeconds > 0); + + if (rows.length < 3) { + return { + plan, + applied: false, + reason: 'insufficient_duration_data', + sourceCount: titles.length, + resultCount: titles.length + }; + } + + const longEpisodeDurations = rows + .map((row) => row.durationSeconds) + .filter((value) => Number.isFinite(value) && value >= (20 * 60)); + const topDurationSampleSize = Math.max( + 3, + Math.min(rows.length, Math.ceil(rows.length * 0.35)) + ); + const topDurations = rows + .map((row) => row.durationSeconds) + .sort((a, b) => b - a) + .slice(0, topDurationSampleSize); + const baselineDurations = (longEpisodeDurations.length >= 2 ? longEpisodeDurations : topDurations) + .slice() + .sort((a, b) => a - b); + const medianDurationSeconds = baselineDurations[Math.floor(baselineDurations.length / 2)]; + if (!Number.isFinite(medianDurationSeconds) || medianDurationSeconds < (10 * 60)) { + return { + plan, + applied: false, + reason: 'median_too_short_for_filter', + sourceCount: titles.length, + resultCount: titles.length + }; + } + + const shortThresholdSeconds = Math.max(5 * 60, Math.round(medianDurationSeconds * 0.5)); + const keptRows = rows.filter((row) => row.durationSeconds >= shortThresholdSeconds); + if (keptRows.length < 2 || keptRows.length >= rows.length) { + return { + plan, + applied: false, + reason: 'no_relevant_short_titles', + sourceCount: titles.length, + resultCount: titles.length + }; + } + + const keepIdSet = new Set(keptRows.map((row) => Number(row.id))); + const filteredTitles = titles + .filter((title) => keepIdSet.has(Number(title?.id))) + .map((title, index) => ({ + ...title, + selectedByMinLength: true, + eligibleForEncode: true, + selectedForEncode: true, + encodeInput: index === 0 + })); + + const selectedTitleIds = normalizeReviewTitleIdList(filteredTitles.map((title) => title?.id)); + const encodeInputTitleId = selectedTitleIds[0] || null; + const selectedEncodeTitle = filteredTitles.find((title) => Number(title?.id) === Number(encodeInputTitleId)) || filteredTitles[0] || null; + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const filteredAssignments = {}; + for (const titleId of selectedTitleIds) { + const assignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; + if (!assignment || typeof assignment !== 'object') { + continue; + } + filteredAssignments[String(titleId)] = assignment; + } + const notes = Array.isArray(plan.notes) ? plan.notes : []; + const filterNotePrefix = 'Serien-Kurztitel-Filter aktiv:'; + const nextNotes = notes.some((entry) => String(entry || '').startsWith(filterNotePrefix)) + ? notes + : [ + ...notes, + `${filterNotePrefix} ${filteredTitles.length}/${titles.length} Titel behalten (Schwelle ${formatDurationClock(shortThresholdSeconds)}).` + ]; + + return { + plan: { + ...plan, + titles: filteredTitles, + selectedTitleIds, + encodeInputTitleId, + encodeInputPath: String( + selectedEncodeTitle?.filePath + || selectedEncodeTitle?.path + || plan.encodeInputPath + || '' + ).trim() || null, + titleSelectionRequired: false, + episodeAssignments: filteredAssignments, + notes: nextNotes + }, + applied: true, + reason: 'filtered_short_titles_by_median', + sourceCount: titles.length, + resultCount: filteredTitles.length, + shortThresholdSeconds + }; +} + function resolveSeriesPlayAllDoubleEpisodeDecision(seriesCandidates = [], allTitles = [], options = {}) { const normalizedSeriesCandidates = (Array.isArray(seriesCandidates) ? seriesCandidates : []) .map((item) => ({ @@ -4486,7 +4945,7 @@ function buildRawMetadataBase(jobLike = {}, fallbackJobId = null, options = {}) || analyzeContext?.mediaProfile || null ); - const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const isSeriesDisc = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); const seasonNumber = normalizePositiveInteger( opts.seriesSeasonNumber ?? selectedMetadata?.seasonNumber @@ -4510,7 +4969,7 @@ function buildRawMetadataBase(jobLike = {}, fallbackJobId = null, options = {}) ? Math.trunc(rawYear) : new Date().getFullYear(); - if (isSeriesDvd && seasonNumber && discNumber) { + if (isSeriesDisc && seasonNumber && discNumber) { const seriesTitle = normalizeCdTrackText( opts.seriesTitle || selectedMetadata?.title @@ -5133,6 +5592,61 @@ function normalizeReviewTitleIdList(rawValues) { return normalized; } +function resolveSeriesBatchSelectedTitleIdsFromPlan(parentPlan = null) { + const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; + const explicitSelectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(plan?.selectedTitleIds) + ? plan.selectedTitleIds + : [plan?.encodeInputTitleId] + ); + if (explicitSelectedTitleIds.length > 1) { + return explicitSelectedTitleIds; + } + + const titles = Array.isArray(plan?.titles) ? plan.titles : []; + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const assignmentTitleIds = normalizeReviewTitleIdList(Object.keys(assignmentMap)); + const episodeHintTitleIds = normalizeReviewTitleIdList( + titles + .filter((title) => { + if (Boolean(title?.selectedForEncode || title?.encodeInput)) { + return true; + } + const hasEpisodeStartHint = normalizePositiveInteger( + title?.episodeNumberStart + ?? title?.episodeNumber + ?? title?.episodeIdStart + ?? title?.episodeId + ?? null + ); + if (hasEpisodeStartHint) { + return true; + } + const hasEpisodeEndHint = normalizePositiveInteger( + title?.episodeNumberEnd + ?? title?.episodeIdEnd + ?? null + ); + if (hasEpisodeEndHint) { + return true; + } + return Boolean(String(title?.episodeTitle || '').trim()); + }) + .map((title) => title?.id) + ); + + const mergedFallbackTitleIds = normalizeReviewTitleIdList([ + ...explicitSelectedTitleIds, + ...assignmentTitleIds, + ...episodeHintTitleIds + ]); + return mergedFallbackTitleIds.length > 0 + ? mergedFallbackTitleIds + : explicitSelectedTitleIds; +} + function normalizeEpisodeNumberValue(value) { const numeric = Number(String(value ?? '').trim()); if (!Number.isFinite(numeric) || numeric <= 0) { @@ -7406,6 +7920,7 @@ class PipelineService extends EventEmitter { effectiveSettings?.series_raw_dir, sourceMap?.raw_dir, sourceMap?.raw_dir_bluray, + sourceMap?.raw_dir_bluray_series, sourceMap?.raw_dir_dvd, sourceMap?.raw_dir_dvd_series, sourceMap?.raw_dir_cd, @@ -7549,6 +8064,7 @@ class PipelineService extends EventEmitter { const rawBaseDir = String(settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '').trim(); const rawExtraDirs = [ settings?.raw_dir_bluray, + settings?.raw_dir_bluray_series, settings?.raw_dir_dvd, settings?.raw_dir_dvd_series, settings?.raw_dir_cd, @@ -8345,6 +8861,11 @@ class PipelineService extends EventEmitter { const status = String(job?.status || '').trim().toUpperCase(); const lastState = String(job?.last_state || '').trim().toUpperCase(); const ripSuccessful = Number(job?.rip_successful || 0) === 1; + const mkInfo = this.safeParseJson(job?.makemkv_info_json); + const isOrphanRawImportJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + if (isOrphanRawImportJob) { + return false; + } if (!this.normalizeDrivePath(job?.disc_device)) { return false; } @@ -8361,7 +8882,7 @@ class PipelineService extends EventEmitter { const db = await getDb(); const rows = await db.all( ` - SELECT id, disc_device, status, last_state, rip_successful, job_kind + SELECT id, disc_device, status, last_state, rip_successful, job_kind, makemkv_info_json FROM jobs WHERE disc_device IS NOT NULL AND TRIM(disc_device) <> '' @@ -9257,9 +9778,10 @@ class PipelineService extends EventEmitter { SELECT * FROM jobs WHERE parent_job_id = ? + OR CAST(json_extract(encode_plan_json, '$.seriesBatchParentJobId') AS INTEGER) = ? ORDER BY id ASC `, - [normalizedParentJobId] + [normalizedParentJobId, normalizedParentJobId] ); return (Array.isArray(rows) ? rows : []).filter((row) => this.isSeriesBatchChildJob(row)); } @@ -9614,13 +10136,12 @@ class PipelineService extends EventEmitter { const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' ? mkInfo.analyzeContext : {}; - const selectedMetadata = resolveSelectedMetadataForJob(sourceJob, analyzeContext, this.snapshot.context || {}); + const activeContextForJob = Number(this.snapshot?.activeJobId) === Number(normalizedParentJobId) + ? (this.snapshot?.context || {}) + : null; + const selectedMetadata = resolveSelectedMetadataForJob(sourceJob, analyzeContext, activeContextForJob); const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); - const selectedTitleIds = normalizeReviewTitleIdList( - Array.isArray(parentPlan?.selectedTitleIds) - ? parentPlan.selectedTitleIds - : [parentPlan?.encodeInputTitleId] - ); + const selectedTitleIds = resolveSeriesBatchSelectedTitleIdsFromPlan(parentPlan); if (!isSeriesDvd || selectedTitleIds.length <= 1) { return { handled: false }; } @@ -12082,6 +12603,8 @@ class PipelineService extends EventEmitter { encode_review_confirmed: 0, ...(resolvedRawPath !== sourceJob.raw_path ? { raw_path: resolvedRawPath } : {}) }); + // Safety net: orphan RAW analysis must never hold/restore a physical drive lock. + this._releaseDriveLockForJob(normalizedJobId, { reason: 'orphan_raw_analyze' }); await historyService.appendLog( normalizedJobId, @@ -12144,14 +12667,16 @@ class PipelineService extends EventEmitter { } } - if (mediaProfile === 'dvd' && analyzePlugin?.id === 'dvd') { + if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) { try { - const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile); const rawMedia = collectRawMediaCandidates(resolvedRawPath); - const dvdScanInputPath = resolveDvdScanInputPathFromRaw(resolvedRawPath, rawMedia) || resolvedRawPath; + const seriesScanInputPath = mediaProfile === 'dvd' + ? (resolveDvdScanInputPathFromRaw(resolvedRawPath, rawMedia) || resolvedRawPath) + : resolvedRawPath; const reviewResult = await this.runPluginReviewScan(analyzePlugin, sourceJob, { jobId: normalizedJobId, - rawPath: dvdScanInputPath, + rawPath: seriesScanInputPath, reviewMode: 'rip', source: 'HANDBRAKE_SCAN', silent: true @@ -12167,13 +12692,13 @@ class PipelineService extends EventEmitter { const dvdSeriesContext = await this.buildPluginContext(dvdSeriesPlugin.id, { discInfo: { ...deviceWithProfile, - path: dvdScanInputPath + path: seriesScanInputPath }, jobId: normalizedJobId, scanText, detectedTitle: effectiveDetectedTitle }); - const seriesPluginResult = await dvdSeriesPlugin.analyze(dvdScanInputPath, sourceJob, dvdSeriesContext); + const seriesPluginResult = await dvdSeriesPlugin.analyze(seriesScanInputPath, sourceJob, dvdSeriesContext); pluginExecution = this.mergePluginExecutionState( pluginExecution, this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) @@ -12185,6 +12710,10 @@ class PipelineService extends EventEmitter { ? seriesAnalysis.summary : null; const seriesLike = Boolean(rawSeriesSummary?.seriesLike); + const hasSeriesLookupHint = Boolean( + String(seriesLookupHint?.query || '').trim() + || Number(seriesLookupHint?.seasonNumber || 0) > 0 + ); seriesAnalysis = rawSeriesSummary ? { source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', @@ -12198,7 +12727,7 @@ class PipelineService extends EventEmitter { } : null; - if (seriesLike) { + if (seriesLike || hasSeriesLookupHint) { metadataProvider = 'tmdb'; metadataCandidates = []; const fallbackQuery = String(seriesLookupHint?.query || effectiveDetectedTitle || '').trim() || null; @@ -12213,7 +12742,7 @@ class PipelineService extends EventEmitter { if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) { metadataCandidates = await tmdbService.searchSeriesWithSeasons( seriesLookupHint.query, - { language: dvdSettings?.dvd_series_language || null } + { language: seriesSettings?.dvd_series_language || null } ).catch((error) => { logger.warn('dvd-series:tmdb-search-failed', { jobId: normalizedJobId, @@ -12287,7 +12816,7 @@ class PipelineService extends EventEmitter { normalizedJobId, 'SYSTEM', metadataProvider === 'tmdb' - ? `Serien-DVD erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.` + ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.` : `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".` ); @@ -12500,9 +13029,9 @@ class PipelineService extends EventEmitter { }); } } - if (mediaProfile === 'dvd' && analyzePlugin?.id === 'dvd') { + if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) { try { - const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile); const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, { jobId: job.id, deviceInfo: deviceWithProfile, @@ -12536,6 +13065,10 @@ class PipelineService extends EventEmitter { ? seriesAnalysis.summary : null; const seriesLike = Boolean(rawSeriesSummary?.seriesLike); + const hasSeriesLookupHint = Boolean( + String(seriesLookupHint?.query || '').trim() + || Number(seriesLookupHint?.seasonNumber || 0) > 0 + ); seriesAnalysis = rawSeriesSummary ? { source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', @@ -12549,7 +13082,7 @@ class PipelineService extends EventEmitter { } : null; - if (seriesLike) { + if (seriesLike || hasSeriesLookupHint) { metadataProvider = 'tmdb'; metadataCandidates = []; const fallbackQuery = String(seriesLookupHint?.query || effectiveDetectedTitle || '').trim() || null; @@ -12564,7 +13097,7 @@ class PipelineService extends EventEmitter { if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) { metadataCandidates = await tmdbService.searchSeriesWithSeasons( seriesLookupHint.query, - { language: dvdSettings?.dvd_series_language || null } + { language: seriesSettings?.dvd_series_language || null } ).catch((error) => { logger.warn('dvd-series:tmdb-search-failed', { jobId: job.id, @@ -12635,7 +13168,7 @@ class PipelineService extends EventEmitter { job.id, 'SYSTEM', metadataProvider === 'tmdb' - ? `Serien-DVD erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.` + ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.` : `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".` ); @@ -12720,6 +13253,18 @@ class PipelineService extends EventEmitter { } const seasonFilter = String(seasonNumber || '').trim(); + const applySeasonFilter = (rows) => { + const sourceRows = Array.isArray(rows) ? rows : []; + if (!seasonFilter) { + return sourceRows; + } + const seasonFilterLower = seasonFilter.toLowerCase(); + return sourceRows.filter((row) => { + const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase(); + const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase(); + return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower); + }); + }; logger.info('tmdb:series-search', { query: normalizedQuery, seasonNumber: seasonFilter || null @@ -12755,30 +13300,38 @@ class PipelineService extends EventEmitter { let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, { language: tmdbLanguage }); - if (!Array.isArray(normalizedResults)) { - normalizedResults = []; - } - - if (seasonFilter) { - const seasonFilterLower = seasonFilter.toLowerCase(); - normalizedResults = normalizedResults.filter((row) => { - const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase(); - const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase(); - return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower); - }); - } - - logger.info('tmdb:series-search:done', { query: normalizedQuery, count: normalizedResults.length }); + normalizedResults = applySeasonFilter(normalizedResults); if (normalizedResults.length === 0) { - const seasonInfo = seasonFilter - ? ` (Staffel-Filter ${seasonFilter})` - : ''; - const error = new Error(`Keine TMDb-Treffer für "${normalizedQuery}"${seasonInfo}.`); - error.statusCode = 404; - throw error; + logger.warn('tmdb:series-search:empty:first-pass', { + query: normalizedQuery, + seasonNumber: seasonFilter || null + }); + let retryResults = []; + try { + retryResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, { + language: tmdbLanguage + }); + } catch (retryError) { + logger.warn('tmdb:series-search:retry:failed', { + query: normalizedQuery, + seasonNumber: seasonFilter || null, + error: errorToMeta(retryError) + }); + } + normalizedResults = applySeasonFilter(retryResults); } + if (normalizedResults.length === 0) { + logger.info('tmdb:series-search:done', { + query: normalizedQuery, + count: 0, + empty: true + }); + return []; + } + logger.info('tmdb:series-search:done', { query: normalizedQuery, count: normalizedResults.length }); + const cacheNowTs = Date.now(); if (tmdbSeriesSearchCache.size >= 200) { for (const [key, entry] of tmdbSeriesSearchCache.entries()) { @@ -12889,7 +13442,7 @@ class PipelineService extends EventEmitter { error.runInfo = runInfo; throw error; } - if (normalizeMediaProfile(mediaProfile) === 'dvd') { + if (isSeriesDiscMediaProfile(mediaProfile)) { const seriesScanLines = Array.isArray(pluginScan?.scanAnalysisLines) && pluginScan.scanAnalysisLines.length > 0 ? pluginScan.scanAnalysisLines : lines; @@ -12902,7 +13455,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - 'Serien-DVD erkannt: Mindestlängen-Filter für Vorab-Spurprüfung ist deaktiviert.' + `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: Mindestlängen-Filter für Vorab-Spurprüfung ist deaktiviert.` ); } @@ -13082,6 +13635,16 @@ class PipelineService extends EventEmitter { : (options?.selectedTitleId ?? analyzeContext.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null); const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedTitleSource); const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); + const disableAnalyzeMinLengthFilter = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const isSeriesBackupReview = disableAnalyzeMinLengthFilter; + const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId); + const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList( + options?.selectedHandBrakeTitleIds + || (preSelectedHandBrakeTitleId ? [preSelectedHandBrakeTitleId] : []) + ); + let handBrakePlaylistMapScanJson = null; + let handBrakePlaylistMapScanLines = []; + let handBrakePlaylistMapRunInfo = null; if (this.isPrimaryJob(jobId)) { await this.setState('MEDIAINFO_CHECK', { @@ -13153,7 +13716,10 @@ class PipelineService extends EventEmitter { ); } else { const analyzeLines = []; - const analyzeConfig = await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, { mediaProfile }); + const analyzeConfig = await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, { + mediaProfile, + disableMinLengthFilter: disableAnalyzeMinLengthFilter + }); logger.info('backup-track-review:makemkv-analyze-command', { jobId, cmd: analyzeConfig.cmd, @@ -13172,9 +13738,12 @@ class PipelineService extends EventEmitter { silent: !this.isPrimaryJob(jobId) }); + const minLengthMinutesForAnalyze = disableAnalyzeMinLengthFilter + ? 0 + : Number(settings.makemkv_min_length_minutes || 60); const analyzed = analyzePlaylistObfuscation( analyzeLines, - Number(settings.makemkv_min_length_minutes || 60), + minLengthMinutesForAnalyze, {} ); playlistAnalysis = analyzed || null; @@ -13230,6 +13799,9 @@ class PipelineService extends EventEmitter { ); const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n')); + handBrakePlaylistMapScanJson = resolveScanJson; + handBrakePlaylistMapScanLines = Array.isArray(resolveScanLines) ? [...resolveScanLines] : []; + handBrakePlaylistMapRunInfo = pluginScan.runInfo || null; if (resolveScanJson) { const preparedCache = buildHandBrakePlaylistScanCache(resolveScanJson, playlistCandidates, rawPath); if (preparedCache) { @@ -13307,6 +13879,413 @@ class PipelineService extends EventEmitter { buildUpdatedMakemkvInfo()?.pluginExecution ); + if (isSeriesBackupReview) { + const normalizedSeriesPreselection = normalizeReviewTitleIdList(preSelectedHandBrakeTitleIds); + let seriesScanJson = handBrakePlaylistMapScanJson; + let seriesScanLines = Array.isArray(handBrakePlaylistMapScanLines) ? [...handBrakePlaylistMapScanLines] : []; + let seriesScanRunInfo = handBrakePlaylistMapRunInfo; + + if (!seriesScanJson) { + await this.updateProgress( + 'MEDIAINFO_CHECK', + 30, + null, + 'HandBrake Serien-Titelanalyse läuft', + jobId + ); + const scanLines = []; + const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', + silent: !this.isPrimaryJob(jobId) + }); + scanLines.push(...pluginScan.scanLines); + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + pluginScan.pluginExecution + ); + seriesScanRunInfo = pluginScan.runInfo || null; + seriesScanJson = parseMediainfoJsonOutput(scanLines.join('\n')); + seriesScanLines = scanLines; + handBrakePlaylistMapRunInfo = seriesScanRunInfo; + handBrakePlaylistMapScanLines = scanLines; + handBrakePlaylistMapScanJson = seriesScanJson; + if (!seriesScanJson) { + const error = new Error('HandBrake Serien-Scan lieferte kein parsebares JSON.'); + error.runInfo = seriesScanRunInfo; + throw error; + } + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'HandBrake Serien-Titelanalyse aus vorhandenem Scan wiederverwendet.' + ); + } + + const seriesScanContextLines = seriesScanLines.length > 0 ? seriesScanLines : null; + const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan( + updatedMakemkvInfoBase.analyzeContext || {}, + seriesScanContextLines + ); + if (seriesScanContextResult?.analyzeContext && typeof seriesScanContextResult.analyzeContext === 'object') { + updatedMakemkvInfoBase.analyzeContext = seriesScanContextResult.analyzeContext; + } + if (seriesScanContextResult?.derived) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Titelklassifizierung aus HandBrake-Scan aktualisiert (PlayAll/Kurz/Duplikate markiert).' + ); + } + + const cacheSeedRows = Array.isArray(playlistAnalysis?.candidates) && playlistAnalysis.candidates.length > 0 + ? playlistAnalysis.candidates + : (Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []); + if (seriesScanJson) { + const preparedCache = buildHandBrakePlaylistScanCache(seriesScanJson, cacheSeedRows, rawPath); + if (preparedCache) { + handBrakePlaylistScan = preparedCache; + playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); + playlistCandidates = buildPlaylistCandidates(playlistAnalysis); + } + } + + const allSeriesTitles = parseHandBrakeTitleList(seriesScanJson); + const filteredSeriesResult = filterSeriesDvdHandBrakeCandidates( + allSeriesTitles, + updatedMakemkvInfoBase.analyzeContext + ); + let seriesCandidates = filteredSeriesResult.candidates; + if (filteredSeriesResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Filter auf Blu-ray Titel angewendet: ${filteredSeriesResult.sourceCount} -> ${filteredSeriesResult.resultCount} (${filteredSeriesResult.reason || 'heuristik'}).` + ); + } + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Blu-ray Titel-Scan: ${allSeriesTitles.length} gesamt, ${seriesCandidates.length} Serienkandidaten.` + ); + + if (normalizedSeriesPreselection.length > 0) { + const selectedSet = new Set(normalizedSeriesPreselection.map((id) => Number(id))); + seriesCandidates = allSeriesTitles.filter((item) => selectedSet.has(Number(item?.handBrakeTitleId))); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Manuelle Serien-Titelauswahl übernommen: ${normalizedSeriesPreselection.map((id) => `-t ${id}`).join(', ')}.` + ); + } + + if ((!Array.isArray(seriesCandidates) || seriesCandidates.length === 0) && allSeriesTitles.length > 0) { + const rowsWithDuration = allSeriesTitles.filter((item) => Number(item?.durationSeconds || 0) > 0); + seriesCandidates = rowsWithDuration.length > 0 ? rowsWithDuration : allSeriesTitles; + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Heuristik lieferte keine Kandidaten; Fallback auf gesamte Titelliste.' + ); + } + + const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( + seriesCandidates, + allSeriesTitles + ); + if (seriesPlayAllDecision.required) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Grenzfall erkannt (PlayAll vs. Doppelfolge). Heuristik bleibt bei Episodenkandidaten ` + + `(${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'} vs ` + + `${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}).` + ); + } + + const seriesCandidateIds = normalizeReviewTitleIdList( + seriesCandidates.map((item) => item?.handBrakeTitleId) + ); + if (seriesCandidateIds.length === 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Heuristik konnte keine verwertbaren Titel-IDs auflösen. Fallback auf generische RAW-Spurprüfung.' + ); + return this.runMediainfoReviewForJob(jobId, rawPath, { + ...options, + mediaProfile + }); + } + + const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( + seriesScanJson, + seriesCandidateIds, + { encodeInputPath: rawPath } + ); + let resolvedSelectedTitleIds = normalizeReviewTitleIdList( + selectedReviewTitles.map((title) => normalizeReviewTitleId(title?.id)) + ); + if (resolvedSelectedTitleIds.length === 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Titel konnten nicht aus HandBrake-Scan übernommen werden. Fallback auf generische RAW-Spurprüfung.' + ); + return this.runMediainfoReviewForJob(jobId, rawPath, { + ...options, + mediaProfile + }); + } + let resolvedPrimaryTitleId = normalizeReviewTitleId(preSelectedHandBrakeTitleId) + || resolvedSelectedTitleIds[0] + || null; + if (!resolvedPrimaryTitleId || !resolvedSelectedTitleIds.includes(resolvedPrimaryTitleId)) { + resolvedPrimaryTitleId = resolvedSelectedTitleIds[0] || null; + } + if (!resolvedPrimaryTitleId) { + const error = new Error('Serien-Titelprüfung aus RAW fehlgeschlagen: keine primäre Titel-ID auflösbar.'); + error.statusCode = 400; + throw error; + } + + const primaryTitleInfo = parseHandBrakeSelectedTitleInfo(seriesScanJson, { + handBrakeTitleId: resolvedPrimaryTitleId, + strictHandBrakeTitleId: true + }); + if (!primaryTitleInfo) { + const error = new Error(`Kein HandBrake-Titel für Serien-Haupttitel -t ${resolvedPrimaryTitleId} gefunden.`); + error.statusCode = 400; + error.runInfo = seriesScanRunInfo; + throw error; + } + + let presetProfile = null; + try { + presetProfile = await settingsService.buildHandBrakePresetProfile(rawPath, { + titleId: resolvedPrimaryTitleId, + mediaProfile + }); + } catch (error) { + logger.warn('backup-track-review:series:preset-profile-failed', { + jobId, + error: errorToMeta(error) + }); + presetProfile = { + source: 'fallback', + message: `Preset-Profil konnte nicht geladen werden: ${error.message}` + }; + } + + const syntheticFilePath = path.join( + rawPath, + `handbrake_t${String(Math.trunc(Number(resolvedPrimaryTitleId))).padStart(2, '0')}.mkv` + ); + const syntheticMediaInfoByPath = { + [syntheticFilePath]: buildSyntheticMediaInfoFromMakeMkvTitle(primaryTitleInfo) + }; + let review = buildMediainfoReview({ + mediaFiles: [{ + path: syntheticFilePath, + size: Number(primaryTitleInfo?.sizeBytes || 0) + }], + mediaInfoByPath: syntheticMediaInfoByPath, + settings, + presetProfile, + playlistAnalysis, + preferredEncodeTitleId: null, + selectedPlaylistId: null, + selectedMakemkvTitleId: null + }); + review = remapReviewTrackIdsToSourceIds(review); + + const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); + let normalizedTitles = selectedReviewTitles.map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = selectedSet.has(Number(titleId)); + return { + ...title, + selectedForEncode, + encodeInput: titleId === resolvedPrimaryTitleId + }; + }); + if (normalizedTitles.length === 0) { + normalizedTitles = review.titles || []; + } + + review = { + ...review, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + reviewConfirmed: false, + partial: false, + processedFiles: normalizedTitles.length, + totalFiles: normalizedTitles.length, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds, + selectedPlaylistId: null, + selectedMakemkvTitleId: null, + titleSelectionRequired: false, + titles: normalizedTitles, + selectedTitleIds: resolvedSelectedTitleIds, + encodeInputTitleId: resolvedPrimaryTitleId, + encodeInputPath: rawPath, + notes: [ + ...(Array.isArray(review.notes) ? review.notes : []), + 'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.', + `HandBrake Serien-Heuristik aktiv: ${resolvedSelectedTitleIds.length} Episoden-Titel aus --scan -t 0 übernommen.` + ] + }; + + const shortTitleFilterResult = filterSeriesBackupReviewTitles(review); + if (shortTitleFilterResult.applied) { + review = shortTitleFilterResult.plan; + resolvedSelectedTitleIds = normalizeReviewTitleIdList(review.selectedTitleIds || []); + resolvedPrimaryTitleId = normalizeReviewTitleId(review.encodeInputTitleId) + || resolvedSelectedTitleIds[0] + || resolvedPrimaryTitleId; + review = { + ...review, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Kurztitel-Filter aktiv: ${shortTitleFilterResult.sourceCount} -> ${shortTitleFilterResult.resultCount}` + + ` Titel (Schwelle ${formatDurationClock(shortTitleFilterResult.shortThresholdSeconds)}).` + ); + } + + const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( + review, + options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' + ? options.previousEncodePlan + : null + ); + review = reviewPrefillResult.plan; + resolvedSelectedTitleIds = normalizeReviewTitleIdList(review.selectedTitleIds || []); + resolvedPrimaryTitleId = normalizeReviewTitleId(review.encodeInputTitleId) + || resolvedSelectedTitleIds[0] + || resolvedPrimaryTitleId; + + updatedMakemkvInfoBase.analyzeContext = { + ...(updatedMakemkvInfoBase.analyzeContext || {}), + mediaProfile, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null, + selectedHandBrakeTitleId: resolvedPrimaryTitleId || null, + selectedHandBrakeTitleIds: resolvedSelectedTitleIds, + handBrakePlaylistScan: handBrakePlaylistScan || null + }; + + const persistedMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + source: 'raw_backup_handbrake_series_scan', + makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo, + makemkvTitleAnalyzeRunInfo: null, + handbrakePlaylistResolveRunInfo: seriesScanRunInfo, + handbrakeTitleRunInfo: seriesScanRunInfo, + handbrakeTitleId: resolvedPrimaryTitleId || null, + handbrakeTitleIds: resolvedSelectedTitleIds + }, reviewPluginExecution); + + await historyService.updateJob(jobId, { + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()), + mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), + encode_plan_json: JSON.stringify(review), + encode_input_path: review.encodeInputPath || null, + encode_review_confirmed: 0 + }); + + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Titel-/Spurprüfung aus RAW abgeschlossen: ${review.titles.length} Titel, Vorauswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.` + ); + if (reviewPrefillResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, ` + + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, ` + + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, ` + + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` + ); + } + + const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId); + const readyStatusText = review.titleSelectionRequired + ? 'Titel-/Spurprüfung fertig - Titel per Checkbox wählen' + : (hasEncodableTitle + ? 'Titel-/Spurprüfung fertig - Auswahl bestätigen, dann Encode manuell starten' + : 'Titel-/Spurprüfung fertig - kein Titel erfüllt MIN_LENGTH_MINUTES'); + if (this.isPrimaryJob(jobId)) { + const mergedPluginExecutionForReady = getMergedReviewPluginExecution(); + await this.setState('READY_TO_ENCODE', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: readyStatusText, + context: { + ...(this.snapshot.context || {}), + jobId, + rawPath, + inputPath: review.encodeInputPath || null, + hasEncodableTitle, + reviewConfirmed: false, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: review, + selectedMetadata, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired: false, + playlistCandidates, + selectedPlaylist: null, + selectedTitleId: null, + ...(mergedPluginExecutionForReady ? { pluginExecution: mergedPluginExecutionForReady } : {}) + } + }); + } else { + await this.updateProgress('READY_TO_ENCODE', 0, null, readyStatusText, jobId, { + contextPatch: { + jobId, + rawPath, + inputPath: review.encodeInputPath || null, + hasEncodableTitle, + reviewConfirmed: false, + mode, + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: review, + selectedMetadata, + playlistAnalysis: playlistAnalysis || null, + playlistDecisionRequired: false, + playlistCandidates, + selectedPlaylist: null, + selectedTitleId: null + } + }); + } + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - RAW geprüft', + message: `Job #${jobId}: bereit zum manuellen Encode-Start` + }); + + return review; + } + if (playlistDecisionRequired && !selectedPlaylistId) { const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates @@ -13416,9 +14395,23 @@ class PipelineService extends EventEmitter { const selectedTitleForReview = pickTitleIdForTrackReview(playlistAnalysis, selectedTitleForContext); if (selectedTitleForReview === null) { - const error = new Error('Titel-/Spurprüfung aus RAW nicht möglich: keine auflösbare Titel-ID vorhanden.'); - error.statusCode = 400; - throw error; + logger.warn('backup-track-review:no-title-id-fallback-mediainfo', { + jobId, + selectedPlaylistId: selectedPlaylistId || null, + selectedTitleForContext, + hasPlaylistAnalysis: Boolean(playlistAnalysis), + candidateCount: Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates.length : 0, + titleCount: Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles.length : 0 + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + 'MakeMKV-Playlist-Mapping ohne verwertbare Titel-ID. Fallback auf generische RAW-Spurprüfung (ohne Playlist-Mapping).' + ); + return this.runMediainfoReviewForJob(jobId, rawPath, { + ...options, + mediaProfile + }); } const selectedTitleFromAnalysis = Array.isArray(playlistAnalysis?.titles) @@ -14420,7 +15413,7 @@ class PipelineService extends EventEmitter { ? 'series' : (effectiveMetadataProvider === 'omdb' ? 'film' : null); const effectiveWorkflowKind = requestedWorkflowKind || providerWorkflowKind || existingWorkflowKind; - if (mediaProfile === 'dvd') { + if (isSeriesDiscMediaProfile(mediaProfile)) { if (effectiveWorkflowKind === 'series') { effectiveMetadataProvider = 'tmdb'; } else if (effectiveWorkflowKind === 'film') { @@ -14468,15 +15461,15 @@ class PipelineService extends EventEmitter { let tmdbLanguage = null; if (effectiveMetadataProvider === 'tmdb') { try { - const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); - tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null; + const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile); + tmdbLanguage = String(seriesSettings?.dvd_series_language || '').trim() || null; } catch (_settingsError) { tmdbLanguage = null; } } - const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveWorkflowKind === 'series'; - if (mediaProfile === 'dvd' && effectiveWorkflowKind === 'film') { + const isDvdTmdbMetadataSelection = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'series'; + if (isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film') { effectiveProviderId = null; effectiveTmdbId = null; effectiveMetadataKind = 'movie'; @@ -14487,7 +15480,7 @@ class PipelineService extends EventEmitter { effectiveEpisodes = []; } if (isDvdTmdbMetadataSelection && !effectiveDiscNumber) { - const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).'); + const error = new Error(`Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: Disk-Nummer ist Pflicht (Start bei 1).`); error.statusCode = 400; throw error; } @@ -14607,7 +15600,7 @@ class PipelineService extends EventEmitter { ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {}) }; - const shouldDetachSeriesContainer = mediaProfile === 'dvd' && effectiveWorkflowKind === 'film'; + const shouldDetachSeriesContainer = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film'; let parentContainerJobId = shouldDetachSeriesContainer ? null : normalizePositiveInteger(job.parent_job_id || null); @@ -14971,7 +15964,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - `Serien-DVD RAW-Pfad aktiv: ${rawBaseDir}` + `Serien-${resolveSeriesDiscLabel(mediaProfile)} RAW-Pfad aktiv: ${rawBaseDir}` ); } @@ -15131,6 +16124,29 @@ class PipelineService extends EventEmitter { const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE'; if (isReadyToEncode) { + const preloadedAnalyzeContext = preloadedMakemkvInfo?.analyzeContext && typeof preloadedMakemkvInfo.analyzeContext === 'object' + ? preloadedMakemkvInfo.analyzeContext + : {}; + const preloadedActiveContextForJob = Number(this.snapshot?.activeJobId) === Number(jobId) + ? (this.snapshot?.context || {}) + : null; + const preloadedSelectedMetadata = resolveSelectedMetadataForJob( + preloadedJob, + preloadedAnalyzeContext, + preloadedActiveContextForJob + ); + const preloadedSelectedTitleIds = resolveSeriesBatchSelectedTitleIdsFromPlan(preloadedEncodePlan); + const preloadedPlanMode = String(preloadedEncodePlan?.mode || '').trim().toLowerCase(); + const preloadedIsPreRipPlan = preloadedPlanMode === 'pre_rip' || Boolean(preloadedEncodePlan?.preRip); + const shouldDispatchSeriesBatchNow = ( + !preloadedIsPreRipPlan + && isSeriesDvdMetadataSelection(preloadedMediaProfile, preloadedSelectedMetadata, preloadedAnalyzeContext) + && preloadedSelectedTitleIds.length > 1 + ); + if (shouldDispatchSeriesBatchNow) { + return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }); + } + // Check whether this confirmed job will rip first (pre_rip mode) or encode directly. // Pre-rip jobs bypass the encode queue because the next step is a rip, not an encode. const jobEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json); @@ -16667,7 +17683,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - 'Serien-DVD erkannt: Mindestlängen-Filter für Titelprüfung ist deaktiviert.' + `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: Mindestlängen-Filter für Titelprüfung ist deaktiviert.` ); } @@ -16793,7 +17809,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - 'Serien-DVD nachträglich im HandBrake-Scan erkannt: Mindestlängen-Filter für Titelprüfung wurde deaktiviert.' + `Serien-${resolveSeriesDiscLabel(mediaProfile)} nachträglich im HandBrake-Scan erkannt: Mindestlängen-Filter für Titelprüfung wurde deaktiviert.` ); } if (seriesScanContextResult.derived) { @@ -16992,6 +18008,92 @@ class PipelineService extends EventEmitter { processedFiles: effectiveMediaFiles.length, totalFiles: effectiveMediaFiles.length }; + const shouldApplySeriesBackupShortTitleFilter = Boolean( + effectiveIsSeriesDvdReview + && hasBluRayBackupStructure(rawPath) + && (!Array.isArray(playlistAnalysis?.titles) || playlistAnalysis.titles.length === 0) + ); + if (shouldApplySeriesBackupShortTitleFilter) { + const shortTitleFilterResult = filterSeriesBackupReviewTitles(enrichedReview); + if (shortTitleFilterResult.applied) { + enrichedReview = shortTitleFilterResult.plan; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Kurztitel-Filter aktiv (Backup-Fallback): ${shortTitleFilterResult.sourceCount} -> ${shortTitleFilterResult.resultCount}` + + ` Titel (Schwelle ${formatDurationClock(shortTitleFilterResult.shortThresholdSeconds)}).` + ); + } + } + const shouldEnrichSeriesBackupLanguages = Boolean( + effectiveIsSeriesDvdReview + && hasBluRayBackupStructure(rawPath) + && Array.isArray(enrichedReview?.titles) + && enrichedReview.titles.length > 0 + ); + if (shouldEnrichSeriesBackupLanguages) { + const hasUnknownTrackLanguages = (Array.isArray(enrichedReview?.titles) ? enrichedReview.titles : []) + .some((title) => { + const audioUnknown = (Array.isArray(title?.audioTracks) ? title.audioTracks : []) + .some((track) => isUnknownTrackLanguage(track?.language || track?.languageLabel)); + const subtitleUnknown = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []) + .some((track) => isUnknownTrackLanguage(track?.language || track?.languageLabel)); + return audioUnknown || subtitleUnknown; + }); + if (hasUnknownTrackLanguages) { + try { + let languageCandidates = buildSeriesBackupLanguageEnrichmentCandidatesFromPlaylistCache( + effectiveAnalyzeContext?.handBrakePlaylistScan || null + ); + if (languageCandidates.length <= 0) { + const handBrakeScan = await this.runPluginReviewScan(reviewPlugin, job, { + jobId, + rawPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN_SERIES_LANG_MAP', + silent: !this.isPrimaryJob(jobId) + }); + reviewPluginExecution = this.mergePluginExecutionState( + reviewPluginExecution, + handBrakeScan.pluginExecution + ); + const handBrakeScanRunInfo = handBrakeScan.runInfo || null; + mediaInfoRuns.push({ filePath: rawPath, runInfo: handBrakeScanRunInfo, fallbackRunInfo: null }); + const handBrakeScanJson = parseMediainfoJsonOutput((handBrakeScan.scanLines || []).join('\n')); + if (handBrakeScanJson) { + languageCandidates = buildSeriesBackupLanguageEnrichmentCandidatesFromHandBrakeScan(handBrakeScanJson); + } + } + + const languageEnrichment = enrichSeriesBackupReviewLanguagesFromCandidates( + enrichedReview, + languageCandidates + ); + if (languageEnrichment.applied) { + enrichedReview = languageEnrichment.plan; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} Sprachabgleich aus HandBrake-Scan: ` + + `${languageEnrichment.matchedTitles} Titel gemappt, ` + + `Audio ${languageEnrichment.enrichedAudioTracks}, ` + + `Untertitel ${languageEnrichment.enrichedSubtitleTracks} aktualisiert.` + ); + } + } catch (error) { + logger.warn('mediainfo:review:series-backup-language-enrichment-failed', { + jobId, + rawPath, + error: errorToMeta(error) + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)} Sprachabgleich aus HandBrake-Scan fehlgeschlagen: ${error?.message || 'Unbekannter Fehler'}` + ); + } + } + } const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( enrichedReview, options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' @@ -17316,11 +18418,11 @@ class PipelineService extends EventEmitter { handBrakeTitleDecisionRequired: false, handBrakeTitleCandidates: [] }; - await historyService.appendLog( - jobId, - 'SYSTEM', - `Serien-DVD: ${resolvedSelectedTitleIds.length} Episoden-Titel aus initialem HandBrake-Scan übernommen (ohne erneuten Titelscan).` - ); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-${resolveSeriesDiscLabel(mediaProfile)}: ${resolvedSelectedTitleIds.length} Episoden-Titel aus initialem HandBrake-Scan übernommen (ohne erneuten Titelscan).` + ); } } else { // Multiple titles passed the MIN_LENGTH filter during the initial DVD scan — @@ -17444,7 +18546,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - 'Serien-DVD nachträglich im HandBrake-Titelscan erkannt: Mindestlängen-Filter für Titelprüfung wurde deaktiviert.' + `Serien-${resolveSeriesDiscLabel(mediaProfile)} nachträglich im HandBrake-Titelscan erkannt: Mindestlängen-Filter für Titelprüfung wurde deaktiviert.` ); } if (seriesScanContextResult.derived) { @@ -17672,7 +18774,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - `Serien-DVD: ${resolvedSelectedTitleIds.length} Episoden-Titel aus HandBrake-Scan übernommen (kein separater Titelauswahl-Schritt).` + `Serien-${resolveSeriesDiscLabel(mediaProfile)}: ${resolvedSelectedTitleIds.length} Episoden-Titel aus HandBrake-Scan übernommen (kein separater Titelauswahl-Schritt).` ); } } else { @@ -19345,18 +20447,22 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - 'Serien-DVD erkannt: MakeMKV-Rip läuft ohne Mindestlängen-Filter (--minlength deaktiviert).' + `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: MakeMKV-Rip läuft ohne Mindestlängen-Filter (--minlength deaktiviert).` ); } if (rawStorage.usingSeriesRawPath) { await historyService.appendLog( jobId, 'SYSTEM', - `Serien-DVD RAW-Pfad aktiv: ${effectiveRawBaseDir}` + `Serien-${resolveSeriesDiscLabel(mediaProfile)} RAW-Pfad aktiv: ${effectiveRawBaseDir}` ); } const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null); if (devicePath) { + const isOrphanRawImportRipJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + if (isOrphanRawImportRipJob) { + this._releaseDriveLockForJob(jobId, { reason: 'orphan_raw_rip_bypass' }); + } else { const existingLock = this._getDriveLockByPath(devicePath); const existingLockJobId = Number(existingLock?.jobId || existingLock?.owner?.jobId || 0) || null; const lockedByOtherJob = ( @@ -19372,6 +20478,7 @@ class PipelineService extends EventEmitter { mediaProfile, reason: 'rip_running' }); + } } let ripCommandSucceeded = false; try { @@ -21450,9 +22557,11 @@ class PipelineService extends EventEmitter { hasRawPath = false; } + const isOrphanRawImportJob = String(makemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; const shouldAutoRecoverEncode = normalizedStage === 'ENCODING' && hasConfirmedPlan - && resolvedMediaProfile !== 'audiobook'; + && resolvedMediaProfile !== 'audiobook' + && !isOrphanRawImportJob; if (shouldAutoRecoverEncode) { try { const recoveryReasonLabel = isCancelled ? 'Abbruch' : 'Fehler'; @@ -21677,6 +22786,43 @@ class PipelineService extends EventEmitter { this.cancelRequestedByJob.delete(Number(jobId)); + const shouldRemoveCancelledOrphanImportJob = Boolean( + isCancelled + && normalizedStage === 'ENCODING' + && isOrphanRawImportJob + && !job?.parent_job_id + ); + if (shouldRemoveCancelledOrphanImportJob) { + try { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Abgebrochener Orphan-RAW-Encode wird aus der Historie entfernt. RAW bleibt erhalten und ist wieder unter /database verfügbar.' + ); + await historyService.deleteJob(jobId, 'none', { + includeRelated: false, + preserveRawForImportJobs: true + }); + await this.onJobsDeleted([jobId], { suppressQueueRefresh: false }); + if (Number(this.snapshot.activeJobId || 0) === Number(jobId)) { + await this.setState('IDLE', { + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + } catch (cleanupError) { + logger.warn('job:cancelled:orphan-import-cleanup-failed', { + jobId, + stage: normalizedStage, + error: errorToMeta(cleanupError) + }); + } + return; + } + const seriesBatchParentJobId = this.resolveSeriesBatchParentJobId({ ...job, encodePlan @@ -23775,7 +24921,8 @@ class PipelineService extends EventEmitter { } }); - if (!skipRip && !String(effectiveDevicePath || '').startsWith('__virtual__')) { + const isOrphanRawImportCdJob = String(cdInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + if (!skipRip && !isOrphanRawImportCdJob && !String(effectiveDevicePath || '').startsWith('__virtual__')) { this._acquireDriveLockForJob(effectiveDevicePath, activeJobId, { stage: 'CD_RIPPING', source: 'CDPARANOIA_RIP', @@ -23783,7 +24930,7 @@ class PipelineService extends EventEmitter { reason: 'rip_running' }); } else { - this._releaseDriveLockForJob(activeJobId, { reason: 'encode_from_raw' }); + this._releaseDriveLockForJob(activeJobId, { reason: isOrphanRawImportCdJob ? 'orphan_raw_rip_bypass' : 'encode_from_raw' }); } logger.info('cd:rip:start', { diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index abc0413..5ee628a 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -65,9 +65,11 @@ const PROFILED_SETTINGS = { audiobook: 'raw_dir_audiobook_owner' }, series_raw_dir: { + bluray: 'raw_dir_bluray_series', dvd: 'raw_dir_dvd_series' }, series_raw_dir_owner: { + bluray: 'raw_dir_bluray_series_owner', dvd: 'raw_dir_dvd_series_owner' }, movie_dir: { @@ -77,6 +79,7 @@ const PROFILED_SETTINGS = { audiobook: 'movie_dir_audiobook' }, series_dir: { + bluray: 'series_dir_bluray', dvd: 'series_dir_dvd' }, movie_dir_owner: { @@ -86,6 +89,7 @@ const PROFILED_SETTINGS = { audiobook: 'movie_dir_audiobook_owner' }, series_dir_owner: { + bluray: 'series_dir_bluray_owner', dvd: 'series_dir_dvd_owner' }, mediainfo_extra_args: { @@ -877,7 +881,12 @@ class SettingsService { const cd = this.resolveEffectiveToolSettings(map, 'cd'); const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook'); return { - bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir }, + bluray: { + raw: bluray.raw_dir, + seriesRaw: bluray.series_raw_dir || bluray.raw_dir, + movies: bluray.movie_dir, + series: bluray.series_dir + }, dvd: { raw: dvd.raw_dir, seriesRaw: dvd.series_raw_dir || dvd.raw_dir, movies: dvd.movie_dir, series: dvd.series_dir }, cd: { raw: cd.raw_dir, movies: cd.movie_dir }, audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir }, @@ -1086,16 +1095,17 @@ class SettingsService { ); const cmd = map.makemkv_command; const extraArgs = splitArgs(map.makemkv_analyze_extra_args); + const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter); const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim())); const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0); const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0 ? Math.round(minLengthMinutes * 60) : 0; - const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0) + const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0) ? [`--minlength=${minLengthSeconds}`] : []; const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', this.resolveSourceArg(map, deviceInfo)]; - logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo }); + logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo, disableMinLengthFilter }); return { cmd, args }; } @@ -1105,12 +1115,13 @@ class SettingsService { const cmd = map.makemkv_command; const sourceArg = `file:${sourcePath}`; const extraArgs = splitArgs(map.makemkv_analyze_extra_args); + const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter); const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim())); const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0); const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0 ? Math.round(minLengthMinutes * 60) : 0; - const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0) + const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0) ? [`--minlength=${minLengthSeconds}`] : []; const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', sourceArg]; @@ -1120,6 +1131,7 @@ class SettingsService { cmd, args, sourcePath, + disableMinLengthFilter, requestedTitleId: Number.isFinite(titleIdRaw) && titleIdRaw >= 0 ? Math.trunc(titleIdRaw) : null }); return { cmd, args, sourceArg }; diff --git a/db/schema.sql b/db/schema.sql index 666767f..3434122 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -262,6 +262,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_bluray_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (Blu-ray Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray).', NULL, '[]', '{}', 1012); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_owner', NULL); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL); @@ -274,6 +278,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_bluray_owner', 'Pfade', 'Eigentümer Serien-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1105); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray_owner', NULL); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL); @@ -308,6 +316,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('raw_dir_bluray', 'Pfade', 'Raw-Ordner (Blu-ray)', 'path', 0, 'RAW-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 101); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_bluray_series', 'Pfade', 'RAW-Ordner (Blu-ray Serie)', 'path', 0, 'RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray).', NULL, '[]', '{}', 1011); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', NULL); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL); @@ -320,6 +332,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_bluray', 'Pfade', 'Serien-Ordner (Blu-ray)', 'path', 0, 'Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 110); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray', NULL); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL); @@ -428,6 +444,14 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, '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.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_bluray_series_episode', 'Pfade', 'Output Template (Blu-ray Serie, Episode)', 'string', 1, '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}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 336); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('output_template_bluray_series_multi_episode', 'Pfade', 'Output Template (Blu-ray Serie, Multi-Episode)', 'string', 1, '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}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 337); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'); + -- Tools – DVD INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('mediainfo_extra_args_dvd', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (DVD).', NULL, '[]', '{}', 500); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5ccbfa0..81dbe0b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.13.1-12", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.13.1-12", + "version": "0.14.0", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 4d22ae7..7c1bc7f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.13.1-12", + "version": "0.14.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 30dbe7c..dcd538d 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -806,10 +806,20 @@ export const api = { afterMutationInvalidate(['/history']); return result; }, - async deleteJobFiles(jobId, target = 'both') { + async deleteJobFiles(jobId, target = 'both', options = {}) { + const includeRelated = Boolean(options?.includeRelated); + const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths) + ? options.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; const result = await request(`/history/${jobId}/delete-files`, { method: 'POST', - body: JSON.stringify({ target }) + body: JSON.stringify({ + target, + includeRelated, + ...(selectedMoviePaths ? { selectedMoviePaths } : {}) + }) }); afterMutationInvalidate(['/history']); return result; diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index d00f0e2..e19c671 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -501,7 +501,15 @@ function buildToolSections(settings) { } // Path keys per medium — _owner keys are rendered inline -const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray']; +const BLURAY_PATH_KEYS = [ + 'raw_dir_bluray', + 'raw_dir_bluray_series', + 'movie_dir_bluray', + 'series_dir_bluray', + 'output_template_bluray', + 'output_template_bluray_series_episode', + 'output_template_bluray_series_multi_episode' +]; const DVD_PATH_KEYS = [ 'raw_dir_dvd', 'raw_dir_dvd_series', @@ -816,7 +824,9 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect const ep = effectivePaths || {}; const blurayRaw = ep.bluray?.raw || defaultRaw; + const bluraySeriesRaw = ep.bluray?.seriesRaw || blurayRaw; const blurayMovies = ep.bluray?.movies || defaultMovies; + const bluraySeries = ep.bluray?.series || defaultSeries; const dvdRaw = ep.dvd?.raw || defaultRaw; const dvdSeriesRaw = ep.dvd?.seriesRaw || dvdRaw; const dvdMovies = ep.dvd?.movies || defaultMovies; @@ -860,6 +870,17 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect {isDefault(blurayMovies, defaultMovies) && Standard} + + Blu-ray Serie + + {bluraySeriesRaw} + {isDefault(bluraySeriesRaw, blurayRaw) && wie Blu-ray RAW} + + + {bluraySeries} + {isDefault(bluraySeries, defaultSeries) && Standard} + + DVD diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 3b5725c..3559f45 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -6,7 +6,7 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg'; import discIndicatorIcon from '../assets/media-disc.svg'; import otherIndicatorIcon from '../assets/media-other.svg'; import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation'; -import { isSeriesDvdJob } from '../utils/jobTaxonomy'; +import { isSeriesVideoJob } from '../utils/jobTaxonomy'; const CD_FORMAT_LABELS = { flac: 'FLAC', @@ -136,6 +136,7 @@ function shellQuote(value) { function trackLang(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) return 'und'; + if (raw === 'und' || raw === 'unknown' || raw === 'un') return 'und'; const map = { en: 'en', eng: 'en', de: 'de', deu: 'de', ger: 'de', tr: 'tr', tur: 'tr', fr: 'fr', fra: 'fr', fre: 'fr', es: 'es', spa: 'es', it: 'it', ita: 'it' }; if (map[raw]) return map[raw]; return raw.length >= 2 ? raw.slice(0, 2) : raw; @@ -964,7 +965,7 @@ export default function JobDetailDialog({ const showCancelAction = (running || softCancelable) && typeof onCancel === 'function'; const showFinalLog = !running; const mediaType = resolveMediaType(job); - const isDvdSeries = mediaType === 'dvd' && isSeriesDvdJob(job); + const isDvdSeries = (mediaType === 'dvd' || mediaType === 'bluray') && isSeriesVideoJob(job); const seriesBatchEpisodes = isDvdSeries ? mergeSeriesBatchEpisodes(job) : []; const seriesEpisodeAssignments = isDvdSeries && job?.encodePlan?.episodeAssignments && typeof job.encodePlan.episodeAssignments === 'object' ? job.encodePlan.episodeAssignments @@ -1151,7 +1152,7 @@ export default function JobDetailDialog({ const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null; const canRetry = isCd && !running && typeof onRetry === 'function'; const mediaTypeLabel = mediaType === 'bluray' - ? 'Blu-ray' + ? (isDvdSeries ? 'Blu-ray Serie' : 'Blu-ray') : mediaType === 'dvd' ? (isDvdSeries ? 'DVD Serie' : 'DVD') : isCd @@ -2323,7 +2324,7 @@ export default function JobDetailDialog({
Dateien löschen
diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index 3fc6141..6d48185 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -486,6 +486,34 @@ function normalizePositiveInt(value) { return Math.trunc(parsed); } +function hasSeriesMetadataShape(source = null) { + const metadata = source && typeof source === 'object' ? source : {}; + const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null); + const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : []; + const metadataProvider = String(metadata?.metadataProvider || '').trim().toLowerCase(); + const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase(); + return Boolean( + seasonNumber + || episodes.length > 0 + || metadataProvider === 'tmdb' + || metadataProvider === 'themoviedb' + || workflowKind === 'series' + || workflowKind === 'season' + ); +} + +function hasSeriesEpisodeAssignmentHints(titles = []) { + const reviewTitles = Array.isArray(titles) ? titles : []; + return reviewTitles.some((title) => { + const seasonNumber = normalizePositiveInt(title?.seasonNumber ?? title?.season ?? null); + const episodeNumber = normalizePositiveInt(title?.episodeNumber ?? title?.number ?? null); + const episodeId = normalizePositiveInt(title?.episodeId ?? null); + const episodeTitle = String(title?.episodeTitle || '').trim(); + const episodeTitleStart = String(title?.episodeTitleStart || '').trim(); + return Boolean(seasonNumber || episodeNumber || episodeId || episodeTitle || episodeTitleStart); + }); +} + function normalizeEpisodeReference(entry = {}) { const source = entry && typeof entry === 'object' ? entry : {}; const episodeId = normalizePositiveInt(source?.episodeId ?? source?.id ?? null); @@ -605,6 +633,20 @@ function normalizeSeriesLanguage(value) { return raw; } +function normalizeSeriesTitleForOutputPath(value, fallback = 'series') { + const raw = String(value || '').trim(); + if (!raw) { + return String(fallback || 'series').trim() || 'series'; + } + const stripped = raw + .replace(/\s*-\s*S\d{1,2}E\d{1,3}(?:-\d{1,3})?\b.*$/i, '') + .trim(); + if (stripped) { + return stripped; + } + return raw; +} + function normalizeSeriesBatchStatus(value) { return String(value || '').trim().toUpperCase() || 'UNKNOWN'; } @@ -1061,6 +1103,7 @@ function buildFallbackEpisodeTitleForOutput(episodeRange = null, template = '') function buildDvdSeriesEpisodeOutputPathPreview( settings, + mediaProfile, metadata, title, assignment = null, @@ -1069,9 +1112,13 @@ function buildDvdSeriesEpisodeOutputPathPreview( assignmentByTitle = {}, selectedTitleIds = [] ) { + const normalizedSeriesMediaProfile = String(mediaProfile || '').trim().toLowerCase() === 'bluray' + ? 'bluray' + : 'dvd'; const seriesRootDir = String( - settings?.series_dir_dvd - || settings?.movie_dir_dvd + (normalizedSeriesMediaProfile === 'bluray' + ? (settings?.series_dir_bluray || settings?.series_dir_dvd || settings?.movie_dir_bluray || settings?.movie_dir_dvd) + : (settings?.series_dir_dvd || settings?.series_dir_bluray || settings?.movie_dir_dvd || settings?.movie_dir_bluray)) || settings?.movie_dir || '' ).trim(); @@ -1105,21 +1152,29 @@ function buildDvdSeriesEpisodeOutputPathPreview( const episodeRangeToken = buildEpisodeRangeToken(effectiveEpisodeRange.start, effectiveEpisodeRange.end); const partsToken = buildEpisodePartsToken(effectiveEpisodeRange); const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null); - const seriesTitleRaw = String( - metadata?.title - || metadata?.seriesTitle - || (fallbackJobId ? `job-${fallbackJobId}` : 'series') - ).trim(); - const seriesTitle = seriesTitleRaw || 'series'; + const seriesTitle = normalizeSeriesTitleForOutputPath( + metadata?.seriesTitle + || metadata?.title + || (fallbackJobId ? `job-${fallbackJobId}` : 'series'), + fallbackJobId ? `job-${fallbackJobId}` : 'series' + ); const year = String(metadata?.year || new Date().getFullYear()).trim(); const language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null); const singleTemplateRaw = String( - settings?.output_template_dvd_series_episode + (normalizedSeriesMediaProfile === 'bluray' + ? settings?.output_template_bluray_series_episode + : settings?.output_template_dvd_series_episode) + || settings?.output_template_dvd_series_episode + || settings?.output_template_bluray_series_episode || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}' ).trim(); const multiTemplateRaw = String( - settings?.output_template_dvd_series_multi_episode + (normalizedSeriesMediaProfile === 'bluray' + ? settings?.output_template_bluray_series_multi_episode + : settings?.output_template_dvd_series_multi_episode) + || settings?.output_template_dvd_series_multi_episode + || settings?.output_template_bluray_series_multi_episode || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})' ).trim(); const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; @@ -1160,7 +1215,15 @@ function buildDvdSeriesEpisodeOutputPathPreview( .filter(Boolean); const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled'; const folderParts = segments.slice(0, -1); - const ext = String(settings?.output_extension_dvd || settings?.output_extension || 'mkv').trim() || 'mkv'; + const ext = String( + (normalizedSeriesMediaProfile === 'bluray' + ? settings?.output_extension_bluray + : settings?.output_extension_dvd) + || settings?.output_extension_dvd + || settings?.output_extension_bluray + || settings?.output_extension + || 'mkv' + ).trim() || 'mkv'; const root = seriesRootDir.replace(/\/+$/g, ''); if (folderParts.length > 0) { return `${root}/${folderParts.join('/')}/${baseName}.${ext}`; @@ -1304,8 +1367,9 @@ export default function PipelineStatusCard({ const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase(); const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip); const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview); - const isDvdTitleSelectionRequired = Boolean( - jobMediaProfile === 'dvd' + const discTypeLabel = jobMediaProfile === 'bluray' ? 'Blu-ray' : 'DVD'; + const isDiscTitleSelectionRequired = Boolean( + (jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray') && (mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired) ); const [liveJobLog, setLiveJobLog] = useState(''); @@ -1467,8 +1531,21 @@ export default function PipelineStatusCard({ ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id), ...(fromReview ? [fromReview] : []) ]); + const isSeriesVideoByMetadata = Boolean( + (jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray') + && ( + hasSeriesMetadataShape(selectedMetadata) + || hasSeriesMetadataShape(mediaInfoReview?.selectedMetadata) + || hasSeriesEpisodeAssignmentHints(reviewTitles) + || Boolean(mediaInfoReview?.seriesBatchParent) + ) + ); const defaultAllTitles = Boolean( - (mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired) + ( + mediaInfoReview?.titleSelectionRequired + || mediaInfoReview?.handBrakeTitleDecisionRequired + || (isSeriesVideoByMetadata && reviewTitles.length > 1) + ) && reviewTitles.length > 0 ); const effectiveSelected = defaultAllTitles @@ -1537,6 +1614,10 @@ export default function PipelineStatusCard({ mediaInfoReview?.outputFormat, mediaInfoReview?.userPreset?.id, mediaInfoReview?.userPreset?.handbrakePreset, + selectedMetadata?.seasonNumber, + selectedMetadata?.episodes, + selectedMetadata?.metadataProvider, + jobMediaProfile, retryJobId ]); @@ -1877,16 +1958,6 @@ export default function PipelineStatusCard({ if (!Array.isArray(selectedMetadataEpisodes) || selectedMetadataEpisodes.length === 0) { return; } - const hasAnyEpisodeAssignment = Object.values(episodeAssignmentsByTitle || {}).some((assignment) => { - const episodeId = Number(assignment?.episodeId || 0); - const episodeNumber = Number(assignment?.episodeNumber || 0); - const seasonNumber = Number(assignment?.seasonNumber || 0); - const episodeTitle = String(assignment?.episodeTitle || '').trim(); - return Boolean(episodeTitle || episodeId > 0 || episodeNumber > 0 || seasonNumber > 0); - }); - if (hasAnyEpisodeAssignment) { - return; - } if (!Array.isArray(episodeFillOptions) || episodeFillOptions.length === 0) { return; } @@ -1903,11 +1974,64 @@ export default function PipelineStatusCard({ } if (!hasValidSelection) { setSelectedEpisodeFillStart(String(defaultStartRef)); + // Keep dropdown + episodentitel in sync when options change (e.g. Disc 2 + // after already used episodes were loaded from the container). + applyEpisodeFillFrom(defaultStartRef); + return; + } + + 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 hasEpisodeAssignmentPayload = (assignment) => { + if (!assignment || typeof assignment !== 'object') { + return false; + } + const episodeId = Number(assignment?.episodeId || assignment?.episodeIdStart || 0); + const episodeNumber = Number(assignment?.episodeNumber || assignment?.episodeNumberStart || 0); + const seasonNumber = Number(assignment?.seasonNumber || 0); + const episodeTitle = String(assignment?.episodeTitle || '').trim(); + return Boolean(episodeTitle || episodeId > 0 || episodeNumber > 0 || seasonNumber > 0); + }; + + const hasAnyEpisodeAssignment = effectiveSelectedTitleIds.some((titleId) => { + const assignment = episodeAssignmentsByTitle?.[titleId] + || episodeAssignmentsByTitle?.[String(titleId)] + || null; + return hasEpisodeAssignmentPayload(assignment); + }); + const hasCompleteEpisodeAssignments = effectiveSelectedTitleIds.length > 0 + && effectiveSelectedTitleIds.every((titleId) => { + const assignment = episodeAssignmentsByTitle?.[titleId] + || episodeAssignmentsByTitle?.[String(titleId)] + || null; + return hasEpisodeAssignmentPayload(assignment); + }); + + if (hasCompleteEpisodeAssignments) { + return; + } + if (hasAnyEpisodeAssignment && normalizedSelection) { + return; } applyEpisodeFillFrom(defaultStartRef); }, [ selectedMetadataEpisodes, selectedEncodeTitleIds, + selectedEncodeTitleId, + mediaInfoReview?.titles, + mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, episodeAssignmentsByTitle, episodeFillOptions, @@ -2247,6 +2371,16 @@ export default function PipelineStatusCard({ pipeline?.context?.selectedPlaylist ]); + const seriesBatchRunningChildJobIdHint = useMemo(() => { + const seriesBatch = pipeline?.context?.seriesBatch; + if (!seriesBatch || typeof seriesBatch !== 'object') { + return null; + } + const children = Array.isArray(seriesBatch?.children) ? seriesBatch.children : []; + const runningChild = children.find((child) => normalizeSeriesBatchStatus(child?.status) === 'RUNNING') || null; + return normalizeJobId(runningChild?.jobId); + }, [pipeline?.context?.seriesBatch]); + useEffect(() => { if (!running || !retryJobId) { setLiveJobLog(''); @@ -2257,7 +2391,38 @@ export default function PipelineStatusCard({ try { const response = await api.getJob(retryJobId, { includeLiveLog: true, lite: true }); if (!cancelled) { - setLiveJobLog(response?.job?.log || ''); + const liveJob = response?.job && typeof response.job === 'object' + ? response.job + : null; + const childRows = Array.isArray(liveJob?.children) ? liveJob.children : []; + const liveHintChildId = normalizeJobId(seriesBatchRunningChildJobIdHint); + const isSeriesBatchParentRun = Boolean( + liveHintChildId + || liveJob?.encodePlan?.seriesBatchParent + || String(liveJob?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch' + ); + + if (isSeriesBatchParentRun) { + const activeChildStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'RUNNING']); + let activeChild = liveHintChildId + ? (childRows.find((child) => normalizeJobId(child?.id) === liveHintChildId) || null) + : null; + if (activeChild) { + const hintChildStatus = String(activeChild?.status || activeChild?.last_state || '').trim().toUpperCase(); + if (!activeChildStates.has(hintChildStatus)) { + activeChild = null; + } + } + if (!activeChild) { + activeChild = childRows.find((child) => ( + activeChildStates.has(String(child?.status || child?.last_state || '').trim().toUpperCase()) + )) || null; + } + setLiveJobLog(String(activeChild?.log || '').trim()); + return; + } + + setLiveJobLog(String(liveJob?.log || '').trim()); } } catch (_err) { // ignore transient polling errors @@ -2269,22 +2434,34 @@ export default function PipelineStatusCard({ cancelled = true; clearInterval(interval); }; - }, [running, retryJobId]); + }, [running, retryJobId, seriesBatchRunningChildJobIdHint]); const manualDecisionState = pipeline?.context?.manualDecisionState || null; const rawDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && manualDecisionState === 'waiting_for_raw_decision'; const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired && !rawDecisionRequiredBeforeStart; const isSeriesDvdReview = useMemo(() => { - if (jobMediaProfile !== 'dvd') { + if (jobMediaProfile !== 'dvd' && jobMediaProfile !== 'bluray') { return false; } - const seasonNumberRaw = selectedMetadata?.seasonNumber ?? null; - const seasonNumber = Number(String(seasonNumberRaw ?? '').replace(',', '.')); - const hasSeasonNumber = Number.isFinite(seasonNumber) && seasonNumber > 0; - const hasEpisodeList = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0; - const metadataProvider = String(selectedMetadata?.metadataProvider || '').trim().toLowerCase(); - return hasSeasonNumber || hasEpisodeList || metadataProvider === 'tmdb' || metadataProvider === 'themoviedb'; - }, [jobMediaProfile, selectedMetadata?.seasonNumber, selectedMetadata?.episodes, selectedMetadata?.metadataProvider]); + return Boolean( + hasSeriesMetadataShape(selectedMetadata) + || hasSeriesMetadataShape(mediaInfoReview?.selectedMetadata) + || hasSeriesEpisodeAssignmentHints(mediaInfoReview?.titles) + || Boolean(mediaInfoReview?.seriesBatchParent) + ); + }, [ + jobMediaProfile, + selectedMetadata?.seasonNumber, + selectedMetadata?.episodes, + selectedMetadata?.metadataProvider, + selectedMetadata?.workflowKind, + mediaInfoReview?.selectedMetadata?.seasonNumber, + mediaInfoReview?.selectedMetadata?.episodes, + mediaInfoReview?.selectedMetadata?.metadataProvider, + mediaInfoReview?.selectedMetadata?.workflowKind, + mediaInfoReview?.titles, + mediaInfoReview?.seriesBatchParent + ]); useEffect(() => { let cancelled = false; @@ -2515,6 +2692,7 @@ export default function PipelineStatusCard({ || null; const path = buildDvdSeriesEpisodeOutputPathPreview( settingsMap, + jobMediaProfile, selectedMetadata || {}, title, assignment, @@ -2533,6 +2711,7 @@ export default function PipelineStatusCard({ mediaInfoReview?.titles, episodeAssignmentsByTitle, settingsMap, + jobMediaProfile, selectedMetadata, retryJobId, selectedEncodeTitleIds @@ -3091,7 +3270,7 @@ export default function PipelineStatusCard({ {handBrakeTitleDecisionRequired && retryJobId && (