const settingsService = require('./settingsService'); const logger = require('./logger').child('OMDB'); const OMDB_BASE_URL = 'https://www.omdbapi.com/'; const OMDB_TIMEOUT_MS = 10000; const OMDB_MAX_ATTEMPTS = 2; const OMDB_RETRY_BASE_DELAY_MS = 300; function normalizeOmdbTimeoutMs(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return OMDB_TIMEOUT_MS; } return Math.max(1000, Math.trunc(parsed)); } function isRetryableOmdbError(error, aborted = false) { if (aborted) { return true; } const code = String(error?.code || '').trim().toUpperCase(); if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') { return true; } const causeCode = String(error?.cause?.code || '').trim().toUpperCase(); if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') { return true; } const message = String(error?.message || '').toLowerCase(); return message.includes('timed out') || message.includes('timeout'); } function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } class OmdbService { mapSearchResults(data) { if (!data || typeof data !== 'object') { return []; } if (data.Response === 'False' || !Array.isArray(data.Search)) { return []; } return data.Search.map((item) => ({ title: item.Title, year: item.Year, imdbId: item.imdbID, type: item.Type, poster: item.Poster })); } async requestJson(url, meta = {}, options = {}) { const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs); const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS))); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': 'Ripster/1.0' }, signal: controller.signal }); if (!response.ok) { logger.warn('request:http-failed', { ...meta, status: response.status, attempt, maxAttempts }); return null; } return await response.json(); } catch (error) { const aborted = error?.name === 'AbortError'; const retryable = isRetryableOmdbError(error, aborted); const willRetry = retryable && attempt < maxAttempts; logger[willRetry ? 'info' : 'warn']('request:failed', { ...meta, timeoutMs, aborted, retryable, attempt, maxAttempts, willRetry, message: error?.message || String(error) }); if (willRetry) { await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt); continue; } return null; } finally { clearTimeout(timer); } } return null; } async search(query) { const normalizedQuery = String(query || '').trim(); if (!normalizedQuery) { return []; } logger.info('search:start', { query: normalizedQuery }); // Allow direct IMDb-ID lookups in the search field (useful for History remapping). const imdbLike = normalizedQuery.toLowerCase(); if (/^tt\d{6,12}$/.test(imdbLike)) { const byId = await this.fetchByImdbId(imdbLike); return byId ? [{ title: byId.title, year: byId.year != null ? String(byId.year) : null, imdbId: byId.imdbId, type: byId.type, poster: byId.poster }] : []; } const settings = await settingsService.getSettingsMap(); const apiKey = settings.omdb_api_key; if (!apiKey) { return []; } const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms); const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie'; const requestSearch = async (type = null) => { const url = new URL(OMDB_BASE_URL); url.searchParams.set('apikey', apiKey); url.searchParams.set('s', normalizedQuery); if (type) { url.searchParams.set('type', type); } const data = await this.requestJson( url, { query: normalizedQuery, action: 'search', type: type || null }, { timeoutMs } ); return { data, results: this.mapSearchResults(data) }; }; let { data, results } = await requestSearch(configuredType); if (results.length === 0 && configuredType) { logger.info('search:fallback-no-type', { query: normalizedQuery, configuredType, response: data?.Response || null, error: data?.Error || null }); ({ data, results } = await requestSearch(null)); } if (results.length === 0) { logger.warn('search:no-results', { query: normalizedQuery, response: data?.Response || null, error: data?.Error || null, configuredType }); return []; } logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType }); return results; } async fetchByImdbId(imdbId) { const normalizedId = String(imdbId || '').trim().toLowerCase(); if (!/^tt\d{6,12}$/.test(normalizedId)) { return null; } logger.info('fetchByImdbId:start', { imdbId: normalizedId }); const settings = await settingsService.getSettingsMap(); const apiKey = settings.omdb_api_key; if (!apiKey) { return null; } const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms); const url = new URL(OMDB_BASE_URL); url.searchParams.set('apikey', apiKey); url.searchParams.set('i', normalizedId); url.searchParams.set('plot', 'full'); const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs }); if (!data || typeof data !== 'object') { return null; } if (data.Response === 'False') { logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error }); return null; } const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/); const year = yearMatch ? Number(yearMatch[0]) : null; const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null; const result = { title: data.Title || null, year: Number.isFinite(year) ? year : null, imdbId: String(data.imdbID || normalizedId), type: data.Type || null, poster, raw: data }; logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title }); return result; } } module.exports = new OmdbService();