diff --git a/backend/package-lock.json b/backend/package-lock.json index bc56949..795b2ee 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.16.1-5", + "version": "0.16.1-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.16.1-5", + "version": "0.16.1-6", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 2cac1b7..3cd8400 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.16.1-5", + "version": "0.16.1-6", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 0f03e51..07a91d2 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -1049,7 +1049,14 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase(); const normalizedJobKind = String(job?.job_kind || '').trim().toLowerCase(); const hasSeriesJobKind = normalizedJobKind === 'dvd_series_child' || normalizedJobKind === 'dvd_series_container'; - const hasSeriesParent = normalizeJobIdValue(job?.parent_job_id) !== null; + const hasParentJob = normalizeJobIdValue(job?.parent_job_id) !== null; + const isMultipartJobKind = ( + normalizedJobKind === 'multipart_movie_container' + || normalizedJobKind === 'multipart_movie_child' + || normalizedJobKind === 'multipart_movie_merge' + ); + // Parent-ID alone is not a reliable series signal: multipart movie jobs also use parent_job_id. + const hasSeriesParent = hasParentJob && !isMultipartJobKind; const hasSeriesRawPathHint = isLikelySeriesRawPath(job?.raw_path, settings || {}); const hasSeriesMetadataHint = hasSeriesMetadataSignals( selectedMetadata, diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 25ed62a..da63331 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -4023,12 +4023,45 @@ function pickScanMainFeatureTitleId(scanJson) { return null; } +function normalizePlaylistAliasIds(values, excludePlaylistId = null) { + const exclude = normalizePlaylistId(excludePlaylistId); + const rows = Array.isArray(values) ? values : []; + const seen = new Set(); + const aliases = []; + for (const value of rows) { + const playlistId = normalizePlaylistId(value); + if (!playlistId || playlistId === exclude || seen.has(playlistId)) { + continue; + } + seen.add(playlistId); + aliases.push(playlistId); + } + return aliases; +} + +function getPlaylistAliasesForPlaylist(playlistAnalysis, playlistIdRaw) { + const playlistId = normalizePlaylistId(playlistIdRaw); + if (!playlistId || !playlistAnalysis || typeof playlistAnalysis !== 'object') { + return []; + } + const aliasMap = playlistAnalysis?.playlistAliasMap + && typeof playlistAnalysis.playlistAliasMap === 'object' + ? playlistAnalysis.playlistAliasMap + : null; + if (!aliasMap) { + return []; + } + return normalizePlaylistAliasIds(aliasMap[playlistId], playlistId); +} + function resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistIdRaw) { const playlistId = normalizePlaylistId(playlistIdRaw); + const playlistAliases = getPlaylistAliasesForPlaylist(playlistAnalysis, playlistId); if (!playlistId || !playlistAnalysis) { return { playlistId: playlistId || null, playlistFile: playlistId ? `${playlistId}.mpls` : null, + playlistAliases, recommended: false, evaluationLabel: null, segmentCommand: playlistId ? `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts` : null, @@ -4050,6 +4083,7 @@ function resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistIdRaw) { return { playlistId, playlistFile: `${playlistId}.mpls`, + playlistAliases, recommended, evaluationLabel: evaluated?.evaluationLabel || (recommended ? 'wahrscheinlich korrekt (Heuristik)' : null), segmentCommand: segmentEntry?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, @@ -4322,8 +4356,13 @@ function resolveHandBrakeTitleIdForPlaylist(scanJson, playlistIdRaw, options = { ? Math.trunc(durationToleranceRaw) : 5; + const playlistAliases = normalizePlaylistAliasIds(options?.playlistAliases, playlistId); + const candidatePlaylistIds = [playlistId, ...playlistAliases]; + const candidatePlaylistSet = new Set(candidatePlaylistIds); + const rows = buildHandBrakeScanTitleRows(scanJson); - const matches = rows.filter((item) => item.playlist === playlistId); + const directMatches = rows.filter((item) => item.playlist === playlistId); + const matches = rows.filter((item) => candidatePlaylistSet.has(item.playlist)); const scoreForExpected = (row) => { const durationDelta = expectedDurationSeconds !== null @@ -4352,15 +4391,36 @@ function resolveHandBrakeTitleIdForPlaylist(scanJson, playlistIdRaw, options = { if (matches.length > 0) { if (expectedDurationSeconds !== null || expectedSizeBytes !== null) { const scored = matches.map(scoreForExpected).sort(sortByExpectedScore); + const scoredDirect = directMatches.map(scoreForExpected).sort(sortByExpectedScore); if (expectedDurationSeconds !== null) { - const withinTolerance = scored.filter((item) => item.durationDelta <= durationToleranceSeconds); - if (withinTolerance.length > 0) { - return withinTolerance[0].row.handBrakeTitleId; + const directWithinTolerance = scoredDirect.filter((item) => item.durationDelta <= durationToleranceSeconds); + if (directWithinTolerance.length > 0) { + return directWithinTolerance[0].row.handBrakeTitleId; } + const aliasWithinTolerance = scored.filter((item) => + item.durationDelta <= durationToleranceSeconds + && normalizePlaylistId(item?.row?.playlist) !== playlistId + ); + if (aliasWithinTolerance.length > 0) { + return aliasWithinTolerance[0].row.handBrakeTitleId; + } + } + if (scoredDirect.length > 0) { + return scoredDirect[0].row.handBrakeTitleId; } return scored[0].row.handBrakeTitleId; } - const best = matches.sort((a, b) => + const directBest = directMatches + .slice() + .sort((a, b) => + b.durationSeconds - a.durationSeconds + || b.sizeBytes - a.sizeBytes + || a.handBrakeTitleId - b.handBrakeTitleId + )[0]; + if (directBest) { + return directBest.handBrakeTitleId; + } + const best = matches.slice().sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.handBrakeTitleId - b.handBrakeTitleId @@ -4416,8 +4476,12 @@ function isHandBrakePlaylistCacheEntryCompatible(entry, playlistIdRaw, options = return false; } + const allowedPlaylistIds = new Set([ + playlistId, + ...normalizePlaylistAliasIds(options?.playlistAliases, playlistId) + ]); const cachedPlaylistId = normalizePlaylistId(titleInfo?.playlistId || null); - if (cachedPlaylistId && cachedPlaylistId !== playlistId) { + if (cachedPlaylistId && !allowedPlaylistIds.has(cachedPlaylistId)) { return false; } @@ -6001,6 +6065,7 @@ function buildDiscScanReview({ eligibleForEncode: title.selectedByMinLength, playlistId: title.playlistMatch?.playlistId || null, playlistFile: title.playlistMatch?.playlistFile || null, + playlistAliases: normalizePlaylistAliasIds(title.playlistMatch?.playlistAliases, title.playlistMatch?.playlistId), playlistRecommended: Boolean(title.playlistMatch?.recommended), playlistEvaluationLabel: title.playlistMatch?.evaluationLabel || null, playlistSegmentCommand: title.playlistMatch?.segmentCommand || null, @@ -6529,6 +6594,9 @@ function buildPlaylistCandidates(playlistAnalysis) { const segmentMap = playlistAnalysis?.playlistSegments && typeof playlistAnalysis.playlistSegments === 'object' ? playlistAnalysis.playlistSegments : {}; + const aliasMap = playlistAnalysis?.playlistAliasMap && typeof playlistAnalysis.playlistAliasMap === 'object' + ? playlistAnalysis.playlistAliasMap + : {}; return rawList .map((playlistId) => normalizePlaylistId(playlistId)) @@ -6536,6 +6604,12 @@ function buildPlaylistCandidates(playlistAnalysis) { .map((playlistId) => { const source = sourceRows.find((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile) === playlistId) || null; const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null; + const playlistAliases = normalizePlaylistAliasIds( + Array.isArray(source?.playlistAliases) && source.playlistAliases.length > 0 + ? source.playlistAliases + : aliasMap[playlistId], + playlistId + ); const score = Number(source?.score); const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence); const titleId = Number(source?.titleId ?? source?.id); @@ -6584,6 +6658,7 @@ function buildPlaylistCandidates(playlistAnalysis) { return { playlistId, playlistFile: toPlaylistFile(playlistId), + playlistAliases, titleId: Number.isFinite(titleId) ? Math.trunc(titleId) : null, durationSeconds, durationLabel: durationLabel || null, @@ -6747,7 +6822,8 @@ function buildHandBrakePlaylistScanCache(scanJson, playlistCandidates = [], rawP candidateMetaByPlaylist.set(playlistId, { expectedMakemkvTitleId: normalizeNonNegativeInteger(row?.titleId), expectedDurationSeconds: Number(row?.durationSeconds || 0) || null, - expectedSizeBytes: Number(row?.sizeBytes || 0) || null + expectedSizeBytes: Number(row?.sizeBytes || 0) || null, + playlistAliases: normalizePlaylistAliasIds(row?.playlistAliases, playlistId) }); } @@ -11238,7 +11314,10 @@ class PipelineService extends EventEmitter { const queueIds = new Set(normalizedIds.map((value) => String(value))); const previousQueueLength = this.queueEntries.length; this.queueEntries = this.queueEntries.filter((entry) => !queueIds.has(String(Number(entry?.jobId || 0)))); - const removedQueueEntries = previousQueueLength - this.queueEntries.length; + let removedQueueEntries = Math.max(0, previousQueueLength - this.queueEntries.length); + for (const deletedJobId of normalizedIds) { + removedQueueEntries += this.removeDetachedQueueAutomationEntriesForJob(deletedJobId); + } for (const jobId of normalizedIds) { this.cancelRequestedByJob.add(jobId); @@ -14484,6 +14563,41 @@ class PipelineService extends EventEmitter { return this.queueEntries.findIndex((entry) => Number(entry?.jobId) === Number(jobId)); } + removeDetachedQueueAutomationEntriesForJob(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + return 0; + } + + const allowedPhases = Array.isArray(options?.phases) + ? new Set( + options.phases + .map((value) => String(value || '').trim().toLowerCase()) + .filter((value) => value === 'pre_encode' || value === 'post_encode') + ) + : null; + const hasPhaseFilter = Boolean(allowedPhases && allowedPhases.size > 0); + + const queueLengthBefore = this.queueEntries.length; + this.queueEntries = this.queueEntries.filter((entry) => { + const entryType = String(entry?.type || '').trim().toLowerCase(); + if (entryType !== 'script' && entryType !== 'chain') { + return true; + } + const sourceJobId = this.normalizeQueueJobId(entry?.sourceJobId); + if (sourceJobId !== normalizedJobId) { + return true; + } + if (!hasPhaseFilter) { + return false; + } + const sourcePhase = String(entry?.sourcePhase || '').trim().toLowerCase(); + return !allowedPhases.has(sourcePhase); + }); + + return Math.max(0, queueLengthBefore - this.queueEntries.length); + } + normalizeQueueChainIdList(rawList) { const list = Array.isArray(rawList) ? rawList : []; const seen = new Set(); @@ -15607,6 +15721,15 @@ class PipelineService extends EventEmitter { 'SYSTEM', `Queue-Start fehlgeschlagen (${QUEUE_ACTION_LABELS[entry.action] || entry.action}): ${error.message}` ); + const removedDetachedEntries = this.removeDetachedQueueAutomationEntriesForJob(entry.jobId); + if (removedDetachedEntries > 0) { + await historyService.appendLog( + entry.jobId, + 'SYSTEM', + `Queue-Cleanup: ${removedDetachedEntries} verknüpfte Skript/Ketten-Eintrag${removedDetachedEntries === 1 ? '' : 'e'} entfernt.` + ); + await this.emitQueueChanged(); + } } } } @@ -18655,13 +18778,21 @@ class PipelineService extends EventEmitter { const cachedHandBrakePlaylistEntry = getCachedHandBrakePlaylistEntry(handBrakePlaylistScan, resolvedPlaylistId); const expectedDurationForCache = Number(selectedTitleFromAnalysis?.durationSeconds || 0) || null; const expectedSizeForCache = Number(selectedTitleFromAnalysis?.sizeBytes || 0) || null; + const playlistAliasesForResolve = normalizePlaylistAliasIds( + [ + ...(Array.isArray(selectedTitleFromAnalysis?.playlistAliases) ? selectedTitleFromAnalysis.playlistAliases : []), + ...getPlaylistAliasesForPlaylist(playlistAnalysis, resolvedPlaylistId) + ], + resolvedPlaylistId + ); const hasCachedHandBrakeEntry = Boolean( isHandBrakePlaylistCacheEntryCompatible( cachedHandBrakePlaylistEntry, resolvedPlaylistId, { expectedDurationSeconds: expectedDurationForCache, - expectedSizeBytes: expectedSizeForCache + expectedSizeBytes: expectedSizeForCache, + playlistAliases: playlistAliasesForResolve } ) ); @@ -18744,7 +18875,8 @@ class PipelineService extends EventEmitter { resolvedHandBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(resolveScanJson, resolvedPlaylistId, { expectedMakemkvTitleId: selectedTitleForReview, expectedDurationSeconds: expectedDurationForCache, - expectedSizeBytes: expectedSizeForCache + expectedSizeBytes: expectedSizeForCache, + playlistAliases: playlistAliasesForResolve }); if (!resolvedHandBrakeTitleId) { const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson); @@ -18776,7 +18908,8 @@ class PipelineService extends EventEmitter { titleInfo: reviewTitleInfo }, resolvedPlaylistId, { expectedDurationSeconds: expectedDurationForCache, - expectedSizeBytes: expectedSizeForCache + expectedSizeBytes: expectedSizeForCache, + playlistAliases: playlistAliasesForResolve })) { const error = new Error( `HandBrake Titel-Mapping inkonsistent für ${toPlaylistFile(resolvedPlaylistId)} (-t ${resolvedHandBrakeTitleId}).` @@ -18880,6 +19013,13 @@ class PipelineService extends EventEmitter { const normalizedTitles = (Array.isArray(review.titles) ? review.titles : []) .slice(0, 1) .map((title) => { + const normalizedHandBrakeTitleId = normalizeReviewTitleId( + resolvedHandBrakeTitleId + ?? title?.handBrakeTitleId + ?? title?.titleIndex + ?? title?.id + ?? selectedTitleForReview + ); const durationSeconds = Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0); const eligibleForEncode = durationSeconds >= minLengthSecondsForReview; const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { @@ -18995,6 +19135,8 @@ class PipelineService extends EventEmitter { return { ...title, + id: normalizedHandBrakeTitleId || title?.id || null, + handBrakeTitleId: normalizedHandBrakeTitleId || null, filePath: rawPath, fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`, durationSeconds, @@ -25141,6 +25283,25 @@ class PipelineService extends EventEmitter { ); const expectedDurationSecondsForResolve = Number(selectedEncodeTitle?.durationSeconds || 0) || null; const expectedSizeBytesForResolve = Number(selectedEncodeTitle?.sizeBytes || 0) || null; + const encodePlanPlaylistAnalysis = encodePlan?.playlistAnalysis && typeof encodePlan.playlistAnalysis === 'object' + ? encodePlan.playlistAnalysis + : null; + const selectedPlaylistAliasesForResolve = selectedPlaylistId + ? normalizePlaylistAliasIds( + [ + ...(Array.isArray(selectedEncodeTitle?.playlistAliases) ? selectedEncodeTitle.playlistAliases : []), + ...getPlaylistAliasesForPlaylist( + encodePlanPlaylistAnalysis + || playlistDecision?.playlistAnalysis + || this.snapshot.context?.playlistAnalysis + || this.safeParseJson(job?.makemkv_info_json)?.analyzeContext?.playlistAnalysis + || null, + selectedPlaylistId + ) + ], + selectedPlaylistId + ) + : []; if (!handBrakeTitleId && selectedPlaylistId) { const titleResolveScanLines = []; const titleResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { @@ -25172,7 +25333,8 @@ class PipelineService extends EventEmitter { handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(titleResolveParsed, selectedPlaylistId, { expectedMakemkvTitleId: expectedMakemkvTitleIdForResolve, expectedDurationSeconds: expectedDurationSecondsForResolve, - expectedSizeBytes: expectedSizeBytesForResolve + expectedSizeBytes: expectedSizeBytesForResolve, + playlistAliases: selectedPlaylistAliasesForResolve }); if (!handBrakeTitleId) { const knownPlaylists = listAvailableHandBrakePlaylists(titleResolveParsed); @@ -25235,6 +25397,29 @@ class PipelineService extends EventEmitter { } } } + if (!handBrakeTitleId) { + const primaryPlanTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId); + const primaryPlanTitle = Array.isArray(encodePlan?.titles) + ? ( + encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === primaryPlanTitleId) + || encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) + || null + ) + : null; + const primaryTitleMappedHandBrakeId = normalizeReviewTitleId( + primaryPlanTitle?.handBrakeTitleId + ?? primaryPlanTitle?.titleIndex + ?? null + ); + if (primaryTitleMappedHandBrakeId) { + handBrakeTitleId = primaryTitleMappedHandBrakeId; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-ID aus Primär-Titel übernommen: -t ${handBrakeTitleId}` + ); + } + } if (!handBrakeTitleId) { const fallbackTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId); if (fallbackTitleId) { @@ -27403,7 +27588,9 @@ class PipelineService extends EventEmitter { const queueEntriesBefore = this.queueEntries.length; this.queueEntries = this.queueEntries.filter((entry) => { const action = String(entry?.action || '').trim().toUpperCase(); + const entryType = String(entry?.type || 'job').trim().toLowerCase(); const entryJobId = Number(entry?.jobId); + const entrySourceJobId = this.normalizeQueueJobId(entry?.sourceJobId); const isLegacyParentEntry = ( entryJobId === Number(normalizedParentJobId) && action === QUEUE_ACTIONS.START_SERIES_EPISODE @@ -27414,7 +27601,15 @@ class PipelineService extends EventEmitter { && entryJobId > 0 && seriesChildJobIdSet.has(entryJobId) ); - return !(isLegacyParentEntry || isSeriesChildJobEntry); + const isDetachedSeriesAutomationEntry = ( + (entryType === 'script' || entryType === 'chain') + && entrySourceJobId + && ( + Number(entrySourceJobId) === Number(normalizedParentJobId) + || seriesChildJobIdSet.has(Number(entrySourceJobId)) + ) + ); + return !(isLegacyParentEntry || isSeriesChildJobEntry || isDetachedSeriesAutomationEntry); }); const removedQueueEntries = Math.max(0, queueEntriesBefore - this.queueEntries.length); @@ -27598,10 +27793,14 @@ class PipelineService extends EventEmitter { const queuedIndex = this.findQueueEntryIndexByJobId(normalizedJobId); if (queuedIndex >= 0) { const [removed] = this.queueEntries.splice(queuedIndex, 1); + const removedDetachedEntries = this.removeDetachedQueueAutomationEntriesForJob(normalizedJobId); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Aus Queue entfernt: ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}` + + (removedDetachedEntries > 0 + ? ` (inkl. ${removedDetachedEntries} verknüpften Skript/Ketten-Eintrag${removedDetachedEntries === 1 ? '' : 'en'}).` + : '') ); await this.emitQueueChanged(); return { @@ -28058,6 +28257,15 @@ class PipelineService extends EventEmitter { const job = await historyService.getJobById(jobId); const title = job?.title || job?.detected_title || `Job #${jobId}`; const finalState = isCancelled ? 'CANCELLED' : 'ERROR'; + const removedDetachedQueueEntries = this.removeDetachedQueueAutomationEntriesForJob(jobId); + if (removedDetachedQueueEntries > 0) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Queue-Cleanup: ${removedDetachedQueueEntries} verknüpfte Skript/Ketten-Eintrag${removedDetachedQueueEntries === 1 ? '' : 'e'} entfernt.` + ); + await this.emitQueueChanged(); + } logger[isCancelled ? 'warn' : 'error']('job:failed', { jobId, stage, error: errorToMeta(error) }); const makemkvInfo = this.safeParseJson(job?.makemkv_info_json); const encodePlan = this.safeParseJson(job?.encode_plan_json); diff --git a/backend/src/utils/playlistAnalysis.js b/backend/src/utils/playlistAnalysis.js index 2aba3a4..8093355 100644 --- a/backend/src/utils/playlistAnalysis.js +++ b/backend/src/utils/playlistAnalysis.js @@ -135,6 +135,83 @@ function extractPlaylistMapping(line) { return null; } +function extractPlaylistEqualityMapping(line) { + const raw = String(line || ''); + if (!raw) { + return null; + } + + // Robot output often ends with explicit args: + // ..., "00100.mpls","00091.mpls" + if (/^MSG:3309,/i.test(raw)) { + const quoted = []; + const regex = /"([^"]*)"/g; + let match = regex.exec(raw); + while (match) { + quoted.push(String(match[1] || '').trim()); + match = regex.exec(raw); + } + if (quoted.length >= 2) { + const aliasId = normalizePlaylistId(quoted[quoted.length - 2]); + const canonicalId = normalizePlaylistId(quoted[quoted.length - 1]); + if (aliasId && canonicalId && aliasId !== canonicalId) { + return { + aliasId, + canonicalId + }; + } + } + } + + // Text fallback: + // "Title 00100.mpls is equal to title 00091.mpls and was skipped" + const textMatch = raw.match(/title\s+(\d{5}\.mpls)\s+is equal to title\s+(\d{5}\.mpls)/i); + if (textMatch) { + const aliasId = normalizePlaylistId(textMatch[1]); + const canonicalId = normalizePlaylistId(textMatch[2]); + if (aliasId && canonicalId && aliasId !== canonicalId) { + return { + aliasId, + canonicalId + }; + } + } + + return null; +} + +function buildPlaylistAliasMap(lines) { + const aliasMap = new Map(); + for (const line of lines || []) { + const mapping = extractPlaylistEqualityMapping(line); + if (!mapping) { + continue; + } + const canonicalId = normalizePlaylistId(mapping.canonicalId); + const aliasId = normalizePlaylistId(mapping.aliasId); + if (!canonicalId || !aliasId || canonicalId === aliasId) { + continue; + } + if (!aliasMap.has(canonicalId)) { + aliasMap.set(canonicalId, new Set()); + } + aliasMap.get(canonicalId).add(aliasId); + } + + const normalized = {}; + for (const [canonicalId, aliasSet] of aliasMap.entries()) { + const aliases = Array.from(aliasSet) + .map((value) => normalizePlaylistId(value)) + .filter(Boolean) + .filter((value) => value !== canonicalId) + .sort(); + if (aliases.length > 0) { + normalized[canonicalId] = aliases; + } + } + return normalized; +} + function parseAnalyzeTitles(lines) { const titleMap = new Map(); @@ -696,6 +773,18 @@ function extractPlaylistMismatchWarnings(titles) { function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) { const parsedTitles = parseAnalyzeTitles(lines); + const playlistAliasMap = buildPlaylistAliasMap(lines); + const parsedTitlesWithAliases = parsedTitles.map((item) => { + const playlistId = normalizePlaylistId(item?.playlistId); + const aliasesRaw = playlistId && Array.isArray(playlistAliasMap?.[playlistId]) + ? playlistAliasMap[playlistId] + : []; + const playlistAliases = uniqueOrdered(aliasesRaw.map((value) => normalizePlaylistId(value)).filter(Boolean)); + return { + ...item, + playlistAliases + }; + }); const reportedTitleCount = parseReportedTitleCount(lines); const minSeconds = Math.max(0, Math.round(Number(minLengthMinutes || 0) * 60)); const durationSimilaritySeconds = Math.max( @@ -703,7 +792,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS)) ); - const candidatesRaw = parsedTitles + const candidatesRaw = parsedTitlesWithAliases .filter((item) => Number(item.durationSeconds || 0) >= minSeconds) .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId); const candidates = suppressRawMirrorCandidates(candidatesRaw) @@ -724,7 +813,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) const recommendation = evaluatedCandidates[0] || null; const candidatePlaylists = manualDecisionRequired ? candidatePlaylistsAll : []; const playlistSegments = buildPlaylistSegmentMap(decisionPool); - const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitles); + const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitlesWithAliases); return { generatedAt: new Date().toISOString(), @@ -732,7 +821,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) minLengthMinutes: Number(minLengthMinutes || 0), minLengthSeconds: minSeconds, durationSimilaritySeconds, - titles: parsedTitles, + titles: parsedTitlesWithAliases, candidates, duplicateDurationGroups: similarityGroups, obfuscationDetected, @@ -743,6 +832,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) candidatePlaylists, candidatePlaylistFiles: candidatePlaylists.map((item) => `${item}.mpls`), playlistToTitleId, + playlistAliasMap, recommendation: recommendation ? { titleId: recommendation.titleId, @@ -765,7 +855,7 @@ function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) ...(reportedTitleCount !== null && reportedTitleCount !== parsedTitles.length ? [`Titel-Anzahl abweichend: TCOUNT=${reportedTitleCount}, geparst=${parsedTitles.length}`] : []), - ...extractPlaylistMismatchWarnings(parsedTitles) + ...extractPlaylistMismatchWarnings(parsedTitlesWithAliases) ].slice(0, 60) }; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 30c257c..a5bd262 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.16.1-5", + "version": "0.16.1-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.16.1-5", + "version": "0.16.1-6", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index e2dd562..76284f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.16.1-5", + "version": "0.16.1-6", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index cfa6137..b0b1381 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -2527,6 +2527,7 @@ export default function MediaInfoReviewPanel({

