0.14.0-3 Analyze fix

This commit is contained in:
2026-04-21 08:20:40 +00:00
parent 720a6f5609
commit caf6a638f7
8 changed files with 516 additions and 60 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.14.0-2", "version": "0.14.0-3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.14.0-2", "version": "0.14.0-3",
"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-2", "version": "0.14.0-3",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+429 -33
View File
@@ -79,6 +79,7 @@ const RAW_FOLDER_STATES = Object.freeze({
RIP_COMPLETE: 'rip_complete', RIP_COMPLETE: 'rip_complete',
COMPLETE: 'complete' COMPLETE: 'complete'
}); });
const RAW_STATE_PREFIX_CLEANUP_PATTERN = /^(?:(?:incomplete|rip[_-]?complete|rip[_-]?incomplete)[_-])+/i;
const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({ const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({
low: 1, low: 1,
medium: 2, 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) { function mergeSeriesAnalysisWithStructuredFallback(seriesAnalysis = null, structuredSeriesAnalysis = null) {
const baseSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' const baseSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
? seriesAnalysis.summary ? seriesAnalysis.summary
@@ -4183,13 +4267,22 @@ function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = n
const scanJson = parseMediainfoJsonOutput(scanText); const scanJson = parseMediainfoJsonOutput(scanText);
const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options?.seriesOptions || {}); const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options?.seriesOptions || {});
const mergedSeriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( const playlistStructuredSeriesAnalysis = buildStructuredSeriesAnalysisFromPlaylistAnalysis(
baseAnalyzeContext?.playlistAnalysis || null,
options?.seriesOptions || {}
);
const mergedFromScan = mergeSeriesAnalysisWithStructuredFallback(
textSeriesAnalysis, textSeriesAnalysis,
structuredSeriesAnalysis structuredSeriesAnalysis
); );
const mergedSeriesAnalysis = mergeSeriesAnalysisWithStructuredFallback(
mergedFromScan,
playlistStructuredSeriesAnalysis
);
const derivedFromText = textSeriesTitles.length > 0; const derivedFromText = textSeriesTitles.length > 0;
const derivedFromStructured = Boolean(structuredSeriesAnalysis?.summary?.seriesLike); const derivedFromStructured = Boolean(structuredSeriesAnalysis?.summary?.seriesLike);
if (!mergedSeriesAnalysis || (!derivedFromText && !derivedFromStructured)) { const derivedFromPlaylistStructured = Boolean(playlistStructuredSeriesAnalysis?.summary?.seriesLike);
if (!mergedSeriesAnalysis || (!derivedFromText && !derivedFromStructured && !derivedFromPlaylistStructured)) {
return { return {
analyzeContext: baseAnalyzeContext, analyzeContext: baseAnalyzeContext,
derived: false, derived: false,
@@ -4211,7 +4304,9 @@ function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = n
derived: true, derived: true,
reason: derivedFromText reason: derivedFromText
? 'scan_classification' ? 'scan_classification'
: 'scan_json_structural_classification' : (derivedFromStructured
? 'scan_json_structural_classification'
: 'playlist_structural_classification')
}; };
} }
@@ -5258,10 +5353,24 @@ function stripRawStatePrefix(folderName) {
if (!rawName) { if (!rawName) {
return ''; return '';
} }
return rawName const cleaned = rawName
.replace(/^Incomplete_/i, '') .replace(RAW_STATE_PREFIX_CLEANUP_PATTERN, '')
.replace(/^Rip_Complete_/i, '') .replace(/^[_-]+/, '')
.trim(); .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) { function extractSeriesSeasonDiscFromRawFolderName(folderName) {
@@ -5302,7 +5411,7 @@ function resolveRawFolderStateFromPath(rawPath) {
return RAW_FOLDER_STATES.COMPLETE; return RAW_FOLDER_STATES.COMPLETE;
} }
const folderName = path.basename(sourcePath); const folderName = path.basename(sourcePath);
if (/^Incomplete_/i.test(folderName)) { if (/^(?:Incomplete_|Rip[_-]?Incomplete_)/i.test(folderName)) {
return RAW_FOLDER_STATES.INCOMPLETE; return RAW_FOLDER_STATES.INCOMPLETE;
} }
if (/^Rip_Complete_/i.test(folderName)) { 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) { isRipSuccessful(job = null) {
if (Number(job?.rip_successful || 0) === 1) { if (Number(job?.rip_successful || 0) === 1) {
return true; return true;
@@ -8333,15 +8525,53 @@ class PipelineService extends EventEmitter {
AND (media_type IS NULL OR media_type <> 'converter') AND (media_type IS NULL OR media_type <> 'converter')
AND (job_kind IS NULL OR job_kind NOT LIKE 'converter_%') AND (job_kind IS NULL OR job_kind NOT LIKE 'converter_%')
`); `);
if (!Array.isArray(rows) || rows.length === 0) { const jobRows = Array.isArray(rows) ? rows : [];
return; 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 renamedCount = 0;
let pathUpdateCount = 0; let pathUpdateCount = 0;
let ripFlagUpdateCount = 0; let ripFlagUpdateCount = 0;
let conflictCount = 0; let conflictCount = 0;
let missingCount = 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(); const discoveredByJobId = new Map();
for (const scanDir of allRawDirs) { for (const scanDir of allRawDirs) {
@@ -8351,12 +8581,8 @@ class PipelineService extends EventEmitter {
if (!entry.isDirectory()) { if (!entry.isDirectory()) {
continue; continue;
} }
const match = String(entry.name || '').match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); const mappedJobId = extractRawFolderJobId(entry.name);
if (!match) { if (!mappedJobId) {
continue;
}
const mappedJobId = Number(match[1]);
if (!Number.isFinite(mappedJobId) || mappedJobId <= 0) {
continue; continue;
} }
const candidatePath = path.join(scanDir, entry.name); 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); const jobId = Number(row?.id);
if (!Number.isFinite(jobId) || jobId <= 0) { if (!Number.isFinite(jobId) || jobId <= 0) {
continue; continue;
@@ -8412,6 +8638,8 @@ class PipelineService extends EventEmitter {
missingCount += 1; missingCount += 1;
continue; continue;
} }
addLinkedRawPath(row.raw_path);
addLinkedRawPath(currentRawPath);
// Keep renamed folder in the same base dir as the current path // Keep renamed folder in the same base dir as the current path
const currentBaseDir = path.dirname(currentRawPath); const currentBaseDir = path.dirname(currentRawPath);
@@ -8459,6 +8687,7 @@ class PipelineService extends EventEmitter {
fs.renameSync(currentRawPath, desiredRawPath); fs.renameSync(currentRawPath, desiredRawPath);
finalRawPath = desiredRawPath; finalRawPath = desiredRawPath;
renamedCount += 1; renamedCount += 1;
addLinkedRawPath(finalRawPath);
} catch (renameError) { } catch (renameError) {
logger.warn('startup:raw-dir-migrate:rename-failed', { logger.warn('startup:raw-dir-migrate:rename-failed', {
jobId, jobId,
@@ -8475,15 +8704,119 @@ class PipelineService extends EventEmitter {
await historyService.updateRawPathByOldPath(row.raw_path, finalRawPath); await historyService.updateRawPathByOldPath(row.raw_path, finalRawPath);
pathUpdateCount += 1; 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', { logger.info('startup:raw-dir-migrate:done', {
renamedCount, renamedCount,
pathUpdateCount, pathUpdateCount,
ripFlagUpdateCount, ripFlagUpdateCount,
conflictCount, conflictCount,
missingCount, missingCount,
orphanScanCount,
orphanCandidateCount,
orphanRenamedCount,
orphanConflictCount,
orphanFailedCount,
orphanAlreadyNormalizedCount,
orphanSkippedLinkedCount,
orphanSkippedKnownJobIdCount,
scannedDirs: allRawDirs scannedDirs: allRawDirs
}); });
} }
@@ -12960,6 +13293,31 @@ class PipelineService extends EventEmitter {
seriesPluginResult?.seriesAnalysis || null, seriesPluginResult?.seriesAnalysis || null,
structuredSeriesAnalysis 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; seriesLookupHint = seriesPluginResult?.seriesLookupHint || null;
const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
? seriesAnalysis.summary ? seriesAnalysis.summary
@@ -13329,6 +13687,31 @@ class PipelineService extends EventEmitter {
seriesPluginResult?.seriesAnalysis || null, seriesPluginResult?.seriesAnalysis || null,
structuredSeriesAnalysis 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; seriesLookupHint = seriesPluginResult?.seriesLookupHint || null;
const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
? seriesAnalysis.summary ? seriesAnalysis.summary
@@ -13530,15 +13913,17 @@ class PipelineService extends EventEmitter {
const seasonFilter = String(seasonNumber || '').trim(); const seasonFilter = String(seasonNumber || '').trim();
const applySeasonFilter = (rows) => { const applySeasonFilter = (rows) => {
const sourceRows = Array.isArray(rows) ? rows : []; const sourceRows = Array.isArray(rows) ? rows : [];
const failureCode = tmdbService.readFailureCode(sourceRows);
if (!seasonFilter) { if (!seasonFilter) {
return sourceRows; return sourceRows;
} }
const seasonFilterLower = seasonFilter.toLowerCase(); const seasonFilterLower = seasonFilter.toLowerCase();
return sourceRows.filter((row) => { const filteredRows = sourceRows.filter((row) => {
const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase(); const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase();
const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase(); const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase();
return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower); return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower);
}); });
return tmdbService.attachFailureCode(filteredRows, failureCode);
}; };
logger.info('tmdb:series-search', { logger.info('tmdb:series-search', {
query: normalizedQuery, query: normalizedQuery,
@@ -13576,32 +13961,43 @@ class PipelineService extends EventEmitter {
language: tmdbLanguage language: tmdbLanguage
}); });
normalizedResults = applySeasonFilter(normalizedResults); normalizedResults = applySeasonFilter(normalizedResults);
const firstPassFailureCode = tmdbService.readFailureCode(normalizedResults);
if (normalizedResults.length === 0) { if (normalizedResults.length === 0) {
logger.warn('tmdb:series-search:empty:first-pass', { if (firstPassFailureCode) {
query: normalizedQuery, logger.warn('tmdb:series-search:empty:first-pass:skip-retry', {
seasonNumber: seasonFilter || null
});
let retryResults = [];
try {
retryResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
language: tmdbLanguage
});
} catch (retryError) {
logger.warn('tmdb:series-search:retry:failed', {
query: normalizedQuery, query: normalizedQuery,
seasonNumber: seasonFilter || null, 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) { if (normalizedResults.length === 0) {
const failureCode = tmdbService.readFailureCode(normalizedResults);
logger.info('tmdb:series-search:done', { logger.info('tmdb:series-search:done', {
query: normalizedQuery, query: normalizedQuery,
count: 0, count: 0,
empty: true empty: true,
failureCode
}); });
return []; return [];
} }
+78 -18
View File
@@ -5,9 +5,58 @@ const logger = require('./logger').child('TMDB');
const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p'; const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
const TMDB_TIMEOUT_MS = 10000; const TMDB_TIMEOUT_MS = 15000;
class TmdbService { 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 = {}) { normalizeNameList(values = [], options = {}) {
const maxItems = Math.max(1, Number(options.maxItems || 10)); const maxItems = Math.max(1, Number(options.maxItems || 10));
const source = Array.isArray(values) ? values : []; const source = Array.isArray(values) ? values : [];
@@ -130,24 +179,30 @@ class TmdbService {
} }
const language = await this.resolveLanguage(options.language); const language = await this.resolveLanguage(options.language);
const data = await this.request('/search/tv', { let data = null;
query: { try {
query: normalizedQuery, data = await this.request('/search/tv', {
first_air_date_year: options.year || undefined, query: {
language, query: normalizedQuery,
page: options.page || 1, first_air_date_year: options.year || undefined,
include_adult: false language,
} page: options.page || 1,
}).catch((error) => { include_adult: false
}
});
} catch (error) {
const failureCode = this.classifyRequestError(error);
logger.warn('search:failed', { logger.warn('search:failed', {
query: normalizedQuery, 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 : []; const rows = Array.isArray(data?.results) ? data.results : [];
return rows const normalizedRows = rows
.map((row) => ({ .map((row) => ({
id: Number(row?.id || 0) || null, id: Number(row?.id || 0) || null,
title: String(row?.name || row?.original_name || '').trim() || null, title: String(row?.name || row?.original_name || '').trim() || null,
@@ -162,6 +217,7 @@ class TmdbService {
popularity: Number(row?.popularity || 0) || 0 popularity: Number(row?.popularity || 0) || 0
})) }))
.filter((row) => row.id && row.title); .filter((row) => row.id && row.title);
return this.attachFailureCode(normalizedRows, null);
} }
async getSeriesDetails(seriesId, options = {}) { async getSeriesDetails(seriesId, options = {}) {
@@ -366,8 +422,9 @@ class TmdbService {
async searchSeriesWithSeasons(query, options = {}) { async searchSeriesWithSeasons(query, options = {}) {
const candidates = await this.searchSeries(query, options); const candidates = await this.searchSeries(query, options);
const failureCode = this.readFailureCode(candidates);
if (candidates.length === 0) { if (candidates.length === 0) {
return []; return this.attachFailureCode([], failureCode);
} }
const limit = Math.max(1, Math.min(10, Number(options.limit || 5))); 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 = {}) { async searchSeriesWithSeason(query, seasonNumber, options = {}) {
@@ -397,8 +455,9 @@ class TmdbService {
} }
const candidates = await this.searchSeries(query, options); const candidates = await this.searchSeries(query, options);
const failureCode = this.readFailureCode(candidates);
if (candidates.length === 0) { if (candidates.length === 0) {
return []; return this.attachFailureCode([], failureCode);
} }
const limit = Math.max(1, Math.min(10, Number(options.limit || 5))); 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) .filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, { .map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
seasonNumber: normalizedSeason seasonNumber: normalizedSeason
})); }));
return this.attachFailureCode(normalized, failureCode);
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.14.0-2", "version": "0.14.0-3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.14.0-2", "version": "0.14.0-3",
"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-2", "version": "0.14.0-3",
"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-2", "version": "0.14.0-3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.14.0-2", "version": "0.14.0-3",
"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-2", "version": "0.14.0-3",
"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",