diff --git a/backend/package-lock.json b/backend/package-lock.json index c588903..5c846ef 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.13.1-5", + "version": "0.13.1-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.13.1-5", + "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 9bf6022..48d88d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.13.1-5", + "version": "0.13.1-6", "private": true, "type": "commonjs", "scripts": { 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 2b2a9e2..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 @@ -5118,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')); @@ -5337,6 +5363,7 @@ class HistoryService { year, imdbId, poster: posterUrl, + workflowKind: 'series', metadataProvider: 'tmdb', providerId, tmdbId: effectiveTmdbId, @@ -5356,6 +5383,7 @@ class HistoryService { : {}; const nextAnalyzeContext = { ...analyzeContext, + workflowKind: 'series', metadataProvider: 'tmdb', metadataKind, selectedMetadata: { @@ -5445,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 @@ -5483,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 949e066..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 @@ -13674,6 +13706,7 @@ class PipelineService extends EventEmitter { providerId = null, tmdbId = null, metadataKind = null, + workflowKind = null, seasonNumber = null, seasonName = null, episodeCount = null, @@ -13694,6 +13727,7 @@ class PipelineService extends EventEmitter { metadataProvider, providerId, tmdbId, + workflowKind, seasonNumber, discNumber }); @@ -14009,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 @@ -14052,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; @@ -14159,6 +14221,7 @@ class PipelineService extends EventEmitter { imdbId: effectiveImdbId, poster: posterValue, metadataProvider: effectiveMetadataProvider, + ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), providerId: effectiveProviderId, tmdbId: effectiveTmdbId, metadataKind: effectiveMetadataKind, @@ -14170,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) => { @@ -14464,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, @@ -14479,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) @@ -16698,6 +16773,27 @@ class PipelineService extends EventEmitter { }; }) .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 @@ -16711,6 +16807,7 @@ class PipelineService extends EventEmitter { ...enrichedReview, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, handBrakeDvdInputPath: dvdScanInputPath, handBrakeDecisionMode: 'series_playall_or_double', handBrakeDecisionSummary: { @@ -16722,6 +16819,7 @@ class PipelineService extends EventEmitter { toleranceSeconds: seriesPlayAllDecision.toleranceSeconds, longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio }, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, selectedHandBrakeTitleIds: effectivePreselectedTitleIds, selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, encodeInputPath: null @@ -16758,9 +16856,11 @@ class PipelineService extends EventEmitter { 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, @@ -17023,6 +17123,27 @@ class PipelineService extends EventEmitter { }; }) .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 @@ -17036,6 +17157,7 @@ class PipelineService extends EventEmitter { ...enrichedReview, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, + handBrakeAllTitles: allTitlesData, handBrakeDvdInputPath: dvdScanInputPath, handBrakeDecisionMode: 'series_playall_or_double', handBrakeDecisionSummary: { @@ -17047,6 +17169,7 @@ class PipelineService extends EventEmitter { toleranceSeconds: seriesPlayAllDecision.toleranceSeconds, longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio }, + handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, selectedHandBrakeTitleIds: effectivePreselectedTitleIds, selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, encodeInputPath: null @@ -17083,9 +17206,11 @@ class PipelineService extends EventEmitter { 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, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2462679..9147818 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.13.1-5", + "version": "0.13.1-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.13.1-5", + "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 ea82318..5d82deb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.13.1-5", + "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 c1ac647..9460c44 100644 --- a/frontend/src/components/MetadataSelectionDialog.jsx +++ b/frontend/src/components/MetadataSelectionDialog.jsx @@ -14,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)) { @@ -37,7 +50,22 @@ export default function MetadataSelectionDialog({ const [submitError, setSubmitError] = useState(''); const [seasonFilter, setSeasonFilter] = useState(''); const [discValidationError, setDiscValidationError] = useState(''); - const contextMetadataProvider = 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 @@ -196,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, @@ -213,6 +242,7 @@ export default function MetadataSelectionDialog({ poster: null, fromOmdb: false, metadataProvider, + workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film', seasonNumber: null, ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) }; diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index 0aa3ad7..901bece 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -810,7 +810,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) { @@ -1943,15 +1946,49 @@ export default function PipelineStatusCard({ ? pipeline.context.handBrakeDecisionSummary : null; const ambiguousLongestTitleId = normalizeTitleId(handBrakeDecisionSummary?.longestCandidateTitleId); + const handBrakeDecisionCandidateTitleIds = useMemo(() => normalizeTitleIdList( + (Array.isArray(pipeline?.context?.handBrakeTitleCandidates) + ? pipeline.context.handBrakeTitleCandidates + : [] + ).map((item) => item?.handBrakeTitleId) + ), [pipeline?.context?.handBrakeTitleCandidates]); + const handBrakeDecisionSelectableTitleIds = useMemo(() => { + const explicit = normalizeTitleIdList(pipeline?.context?.handBrakeDecisionSelectableTitleIds); + if (explicit.length > 0) { + return explicit; + } + if (isSeriesPlayAllDecisionMode && ambiguousLongestTitleId) { + return [ambiguousLongestTitleId]; + } + return handBrakeDecisionCandidateTitleIds; + }, [ + isSeriesPlayAllDecisionMode, + ambiguousLongestTitleId, + handBrakeDecisionCandidateTitleIds, + pipeline?.context?.handBrakeDecisionSelectableTitleIds + ]); + const handBrakeDecisionSelectableTitleIdSet = useMemo( + () => new Set(handBrakeDecisionSelectableTitleIds.map((id) => Number(id))), + [handBrakeDecisionSelectableTitleIds] + ); const waitingHandBrakeTitleRows = useMemo(() => { - const raw = Array.isArray(pipeline?.context?.handBrakeTitleCandidates) + const rawCandidates = Array.isArray(pipeline?.context?.handBrakeTitleCandidates) ? pipeline.context.handBrakeTitleCandidates : []; - return raw + const rawAllTitles = isSeriesPlayAllDecisionMode && Array.isArray(pipeline?.context?.handBrakeAllTitles) + ? pipeline.context.handBrakeAllTitles + : []; + const sourceRows = rawAllTitles.length > 0 ? rawAllTitles : rawCandidates; + const candidateIdSet = new Set( + normalizeTitleIdList(rawCandidates.map((item) => item?.handBrakeTitleId)).map((id) => Number(id)) + ); + return sourceRows .map((item) => { - const titleId = Number(item?.handBrakeTitleId); - if (!Number.isFinite(titleId) || titleId <= 0) return null; + const titleId = normalizeTitleId(item?.handBrakeTitleId); + if (!titleId) { + return null; + } const durationSeconds = Number(item?.durationSeconds || 0); const minutes = Math.floor(durationSeconds / 60); const seconds = durationSeconds % 60; @@ -1963,12 +2000,23 @@ export default function PipelineStatusCard({ durationMinutes: Number(item?.durationMinutes || 0), audioTrackCount: Number(item?.audioTrackCount || 0), subtitleTrackCount: Number(item?.subtitleTrackCount || 0), - sizeBytes: Number(item?.sizeBytes || 0) + sizeBytes: Number(item?.sizeBytes || 0), + decisionCandidate: candidateIdSet.has(Number(titleId)) }; }) .filter(Boolean) .sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId); - }, [pipeline?.context?.handBrakeTitleCandidates]); + }, [ + isSeriesPlayAllDecisionMode, + pipeline?.context?.handBrakeAllTitles, + pipeline?.context?.handBrakeTitleCandidates + ]); + const visibleWaitingHandBrakeTitleRows = useMemo(() => { + if (!isSeriesPlayAllDecisionMode) { + return waitingHandBrakeTitleRows; + } + return waitingHandBrakeTitleRows.filter((row) => row.decisionCandidate); + }, [isSeriesPlayAllDecisionMode, waitingHandBrakeTitleRows]); useEffect(() => { if (!handBrakeTitleDecisionRequired) { @@ -1991,11 +2039,24 @@ export default function PipelineStatusCard({ setSelectedHandBrakeTitleIdsState([Math.trunc(singleContextId)]); return; } - const best = waitingHandBrakeTitleRows[0]?.handBrakeTitleId || null; - setSelectedHandBrakeTitleIdsState(best ? [best] : []); + if (isSeriesPlayAllDecisionMode) { + const lockedDefaultIds = waitingHandBrakeTitleRows + .filter((row) => row.decisionCandidate && !handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId))) + .map((row) => row.handBrakeTitleId); + if (lockedDefaultIds.length > 0) { + setSelectedHandBrakeTitleIdsState(normalizeTitleIdList(lockedDefaultIds)); + return; + } + } + const firstSelectable = waitingHandBrakeTitleRows.find((row) => ( + !isSeriesPlayAllDecisionMode || handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId)) + ))?.handBrakeTitleId || null; + setSelectedHandBrakeTitleIdsState(firstSelectable ? [firstSelectable] : []); }, [ handBrakeTitleDecisionRequired, + isSeriesPlayAllDecisionMode, retryJobId, + handBrakeDecisionSelectableTitleIdSet, waitingHandBrakeTitleRows, pipeline?.context?.selectedHandBrakeTitleId, pipeline?.context?.selectedHandBrakeTitleIds @@ -3087,40 +3148,58 @@ export default function PipelineStatusCard({

