From 720a6f560902008fd80a8927591eb736d5459950 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Mon, 20 Apr 2026 19:16:22 +0000 Subject: [PATCH] 0.14.0-2 Series Scan Heuristics --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/services/pipelineService.js | 325 ++++++++++++++++++++++-- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- package-lock.json | 4 +- package.json | 2 +- 7 files changed, 309 insertions(+), 34 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index d0fd316..454d432 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.14.0-1", + "version": "0.14.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.14.0-1", + "version": "0.14.0-2", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 65df263..f41c324 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.14.0-1", + "version": "0.14.0-2", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index abdc07a..472913d 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -3815,6 +3815,237 @@ function parseHandBrakeTitleList(scanJson) { }); } +function normalizeSeriesDetectionConfidence(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'high' || normalized === 'medium' || normalized === 'low') { + return normalized; + } + return 'low'; +} + +function pickHigherSeriesDetectionConfidence(left, right) { + const scores = { + low: 1, + medium: 2, + high: 3 + }; + const leftNormalized = normalizeSeriesDetectionConfidence(left); + const rightNormalized = normalizeSeriesDetectionConfidence(right); + return (scores[rightNormalized] || 0) > (scores[leftNormalized] || 0) + ? rightNormalized + : leftNormalized; +} + +function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options = {}) { + const minEpisodeSeconds = Math.max( + 60, + Math.round(Number(options?.minEpisodeMinutes || 18) * 60) + ); + const maxEpisodeSeconds = Math.max( + minEpisodeSeconds, + Math.round(Number(options?.maxEpisodeMinutes || 75) * 60) + ); + const shortTitleSeconds = Math.max( + 60, + Math.round(Number(options?.shortTitleMinutes || 5) * 60) + ); + + const titles = parseHandBrakeTitleList(scanJson); + if (!Array.isArray(titles) || titles.length === 0) { + return null; + } + + const titlesWithDuration = titles + .map((title) => ({ + id: Number(title?.handBrakeTitleId || 0) || null, + durationSeconds: Number(title?.durationSeconds || 0) || 0 + })) + .filter((title) => Number.isFinite(title.durationSeconds) && title.durationSeconds > 0); + + if (titlesWithDuration.length === 0) { + return null; + } + + const episodeWindowCandidates = titlesWithDuration.filter((title) => + title.durationSeconds >= minEpisodeSeconds + && title.durationSeconds <= maxEpisodeSeconds + ); + + let bestCluster = []; + for (const anchor of episodeWindowCandidates) { + const toleranceSeconds = Math.max(90, Math.round(anchor.durationSeconds * 0.12)); + const cluster = episodeWindowCandidates.filter((candidate) => + Math.abs(candidate.durationSeconds - anchor.durationSeconds) <= toleranceSeconds + ); + if (cluster.length > bestCluster.length) { + bestCluster = cluster; + continue; + } + if (cluster.length === bestCluster.length && cluster.length > 0) { + const clusterMedian = medianPositiveNumber(cluster.map((entry) => entry.durationSeconds)); + const bestMedian = medianPositiveNumber(bestCluster.map((entry) => entry.durationSeconds)); + if (clusterMedian > bestMedian) { + bestCluster = cluster; + } + } + } + + const episodeCandidateCount = bestCluster.length; + const clusterMedianDuration = medianPositiveNumber(bestCluster.map((entry) => entry.durationSeconds)); + const clusterIds = new Set( + bestCluster + .map((entry) => Number(entry?.id)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + const shortCount = titlesWithDuration.filter((title) => title.durationSeconds <= shortTitleSeconds).length; + const playAllCount = (() => { + if (episodeCandidateCount < 2 || clusterMedianDuration <= 0) { + return 0; + } + const minPlayAllSeconds = clusterMedianDuration * Math.max(2, episodeCandidateCount - 1) * 0.75; + const hasPlayAll = titlesWithDuration.some((title) => + !clusterIds.has(Number(title?.id)) + && title.durationSeconds >= minPlayAllSeconds + ); + return hasPlayAll ? 1 : 0; + })(); + const extraCount = titlesWithDuration.filter((title) => + !clusterIds.has(Number(title?.id)) + && title.durationSeconds > shortTitleSeconds + ).length; + const seriesLike = episodeCandidateCount >= 2; + + let confidence = 'low'; + if (seriesLike) { + if (episodeCandidateCount >= 3 || playAllCount > 0) { + confidence = 'high'; + } else if (titlesWithDuration.length >= 3) { + confidence = 'medium'; + } + } + + const reasons = []; + if (episodeCandidateCount >= 3) { + reasons.push(`${episodeCandidateCount} ähnlich lange Episodenkandidaten im HandBrake-JSON erkannt.`); + } else if (episodeCandidateCount === 2) { + reasons.push('Zwei ähnlich lange Episodenkandidaten im HandBrake-JSON erkannt.'); + } + if (clusterMedianDuration > 0) { + reasons.push(`Typische Episodenlänge ca. ${Math.round(clusterMedianDuration / 60)} Minuten.`); + } + if (playAllCount > 0) { + reasons.push('Ein zusätzlicher langer Play-All-Kandidat wurde erkannt.'); + } + if (shortCount > 0) { + reasons.push(`${shortCount} kurze Titel als Extras/Kurzsegmente erkannt.`); + } + + return { + source: 'handbrake_scan_json', + generatedAt: nowIso(), + summary: { + seriesLike, + confidence, + reasons, + titleCount: titles.length, + episodeCandidateCount, + playAllCount, + duplicateCount: 0, + extraCount, + typicalEpisodeMinutes: clusterMedianDuration > 0 + ? Number((clusterMedianDuration / 60).toFixed(2)) + : 0 + } + }; +} + +function mergeSeriesAnalysisWithStructuredFallback(seriesAnalysis = null, structuredSeriesAnalysis = null) { + const baseSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' + ? seriesAnalysis.summary + : null; + const structuredSummary = structuredSeriesAnalysis?.summary && typeof structuredSeriesAnalysis.summary === 'object' + ? structuredSeriesAnalysis.summary + : null; + + if (!baseSummary && !structuredSummary) { + return null; + } + + if (!baseSummary && structuredSummary) { + return { + source: String(structuredSeriesAnalysis?.source || 'handbrake_scan_json').trim() || 'handbrake_scan_json', + generatedAt: structuredSeriesAnalysis?.generatedAt || nowIso(), + summary: { + ...structuredSummary, + confidence: normalizeSeriesDetectionConfidence(structuredSummary?.confidence) + } + }; + } + + if (baseSummary && !structuredSummary) { + return { + source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', + generatedAt: seriesAnalysis?.generatedAt || nowIso(), + summary: { + ...baseSummary, + confidence: normalizeSeriesDetectionConfidence(baseSummary?.confidence) + } + }; + } + + const seriesLike = Boolean(baseSummary?.seriesLike) || Boolean(structuredSummary?.seriesLike); + const confidence = seriesLike + ? pickHigherSeriesDetectionConfidence(baseSummary?.confidence, structuredSummary?.confidence) + : 'low'; + const reasons = Array.from(new Set([ + ...(Array.isArray(baseSummary?.reasons) ? baseSummary.reasons : []), + ...(Array.isArray(structuredSummary?.reasons) ? structuredSummary.reasons : []) + ])) + .map((reason) => String(reason || '').trim()) + .filter(Boolean); + const summary = { + ...baseSummary, + seriesLike, + confidence, + reasons, + titleCount: Math.max( + Number(baseSummary?.titleCount || 0) || 0, + Number(structuredSummary?.titleCount || 0) || 0 + ), + episodeCandidateCount: Math.max( + Number(baseSummary?.episodeCandidateCount || 0) || 0, + Number(structuredSummary?.episodeCandidateCount || 0) || 0 + ), + playAllCount: Math.max( + Number(baseSummary?.playAllCount || 0) || 0, + Number(structuredSummary?.playAllCount || 0) || 0 + ), + duplicateCount: Math.max( + Number(baseSummary?.duplicateCount || 0) || 0, + Number(structuredSummary?.duplicateCount || 0) || 0 + ), + extraCount: Math.max( + Number(baseSummary?.extraCount || 0) || 0, + Number(structuredSummary?.extraCount || 0) || 0 + ), + typicalEpisodeMinutes: Math.max( + Number(baseSummary?.typicalEpisodeMinutes || 0) || 0, + Number(structuredSummary?.typicalEpisodeMinutes || 0) || 0 + ) + }; + const structuredSeriesLike = Boolean(structuredSummary?.seriesLike); + const baseSeriesLike = Boolean(baseSummary?.seriesLike); + + return { + source: seriesLike && structuredSeriesLike && !baseSeriesLike + ? 'handbrake_scan_json' + : (String(seriesAnalysis?.source || structuredSeriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text'), + generatedAt: nowIso(), + summary + }; +} + function normalizeSeriesScanTitleKind(rawKind) { return String(rawKind || '').trim().toLowerCase(); } @@ -3939,33 +4170,49 @@ function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = n }; } + let textSeriesAnalysis = null; + let textSeriesTitles = []; try { const analysisResult = dvdSeriesScanService.analyzeHandBrakeScan(scanText, options?.seriesOptions || {}); - const analysis = analysisResult?.analysis || null; - const analysisTitles = Array.isArray(analysis?.titles) ? analysis.titles : []; - if (analysisTitles.length === 0) { - return { - analyzeContext: baseAnalyzeContext, - derived: false, - reason: 'no_titles' - }; - } - - return { - analyzeContext: { - ...baseAnalyzeContext, - seriesAnalysis: analysis - }, - derived: true, - reason: 'scan_classification' - }; + textSeriesAnalysis = analysisResult?.analysis || null; + textSeriesTitles = Array.isArray(textSeriesAnalysis?.titles) ? textSeriesAnalysis.titles : []; } catch (_error) { + textSeriesAnalysis = null; + textSeriesTitles = []; + } + + const scanJson = parseMediainfoJsonOutput(scanText); + const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options?.seriesOptions || {}); + const mergedSeriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( + textSeriesAnalysis, + structuredSeriesAnalysis + ); + const derivedFromText = textSeriesTitles.length > 0; + const derivedFromStructured = Boolean(structuredSeriesAnalysis?.summary?.seriesLike); + if (!mergedSeriesAnalysis || (!derivedFromText && !derivedFromStructured)) { return { analyzeContext: baseAnalyzeContext, derived: false, - reason: 'scan_parse_failed' + reason: derivedFromText ? 'scan_parse_failed' : 'no_titles' }; } + + const nextSeriesAnalysis = derivedFromText + ? { + ...mergedSeriesAnalysis, + titles: textSeriesTitles + } + : mergedSeriesAnalysis; + return { + analyzeContext: { + ...baseAnalyzeContext, + seriesAnalysis: nextSeriesAnalysis + }, + derived: true, + reason: derivedFromText + ? 'scan_classification' + : 'scan_json_structural_classification' + }; } function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = null) { @@ -12703,8 +12950,16 @@ class PipelineService extends EventEmitter { pluginExecution, this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) ); - - seriesAnalysis = seriesPluginResult?.seriesAnalysis || null; + const scanJson = parseMediainfoJsonOutput( + Array.isArray(reviewResult?.scanLines) + ? reviewResult.scanLines.join('\n') + : '' + ); + const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson); + seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( + seriesPluginResult?.seriesAnalysis || null, + structuredSeriesAnalysis + ); seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' ? seriesAnalysis.summary @@ -12722,7 +12977,12 @@ class PipelineService extends EventEmitter { seriesLike, confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], - titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0 + titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0, + episodeCandidateCount: Number(rawSeriesSummary?.episodeCandidateCount || 0) || 0, + playAllCount: Number(rawSeriesSummary?.playAllCount || 0) || 0, + duplicateCount: Number(rawSeriesSummary?.duplicateCount || 0) || 0, + extraCount: Number(rawSeriesSummary?.extraCount || 0) || 0, + typicalEpisodeMinutes: Number(rawSeriesSummary?.typicalEpisodeMinutes || 0) || 0 } } : null; @@ -12759,6 +13019,7 @@ class PipelineService extends EventEmitter { jobId: normalizedJobId, seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike), confidence: seriesAnalysis?.summary?.confidence || null, + seriesSource: seriesAnalysis?.source || null, metadataProvider, metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, seriesLookupHint, @@ -13058,8 +13319,16 @@ class PipelineService extends EventEmitter { pluginExecution, this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) ); - - seriesAnalysis = seriesPluginResult?.seriesAnalysis || null; + const scanJson = parseMediainfoJsonOutput( + Array.isArray(reviewResult?.scanLines) + ? reviewResult.scanLines.join('\n') + : '' + ); + const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson); + seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( + seriesPluginResult?.seriesAnalysis || null, + structuredSeriesAnalysis + ); seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' ? seriesAnalysis.summary @@ -13077,7 +13346,12 @@ class PipelineService extends EventEmitter { seriesLike, confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], - titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0 + titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0, + episodeCandidateCount: Number(rawSeriesSummary?.episodeCandidateCount || 0) || 0, + playAllCount: Number(rawSeriesSummary?.playAllCount || 0) || 0, + duplicateCount: Number(rawSeriesSummary?.duplicateCount || 0) || 0, + extraCount: Number(rawSeriesSummary?.extraCount || 0) || 0, + typicalEpisodeMinutes: Number(rawSeriesSummary?.typicalEpisodeMinutes || 0) || 0 } } : null; @@ -13114,6 +13388,7 @@ class PipelineService extends EventEmitter { jobId: job.id, seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike), confidence: seriesAnalysis?.summary?.confidence || null, + seriesSource: seriesAnalysis?.source || null, metadataProvider, metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, seriesLookupHint diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 915a335..cb77d86 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.14.0-1", + "version": "0.14.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.14.0-1", + "version": "0.14.0-2", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 2dbbd73..abc614e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.14.0-1", + "version": "0.14.0-2", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index 855a552..e8523b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.14.0-1", + "version": "0.14.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.14.0-1", + "version": "0.14.0-2", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index c4ebe3f..79f4c09 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.14.0-1", + "version": "0.14.0-2", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",