From cac6442b5ba9ef78d4234f6711b3a3a696a7f7b5 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 14 Apr 2026 05:54:48 +0000 Subject: [PATCH 1/2] 0.13.1-5 Adjust Detection --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/db/database.js | 46 +- backend/src/routes/historyRoutes.js | 19 +- backend/src/services/historyService.js | 130 +-- backend/src/services/pipelineService.js | 807 +++++++++++++++++- db/schema.sql | 4 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../components/MetadataSelectionDialog.jsx | 33 +- .../src/components/PipelineStatusCard.jsx | 22 +- frontend/src/pages/DatabasePage.jsx | 14 +- package-lock.json | 4 +- package.json | 2 +- 14 files changed, 937 insertions(+), 156 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index c0a8da3..c588903 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-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.13.1-4", + "version": "0.13.1-5", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 99facb9..9bf6022 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-5", "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/services/historyService.js b/backend/src/services/historyService.js index 3ae364f..2b2a9e2 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -4967,114 +4967,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 +4975,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 +5001,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 +5080,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, diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index e995533..949e066 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -874,7 +874,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 +1034,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 +3555,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 +6339,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 +11696,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 }); @@ -13908,7 +14407,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 +14453,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, @@ -13987,6 +14498,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 +14532,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 +14591,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 +14608,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 +14648,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 +14704,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 +14720,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 +14812,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 +14957,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 +16274,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 +16309,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 +16668,130 @@ 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 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, + 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 + }, + 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, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary, + 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 +16996,127 @@ 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 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, + 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 + }, + 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, + handBrakeDvdInputPath: dvdScanInputPath, + handBrakeDecisionMode: 'series_playall_or_double', + handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary, + 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..2462679 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-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.13.1-4", + "version": "0.13.1-5", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 2c2e0af..ea82318 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-5", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx index de859cd..c1ac647 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, @@ -36,12 +37,20 @@ 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 contextMetadataProvider = 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 +79,7 @@ export default function MetadataSelectionDialog({ setSubmitError(''); setSeasonFilter(''); setDiscValidationError(''); + setSelectedMetadataProvider(contextMetadataProvider); }, [visible, context]); useEffect(() => { @@ -85,7 +95,7 @@ export default function MetadataSelectionDialog({ if (submitError) { setSubmitError(''); } - }, [manualDiscNumber, seasonFilter, selected]); + }, [manualDiscNumber, seasonFilter, selected, metadataProvider]); const rows = useMemo(() => { const base = Array.isArray(context?.metadataCandidates) @@ -240,6 +250,25 @@ export default function MetadataSelectionDialog({ modal >
+ {providerOptions.length > 1 ? ( + { + const nextProvider = String(event?.value || 'omdb').trim().toLowerCase() || 'omdb'; + setSelectedMetadataProvider(nextProvider); + setSelected(null); + setExtraResults([]); + setSearchError(''); + setSubmitError(''); + setDiscValidationError(''); + }} + disabled={searchBusy || busy} + style={{ minWidth: '12.5rem' }} + /> + ) : null} setQuery(event.target.value)} diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index f202f43..0aa3ad7 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -985,10 +985,10 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig ).trim(); const multiTemplateRaw = String( settings?.output_template_dvd_series_multi_episode - || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}' + || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})' ).trim(); const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; - const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'; + const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; const template = episodeRange.isMulti ? multiTemplate : singleTemplate; const rendered = renderTemplate(template, { seriesTitle, @@ -1936,6 +1936,13 @@ export default function PipelineStatusCard({ const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION' && Boolean(pipeline?.context?.handBrakeTitleDecisionRequired); + const handBrakeDecisionMode = String(pipeline?.context?.handBrakeDecisionMode || '').trim().toLowerCase(); + const isSeriesPlayAllDecisionMode = handBrakeDecisionMode === 'series_playall_or_double'; + const handBrakeDecisionSummary = pipeline?.context?.handBrakeDecisionSummary + && typeof pipeline.context.handBrakeDecisionSummary === 'object' + ? pipeline.context.handBrakeDecisionSummary + : null; + const ambiguousLongestTitleId = normalizeTitleId(handBrakeDecisionSummary?.longestCandidateTitleId); const waitingHandBrakeTitleRows = useMemo(() => { const raw = Array.isArray(pipeline?.context?.handBrakeTitleCandidates) @@ -2849,7 +2856,7 @@ export default function PipelineStatusCard({ {handBrakeTitleDecisionRequired && retryJobId && (