0.14.0-3 Analyze fix
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.14.0-2",
|
||||
"version": "0.14.0-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.14.0-2",
|
||||
"version": "0.14.0-3",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.14.0-2",
|
||||
"version": "0.14.0-3",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -79,6 +79,7 @@ const RAW_FOLDER_STATES = Object.freeze({
|
||||
RIP_COMPLETE: 'rip_complete',
|
||||
COMPLETE: 'complete'
|
||||
});
|
||||
const RAW_STATE_PREFIX_CLEANUP_PATTERN = /^(?:(?:incomplete|rip[_-]?complete|rip[_-]?incomplete)[_-])+/i;
|
||||
const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({
|
||||
low: 1,
|
||||
medium: 2,
|
||||
@@ -3960,6 +3961,89 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options =
|
||||
};
|
||||
}
|
||||
|
||||
function buildStructuredSeriesAnalysisFromPlaylistAnalysis(playlistAnalysis, options = {}) {
|
||||
const sourceTitles = Array.isArray(playlistAnalysis?.titles)
|
||||
? playlistAnalysis.titles
|
||||
: [];
|
||||
if (sourceTitles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const syntheticScanJson = {
|
||||
MainFeature: 0,
|
||||
TitleList: sourceTitles.map((title, index) => ({
|
||||
Index: normalizeScanTrackId(title?.titleId, index),
|
||||
Duration: Number(title?.durationSeconds || 0) || String(title?.durationLabel || '').trim() || 0,
|
||||
AudioList: Array.isArray(title?.audioTracks) ? title.audioTracks : [],
|
||||
SubtitleList: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [],
|
||||
Size: {
|
||||
Bytes: Number(title?.sizeBytes || 0) || 0
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
const structured = buildStructuredSeriesAnalysisFromHandBrakeScanJson(syntheticScanJson, options);
|
||||
if (!structured?.summary || typeof structured.summary !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
...structured.summary,
|
||||
titleCount: Math.max(
|
||||
Number(structured.summary?.titleCount || 0) || 0,
|
||||
sourceTitles.length
|
||||
)
|
||||
};
|
||||
|
||||
return {
|
||||
source: 'makemkv_analyze_structured',
|
||||
generatedAt: nowIso(),
|
||||
summary
|
||||
};
|
||||
}
|
||||
|
||||
function hasDiscDecryptWarningInScan(scanAnalysisLines = null) {
|
||||
const lines = Array.isArray(scanAnalysisLines)
|
||||
? scanAnalysisLines
|
||||
: (String(scanAnalysisLines || '').trim()
|
||||
? String(scanAnalysisLines || '').split(/\r?\n/)
|
||||
: []);
|
||||
if (lines.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return lines.some((line) =>
|
||||
/(aacs|keydbcfg|no valid aacs|cannot decrypt|can't decrypt|encrypted|libdvdcss|\bcss\b|copy protection|bd\+)/i
|
||||
.test(String(line || ''))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRunMakeMkvSeriesSecondChance(seriesAnalysis = null, scanJson = null, scanAnalysisLines = null) {
|
||||
const summary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
|
||||
? seriesAnalysis.summary
|
||||
: null;
|
||||
if (Boolean(summary?.seriesLike)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedTitles = parseHandBrakeTitleList(scanJson);
|
||||
const durations = parsedTitles
|
||||
.map((title) => Number(title?.durationSeconds || 0))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
const titleCount = Math.max(
|
||||
Number(summary?.titleCount || 0) || 0,
|
||||
parsedTitles.length
|
||||
);
|
||||
const maxDuration = durations.length > 0 ? Math.max(...durations) : 0;
|
||||
const shortOnlyScan = titleCount > 0 && titleCount <= 2 && maxDuration > 0 && maxDuration <= 600;
|
||||
const hasDecryptWarning = hasDiscDecryptWarningInScan(scanAnalysisLines);
|
||||
const noEpisodeSignals = (Number(summary?.episodeCandidateCount || 0) || 0) <= 0
|
||||
&& (Number(summary?.playAllCount || 0) || 0) <= 0;
|
||||
const noFeatureExtras = (Number(summary?.extraCount || 0) || 0) <= 0;
|
||||
const lowConfidence = normalizeSeriesDetectionConfidence(summary?.confidence) === 'low';
|
||||
return lowConfidence && noEpisodeSignals && (shortOnlyScan || (hasDecryptWarning && noFeatureExtras));
|
||||
}
|
||||
|
||||
function mergeSeriesAnalysisWithStructuredFallback(seriesAnalysis = null, structuredSeriesAnalysis = null) {
|
||||
const baseSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
|
||||
? seriesAnalysis.summary
|
||||
@@ -4183,13 +4267,22 @@ function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = n
|
||||
|
||||
const scanJson = parseMediainfoJsonOutput(scanText);
|
||||
const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options?.seriesOptions || {});
|
||||
const mergedSeriesAnalysis = mergeSeriesAnalysisWithStructuredFallback(
|
||||
const playlistStructuredSeriesAnalysis = buildStructuredSeriesAnalysisFromPlaylistAnalysis(
|
||||
baseAnalyzeContext?.playlistAnalysis || null,
|
||||
options?.seriesOptions || {}
|
||||
);
|
||||
const mergedFromScan = mergeSeriesAnalysisWithStructuredFallback(
|
||||
textSeriesAnalysis,
|
||||
structuredSeriesAnalysis
|
||||
);
|
||||
const mergedSeriesAnalysis = mergeSeriesAnalysisWithStructuredFallback(
|
||||
mergedFromScan,
|
||||
playlistStructuredSeriesAnalysis
|
||||
);
|
||||
const derivedFromText = textSeriesTitles.length > 0;
|
||||
const derivedFromStructured = Boolean(structuredSeriesAnalysis?.summary?.seriesLike);
|
||||
if (!mergedSeriesAnalysis || (!derivedFromText && !derivedFromStructured)) {
|
||||
const derivedFromPlaylistStructured = Boolean(playlistStructuredSeriesAnalysis?.summary?.seriesLike);
|
||||
if (!mergedSeriesAnalysis || (!derivedFromText && !derivedFromStructured && !derivedFromPlaylistStructured)) {
|
||||
return {
|
||||
analyzeContext: baseAnalyzeContext,
|
||||
derived: false,
|
||||
@@ -4211,7 +4304,9 @@ function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = n
|
||||
derived: true,
|
||||
reason: derivedFromText
|
||||
? 'scan_classification'
|
||||
: 'scan_json_structural_classification'
|
||||
: (derivedFromStructured
|
||||
? 'scan_json_structural_classification'
|
||||
: 'playlist_structural_classification')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5258,10 +5353,24 @@ function stripRawStatePrefix(folderName) {
|
||||
if (!rawName) {
|
||||
return '';
|
||||
}
|
||||
return rawName
|
||||
.replace(/^Incomplete_/i, '')
|
||||
.replace(/^Rip_Complete_/i, '')
|
||||
const cleaned = rawName
|
||||
.replace(RAW_STATE_PREFIX_CLEANUP_PATTERN, '')
|
||||
.replace(/^[_-]+/, '')
|
||||
.trim();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function extractRawFolderJobId(folderName) {
|
||||
const rawName = stripRawStatePrefix(path.basename(String(folderName || '').trim()));
|
||||
if (!rawName) {
|
||||
return null;
|
||||
}
|
||||
const match = rawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i);
|
||||
const parsed = Number(match?.[1]);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function extractSeriesSeasonDiscFromRawFolderName(folderName) {
|
||||
@@ -5302,7 +5411,7 @@ function resolveRawFolderStateFromPath(rawPath) {
|
||||
return RAW_FOLDER_STATES.COMPLETE;
|
||||
}
|
||||
const folderName = path.basename(sourcePath);
|
||||
if (/^Incomplete_/i.test(folderName)) {
|
||||
if (/^(?:Incomplete_|Rip[_-]?Incomplete_)/i.test(folderName)) {
|
||||
return RAW_FOLDER_STATES.INCOMPLETE;
|
||||
}
|
||||
if (/^Rip_Complete_/i.test(folderName)) {
|
||||
@@ -8045,6 +8154,89 @@ class PipelineService extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
async runMakeMkvSeriesSecondChance({
|
||||
jobId,
|
||||
mediaProfile,
|
||||
deviceInfo = null,
|
||||
rawPath = null,
|
||||
silent = true
|
||||
} = {}) {
|
||||
const normalizedJobId = this.normalizeQueueJobId(jobId);
|
||||
const normalizedMediaProfile = normalizeMediaProfile(mediaProfile);
|
||||
if (!normalizedJobId || !isSeriesDiscMediaProfile(normalizedMediaProfile)) {
|
||||
return {
|
||||
runInfo: null,
|
||||
scanLines: [],
|
||||
playlistAnalysis: null,
|
||||
structuredSeriesAnalysis: null,
|
||||
reason: 'invalid_input'
|
||||
};
|
||||
}
|
||||
|
||||
const scanLines = [];
|
||||
let runInfo = null;
|
||||
try {
|
||||
await this.ensureMakeMKVRegistration(normalizedJobId, 'ANALYZING').catch((error) => {
|
||||
logger.warn('dvd-series:makemkv-second-chance:registration-failed', {
|
||||
jobId: normalizedJobId,
|
||||
mediaProfile: normalizedMediaProfile,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
});
|
||||
|
||||
const settings = await settingsService.getEffectiveSettingsMap(normalizedMediaProfile);
|
||||
const analyzeConfig = rawPath
|
||||
? await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, {
|
||||
mediaProfile: normalizedMediaProfile,
|
||||
disableMinLengthFilter: true,
|
||||
settingsMap: settings
|
||||
})
|
||||
: await settingsService.buildMakeMKVAnalyzeConfig(deviceInfo, {
|
||||
mediaProfile: normalizedMediaProfile,
|
||||
disableMinLengthFilter: true,
|
||||
settingsMap: settings
|
||||
});
|
||||
|
||||
runInfo = await this.runCommand({
|
||||
jobId: normalizedJobId,
|
||||
stage: 'ANALYZING',
|
||||
source: 'MAKEMKV_ANALYZE_SERIES_SECOND_CHANCE',
|
||||
cmd: analyzeConfig.cmd,
|
||||
args: analyzeConfig.args,
|
||||
parser: parseMakeMkvProgress,
|
||||
collectLines: scanLines,
|
||||
silent: Boolean(silent)
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('dvd-series:makemkv-second-chance:failed', {
|
||||
jobId: normalizedJobId,
|
||||
mediaProfile: normalizedMediaProfile,
|
||||
source: rawPath ? 'raw_path' : 'device',
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
return {
|
||||
runInfo: error?.runInfo || runInfo || null,
|
||||
scanLines,
|
||||
playlistAnalysis: null,
|
||||
structuredSeriesAnalysis: null,
|
||||
reason: 'command_failed'
|
||||
};
|
||||
}
|
||||
|
||||
const playlistAnalysis = analyzePlaylistObfuscation(scanLines, 0, {});
|
||||
const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromPlaylistAnalysis(
|
||||
playlistAnalysis,
|
||||
{}
|
||||
);
|
||||
return {
|
||||
runInfo,
|
||||
scanLines,
|
||||
playlistAnalysis,
|
||||
structuredSeriesAnalysis,
|
||||
reason: structuredSeriesAnalysis ? 'ok' : 'no_structured_signal'
|
||||
};
|
||||
}
|
||||
|
||||
isRipSuccessful(job = null) {
|
||||
if (Number(job?.rip_successful || 0) === 1) {
|
||||
return true;
|
||||
@@ -8333,15 +8525,53 @@ class PipelineService extends EventEmitter {
|
||||
AND (media_type IS NULL OR media_type <> 'converter')
|
||||
AND (job_kind IS NULL OR job_kind NOT LIKE 'converter_%')
|
||||
`);
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const jobRows = Array.isArray(rows) ? rows : [];
|
||||
const existingJobIdRows = await db.all('SELECT id FROM jobs');
|
||||
const existingJobIdSet = new Set(
|
||||
(Array.isArray(existingJobIdRows) ? existingJobIdRows : [])
|
||||
.map((row) => Number(row?.id))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value))
|
||||
);
|
||||
|
||||
const linkedRawPathSet = new Set();
|
||||
const addLinkedRawPath = (candidatePath) => {
|
||||
const normalizedCandidate = normalizeComparablePath(candidatePath);
|
||||
if (!normalizedCandidate) {
|
||||
return;
|
||||
}
|
||||
linkedRawPathSet.add(normalizedCandidate);
|
||||
for (const rawRoot of allRawDirs) {
|
||||
if (!isPathInsideDirectory(rawRoot, normalizedCandidate)) {
|
||||
continue;
|
||||
}
|
||||
const relative = String(path.relative(rawRoot, normalizedCandidate) || '').trim();
|
||||
if (!relative || relative === '.' || relative.startsWith('..')) {
|
||||
continue;
|
||||
}
|
||||
const topSegment = relative
|
||||
.split(path.sep)
|
||||
.find((segment) => String(segment || '').trim() && segment !== '.');
|
||||
if (!topSegment) {
|
||||
continue;
|
||||
}
|
||||
linkedRawPathSet.add(normalizeComparablePath(path.join(rawRoot, topSegment)));
|
||||
}
|
||||
};
|
||||
|
||||
let renamedCount = 0;
|
||||
let pathUpdateCount = 0;
|
||||
let ripFlagUpdateCount = 0;
|
||||
let conflictCount = 0;
|
||||
let missingCount = 0;
|
||||
let orphanScanCount = 0;
|
||||
let orphanCandidateCount = 0;
|
||||
let orphanRenamedCount = 0;
|
||||
let orphanConflictCount = 0;
|
||||
let orphanFailedCount = 0;
|
||||
let orphanAlreadyNormalizedCount = 0;
|
||||
let orphanSkippedLinkedCount = 0;
|
||||
let orphanSkippedKnownJobIdCount = 0;
|
||||
const discoveredByJobId = new Map();
|
||||
|
||||
for (const scanDir of allRawDirs) {
|
||||
@@ -8351,12 +8581,8 @@ class PipelineService extends EventEmitter {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const match = String(entry.name || '').match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const mappedJobId = Number(match[1]);
|
||||
if (!Number.isFinite(mappedJobId) || mappedJobId <= 0) {
|
||||
const mappedJobId = extractRawFolderJobId(entry.name);
|
||||
if (!mappedJobId) {
|
||||
continue;
|
||||
}
|
||||
const candidatePath = path.join(scanDir, entry.name);
|
||||
@@ -8382,7 +8608,7 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
for (const row of jobRows) {
|
||||
const jobId = Number(row?.id);
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
continue;
|
||||
@@ -8412,6 +8638,8 @@ class PipelineService extends EventEmitter {
|
||||
missingCount += 1;
|
||||
continue;
|
||||
}
|
||||
addLinkedRawPath(row.raw_path);
|
||||
addLinkedRawPath(currentRawPath);
|
||||
|
||||
// Keep renamed folder in the same base dir as the current path
|
||||
const currentBaseDir = path.dirname(currentRawPath);
|
||||
@@ -8459,6 +8687,7 @@ class PipelineService extends EventEmitter {
|
||||
fs.renameSync(currentRawPath, desiredRawPath);
|
||||
finalRawPath = desiredRawPath;
|
||||
renamedCount += 1;
|
||||
addLinkedRawPath(finalRawPath);
|
||||
} catch (renameError) {
|
||||
logger.warn('startup:raw-dir-migrate:rename-failed', {
|
||||
jobId,
|
||||
@@ -8475,15 +8704,119 @@ class PipelineService extends EventEmitter {
|
||||
await historyService.updateRawPathByOldPath(row.raw_path, finalRawPath);
|
||||
pathUpdateCount += 1;
|
||||
}
|
||||
addLinkedRawPath(finalRawPath);
|
||||
}
|
||||
|
||||
if (renamedCount > 0 || pathUpdateCount > 0 || ripFlagUpdateCount > 0 || conflictCount > 0 || missingCount > 0) {
|
||||
for (const scanDir of allRawDirs) {
|
||||
let dirEntries = [];
|
||||
try {
|
||||
dirEntries = fs.readdirSync(scanDir, { withFileTypes: true });
|
||||
} catch (scanError) {
|
||||
logger.warn('startup:raw-orphan-normalize:scan-failed', {
|
||||
scanDir,
|
||||
error: errorToMeta(scanError)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of dirEntries) {
|
||||
if (!entry?.isDirectory?.()) {
|
||||
continue;
|
||||
}
|
||||
const rawFolderName = String(entry.name || '').trim();
|
||||
if (!rawFolderName || rawFolderName.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
orphanScanCount += 1;
|
||||
|
||||
const currentRawPath = normalizeComparablePath(path.join(scanDir, rawFolderName));
|
||||
if (!currentRawPath) {
|
||||
continue;
|
||||
}
|
||||
if (linkedRawPathSet.has(currentRawPath)) {
|
||||
orphanSkippedLinkedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderJobId = extractRawFolderJobId(rawFolderName);
|
||||
if (folderJobId && existingJobIdSet.has(folderJobId)) {
|
||||
orphanSkippedKnownJobIdCount += 1;
|
||||
addLinkedRawPath(currentRawPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
orphanCandidateCount += 1;
|
||||
const cleanedBaseName = stripRawStatePrefix(rawFolderName);
|
||||
const targetFolderName = applyRawFolderStateToName(cleanedBaseName, RAW_FOLDER_STATES.RIP_COMPLETE);
|
||||
if (!targetFolderName) {
|
||||
continue;
|
||||
}
|
||||
const targetRawPath = normalizeComparablePath(path.join(scanDir, targetFolderName));
|
||||
if (!targetRawPath || targetRawPath === currentRawPath) {
|
||||
orphanAlreadyNormalizedCount += 1;
|
||||
addLinkedRawPath(currentRawPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fs.existsSync(targetRawPath)) {
|
||||
orphanConflictCount += 1;
|
||||
logger.warn('startup:raw-orphan-normalize:target-exists', {
|
||||
scanDir,
|
||||
currentRawPath,
|
||||
targetRawPath,
|
||||
folderJobId,
|
||||
folderJobIdExists: existingJobIdSet.has(Number(folderJobId || 0))
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(currentRawPath, targetRawPath);
|
||||
orphanRenamedCount += 1;
|
||||
addLinkedRawPath(targetRawPath);
|
||||
logger.info('startup:raw-orphan-normalize:renamed', {
|
||||
from: currentRawPath,
|
||||
to: targetRawPath,
|
||||
folderJobId,
|
||||
folderJobIdExists: existingJobIdSet.has(Number(folderJobId || 0))
|
||||
});
|
||||
} catch (renameError) {
|
||||
orphanFailedCount += 1;
|
||||
logger.warn('startup:raw-orphan-normalize:rename-failed', {
|
||||
scanDir,
|
||||
currentRawPath,
|
||||
targetRawPath,
|
||||
error: errorToMeta(renameError)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
renamedCount > 0
|
||||
|| pathUpdateCount > 0
|
||||
|| ripFlagUpdateCount > 0
|
||||
|| conflictCount > 0
|
||||
|| missingCount > 0
|
||||
|| orphanCandidateCount > 0
|
||||
|| orphanRenamedCount > 0
|
||||
|| orphanConflictCount > 0
|
||||
|| orphanFailedCount > 0
|
||||
) {
|
||||
logger.info('startup:raw-dir-migrate:done', {
|
||||
renamedCount,
|
||||
pathUpdateCount,
|
||||
ripFlagUpdateCount,
|
||||
conflictCount,
|
||||
missingCount,
|
||||
orphanScanCount,
|
||||
orphanCandidateCount,
|
||||
orphanRenamedCount,
|
||||
orphanConflictCount,
|
||||
orphanFailedCount,
|
||||
orphanAlreadyNormalizedCount,
|
||||
orphanSkippedLinkedCount,
|
||||
orphanSkippedKnownJobIdCount,
|
||||
scannedDirs: allRawDirs
|
||||
});
|
||||
}
|
||||
@@ -12960,6 +13293,31 @@ class PipelineService extends EventEmitter {
|
||||
seriesPluginResult?.seriesAnalysis || null,
|
||||
structuredSeriesAnalysis
|
||||
);
|
||||
if (shouldRunMakeMkvSeriesSecondChance(
|
||||
seriesAnalysis,
|
||||
scanJson,
|
||||
reviewResult?.scanAnalysisLines || reviewResult?.scanLines || null
|
||||
)) {
|
||||
const secondChance = await this.runMakeMkvSeriesSecondChance({
|
||||
jobId: normalizedJobId,
|
||||
mediaProfile,
|
||||
rawPath: seriesScanInputPath,
|
||||
silent: true
|
||||
});
|
||||
if (secondChance?.structuredSeriesAnalysis) {
|
||||
seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback(
|
||||
seriesAnalysis,
|
||||
secondChance.structuredSeriesAnalysis
|
||||
);
|
||||
logger.info('dvd-series:analyze:makemkv-second-chance', {
|
||||
jobId: normalizedJobId,
|
||||
source: 'raw_import',
|
||||
titleCount: Number(secondChance?.structuredSeriesAnalysis?.summary?.titleCount || 0) || 0,
|
||||
episodeCandidateCount: Number(secondChance?.structuredSeriesAnalysis?.summary?.episodeCandidateCount || 0) || 0,
|
||||
seriesLike: Boolean(secondChance?.structuredSeriesAnalysis?.summary?.seriesLike)
|
||||
});
|
||||
}
|
||||
}
|
||||
seriesLookupHint = seriesPluginResult?.seriesLookupHint || null;
|
||||
const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
|
||||
? seriesAnalysis.summary
|
||||
@@ -13329,6 +13687,31 @@ class PipelineService extends EventEmitter {
|
||||
seriesPluginResult?.seriesAnalysis || null,
|
||||
structuredSeriesAnalysis
|
||||
);
|
||||
if (shouldRunMakeMkvSeriesSecondChance(
|
||||
seriesAnalysis,
|
||||
scanJson,
|
||||
reviewResult?.scanAnalysisLines || reviewResult?.scanLines || null
|
||||
)) {
|
||||
const secondChance = await this.runMakeMkvSeriesSecondChance({
|
||||
jobId: job.id,
|
||||
mediaProfile,
|
||||
deviceInfo: deviceWithProfile,
|
||||
silent: true
|
||||
});
|
||||
if (secondChance?.structuredSeriesAnalysis) {
|
||||
seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback(
|
||||
seriesAnalysis,
|
||||
secondChance.structuredSeriesAnalysis
|
||||
);
|
||||
logger.info('dvd-series:analyze:makemkv-second-chance', {
|
||||
jobId: job.id,
|
||||
source: 'drive_analyze',
|
||||
titleCount: Number(secondChance?.structuredSeriesAnalysis?.summary?.titleCount || 0) || 0,
|
||||
episodeCandidateCount: Number(secondChance?.structuredSeriesAnalysis?.summary?.episodeCandidateCount || 0) || 0,
|
||||
seriesLike: Boolean(secondChance?.structuredSeriesAnalysis?.summary?.seriesLike)
|
||||
});
|
||||
}
|
||||
}
|
||||
seriesLookupHint = seriesPluginResult?.seriesLookupHint || null;
|
||||
const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
|
||||
? seriesAnalysis.summary
|
||||
@@ -13530,15 +13913,17 @@ class PipelineService extends EventEmitter {
|
||||
const seasonFilter = String(seasonNumber || '').trim();
|
||||
const applySeasonFilter = (rows) => {
|
||||
const sourceRows = Array.isArray(rows) ? rows : [];
|
||||
const failureCode = tmdbService.readFailureCode(sourceRows);
|
||||
if (!seasonFilter) {
|
||||
return sourceRows;
|
||||
}
|
||||
const seasonFilterLower = seasonFilter.toLowerCase();
|
||||
return sourceRows.filter((row) => {
|
||||
const filteredRows = sourceRows.filter((row) => {
|
||||
const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase();
|
||||
const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase();
|
||||
return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower);
|
||||
});
|
||||
return tmdbService.attachFailureCode(filteredRows, failureCode);
|
||||
};
|
||||
logger.info('tmdb:series-search', {
|
||||
query: normalizedQuery,
|
||||
@@ -13576,32 +13961,43 @@ class PipelineService extends EventEmitter {
|
||||
language: tmdbLanguage
|
||||
});
|
||||
normalizedResults = applySeasonFilter(normalizedResults);
|
||||
const firstPassFailureCode = tmdbService.readFailureCode(normalizedResults);
|
||||
|
||||
if (normalizedResults.length === 0) {
|
||||
logger.warn('tmdb:series-search:empty:first-pass', {
|
||||
query: normalizedQuery,
|
||||
seasonNumber: seasonFilter || null
|
||||
});
|
||||
let retryResults = [];
|
||||
try {
|
||||
retryResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
|
||||
language: tmdbLanguage
|
||||
});
|
||||
} catch (retryError) {
|
||||
logger.warn('tmdb:series-search:retry:failed', {
|
||||
if (firstPassFailureCode) {
|
||||
logger.warn('tmdb:series-search:empty:first-pass:skip-retry', {
|
||||
query: normalizedQuery,
|
||||
seasonNumber: seasonFilter || null,
|
||||
error: errorToMeta(retryError)
|
||||
failureCode: firstPassFailureCode
|
||||
});
|
||||
} else {
|
||||
logger.warn('tmdb:series-search:empty:first-pass', {
|
||||
query: normalizedQuery,
|
||||
seasonNumber: seasonFilter || null
|
||||
});
|
||||
let retryResults = [];
|
||||
try {
|
||||
retryResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
|
||||
language: tmdbLanguage
|
||||
});
|
||||
} catch (retryError) {
|
||||
logger.warn('tmdb:series-search:retry:failed', {
|
||||
query: normalizedQuery,
|
||||
seasonNumber: seasonFilter || null,
|
||||
error: errorToMeta(retryError)
|
||||
});
|
||||
}
|
||||
normalizedResults = applySeasonFilter(retryResults);
|
||||
}
|
||||
normalizedResults = applySeasonFilter(retryResults);
|
||||
}
|
||||
|
||||
if (normalizedResults.length === 0) {
|
||||
const failureCode = tmdbService.readFailureCode(normalizedResults);
|
||||
logger.info('tmdb:series-search:done', {
|
||||
query: normalizedQuery,
|
||||
count: 0,
|
||||
empty: true
|
||||
empty: true,
|
||||
failureCode
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -5,9 +5,58 @@ const logger = require('./logger').child('TMDB');
|
||||
|
||||
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
||||
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
|
||||
const TMDB_TIMEOUT_MS = 10000;
|
||||
const TMDB_TIMEOUT_MS = 15000;
|
||||
|
||||
class TmdbService {
|
||||
isAbortError(error) {
|
||||
const name = String(error?.name || '').trim().toLowerCase();
|
||||
const message = String(error?.message || '').trim().toLowerCase();
|
||||
return name === 'aborterror' || message.includes('aborted');
|
||||
}
|
||||
|
||||
classifyRequestError(error) {
|
||||
if (this.isAbortError(error)) {
|
||||
return 'timeout';
|
||||
}
|
||||
const statusCode = Number(error?.statusCode || 0) || null;
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return 'auth';
|
||||
}
|
||||
if (statusCode >= 500) {
|
||||
return 'upstream';
|
||||
}
|
||||
if (statusCode >= 400) {
|
||||
return 'request_failed';
|
||||
}
|
||||
return 'network';
|
||||
}
|
||||
|
||||
readFailureCode(rows) {
|
||||
const value = rows && typeof rows.tmdbFailureCode === 'string'
|
||||
? rows.tmdbFailureCode
|
||||
: '';
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
attachFailureCode(rows, failureCode = null) {
|
||||
const output = Array.isArray(rows) ? rows : [];
|
||||
const normalized = String(failureCode || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return output;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(output, 'tmdbFailureCode', {
|
||||
value: normalized,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
} catch (_error) {
|
||||
output.tmdbFailureCode = normalized;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
normalizeNameList(values = [], options = {}) {
|
||||
const maxItems = Math.max(1, Number(options.maxItems || 10));
|
||||
const source = Array.isArray(values) ? values : [];
|
||||
@@ -130,24 +179,30 @@ class TmdbService {
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
|
||||
const data = await this.request('/search/tv', {
|
||||
query: {
|
||||
query: normalizedQuery,
|
||||
first_air_date_year: options.year || undefined,
|
||||
language,
|
||||
page: options.page || 1,
|
||||
include_adult: false
|
||||
}
|
||||
}).catch((error) => {
|
||||
let data = null;
|
||||
try {
|
||||
data = await this.request('/search/tv', {
|
||||
query: {
|
||||
query: normalizedQuery,
|
||||
first_air_date_year: options.year || undefined,
|
||||
language,
|
||||
page: options.page || 1,
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const failureCode = this.classifyRequestError(error);
|
||||
logger.warn('search:failed', {
|
||||
query: normalizedQuery,
|
||||
error: error?.message || String(error)
|
||||
error: error?.message || String(error),
|
||||
failureCode,
|
||||
statusCode: Number(error?.statusCode || 0) || null
|
||||
});
|
||||
return null;
|
||||
});
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data?.results) ? data.results : [];
|
||||
return rows
|
||||
const normalizedRows = rows
|
||||
.map((row) => ({
|
||||
id: Number(row?.id || 0) || null,
|
||||
title: String(row?.name || row?.original_name || '').trim() || null,
|
||||
@@ -162,6 +217,7 @@ class TmdbService {
|
||||
popularity: Number(row?.popularity || 0) || 0
|
||||
}))
|
||||
.filter((row) => row.id && row.title);
|
||||
return this.attachFailureCode(normalizedRows, null);
|
||||
}
|
||||
|
||||
async getSeriesDetails(seriesId, options = {}) {
|
||||
@@ -366,8 +422,9 @@ class TmdbService {
|
||||
|
||||
async searchSeriesWithSeasons(query, options = {}) {
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
@@ -387,7 +444,8 @@ class TmdbService {
|
||||
}));
|
||||
}));
|
||||
|
||||
return expanded.flat().filter((item) => item?.tmdbId && item?.title);
|
||||
const normalized = expanded.flat().filter((item) => item?.tmdbId && item?.title);
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
@@ -397,8 +455,9 @@ class TmdbService {
|
||||
}
|
||||
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
const failureCode = this.readFailureCode(candidates);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
return this.attachFailureCode([], failureCode);
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
@@ -417,11 +476,12 @@ class TmdbService {
|
||||
};
|
||||
}));
|
||||
|
||||
return withSeasons
|
||||
const normalized = withSeasons
|
||||
.filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
|
||||
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
|
||||
seasonNumber: normalizedSeason
|
||||
}));
|
||||
return this.attachFailureCode(normalized, failureCode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.14.0-2",
|
||||
"version": "0.14.0-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.14.0-2",
|
||||
"version": "0.14.0-3",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.14.0-2",
|
||||
"version": "0.14.0-3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.14.0-2",
|
||||
"version": "0.14.0-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.14.0-2",
|
||||
"version": "0.14.0-3",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.14.0-2",
|
||||
"version": "0.14.0-3",
|
||||
"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