0.14.0-2 Series Scan Heuristics
This commit is contained in:
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.14.0-1",
|
||||
"version": "0.14.0-2",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -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) {
|
||||
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: 'no_titles'
|
||||
reason: derivedFromText ? 'scan_parse_failed' : 'no_titles'
|
||||
};
|
||||
}
|
||||
|
||||
const nextSeriesAnalysis = derivedFromText
|
||||
? {
|
||||
...mergedSeriesAnalysis,
|
||||
titles: textSeriesTitles
|
||||
}
|
||||
: mergedSeriesAnalysis;
|
||||
return {
|
||||
analyzeContext: {
|
||||
...baseAnalyzeContext,
|
||||
seriesAnalysis: analysis
|
||||
seriesAnalysis: nextSeriesAnalysis
|
||||
},
|
||||
derived: true,
|
||||
reason: 'scan_classification'
|
||||
reason: derivedFromText
|
||||
? 'scan_classification'
|
||||
: 'scan_json_structural_classification'
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
analyzeContext: baseAnalyzeContext,
|
||||
derived: false,
|
||||
reason: 'scan_parse_failed'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.14.0-1",
|
||||
"version": "0.14.0-2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user