{isSeriesPlayAllDecisionMode ? 'Serien-Grenzfall: PlayAll oder Doppelfolge?' : 'DVD Titel-Auswahl erforderlich'}

{isSeriesPlayAllDecisionMode - ? 'Grenzfall erkannt: Der längste Titel passt zeitlich zur Summe der kürzeren Episoden. Bitte entscheiden, ob er als PlayAll ausgeschlossen oder als Doppelfolge mit encodiert werden soll.' + ? 'Min. ein Titel passt zeitlich zur Summe der kürzeren Episoden. Bitte durch Auswahl entscheiden, ob PlayAll Multi-Episode (nicht anhaken) oder Doppelfolge (anhaken).' : 'Mehrere DVD-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episoden-Titel auswählen.'} - {waitingHandBrakeTitleRows.length > 0 ? ( + {visibleWaitingHandBrakeTitleRows.length > 0 ? (
- {waitingHandBrakeTitleRows.map((row) => ( -
- +
+ ); + })}
) : ( Keine Kandidaten verfügbar. Bitte Analyse erneut ausführen. @@ -3156,7 +3235,7 @@ export default function PipelineStatusCard({ ) : null} - {(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? ( + {(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
{!isDvdTitleSelectionRequired ?

Titel-/Spurprüfung

: null} {state === 'READY_TO_ENCODE' && !queueLocked && !isDvdTitleSelectionRequired ? ( diff --git a/frontend/src/pages/HistoryPage.jsx b/frontend/src/pages/HistoryPage.jsx index 6e5c034..efb237f 100644 --- a/frontend/src/pages/HistoryPage.jsx +++ b/frontend/src/pages/HistoryPage.jsx @@ -1112,8 +1112,20 @@ export default function HistoryPage({ refreshToken = 0 }) { : (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' ? makemkvInfo.selectedMetadata : {}); + const workflowKindRaw = String( + selectedMetadata?.workflowKind + || analyzeContext?.workflowKind + || '' + ).trim().toLowerCase(); + const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw) + ? 'film' + : (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null); const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row); - const metadataProvider = isSeriesDvd ? 'tmdb' : 'omdb'; + const metadataProvider = workflowKind === 'series' + ? 'tmdb' + : (workflowKind === 'film' + ? 'omdb' + : (String(selectedMetadata?.metadataProvider || analyzeContext?.metadataProvider || (isSeriesDvd ? 'tmdb' : 'omdb')).trim().toLowerCase() || 'omdb')); const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null; const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null; const context = { diff --git a/frontend/src/pages/RipperPage.jsx b/frontend/src/pages/RipperPage.jsx index dc73a57..ec53d1f 100644 --- a/frontend/src/pages/RipperPage.jsx +++ b/frontend/src/pages/RipperPage.jsx @@ -1352,7 +1352,7 @@ export default function RipperPage({ jobId: normalizedJobId, detectedTitle: context?.detectedTitle || job?.detected_title || selectedMetadata?.title || '', selectedMetadata, - metadataProvider: context?.metadataProvider || selectedMetadata?.metadataProvider || 'omdb', + metadataProvider: selectedMetadata?.metadataProvider || context?.metadataProvider || 'omdb', metadataCandidates: Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [], seriesAnalysis: context?.seriesAnalysis || null, seriesLookupHint: context?.seriesLookupHint || null, @@ -1382,7 +1382,7 @@ export default function RipperPage({ seasonNumber: null, discNumber: null }, - metadataProvider: currentContext?.metadataProvider || currentContext?.selectedMetadata?.metadataProvider || 'omdb', + metadataProvider: currentContext?.selectedMetadata?.metadataProvider || currentContext?.metadataProvider || 'omdb', metadataCandidates: Array.isArray(currentContext?.metadataCandidates) ? currentContext.metadataCandidates : [], seriesAnalysis: currentContext?.seriesAnalysis || null, seriesLookupHint: currentContext?.seriesLookupHint || null, diff --git a/frontend/src/utils/jobTaxonomy.js b/frontend/src/utils/jobTaxonomy.js index 5bd398d..87f6aa8 100644 --- a/frontend/src/utils/jobTaxonomy.js +++ b/frontend/src/utils/jobTaxonomy.js @@ -217,6 +217,17 @@ export function isSeriesDvdJob(job) { : (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object' ? job.makemkvInfo.selectedMetadata : {}); + const workflowKind = String( + selectedMetadata?.workflowKind + || analyzeContext?.workflowKind + || '' + ).trim().toLowerCase(); + if (['film', 'movie', 'feature'].includes(workflowKind)) { + return false; + } + if (['series', 'tv', 'season', 'episode'].includes(workflowKind)) { + return true; + } const metadataProvider = String( analyzeContext?.metadataProvider diff --git a/package-lock.json b/package-lock.json index 899480d..d19704b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.13.1-5", + "version": "0.13.1-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.13.1-5", + "version": "0.13.1-6", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 7a6ce85..2e3caf5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.13.1-5", + "version": "0.13.1-6", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",