diff --git a/backend/package-lock.json b/backend/package-lock.json index c0a8da3..5c846ef 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.13.1-4", + "version": "0.13.1-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.13.1-4", + "version": "0.13.1-6", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 99facb9..48d88d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.13.1-4", + "version": "0.13.1-6", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 244adec..d3e7e41 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -850,7 +850,7 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [ { key: 'output_template_dvd_series_multi_episode', required: 1, - description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', + description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', validation_json: '{"minLength":1}' } ]; @@ -925,6 +925,46 @@ async function migrateSettingsSchemaMetadata(db) { }); } + const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; + const legacyDvdSeriesMultiEpisodeTemplateDefaults = [ + '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}' + ]; + const legacyTemplatePlaceholders = legacyDvdSeriesMultiEpisodeTemplateDefaults.map(() => '?').join(', '); + await db.run( + ` + UPDATE settings_schema + SET default_value = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'output_template_dvd_series_multi_episode' + AND ( + default_value IS NULL + OR TRIM(default_value) = '' + OR default_value IN (${legacyTemplatePlaceholders}) + ) + `, + [dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults] + ); + await db.run( + ` + INSERT INTO settings_values (key, value, updated_at) + VALUES ('output_template_dvd_series_multi_episode', ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO NOTHING + `, + [dvdSeriesMultiEpisodeTemplateDefault] + ); + await db.run( + ` + UPDATE settings_values + SET value = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'output_template_dvd_series_multi_episode' + AND ( + value IS NULL + OR TRIM(value) = '' + OR value IN (${legacyTemplatePlaceholders}) + ) + `, + [dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults] + ); + // Migrate raw_dir_cd_owner label await db.run( `UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP @@ -1042,9 +1082,9 @@ async function migrateSettingsSchemaMetadata(db) { 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_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537)` + VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)` ); - await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}')`); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_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) diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index ffa9378..e6e54ab 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -56,9 +56,22 @@ router.post( asyncHandler(async (req, res) => { const rawPath = String(req.body?.rawPath || '').trim(); logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath }); - const job = await historyService.importOrphanRawFolder(rawPath); - const uiReset = await pipelineService.resetFrontendState('history_orphan_import'); - res.json({ job, uiReset }); + 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; + + const refreshedJob = importedJobId > 0 + ? await historyService.getJobById(importedJobId) + : null; + + res.json({ + job: refreshedJob || importedJob, + activation + }); }) ); diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index d2a7c2c..7493541 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -366,6 +366,7 @@ router.post( providerId, tmdbId, metadataKind, + workflowKind, seasonNumber, seasonName, episodeCount, @@ -393,6 +394,7 @@ router.post( metadataProvider, providerId, tmdbId, + workflowKind, seasonNumber, discNumber }); @@ -411,6 +413,7 @@ router.post( providerId, tmdbId, metadataKind, + workflowKind, seasonNumber, seasonName, episodeCount, diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 3ae364f..bc91b5a 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -1159,6 +1159,20 @@ function normalizePositiveIntegerOrNull(value) { return Math.trunc(parsed); } +function normalizeDvdMetadataWorkflowKind(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (['film', 'movie', 'feature'].includes(raw)) { + return 'film'; + } + if (['series', 'tv', 'season', 'episode'].includes(raw)) { + return 'series'; + } + return null; +} + function resolveSeriesAssignmentEpisodeSpan(assignment = null) { const source = assignment && typeof assignment === 'object' ? assignment : {}; const start = normalizePositiveIntegerOrNull( @@ -1313,6 +1327,17 @@ function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = {}) { function hasSeriesMetadataSignals(selectedMetadata = {}, analyzeContext = {}) { const metadata = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}; const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {}; + const workflowKind = normalizeDvdMetadataWorkflowKind( + metadata.workflowKind + || analyze.workflowKind + || null + ); + if (workflowKind === 'film') { + return false; + } + if (workflowKind === 'series') { + return true; + } const metadataKind = String(metadata.metadataKind || analyze.metadataKind || '').trim().toLowerCase(); const seasonNumber = normalizePositiveNumberOrNull( metadata.seasonNumber ?? analyze?.seriesLookupHint?.seasonNumber ?? null @@ -4967,114 +4992,6 @@ class HistoryService { const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object' ? sourceJobContext.selectedMetadata : {}; - const sourceAnalyzeContext = sourceJobContext?.analyzeContext && typeof sourceJobContext.analyzeContext === 'object' - ? sourceJobContext.analyzeContext - : {}; - const sourceHasSeriesSignals = hasSeriesMetadataSignals(sourceSelectedMetadata, sourceAnalyzeContext); - const seriesImportHints = effectiveDetectedMediaType === 'dvd' && (seriesRawPathHint || sourceHasSeriesSignals) - ? buildSeriesImportHints({ - folderName: path.basename(finalRawPath), - rawPath: finalRawPath, - metadata, - sourceSelectedMetadata, - sourceAnalyzeContext - }) - : null; - let effectiveSeriesImportHints = seriesImportHints; - let inferredSeriesContainer = null; - if (effectiveDetectedMediaType === 'dvd') { - const hintedTmdbId = normalizePositiveIntegerOrNull( - effectiveSeriesImportHints?.selectedMetadata?.tmdbId - ?? effectiveSeriesImportHints?.selectedMetadata?.providerId - ?? null - ); - const hintedSeasonNumber = normalizePositiveIntegerOrNull( - effectiveSeriesImportHints?.selectedMetadata?.seasonNumber - ?? effectiveSeriesImportHints?.analyzeContextPatch?.seriesLookupHint?.seasonNumber - ?? null - ); - - if (hintedTmdbId && hintedSeasonNumber) { - inferredSeriesContainer = await this.findSeriesContainerJob(hintedTmdbId, hintedSeasonNumber); - } - - if (!inferredSeriesContainer && seriesRawPathHint) { - inferredSeriesContainer = await this.findLikelySeriesContainerJob({ - title: sourceSelectedMetadata?.title || sourceSelectedMetadata?.seriesTitle || effectiveTitle || metadata.title || null, - year: sourceSelectedMetadata?.year || sourceJob?.year || metadata.year || null - }); - } - - if (inferredSeriesContainer) { - const containerInfo = parseJsonSafe(inferredSeriesContainer?.makemkv_info_json, {}) || {}; - const containerSelectedMetadata = extractSelectedMetadataFromMakemkvInfo(containerInfo); - const containerAnalyzeContext = containerInfo?.analyzeContext && typeof containerInfo.analyzeContext === 'object' - ? containerInfo.analyzeContext - : {}; - const containerTmdbId = normalizePositiveIntegerOrNull( - containerSelectedMetadata?.tmdbId - || containerSelectedMetadata?.providerId - || null - ); - const containerSeasonNumber = normalizePositiveIntegerOrNull(containerSelectedMetadata?.seasonNumber || null); - const discNumberHint = normalizePositiveIntegerOrNull( - effectiveSeriesImportHints?.selectedMetadata?.discNumber - ?? extractDiscNumberFromText(`${path.basename(finalRawPath)} ${path.basename(absRawPath)}`) - ?? null - ); - - if (containerTmdbId && containerSeasonNumber) { - const mergedTitle = String( - effectiveSeriesImportHints?.selectedMetadata?.title - || containerSelectedMetadata?.title - || effectiveTitle - || inferredSeriesContainer?.title - || '' - ).trim() || null; - effectiveSeriesImportHints = { - selectedMetadata: { - ...containerSelectedMetadata, - ...(effectiveSeriesImportHints?.selectedMetadata && typeof effectiveSeriesImportHints.selectedMetadata === 'object' - ? effectiveSeriesImportHints.selectedMetadata - : {}), - metadataProvider: 'tmdb', - metadataKind: String(containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series', - tmdbId: containerTmdbId, - seasonNumber: containerSeasonNumber, - title: mergedTitle, - ...(discNumberHint ? { discNumber: discNumberHint } : {}) - }, - analyzeContextPatch: { - ...(effectiveSeriesImportHints?.analyzeContextPatch && typeof effectiveSeriesImportHints.analyzeContextPatch === 'object' - ? effectiveSeriesImportHints.analyzeContextPatch - : {}), - metadataProvider: 'tmdb', - metadataKind: String(containerAnalyzeContext?.metadataKind || containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series', - seriesLookupHint: { - ...(containerAnalyzeContext?.seriesLookupHint && typeof containerAnalyzeContext.seriesLookupHint === 'object' - ? containerAnalyzeContext.seriesLookupHint - : {}), - query: mergedTitle, - seasonNumber: containerSeasonNumber, - ...(discNumberHint ? { discNumber: discNumberHint } : {}) - }, - seriesAnalysis: { - ...(containerAnalyzeContext?.seriesAnalysis && typeof containerAnalyzeContext.seriesAnalysis === 'object' - ? containerAnalyzeContext.seriesAnalysis - : {}), - summary: { - ...(containerAnalyzeContext?.seriesAnalysis?.summary && typeof containerAnalyzeContext.seriesAnalysis.summary === 'object' - ? containerAnalyzeContext.seriesAnalysis.summary - : {}), - seriesLike: true, - confidence: containerAnalyzeContext?.seriesAnalysis?.summary?.confidence || 'high' - } - } - } - }; - } - } - } const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null); const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null; const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({ @@ -5083,12 +5000,13 @@ class HistoryService { requestedRawPath: absRawPath, sourceFolderJobId: metadata.folderJobId || null, mediaProfile: effectiveDetectedMediaType, - existingInfo: sourceJobContext?.makemkvInfo || null, + // RAW-Import soll wie "Disk analysieren" starten: + // keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan. + existingInfo: cdRecovery ? (sourceJobContext?.makemkvInfo || null) : null, recovery: cdRecovery, - selectedMetadata: effectiveSeriesImportHints?.selectedMetadata || null, - analyzeContextPatch: effectiveSeriesImportHints?.analyzeContextPatch || null + selectedMetadata: null, + analyzeContextPatch: null }); - const inferredSeriesContainerId = normalizeJobIdValue(inferredSeriesContainer?.id); const recoveredPosterUrl = orphanPosterUrl || (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate) || null; @@ -5108,11 +5026,7 @@ class HistoryService { await this.updateJob(created.id, { ...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), ...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}), - ...(effectiveDetectedMediaType === 'dvd' - ? (inferredSeriesContainerId - ? { job_kind: 'dvd_series_child', media_type: 'dvd', parent_job_id: inferredSeriesContainerId } - : { job_kind: 'dvd', media_type: 'dvd' }) - : {}), + ...(effectiveDetectedMediaType === 'dvd' ? { job_kind: 'dvd', media_type: 'dvd', parent_job_id: null } : {}), ...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}), status: 'FINISHED', last_state: 'FINISHED', @@ -5191,13 +5105,6 @@ class HistoryService { `Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.` ); } - if (inferredSeriesContainerId) { - await this.appendLog( - created.id, - 'SYSTEM', - `Automatisch dem Serien-Container #${inferredSeriesContainerId} zugeordnet.` - ); - } logger.info('job:import-orphan-raw', { jobId: created.id, @@ -5236,6 +5143,7 @@ class HistoryService { }; const requestedProviderRaw = String(payload.metadataProvider || '').trim().toLowerCase(); const requestedTmdbId = parseTmdbId(payload?.tmdbId ?? payload?.providerId ?? null); + const requestedWorkflowKind = normalizeDvdMetadataWorkflowKind(payload?.workflowKind); const metadataProvider = requestedProviderRaw === 'themoviedb' ? 'tmdb' : (requestedProviderRaw || (requestedTmdbId !== null ? 'tmdb' : 'omdb')); @@ -5455,6 +5363,7 @@ class HistoryService { year, imdbId, poster: posterUrl, + workflowKind: 'series', metadataProvider: 'tmdb', providerId, tmdbId: effectiveTmdbId, @@ -5474,6 +5383,7 @@ class HistoryService { : {}; const nextAnalyzeContext = { ...analyzeContext, + workflowKind: 'series', metadataProvider: 'tmdb', metadataKind, selectedMetadata: { @@ -5563,24 +5473,68 @@ class HistoryService { const imdbId = omdb?.imdbId || imdbIdInput || job.imdb_id || null; const posterUrl = omdb?.poster || manualPoster || job.poster_url || null; const selectedFromOmdb = omdb ? 1 : Number(payload.fromOmdb ? 1 : 0); + const resolvedJobMediaType = inferMediaType( + job, + makemkvInfo, + job?.mediainfo_info_json, + job?.encode_plan_json, + job?.handbrake_info_json + ); + const shouldUseFilmWorkflow = resolvedJobMediaType === 'dvd' && metadataProvider === 'omdb'; + const existingWorkflowKind = normalizeDvdMetadataWorkflowKind( + existingSelectedMetadata?.workflowKind + || analyzeContext?.workflowKind + || null + ); + const effectiveWorkflowKind = shouldUseFilmWorkflow + ? 'film' + : (requestedWorkflowKind || existingWorkflowKind); const nextSelectedMetadata = { ...(existingSelectedMetadata && typeof existingSelectedMetadata === 'object' ? existingSelectedMetadata : {}), title, year, imdbId, poster: posterUrl, - metadataProvider: 'omdb' + metadataProvider: 'omdb', + ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), + ...(shouldUseFilmWorkflow + ? { + providerId: null, + tmdbId: null, + metadataKind: 'movie', + seasonNumber: null, + seasonName: null, + episodeCount: 0, + episodes: [], + discNumber: null, + tmdbDetails: null + } + : {}) }; const existingAnalyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' ? analyzeContext.selectedMetadata : {}; + const existingSeriesLookupHint = analyzeContext?.seriesLookupHint && typeof analyzeContext.seriesLookupHint === 'object' + ? analyzeContext.seriesLookupHint + : {}; const nextAnalyzeContext = { ...analyzeContext, + ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), metadataProvider: 'omdb', + ...(shouldUseFilmWorkflow ? { metadataKind: 'movie' } : {}), selectedMetadata: { ...existingAnalyzeSelected, ...nextSelectedMetadata - } + }, + ...(shouldUseFilmWorkflow + ? { + seriesLookupHint: { + ...existingSeriesLookupHint, + seasonNumber: null, + discNumber: null + } + } + : {}) }; const topLevelSelected = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' ? makemkvInfo.selectedMetadata @@ -5601,7 +5555,14 @@ class HistoryService { poster_url: posterUrl, omdb_json: omdb?.raw ? JSON.stringify(omdb.raw) : (job.omdb_json || null), selected_from_omdb: selectedFromOmdb, - makemkv_info_json: JSON.stringify(nextMakemkvInfo) + makemkv_info_json: JSON.stringify(nextMakemkvInfo), + ...(shouldUseFilmWorkflow + ? { + parent_job_id: null, + job_kind: 'dvd', + media_type: 'dvd' + } + : {}) }); // Bild herunterladen, in persistenten Ordner verschieben und poster_url aktualisieren diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index e995533..b850f52 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -117,6 +117,20 @@ function normalizePositiveInteger(value) { return Math.trunc(parsed); } +function normalizeDvdMetadataWorkflowKind(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (['film', 'movie', 'feature'].includes(raw)) { + return 'film'; + } + if (['series', 'tv', 'season', 'episode'].includes(raw)) { + return 'series'; + } + return null; +} + function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) { const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' ? analyzeContext.selectedMetadata @@ -128,6 +142,12 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active ...analyzeSelectedMetadata, ...activeSelectedMetadata }; + const workflowKind = normalizeDvdMetadataWorkflowKind( + merged.workflowKind + || analyzeContext?.workflowKind + || activeContext?.workflowKind + || null + ); return { ...merged, @@ -135,6 +155,7 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active year: Number(merged.year ?? job?.year ?? 0) || null, imdbId: String(merged.imdbId || job?.imdb_id || '').trim() || null, poster: String(merged.poster || job?.poster_url || '').trim() || null, + ...(workflowKind ? { workflowKind } : {}), metadataProvider: String( merged.metadataProvider || analyzeContext?.metadataProvider @@ -152,6 +173,17 @@ function isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata = {}, analy const metadata = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}; + const workflowKind = normalizeDvdMetadataWorkflowKind( + metadata.workflowKind + || analyzeContext?.workflowKind + || null + ); + if (workflowKind === 'film') { + return false; + } + if (workflowKind === 'series') { + return true; + } const provider = String( metadata.metadataProvider || analyzeContext?.metadataProvider @@ -874,7 +906,7 @@ function resolveOutputTemplateValues(job, fallbackJobId = null) { const DEFAULT_OUTPUT_TEMPLATE = '${title} (${year})/${title} (${year})'; const DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; -const DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'; +const DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; function parseJsonObjectSafe(raw) { if (!raw) { @@ -1034,7 +1066,10 @@ function stripSeriesEpisodePartSuffix(value) { if (!source) { return ''; } - const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)\s*$/i; + // Output cleanup for multi-episode labels: + // remove classic "Teil/Part" markers and pure numeric/roman suffixes in + // parentheses like "(1)" / "(2)" without affecting assignment logic. + const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)|\(\s*(?:\d{1,2}|[ivxlcdm]{1,6})\s*\))\s*$/i; let normalized = source; let changed = false; for (let i = 0; i < 2; i += 1) { @@ -3552,6 +3587,96 @@ function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = nu }; } +function resolveSeriesPlayAllDoubleEpisodeDecision(seriesCandidates = [], allTitles = [], options = {}) { + const normalizedSeriesCandidates = (Array.isArray(seriesCandidates) ? seriesCandidates : []) + .map((item) => ({ + handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + if (normalizedSeriesCandidates.length < 2) { + return { + required: false, + reason: 'insufficient_series_candidates' + }; + } + + const allRows = (Array.isArray(allTitles) ? allTitles : []) + .map((item) => ({ + handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); + if (allRows.length === 0) { + return { + required: false, + reason: 'all_titles_missing' + }; + } + + const selectedIdSet = new Set(normalizedSeriesCandidates.map((item) => Number(item.handBrakeTitleId))); + const nonSelectedRows = allRows + .filter((item) => !selectedIdSet.has(Number(item.handBrakeTitleId))) + .sort((a, b) => b.durationSeconds - a.durationSeconds || a.handBrakeTitleId - b.handBrakeTitleId); + if (nonSelectedRows.length === 0) { + return { + required: false, + reason: 'no_non_selected_title' + }; + } + + const longestCandidate = nonSelectedRows[0]; + const selectedDurationSumSeconds = normalizedSeriesCandidates + .reduce((sum, item) => sum + Number(item.durationSeconds || 0), 0); + const longestSelectedDurationSeconds = normalizedSeriesCandidates + .reduce((maxValue, item) => Math.max(maxValue, Number(item.durationSeconds || 0)), 0); + if (!selectedDurationSumSeconds || !longestSelectedDurationSeconds) { + return { + required: false, + reason: 'duration_data_missing' + }; + } + + const minToleranceSeconds = Number(options?.minToleranceSeconds); + const ratioTolerance = Number(options?.ratioTolerance); + const minDominanceRatio = Number(options?.minDominanceRatio); + const maxAutoEpisodeCandidates = Number(options?.maxAutoEpisodeCandidates); + const toleranceSeconds = Math.max( + Number.isFinite(minToleranceSeconds) && minToleranceSeconds > 0 ? Math.trunc(minToleranceSeconds) : 120, + Math.round( + selectedDurationSumSeconds * ( + Number.isFinite(ratioTolerance) && ratioTolerance > 0 + ? ratioTolerance + : 0.08 + ) + ) + ); + const durationDeltaSeconds = Math.abs(longestCandidate.durationSeconds - selectedDurationSumSeconds); + const withinTolerance = durationDeltaSeconds <= toleranceSeconds; + const longestVsEpisodeRatio = longestCandidate.durationSeconds / Math.max(1, longestSelectedDurationSeconds); + const dominanceThreshold = Number.isFinite(minDominanceRatio) && minDominanceRatio > 0 + ? minDominanceRatio + : 1.4; + const hasDominantLongestCandidate = longestVsEpisodeRatio >= dominanceThreshold; + const maxAutoCandidates = Number.isFinite(maxAutoEpisodeCandidates) && maxAutoEpisodeCandidates > 0 + ? Math.trunc(maxAutoEpisodeCandidates) + : 2; + const isBoundaryEpisodeCount = normalizedSeriesCandidates.length <= maxAutoCandidates; + const required = withinTolerance && hasDominantLongestCandidate && isBoundaryEpisodeCount; + + return { + required, + reason: required ? 'playall_or_double_episode_boundary' : 'not_boundary_case', + defaultSelectedTitleIds: normalizeReviewTitleIdList(normalizedSeriesCandidates.map((item) => item.handBrakeTitleId)), + longestCandidateTitleId: longestCandidate.handBrakeTitleId, + selectedDurationSumSeconds, + longestCandidateDurationSeconds: longestCandidate.durationSeconds, + durationDeltaSeconds, + toleranceSeconds, + longestVsEpisodeRatio: Number(longestVsEpisodeRatio.toFixed(3)) + }; +} + function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) { const titleList = pickScanTitleList(scanJson); if (!Array.isArray(titleList) || titleList.length === 0) { @@ -6246,6 +6371,31 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP }; } +function resolveDvdScanInputPathFromRaw(rawPath, rawMedia = null) { + const sourcePath = String(rawPath || '').trim(); + const media = rawMedia && typeof rawMedia === 'object' + ? rawMedia + : collectRawMediaCandidates(sourcePath); + const mediaFiles = Array.isArray(media?.mediaFiles) ? media.mediaFiles : []; + const mediaSource = String(media?.source || '').trim().toLowerCase(); + + if (mediaFiles.length > 0 && mediaSource === 'dvd') { + // mediaFiles[0] points to ".../VIDEO_TS/VTS_XX_*.VOB" -> scan parent folder that contains VIDEO_TS. + const firstPath = String(mediaFiles[0]?.path || '').trim(); + if (firstPath) { + return path.dirname(path.dirname(firstPath)); + } + } + if ( + mediaFiles.length > 0 + && (mediaSource === 'dvd_image' || mediaSource === 'single_extensionless' || mediaSource === 'single_file') + ) { + return String(mediaFiles[0]?.path || '').trim() || sourcePath || null; + } + + return sourcePath || null; +} + function hasBluRayBackupStructure(rawPath) { if (!rawPath) { return false; @@ -11578,6 +11728,387 @@ class PipelineService extends EventEmitter { }; } + async analyzeRawImportJob(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID für RAW-Analyse.'); + error.statusCode = 400; + throw error; + } + + this.ensureNotBusy('analyzeRawImportJob', normalizedJobId); + logger.info('analyze:raw-import:start', { + jobId: normalizedJobId, + rawPath: options?.rawPath || null + }); + + const sourceJob = await historyService.getJobById(normalizedJobId); + if (!sourceJob) { + const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); + + const requestedRawPath = String(options?.rawPath || sourceJob.raw_path || '').trim(); + if (!requestedRawPath) { + const error = new Error('RAW-Analyse nicht möglich: raw_path fehlt.'); + error.statusCode = 400; + throw error; + } + + const globalSettings = await settingsService.getSettingsMap(); + const resolvedRawPath = this.resolveCurrentRawPathForSettings( + globalSettings, + null, + requestedRawPath + ) || requestedRawPath; + if (!resolvedRawPath || !fs.existsSync(resolvedRawPath)) { + const error = new Error(`RAW-Analyse nicht möglich: RAW-Pfad nicht gefunden (${requestedRawPath}).`); + error.statusCode = 400; + throw error; + } + + const inferredRawProfile = normalizeMediaProfile( + options?.mediaProfile || inferMediaProfileFromRawPath(resolvedRawPath) + ); + const fallbackProfile = normalizeMediaProfile( + sourceJob?.media_type + || inferMediaProfileFromJobKind(sourceJob?.job_kind) + || null + ); + const mediaProfile = isSpecificMediaProfile(inferredRawProfile) + ? inferredRawProfile + : (isSpecificMediaProfile(fallbackProfile) ? fallbackProfile : null); + + if (!mediaProfile) { + const error = new Error(`RAW-Analyse nicht möglich: Medientyp konnte nicht bestimmt werden (${resolvedRawPath}).`); + error.statusCode = 400; + throw error; + } + + if (mediaProfile === 'cd') { + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + 'RAW-Import erkannt als CD. Starte CD-Vorprüfung wie nach "Disk analysieren".' + ); + const cdResult = await this.restartCdReviewFromRaw(normalizedJobId, {}); + return { + started: true, + stage: 'CD_METADATA_SELECTION', + mediaProfile: 'cd', + ...(cdResult && typeof cdResult === 'object' ? cdResult : {}) + }; + } + + if (mediaProfile === 'audiobook' || mediaProfile === 'converter') { + const error = new Error(`RAW-Analyse für Medientyp "${mediaProfile}" wird über diesen Einstieg nicht unterstützt.`); + error.statusCode = 400; + throw error; + } + + const detectedTitle = String( + options?.detectedTitle + || sourceJob.detected_title + || sourceJob.title + || path.basename(resolvedRawPath) + || 'Unknown Disc' + ).trim() || 'Unknown Disc'; + const deviceWithProfile = { + path: resolvedRawPath, + discLabel: detectedTitle, + label: detectedTitle, + mediaProfile, + source: 'raw_import' + }; + const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile, 'analyze'); + + await historyService.resetProcessLog(normalizedJobId); + await historyService.updateJob(normalizedJobId, { + status: 'ANALYZING', + last_state: 'ANALYZING', + error_message: null, + detected_title: detectedTitle, + title: null, + year: null, + imdb_id: null, + poster_url: null, + omdb_json: null, + selected_from_omdb: 0, + rip_successful: 0, + end_time: null, + media_type: mediaProfile, + job_kind: resolveJobKindForMediaProfile(mediaProfile), + parent_job_id: null, + disc_device: null, + output_path: null, + handbrake_info_json: null, + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0, + ...(resolvedRawPath !== sourceJob.raw_path ? { raw_path: resolvedRawPath } : {}) + }); + + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + `RAW-Import wird wie "Disk analysieren" verarbeitet: ${path.basename(resolvedRawPath)} (Profil: ${mediaProfile}).` + ); + + await this.setState('ANALYZING', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: 'Disk wird analysiert ...', + context: { + jobId: normalizedJobId, + detectedTitle, + mediaProfile, + rawPath: resolvedRawPath, + rawImport: true + } + }); + + try { + let effectiveDetectedTitle = detectedTitle; + let omdbCandidates = null; + let metadataCandidates = null; + let metadataProvider = 'omdb'; + let seriesAnalysis = null; + let seriesLookupHint = null; + let pluginExecution = null; + + if (analyzePlugin) { + const pluginContext = await this.buildPluginContext(analyzePlugin.id, { + discInfo: deviceWithProfile, + jobId: normalizedJobId + }); + try { + const pluginResult = await analyzePlugin.analyze(resolvedRawPath, sourceJob, pluginContext); + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim(); + if (pluginDetectedTitle) { + effectiveDetectedTitle = pluginDetectedTitle; + } + if (Array.isArray(pluginResult?.omdbCandidates)) { + omdbCandidates = pluginResult.omdbCandidates; + } + logger.info('plugin:analyze:used', { + jobId: normalizedJobId, + pluginId: analyzePlugin.id, + mediaProfile, + source: 'raw_import' + }); + } catch (error) { + pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); + logger.warn('plugin:analyze:raw-import:fallback-legacy', { + jobId: normalizedJobId, + pluginId: analyzePlugin.id, + mediaProfile, + error: errorToMeta(error) + }); + } + } + + if (mediaProfile === 'dvd' && analyzePlugin?.id === 'dvd') { + try { + const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + const rawMedia = collectRawMediaCandidates(resolvedRawPath); + const dvdScanInputPath = resolveDvdScanInputPathFromRaw(resolvedRawPath, rawMedia) || resolvedRawPath; + const reviewResult = await this.runPluginReviewScan(analyzePlugin, sourceJob, { + jobId: normalizedJobId, + rawPath: dvdScanInputPath, + reviewMode: 'rip', + source: 'HANDBRAKE_SCAN', + silent: true + }); + pluginExecution = this.mergePluginExecutionState(pluginExecution, reviewResult?.pluginExecution || null); + + const scanText = Array.isArray(reviewResult?.scanLines) + ? reviewResult.scanLines.join('\n') + : ''; + if (scanText) { + const { DVDSeriesPlugin } = require('../plugins/DVDSeriesPlugin'); + const dvdSeriesPlugin = new DVDSeriesPlugin(); + const dvdSeriesContext = await this.buildPluginContext(dvdSeriesPlugin.id, { + discInfo: { + ...deviceWithProfile, + path: dvdScanInputPath + }, + jobId: normalizedJobId, + scanText, + detectedTitle: effectiveDetectedTitle + }); + const seriesPluginResult = await dvdSeriesPlugin.analyze(dvdScanInputPath, sourceJob, dvdSeriesContext); + pluginExecution = this.mergePluginExecutionState( + pluginExecution, + this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) + ); + + seriesAnalysis = seriesPluginResult?.seriesAnalysis || null; + seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; + const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' + ? seriesAnalysis.summary + : null; + const seriesLike = Boolean(rawSeriesSummary?.seriesLike); + seriesAnalysis = rawSeriesSummary + ? { + source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', + generatedAt: seriesAnalysis?.generatedAt || nowIso(), + summary: { + seriesLike, + confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', + reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], + titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0 + } + } + : null; + + if (seriesLike) { + metadataProvider = 'tmdb'; + metadataCandidates = []; + const fallbackQuery = String(seriesLookupHint?.query || effectiveDetectedTitle || '').trim() || null; + if (fallbackQuery) { + seriesLookupHint = { + ...(seriesLookupHint && typeof seriesLookupHint === 'object' ? seriesLookupHint : {}), + query: fallbackQuery, + seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery + }; + } + + if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) { + metadataCandidates = await tmdbService.searchSeriesWithSeasons( + seriesLookupHint.query, + { language: dvdSettings?.dvd_series_language || null } + ).catch((error) => { + logger.warn('dvd-series:tmdb-search-failed', { + jobId: normalizedJobId, + query: seriesLookupHint?.query || null, + seasonNumber: seriesLookupHint?.seasonNumber || null, + error: errorToMeta(error) + }); + return []; + }); + } + } + + logger.info('dvd-series:analyze:done', { + jobId: normalizedJobId, + seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike), + confidence: seriesAnalysis?.summary?.confidence || null, + metadataProvider, + metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, + seriesLookupHint, + source: 'raw_import' + }); + } + } catch (error) { + logger.warn('dvd-series:analyze:raw-import:fallback-legacy', { + jobId: normalizedJobId, + error: errorToMeta(error) + }); + } + } + + if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) { + omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []); + } + if (metadataProvider !== 'tmdb') { + metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : []; + } + + const prepareInfo = this.withPluginExecutionMeta( + this.withAnalyzeContextMediaProfile({ + source: 'orphan_raw_import', + importContext: sourceMakemkvInfo?.importContext && typeof sourceMakemkvInfo.importContext === 'object' + ? sourceMakemkvInfo.importContext + : null, + rawPath: resolvedRawPath, + phase: 'PREPARE', + preparedAt: nowIso(), + analyzeContext: { + playlistAnalysis: null, + playlistDecisionRequired: false, + selectedPlaylist: null, + selectedTitleId: null, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis, + seriesLookupHint + } + }, mediaProfile), + pluginExecution + ); + + await historyService.updateJob(normalizedJobId, { + status: 'METADATA_SELECTION', + last_state: 'METADATA_SELECTION', + detected_title: effectiveDetectedTitle, + media_type: mediaProfile, + job_kind: resolveJobKindForMediaProfile(mediaProfile), + parent_job_id: null, + makemkv_info_json: JSON.stringify(prepareInfo) + }); + await historyService.appendLog( + normalizedJobId, + 'SYSTEM', + metadataProvider === 'tmdb' + ? `Serien-DVD erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.` + : `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".` + ); + + await this.setState('METADATA_SELECTION', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: 'Metadaten auswählen', + context: { + jobId: normalizedJobId, + detectedTitle: effectiveDetectedTitle, + detectedTitleSource: effectiveDetectedTitle !== detectedTitle ? 'plugin' : 'raw_import', + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + omdbCandidates, + mediaProfile, + seriesAnalysis, + seriesLookupHint, + playlistAnalysis: null, + playlistDecisionRequired: false, + playlistCandidates: [], + selectedPlaylist: null, + selectedTitleId: null, + rawPath: resolvedRawPath, + rawImport: true, + ...(pluginExecution ? { pluginExecution } : {}) + } + }); + + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Metadaten bereit', + message: `Job #${normalizedJobId}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)` + }); + + return { + started: true, + stage: 'METADATA_SELECTION', + jobId: normalizedJobId, + rawPath: resolvedRawPath, + mediaProfile, + detectedTitle: effectiveDetectedTitle, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis, + seriesLookupHint + }; + } catch (error) { + logger.error('metadata:prepare:raw-import:failed', { jobId: normalizedJobId, error: errorToMeta(error) }); + await this.failJob(normalizedJobId, 'METADATA_SELECTION', error); + throw error; + } + } + async analyzeDisc(devicePath = null) { this.ensureNotBusy('analyze'); logger.info('analyze:start', { devicePath }); @@ -13175,6 +13706,7 @@ class PipelineService extends EventEmitter { providerId = null, tmdbId = null, metadataKind = null, + workflowKind = null, seasonNumber = null, seasonName = null, episodeCount = null, @@ -13195,6 +13727,7 @@ class PipelineService extends EventEmitter { metadataProvider, providerId, tmdbId, + workflowKind, seasonNumber, discNumber }); @@ -13510,28 +14043,46 @@ class PipelineService extends EventEmitter { const posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); - const effectiveMetadataProvider = String( + let effectiveMetadataProvider = String( metadataProvider || mkInfo?.analyzeContext?.metadataProvider || 'omdb' ).trim().toLowerCase() || 'omdb'; - const effectiveProviderId = String( + const requestedWorkflowKind = normalizeDvdMetadataWorkflowKind(workflowKind); + const existingWorkflowKind = normalizeDvdMetadataWorkflowKind( + mkInfo?.analyzeContext?.selectedMetadata?.workflowKind + || mkInfo?.analyzeContext?.workflowKind + || mkInfo?.selectedMetadata?.workflowKind + || null + ); + const providerWorkflowKind = effectiveMetadataProvider === 'tmdb' + ? 'series' + : (effectiveMetadataProvider === 'omdb' ? 'film' : null); + const effectiveWorkflowKind = requestedWorkflowKind || providerWorkflowKind || existingWorkflowKind; + if (mediaProfile === 'dvd') { + if (effectiveWorkflowKind === 'series') { + effectiveMetadataProvider = 'tmdb'; + } else if (effectiveWorkflowKind === 'film') { + effectiveMetadataProvider = 'omdb'; + } + } + let effectiveProviderId = String( providerId || mkInfo?.analyzeContext?.selectedMetadata?.providerId || '' ).trim() || null; - const effectiveTmdbId = Number( + let effectiveTmdbId = Number( tmdbId || mkInfo?.analyzeContext?.selectedMetadata?.tmdbId || 0 ) || null; - const effectiveMetadataKind = String( + let effectiveMetadataKind = String( metadataKind || mkInfo?.analyzeContext?.selectedMetadata?.metadataKind || '' ).trim().toLowerCase() || null; - const effectiveDiscNumber = normalizePositiveInteger(discNumber); - const effectiveSeasonNumber = Number( + let effectiveDiscNumber = normalizePositiveInteger(discNumber); + let effectiveSeasonNumber = Number( seasonNumber || mkInfo?.analyzeContext?.selectedMetadata?.seasonNumber || 0 @@ -13553,7 +14104,17 @@ class PipelineService extends EventEmitter { : []); let tmdbDetails = null; - const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveMetadataProvider === 'tmdb'; + const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveWorkflowKind === 'series'; + if (mediaProfile === 'dvd' && effectiveWorkflowKind === 'film') { + effectiveProviderId = null; + effectiveTmdbId = null; + effectiveMetadataKind = 'movie'; + effectiveDiscNumber = null; + effectiveSeasonNumber = null; + effectiveSeasonName = null; + effectiveEpisodeCount = 0; + effectiveEpisodes = []; + } if (isDvdTmdbMetadataSelection && !effectiveDiscNumber) { const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).'); error.statusCode = 400; @@ -13660,6 +14221,7 @@ class PipelineService extends EventEmitter { imdbId: effectiveImdbId, poster: posterValue, metadataProvider: effectiveMetadataProvider, + ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), providerId: effectiveProviderId, tmdbId: effectiveTmdbId, metadataKind: effectiveMetadataKind, @@ -13671,7 +14233,10 @@ class PipelineService extends EventEmitter { ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {}) }; - let parentContainerJobId = normalizePositiveInteger(job.parent_job_id || null); + const shouldDetachSeriesContainer = mediaProfile === 'dvd' && effectiveWorkflowKind === 'film'; + let parentContainerJobId = shouldDetachSeriesContainer + ? null + : normalizePositiveInteger(job.parent_job_id || null); const isDvdSeriesSelection = isDvdTmdbMetadataSelection; if (isDvdSeriesSelection) { const normalizeEpisodeRows = (rows) => { @@ -13908,7 +14473,18 @@ class PipelineService extends EventEmitter { || '' ).trim(); const existingRawPath = findExistingRawDirectory(rawBaseDir, metadataBase); - let updatedRawPath = existingRawPath || null; + const resolvedCurrentJobRawPath = this.resolveCurrentRawPathForSettings( + settings, + mediaProfile, + job.raw_path + ) || String(job.raw_path || '').trim() || null; + const currentJobRawPathPresent = Boolean(resolvedCurrentJobRawPath); + const currentJobRawPathExists = Boolean( + resolvedCurrentJobRawPath + && fs.existsSync(resolvedCurrentJobRawPath) + ); + let updatedRawPath = existingRawPath || (currentJobRawPathPresent ? resolvedCurrentJobRawPath : null); + const shouldAutoReviewFromRaw = Boolean(existingRawPath || currentJobRawPathExists); if (existingRawPath) { const existingRawState = resolveRawFolderStateFromPath(existingRawPath); const renameState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE @@ -13943,6 +14519,7 @@ class PipelineService extends EventEmitter { playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null ); const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; + const isRawImportMetadataJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({ ...mkInfo, @@ -13953,10 +14530,18 @@ class PipelineService extends EventEmitter { selectedPlaylist: playlistDecision.selectedPlaylist || null, selectedTitleId: playlistDecision.selectedTitleId ?? null, metadataProvider: effectiveMetadataProvider, + ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), selectedMetadata } }, mediaProfile); + const seriesDetachPatch = shouldDetachSeriesContainer + ? { + parent_job_id: null, + job_kind: resolveJobKindForMediaProfile(mediaProfile), + media_type: mediaProfile + } + : {}; await historyService.updateJob(jobId, { title: effectiveTitle, year: effectiveYear, @@ -13968,7 +14553,8 @@ class PipelineService extends EventEmitter { last_state: nextStatus, raw_path: updatedRawPath, makemkv_info_json: JSON.stringify(updatedMakemkvInfo), - ...(parentContainerJobId ? { parent_job_id: parentContainerJobId, job_kind: 'dvd_series_child', media_type: mediaProfile } : {}) + ...(parentContainerJobId ? { parent_job_id: parentContainerJobId, job_kind: 'dvd_series_child', media_type: mediaProfile } : {}), + ...seriesDetachPatch }); // Bild in Cache laden (async, blockiert nicht) @@ -13987,6 +14573,18 @@ class PipelineService extends EventEmitter { 'SYSTEM', `Vorhandenes RAW-Verzeichnis erkannt: ${existingRawPath}` ); + } else if (currentJobRawPathExists) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Metadaten-Match im RAW-Root nicht gefunden (${metadataBase}). Verwende vorhandenes Job-RAW: ${resolvedCurrentJobRawPath}` + ); + } else if (currentJobRawPathPresent) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Metadaten-Match im RAW-Root nicht gefunden (${metadataBase}). Job-RAW ist aktuell nicht verfügbar: ${resolvedCurrentJobRawPath}` + ); } else { await historyService.appendLog( jobId, @@ -14009,7 +14607,7 @@ class PipelineService extends EventEmitter { eta: null, statusText: requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' - : (existingRawPath + : (shouldAutoReviewFromRaw ? 'Metadaten übernommen - vorhandenes RAW erkannt' : 'Metadaten übernommen - bereit zum Start'), context: { @@ -14068,11 +14666,11 @@ class PipelineService extends EventEmitter { ); } - if (existingRawPath) { + if (shouldAutoReviewFromRaw) { await historyService.appendLog( jobId, 'SYSTEM', - 'Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW.' + `Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW: ${updatedRawPath}` ); const startResult = await this.startPreparedJob(jobId); logger.info('metadata:auto-track-review-started', { @@ -14085,6 +14683,12 @@ class PipelineService extends EventEmitter { return historyService.getJobById(jobId); } + if (isRawImportMetadataJob) { + const error = new Error('RAW-Import-Job: kein verwertbares RAW gefunden. Laufwerks-Rip ist deaktiviert.'); + error.statusCode = 409; + throw error; + } + await historyService.appendLog( jobId, 'SYSTEM', @@ -14119,6 +14723,7 @@ class PipelineService extends EventEmitter { const preloadedMakemkvInfo = this.safeParseJson(preloadedJob.makemkv_info_json); const preloadedEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json); + const isRawImportPreparedJob = String(preloadedMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; const preloadedMediaProfile = this.resolveMediaProfileForJob(preloadedJob, { makemkvInfo: preloadedMakemkvInfo, encodePlan: preloadedEncodePlan @@ -14174,6 +14779,11 @@ class PipelineService extends EventEmitter { } if (!hasUsableRawInput) { + if (isRawImportPreparedJob) { + const error = new Error('RAW-Import-Job: kein nutzbares RAW gefunden. Laufwerks-Rip wird nicht gestartet.'); + error.statusCode = 409; + throw error; + } // No raw input yet → will rip from disc. Bypass the encode queue entirely. return this.startPreparedJob(jobId, { ...options, immediate: true }); } @@ -14185,6 +14795,7 @@ class PipelineService extends EventEmitter { const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json); const jobEncodePlan = this.safeParseJson(job.encode_plan_json); + const isRawImportPreparedJob = String(jobMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; const jobMediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: jobMakemkvInfo, encodePlan: jobEncodePlan @@ -14276,6 +14887,12 @@ class PipelineService extends EventEmitter { return { started: true, stage: 'ENCODING', reusedRaw: true }; } + if (isRawImportPreparedJob) { + const error = new Error('RAW-Import-Job: kein nutzbares RAW gefunden. Laufwerks-Rip ist deaktiviert.'); + error.statusCode = 409; + throw error; + } + await historyService.updateJob(jobId, { status: 'RIPPING', last_state: 'RIPPING', @@ -14415,6 +15032,12 @@ class PipelineService extends EventEmitter { ); } + if (isRawImportPreparedJob) { + const error = new Error('RAW-Import-Job: kein verwertbares RAW gefunden. Laufwerks-Rip ist deaktiviert.'); + error.statusCode = 409; + throw error; + } + await historyService.updateJob(jobId, { status: 'RIPPING', last_state: 'RIPPING', @@ -15726,6 +16349,7 @@ class PipelineService extends EventEmitter { let dvdHandBrakeScanInputPath = null; let dvdSelectedTitleInfo = null; // set when HandBrake scan succeeds; used to skip re-selection let dvdScannedCandidates = null; // set when scan yields multiple MIN_LENGTH candidates needing user selection + let dvdAllTitles = null; // full DVD title list from initial scan if (isDvdSource && mediaFiles.length > 0) { // For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir). @@ -15760,6 +16384,7 @@ class PipelineService extends EventEmitter { dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n')); if (dvdHandBrakeScanJson) { const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson); + dvdAllTitles = allDvdTitles; const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdScanLines); effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; const wasSeriesDvdReview = effectiveIsSeriesDvdReview; @@ -16118,6 +16743,155 @@ class PipelineService extends EventEmitter { const seriesTitleIds = normalizeReviewTitleIdList( dvdScannedCandidates.map((item) => item?.handBrakeTitleId) ); + const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( + dvdScannedCandidates, + Array.isArray(dvdAllTitles) && dvdAllTitles.length > 0 ? dvdAllTitles : dvdScannedCandidates + ); + if (seriesPlayAllDecision.required) { + const manualTitleSourceRows = Array.isArray(dvdAllTitles) && dvdAllTitles.length > 0 + ? dvdAllTitles + : dvdScannedCandidates; + const manualCandidateIds = normalizeReviewTitleIdList([ + ...(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) + ? seriesPlayAllDecision.defaultSelectedTitleIds + : []), + seriesPlayAllDecision.longestCandidateTitleId + ]); + const candidatesData = manualCandidateIds + .map((titleId) => { + const matched = manualTitleSourceRows.find((item) => + normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id) === titleId + ) || null; + const durationSeconds = Number(matched?.durationSeconds || 0); + return { + handBrakeTitleId: titleId, + durationSeconds, + durationMinutes: Number((durationSeconds / 60).toFixed(1)), + audioTrackCount: Number(matched?.audioTrackCount || 0), + subtitleTrackCount: Number(matched?.subtitleTrackCount || 0), + sizeBytes: Number(matched?.sizeBytes || 0) + }; + }) + .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0); + const allTitlesData = manualTitleSourceRows + .map((item) => { + const titleId = normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id); + if (!titleId) { + return null; + } + const durationSeconds = Number(item?.durationSeconds || 0); + return { + handBrakeTitleId: titleId, + durationSeconds, + durationMinutes: Number((durationSeconds / 60).toFixed(1)), + audioTrackCount: Number(item?.audioTrackCount || 0), + subtitleTrackCount: Number(item?.subtitleTrackCount || 0), + sizeBytes: Number(item?.sizeBytes || 0) + }; + }) + .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0) + .sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId); + const decisionSelectableTitleIds = normalizeReviewTitleIdList([ + seriesPlayAllDecision.longestCandidateTitleId + ]); + const preselectedTitleIds = normalizeReviewTitleIdList( + (Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) + ? seriesPlayAllDecision.defaultSelectedTitleIds + : [] + ).filter((titleId) => candidatesData.some((row) => Number(row.handBrakeTitleId) === Number(titleId))) + ); + const effectivePreselectedTitleIds = preselectedTitleIds.length > 0 + ? preselectedTitleIds + : (candidatesData[0]?.handBrakeTitleId ? [candidatesData[0].handBrakeTitleId] : []); + enrichedReview = { + ...enrichedReview, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: { + reason: seriesPlayAllDecision.reason, + longestCandidateTitleId: seriesPlayAllDecision.longestCandidateTitleId, + selectedDurationSumSeconds: seriesPlayAllDecision.selectedDurationSumSeconds, + longestCandidateDurationSeconds: seriesPlayAllDecision.longestCandidateDurationSeconds, + durationDeltaSeconds: seriesPlayAllDecision.durationDeltaSeconds, + toleranceSeconds: seriesPlayAllDecision.toleranceSeconds, + longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio + }, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, + selectedHandBrakeTitleIds: effectivePreselectedTitleIds, + selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, + encodeInputPath: null + }; + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + analyzeContext: effectiveAnalyzeContext + }, mediaProfile)), + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), + encode_plan_json: JSON.stringify(enrichedReview), + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Grenzfall erkannt: Längster Titel -t ${seriesPlayAllDecision.longestCandidateTitleId || '-'} ` + + `(${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}) liegt innerhalb der ` + + `PlayAll-Toleranz (Summe Episoden=${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'}, ` + + `Delta=${seriesPlayAllDecision.durationDeltaSeconds || 0}s, Toleranz=${seriesPlayAllDecision.toleranceSeconds || 0}s). ` + + 'Bitte manuell entscheiden: PlayAll oder Doppelfolge.' + ); + const dvdWaitingStatusText = 'Serien-Grenzfall: PlayAll oder Doppelfolge?'; + const dvdWaitingContextPatch = { + jobId, + rawPath, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, + selectedHandBrakeTitleIds: effectivePreselectedTitleIds, + selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + }; + if (this.isPrimaryJob(jobId)) { + await this.setState('WAITING_FOR_USER_DECISION', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: dvdWaitingStatusText, + context: { + ...dvdWaitingContextPatch, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + } else { + await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { + contextPatch: dvdWaitingContextPatch + }); + } + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Serien-Grenzfall', + message: `Job #${jobId}: Bitte entscheiden, ob Titel ${seriesPlayAllDecision.longestCandidateTitleId || '-'} PlayAll oder Doppelfolge ist` + }); + return enrichedReview; + } const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( dvdHandBrakeScanJson, seriesTitleIds, @@ -16322,6 +17096,152 @@ class PipelineService extends EventEmitter { const seriesTitleIds = normalizeReviewTitleIdList( titleCandidates.map((item) => item?.handBrakeTitleId) ); + const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( + titleCandidates, + allTitles + ); + if (seriesPlayAllDecision.required) { + const manualCandidateIds = normalizeReviewTitleIdList([ + ...(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) + ? seriesPlayAllDecision.defaultSelectedTitleIds + : []), + seriesPlayAllDecision.longestCandidateTitleId + ]); + const candidatesData = manualCandidateIds + .map((titleId) => { + const matched = allTitles.find((item) => + normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id) === titleId + ) || null; + const durationSeconds = Number(matched?.durationSeconds || 0); + return { + handBrakeTitleId: titleId, + durationSeconds, + durationMinutes: Number((durationSeconds / 60).toFixed(1)), + audioTrackCount: Number(matched?.audioTrackCount || 0), + subtitleTrackCount: Number(matched?.subtitleTrackCount || 0), + sizeBytes: Number(matched?.sizeBytes || 0) + }; + }) + .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0); + const allTitlesData = allTitles + .map((item) => { + const titleId = normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id); + if (!titleId) { + return null; + } + const durationSeconds = Number(item?.durationSeconds || 0); + return { + handBrakeTitleId: titleId, + durationSeconds, + durationMinutes: Number((durationSeconds / 60).toFixed(1)), + audioTrackCount: Number(item?.audioTrackCount || 0), + subtitleTrackCount: Number(item?.subtitleTrackCount || 0), + sizeBytes: Number(item?.sizeBytes || 0) + }; + }) + .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0) + .sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId); + const decisionSelectableTitleIds = normalizeReviewTitleIdList([ + seriesPlayAllDecision.longestCandidateTitleId + ]); + const preselectedTitleIds = normalizeReviewTitleIdList( + (Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) + ? seriesPlayAllDecision.defaultSelectedTitleIds + : [] + ).filter((titleId) => candidatesData.some((row) => Number(row.handBrakeTitleId) === Number(titleId))) + ); + const effectivePreselectedTitleIds = preselectedTitleIds.length > 0 + ? preselectedTitleIds + : (candidatesData[0]?.handBrakeTitleId ? [candidatesData[0].handBrakeTitleId] : []); + enrichedReview = { + ...enrichedReview, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: { + reason: seriesPlayAllDecision.reason, + longestCandidateTitleId: seriesPlayAllDecision.longestCandidateTitleId, + selectedDurationSumSeconds: seriesPlayAllDecision.selectedDurationSumSeconds, + longestCandidateDurationSeconds: seriesPlayAllDecision.longestCandidateDurationSeconds, + durationDeltaSeconds: seriesPlayAllDecision.durationDeltaSeconds, + toleranceSeconds: seriesPlayAllDecision.toleranceSeconds, + longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio + }, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, + selectedHandBrakeTitleIds: effectivePreselectedTitleIds, + selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, + encodeInputPath: null + }; + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + analyzeContext: effectiveAnalyzeContext + }, mediaProfile)), + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), + encode_plan_json: JSON.stringify(enrichedReview), + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Grenzfall erkannt: Längster Titel -t ${seriesPlayAllDecision.longestCandidateTitleId || '-'} ` + + `(${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}) liegt innerhalb der ` + + `PlayAll-Toleranz (Summe Episoden=${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'}, ` + + `Delta=${seriesPlayAllDecision.durationDeltaSeconds || 0}s, Toleranz=${seriesPlayAllDecision.toleranceSeconds || 0}s). ` + + 'Bitte manuell entscheiden: PlayAll oder Doppelfolge.' + ); + const dvdWaitingStatusText = 'Serien-Grenzfall: PlayAll oder Doppelfolge?'; + const dvdWaitingContextPatch = { + jobId, + rawPath, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, + selectedHandBrakeTitleIds: effectivePreselectedTitleIds, + selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + }; + if (this.isPrimaryJob(jobId)) { + await this.setState('WAITING_FOR_USER_DECISION', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: dvdWaitingStatusText, + context: { + ...dvdWaitingContextPatch, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + } else { + await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { + contextPatch: dvdWaitingContextPatch + }); + } + void this.notifyPushover('metadata_ready', { + title: 'Ripster - Serien-Grenzfall', + message: `Job #${jobId}: Bitte entscheiden, ob Titel ${seriesPlayAllDecision.longestCandidateTitleId || '-'} PlayAll oder Doppelfolge ist` + }); + return enrichedReview; + } const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( dvdTitleScanJson, seriesTitleIds, diff --git a/db/schema.sql b/db/schema.sql index eb96e44..4ba6fc2 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -468,8 +468,8 @@ VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Ser INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_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_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'); +VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'); -- Tools – CD INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 65e6606..9147818 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.13.1-4", + "version": "0.13.1-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.13.1-4", + "version": "0.13.1-6", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 2c2e0af..5d82deb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.13.1-4", + "version": "0.13.1-6", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index 0722f37..0a452f6 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -78,7 +78,10 @@ function stripEpisodePartSuffix(value) { if (!source) { return ''; } - const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)\s*$/i; + // Output cleanup for multi-episode labels: + // remove "Teil/Part" markers and pure numeric/roman suffixes in + // parentheses like "(1)" / "(2)". + const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)|\(\s*(?:\d{1,2}|[ivxlcdm]{1,6})\s*\))\s*$/i; let normalized = source; let changed = false; for (let i = 0; i < 2; i += 1) { diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx index de859cd..9460c44 100644 --- a/frontend/src/components/MetadataSelectionDialog.jsx +++ b/frontend/src/components/MetadataSelectionDialog.jsx @@ -4,6 +4,7 @@ import { Button } from 'primereact/button'; import { DataTable } from 'primereact/datatable'; import { Column } from 'primereact/column'; import { InputText } from 'primereact/inputtext'; +import { Dropdown } from 'primereact/dropdown'; export default function MetadataSelectionDialog({ visible, @@ -13,6 +14,19 @@ export default function MetadataSelectionDialog({ onSearch, busy }) { + const normalizeWorkflowKind = (value) => { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (['film', 'movie', 'feature'].includes(raw)) { + return 'film'; + } + if (['series', 'tv', 'season', 'episode'].includes(raw)) { + return 'series'; + } + return null; + }; const parseDiscNumber = (rawValue) => { const text = String(rawValue ?? '').trim(); if (!/^\d+$/.test(text)) { @@ -36,12 +50,35 @@ export default function MetadataSelectionDialog({ const [submitError, setSubmitError] = useState(''); const [seasonFilter, setSeasonFilter] = useState(''); const [discValidationError, setDiscValidationError] = useState(''); - const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; + const contextWorkflowKind = normalizeWorkflowKind( + context?.selectedMetadata?.workflowKind + || context?.workflowKind + || null + ); + const contextSelectedMetadataProvider = String( + context?.selectedMetadata?.metadataProvider + || '' + ).trim().toLowerCase(); + const contextMetadataProvider = ( + contextWorkflowKind === 'series' + ? 'tmdb' + : (contextWorkflowKind === 'film' + ? 'omdb' + : (contextSelectedMetadataProvider || String(context?.metadataProvider || 'omdb').trim().toLowerCase())) + ) || 'omdb'; + const [selectedMetadataProvider, setSelectedMetadataProvider] = useState(contextMetadataProvider); const mediaProfile = String( context?.mediaProfile || context?.selectedMetadata?.mediaProfile || '' ).trim().toLowerCase(); + const metadataProvider = selectedMetadataProvider === 'tmdb' ? 'tmdb' : 'omdb'; + const providerOptions = mediaProfile === 'dvd' + ? [ + { label: 'Film (OMDb)', value: 'omdb' }, + { label: 'Serie (TMDb)', value: 'tmdb' } + ] + : [{ label: contextMetadataProvider === 'tmdb' ? 'TMDb' : 'OMDb', value: contextMetadataProvider }]; const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'; const supportsExternalIdInput = metadataProvider === 'omdb'; const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile); @@ -70,6 +107,7 @@ export default function MetadataSelectionDialog({ setSubmitError(''); setSeasonFilter(''); setDiscValidationError(''); + setSelectedMetadataProvider(contextMetadataProvider); }, [visible, context]); useEffect(() => { @@ -85,7 +123,7 @@ export default function MetadataSelectionDialog({ if (submitError) { setSubmitError(''); } - }, [manualDiscNumber, seasonFilter, selected]); + }, [manualDiscNumber, seasonFilter, selected, metadataProvider]); const rows = useMemo(() => { const base = Array.isArray(context?.metadataCandidates) @@ -186,6 +224,7 @@ export default function MetadataSelectionDialog({ poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null, fromOmdb: metadataProvider === 'omdb', metadataProvider, + workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film', providerId: selected.providerId || null, tmdbId: selected.tmdbId || null, metadataKind: selected.metadataKind || null, @@ -203,6 +242,7 @@ export default function MetadataSelectionDialog({ poster: null, fromOmdb: false, metadataProvider, + workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film', seasonNumber: null, ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) }; @@ -240,6 +280,25 @@ export default function MetadataSelectionDialog({ modal >