226 lines
6.7 KiB
JavaScript
226 lines
6.7 KiB
JavaScript
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();
|