Keine Titel analysiert.

) : displayTitles.map((title) => { const normalizedTitleId = normalizeTitleId(title.id); + const displayTitleId = normalizeTitleId(title?.handBrakeTitleId) || normalizedTitleId; const titleChecked = allowTitleSelection ? (selectedTitleIdSet.size > 0 ? selectedTitleIdSet.has(String(normalizedTitleId)) @@ -2634,7 +2635,7 @@ export default function MediaInfoReviewPanel({ disabled={!allowTitleSelection} /> - #{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)} + #{displayTitleId ?? '-'} | {title.fileName} | {formatDuration(title.durationMinutes)} {audioCount > 0 ? ` | Audio: ${audioCount}` : ''} {subtitleCount > 0 ? ` | Untertitel: ${subtitleCount}` : ''} {title.encodeInput ? ' | Encode-Input' : ''} @@ -2692,7 +2693,7 @@ export default function MediaInfoReviewPanel({ Tonspuren und Untertitel
!isBurnedSubtitleTrack(track)) : subtitleTracks} @@ -2727,7 +2728,7 @@ export default function MediaInfoReviewPanel({ ) : (!compactTitleSelection ? (
!isBurnedSubtitleTrack(track)) : subtitleTracks} @@ -2817,6 +2818,7 @@ export default function MediaInfoReviewPanel({ Keine Titel in der Referenz-Disc gefunden. ) : comparisonTitles.map((title) => { const normalizedTitleId = normalizeTitleId(title?.id); + const displayTitleId = normalizeTitleId(title?.handBrakeTitleId) || normalizedTitleId; const titleChecked = normalizedTitleId !== null && comparisonSelectedTitleSet.has(String(normalizedTitleId)); const titleIsPrimary = normalizedTitleId !== null @@ -2839,7 +2841,7 @@ export default function MediaInfoReviewPanel({ disabled /> - #{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)} + #{displayTitleId ?? '-'} | {title.fileName} | {formatDuration(title.durationMinutes)} {audioCount > 0 ? ` | Audio: ${audioCount}` : ''} {subtitleCount > 0 ? ` | Untertitel: ${subtitleCount}` : ''} {title.encodeInput ? ' | Encode-Input' : ''} @@ -2872,13 +2874,13 @@ export default function MediaInfoReviewPanel({