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",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.14.0-2",
"version": "0.14.0-3",
"private": true,
"type": "commonjs",
"scripts": {
+429 -33
View File
@@ -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 [];
}
+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_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);
}
}