0.14.0-2 Series Scan Heuristics

This commit is contained in:
2026-04-20 19:16:22 +00:00
parent d423527f0a
commit 720a6f5609
7 changed files with 309 additions and 34 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.14.0-1", "version": "0.14.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.14.0-1", "version": "0.14.0-2",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.14.0-1", "version": "0.14.0-2",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+300 -25
View File
@@ -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) { function normalizeSeriesScanTitleKind(rawKind) {
return String(rawKind || '').trim().toLowerCase(); return String(rawKind || '').trim().toLowerCase();
} }
@@ -3939,33 +4170,49 @@ function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = n
}; };
} }
let textSeriesAnalysis = null;
let textSeriesTitles = [];
try { try {
const analysisResult = dvdSeriesScanService.analyzeHandBrakeScan(scanText, options?.seriesOptions || {}); const analysisResult = dvdSeriesScanService.analyzeHandBrakeScan(scanText, options?.seriesOptions || {});
const analysis = analysisResult?.analysis || null; textSeriesAnalysis = analysisResult?.analysis || null;
const analysisTitles = Array.isArray(analysis?.titles) ? analysis.titles : []; textSeriesTitles = Array.isArray(textSeriesAnalysis?.titles) ? textSeriesAnalysis.titles : [];
if (analysisTitles.length === 0) {
return {
analyzeContext: baseAnalyzeContext,
derived: false,
reason: 'no_titles'
};
}
return {
analyzeContext: {
...baseAnalyzeContext,
seriesAnalysis: analysis
},
derived: true,
reason: 'scan_classification'
};
} catch (_error) { } 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 { return {
analyzeContext: baseAnalyzeContext, analyzeContext: baseAnalyzeContext,
derived: false, 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) { function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = null) {
@@ -12703,8 +12950,16 @@ class PipelineService extends EventEmitter {
pluginExecution, pluginExecution,
this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution())
); );
const scanJson = parseMediainfoJsonOutput(
seriesAnalysis = seriesPluginResult?.seriesAnalysis || null; Array.isArray(reviewResult?.scanLines)
? reviewResult.scanLines.join('\n')
: ''
);
const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson);
seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback(
seriesPluginResult?.seriesAnalysis || null,
structuredSeriesAnalysis
);
seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; seriesLookupHint = seriesPluginResult?.seriesLookupHint || null;
const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
? seriesAnalysis.summary ? seriesAnalysis.summary
@@ -12722,7 +12977,12 @@ class PipelineService extends EventEmitter {
seriesLike, seriesLike,
confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low',
reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], 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; : null;
@@ -12759,6 +13019,7 @@ class PipelineService extends EventEmitter {
jobId: normalizedJobId, jobId: normalizedJobId,
seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike), seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike),
confidence: seriesAnalysis?.summary?.confidence || null, confidence: seriesAnalysis?.summary?.confidence || null,
seriesSource: seriesAnalysis?.source || null,
metadataProvider, metadataProvider,
metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0,
seriesLookupHint, seriesLookupHint,
@@ -13058,8 +13319,16 @@ class PipelineService extends EventEmitter {
pluginExecution, pluginExecution,
this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution())
); );
const scanJson = parseMediainfoJsonOutput(
seriesAnalysis = seriesPluginResult?.seriesAnalysis || null; Array.isArray(reviewResult?.scanLines)
? reviewResult.scanLines.join('\n')
: ''
);
const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson);
seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback(
seriesPluginResult?.seriesAnalysis || null,
structuredSeriesAnalysis
);
seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; seriesLookupHint = seriesPluginResult?.seriesLookupHint || null;
const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
? seriesAnalysis.summary ? seriesAnalysis.summary
@@ -13077,7 +13346,12 @@ class PipelineService extends EventEmitter {
seriesLike, seriesLike,
confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low',
reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], 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; : null;
@@ -13114,6 +13388,7 @@ class PipelineService extends EventEmitter {
jobId: job.id, jobId: job.id,
seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike), seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike),
confidence: seriesAnalysis?.summary?.confidence || null, confidence: seriesAnalysis?.summary?.confidence || null,
seriesSource: seriesAnalysis?.source || null,
metadataProvider, metadataProvider,
metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0,
seriesLookupHint seriesLookupHint
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.14.0-1", "version": "0.14.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.14.0-1", "version": "0.14.0-2",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.14.0-1", "version": "0.14.0-2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.14.0-1", "version": "0.14.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.14.0-1", "version": "0.14.0-2",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.14.0-1", "version": "0.14.0-2",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",