diff --git a/backend/package-lock.json b/backend/package-lock.json index 454d432..d1a493f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index f41c324..f8f647e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.14.0-2", + "version": "0.14.0-3", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 472913d..727a148 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -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 []; } diff --git a/backend/src/services/tmdbService.js b/backend/src/services/tmdbService.js index bfb9b90..bb7db70 100644 --- a/backend/src/services/tmdbService.js +++ b/backend/src/services/tmdbService.js @@ -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); } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb77d86..994030a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index abc614e..aa26075 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.14.0-2", + "version": "0.14.0-3", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index e8523b1..6c6ad29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index 79f4c09..46a5f31 100644 --- a/package.json +++ b/package.json @@ -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",