diff --git a/Bildschirmfoto 2026-04-11 um 21.20.19.png b/Bildschirmfoto 2026-04-11 um 21.20.19.png new file mode 100644 index 0000000..538aeec Binary files /dev/null and b/Bildschirmfoto 2026-04-11 um 21.20.19.png differ diff --git a/Bildschirmfoto 2026-04-11 um 21.20.25.png b/Bildschirmfoto 2026-04-11 um 21.20.25.png new file mode 100644 index 0000000..6f82326 Binary files /dev/null and b/Bildschirmfoto 2026-04-11 um 21.20.25.png differ diff --git a/backend/package-lock.json b/backend/package-lock.json index 6dff27a..4251329 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.13.0-1", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.13.0-1", + "version": "0.13.1", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index f981696..5c0b80f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.13.0-1", + "version": "0.13.1", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index e4efa0e..253dce3 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -1159,6 +1159,29 @@ function normalizePositiveIntegerOrNull(value) { return Math.trunc(parsed); } +function isSeriesBatchEpisodeSubJobPlan(encodePlan = null) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + if (Boolean(plan?.seriesBatchChild) || Boolean(plan?.seriesBatchVirtualEpisode)) { + return true; + } + const parentJobId = normalizePositiveIntegerOrNull(plan?.seriesBatchParentJobId); + const titleId = normalizePositiveIntegerOrNull(plan?.seriesBatchTitleId); + const childIndex = normalizePositiveIntegerOrNull(plan?.seriesBatchChildIndex); + const childCount = normalizePositiveIntegerOrNull(plan?.seriesBatchChildCount); + if (parentJobId && (titleId || childIndex || childCount)) { + return true; + } + return false; +} + +function isSeriesBatchEpisodeSubJobRow(row = null) { + const source = row && typeof row === 'object' ? row : {}; + const plan = source?.encodePlan && typeof source.encodePlan === 'object' + ? source.encodePlan + : parseJsonSafe(source?.encode_plan_json, null); + return isSeriesBatchEpisodeSubJobPlan(plan); +} + function extractSeasonNumberFromText(value) { const text = String(value || '').trim(); if (!text) { @@ -3675,8 +3698,24 @@ class HistoryService { } const allContainerChildRows = [...childRows, ...nestedChildRows]; + const directDiskRowsByContainerId = new Map(); + const directEpisodeRowsByContainerId = new Map(); const directChildRowById = new Map(); for (const row of childRows) { + const containerId = normalizeJobIdValue(row?.parent_job_id); + const isEpisodeSubJob = isSeriesBatchEpisodeSubJobRow(row); + if (containerId) { + const map = isEpisodeSubJob + ? directEpisodeRowsByContainerId + : directDiskRowsByContainerId; + if (!map.has(containerId)) { + map.set(containerId, []); + } + map.get(containerId).push(row); + } + if (isEpisodeSubJob) { + continue; + } const childId = normalizeJobIdValue(row?.id); if (childId) { directChildRowById.set(childId, row); @@ -3737,8 +3776,9 @@ class HistoryService { const episodeCount = Number(selectedMetadata?.episodeCount || 0); const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0; const expectedTotal = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0); - const childIds = childRows - .filter((row) => normalizeJobIdValue(row?.parent_job_id) === containerId) + const containerChildRows = directDiskRowsByContainerId.get(containerId) || []; + const containerEpisodeRows = directEpisodeRowsByContainerId.get(containerId) || []; + const childIds = containerChildRows .map((row) => normalizeJobIdValue(row?.id)) .filter(Boolean); let rawExistsAny = false; @@ -3851,6 +3891,22 @@ class HistoryService { } } } + for (const episodeRow of containerEpisodeRows) { + const episodeRowId = normalizeJobIdValue(episodeRow?.id); + if (!episodeRowId) { + continue; + } + const outputs = Array.from(childOutputMap.get(episodeRowId) || []); + for (const outputPath of outputs) { + if (!outputPath || seenOutputs.has(outputPath)) { + continue; + } + seenOutputs.add(outputPath); + if (!includeFsChecks || fs.existsSync(outputPath)) { + existingCount += 1; + } + } + } if (existingCountFromSeriesBatch > existingCount) { existingCount = existingCountFromSeriesBatch; } @@ -4319,6 +4375,10 @@ class HistoryService { const childJobs = await Promise.all( childRows.map((row) => this.repairImportedOrphanJobClassification(row, settings)) ); + const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; + const detailChildJobs = isSeriesContainer + ? childJobs.filter((child) => !isSeriesBatchEpisodeSubJobRow(child)) + : childJobs; const parsedTail = Number(options.logTailLines); const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0 ? Math.trunc(parsedTail) @@ -4330,7 +4390,7 @@ class HistoryService { const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false; const baseLogCount = hasProcessLog ? 1 : 0; const enrichedChildren = await Promise.all( - childJobs.map(async (child) => { + detailChildJobs.map(async (child) => { const base = { ...enrichJobRow(child, settings, { includeFsChecks }), log_count: includeFsChecks ? (hasProcessLogFile(child.id) ? 1 : 0) : 0 @@ -4365,7 +4425,6 @@ class HistoryService { }) ); - const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; let seriesChildSummary = null; if (isSeriesContainer && enrichedChildren.length > 0) { const total = enrichedChildren.length; diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index f8b4f40..a696247 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -13172,6 +13172,19 @@ class PipelineService extends EventEmitter { let parentContainerJobId = normalizePositiveInteger(job.parent_job_id || null); const isDvdSeriesSelection = isDvdTmdbMetadataSelection; if (isDvdSeriesSelection) { + const resolveDiscNumberFromJobRow = (row) => { + const rowMkInfo = this.safeParseJson(row?.makemkv_info_json) || {}; + const rowAnalyzeContext = rowMkInfo?.analyzeContext && typeof rowMkInfo.analyzeContext === 'object' + ? rowMkInfo.analyzeContext + : {}; + const rowSelectedMetadata = rowAnalyzeContext?.selectedMetadata && typeof rowAnalyzeContext.selectedMetadata === 'object' + ? rowAnalyzeContext.selectedMetadata + : (rowMkInfo?.selectedMetadata && typeof rowMkInfo.selectedMetadata === 'object' + ? rowMkInfo.selectedMetadata + : {}); + return normalizePositiveInteger(rowSelectedMetadata?.discNumber); + }; + if (!parentContainerJobId) { const existingContainer = await historyService.findSeriesContainerJob(effectiveTmdbId, effectiveSeasonNumber); if (existingContainer) { @@ -13212,6 +13225,38 @@ class PipelineService extends EventEmitter { parentContainerJobId = Number(containerJob?.id || 0) || null; } } + + if (parentContainerJobId && effectiveDiscNumber) { + const containerChildren = await historyService.listChildJobs(parentContainerJobId); + const conflictingDiscChild = (Array.isArray(containerChildren) ? containerChildren : []).find((childRow) => { + const childId = Number(childRow?.id || 0); + if (!childId || childId === Number(jobId)) { + return false; + } + const childDiscNumber = resolveDiscNumberFromJobRow(childRow); + return childDiscNumber === effectiveDiscNumber; + }); + + if (conflictingDiscChild) { + const existingChildJobId = Number(conflictingDiscChild?.id || 0) || null; + const error = new Error( + `Disk ${effectiveDiscNumber} ist fuer diese Serie/Staffel bereits vorhanden (Job #${existingChildJobId || '?'}). Bitte andere Disk-Nummer waehlen.` + ); + error.statusCode = 409; + error.details = [ + { + code: 'SERIES_DISC_ALREADY_EXISTS', + containerJobId: Number(parentContainerJobId), + existingJobId: existingChildJobId, + discNumber: Number(effectiveDiscNumber), + tmdbId: Number(effectiveTmdbId || 0) || null, + seasonNumber: Number(effectiveSeasonNumber || 0) || null + } + ]; + throw error; + } + } + if (parentContainerJobId) { const containerMkInfo = this.withAnalyzeContextMediaProfile({ analyzeContext: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec372d5..f51ff71 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.13.0-1", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.13.0-1", + "version": "0.13.1", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index ca849ba..530b7d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.13.0-1", + "version": "0.13.1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 32d6187..3b5725c 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -739,6 +739,47 @@ function resolveSeriesDiscNumber(job) { ); } +function isSeriesBatchEpisodeChildJob(job) { + const plan = job?.encodePlan && typeof job.encodePlan === 'object' + ? job.encodePlan + : null; + if (!plan) { + return false; + } + if (Boolean(plan?.seriesBatchChild) || Boolean(plan?.seriesBatchVirtualEpisode)) { + return true; + } + const hasParent = normalizePositiveInteger(plan?.seriesBatchParentJobId) !== null; + const hasEpisodeMarker = normalizePositiveInteger(plan?.seriesBatchTitleId) !== null + || normalizePositiveInteger(plan?.seriesBatchChildIndex) !== null + || normalizePositiveInteger(plan?.seriesBatchChildCount) !== null; + return hasParent && hasEpisodeMarker; +} + +function buildSeriesContainerDiskChildren(children = []) { + const rows = Array.isArray(children) ? children : []; + const diskCandidates = rows.filter((child) => !isSeriesBatchEpisodeChildJob(child)); + const byDisc = new Map(); + const withoutDisc = []; + + for (const child of [...diskCandidates].sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0))) { + const discNumber = resolveSeriesDiscNumber(child); + if (discNumber !== null) { + if (!byDisc.has(discNumber)) { + byDisc.set(discNumber, child); + } + continue; + } + withoutDisc.push(child); + } + + const withDisc = Array.from(byDisc.entries()) + .sort((left, right) => left[0] - right[0]) + .map(([, child]) => child); + const withoutDiscSorted = withoutDisc.sort(compareSeriesChildJobsByDisc); + return [...withDisc, ...withoutDiscSorted]; +} + function compareSeriesChildJobsByDisc(leftJob, rightJob) { const leftDisc = resolveSeriesDiscNumber(leftJob); const rightDisc = resolveSeriesDiscNumber(rightJob); @@ -1128,10 +1169,14 @@ export default function JobDetailDialog({ const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo); const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null; const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt'; - const childJobs = Array.isArray(job?.children) - ? [...job.children].sort(compareSeriesChildJobsByDisc) - : []; const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; + const childJobs = Array.isArray(job?.children) + ? ( + isSeriesContainer + ? buildSeriesContainerDiskChildren(job.children) + : [...job.children].sort(compareSeriesChildJobsByDisc) + ) + : []; const seriesChildSummary = job?.seriesChildSummary || null; const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null; const seriesEncodeSummary = isSeriesContainer ? seriesChildSummary?.encode : null; diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx index 425df94..de859cd 100644 --- a/frontend/src/components/MetadataSelectionDialog.jsx +++ b/frontend/src/components/MetadataSelectionDialog.jsx @@ -33,6 +33,7 @@ export default function MetadataSelectionDialog({ const [extraResults, setExtraResults] = useState([]); const [searchBusy, setSearchBusy] = useState(false); const [searchError, setSearchError] = useState(''); + const [submitError, setSubmitError] = useState(''); const [seasonFilter, setSeasonFilter] = useState(''); const [discValidationError, setDiscValidationError] = useState(''); const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; @@ -56,20 +57,17 @@ export default function MetadataSelectionDialog({ const defaultTitle = selectedMetadata.title || context?.detectedTitle || ''; const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : ''; const defaultImdb = selectedMetadata.imdbId || ''; - const hintedDiscNumber = selectedMetadata.discNumber || context?.seriesLookupHint?.discNumber || null; - const defaultDiscNumber = Number(hintedDiscNumber || 0) > 0 - ? String(Math.trunc(Number(hintedDiscNumber || 0))) - : ''; setSelected(null); setQuery(defaultTitle); setManualTitle(defaultTitle); setManualYear(defaultYear); setManualImdb(defaultImdb); - setManualDiscNumber(defaultDiscNumber); + setManualDiscNumber(''); setExtraResults([]); setSearchBusy(false); setSearchError(''); + setSubmitError(''); setSeasonFilter(''); setDiscValidationError(''); }, [visible, context]); @@ -83,6 +81,12 @@ export default function MetadataSelectionDialog({ } }, [discValidationError, requiresDiscNumber, hasValidDiscNumber]); + useEffect(() => { + if (submitError) { + setSubmitError(''); + } + }, [manualDiscNumber, seasonFilter, selected]); + const rows = useMemo(() => { const base = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates @@ -166,6 +170,8 @@ export default function MetadataSelectionDialog({ }; const handleSubmit = async () => { + setDiscValidationError(''); + setSubmitError(''); if (requiresDiscNumber && !hasValidDiscNumber) { setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.'); return; @@ -201,7 +207,18 @@ export default function MetadataSelectionDialog({ ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) }; - await onSubmit(payload); + try { + await onSubmit(payload); + } catch (error) { + const details = Array.isArray(error?.details) ? error.details : []; + const hasDuplicateDiscError = details.some((detail) => String(detail?.code || '').trim().toUpperCase() === 'SERIES_DISC_ALREADY_EXISTS'); + const message = String(error?.message || 'Metadaten konnten nicht uebernommen werden.').trim() || 'Metadaten konnten nicht uebernommen werden.'; + if (hasDuplicateDiscError) { + setSubmitError(message); + return; + } + setSubmitError(message); + } }; const handleHideRequest = () => { @@ -262,6 +279,7 @@ export default function MetadataSelectionDialog({ {searchError ? {searchError} : null} {discValidationError ? {discValidationError} : null} + {submitError ? {submitError} : null}