// Copyright (c) 2026 Gordon Bolton. MIT License. const ArrRetriever = require('./ArrRetriever'); const OmbiClient = require('./OmbiClient'); const { logToFile } = require('../utils/logger'); /** * Ombi data retriever with caching support. * Extends ArrRetriever for PALDRA compliance. * Manages Ombi request data and provides lookup maps for efficient matching. */ class OmbiRetriever extends ArrRetriever { constructor(instanceConfig) { super(instanceConfig); this.client = new OmbiClient(this.url, this.apiKey); this.baseUrl = this.url; this.cache = { movieRequests: [], tvRequests: [], users: [], movieMap: new Map(), // tmdbId -> request tvMap: new Map(), // tvdbId -> request userMap: new Map(), // id -> user lastFetch: 0, ttl: 5 * 60 * 1000 // 5 minutes TTL }; } /** * Get retriever type * @returns {string} The retriever type */ getRetrieverType() { return 'ombi'; } /** * Get the unique instance ID * @returns {string} The instance ID */ getInstanceId() { return this.id; } /** * Get tags from Ombi (not applicable, returns empty array) * @returns {Promise} Empty array (Ombi doesn't have tags) */ async getTags() { return []; } /** * Get queue from Ombi (active requests) * @returns {Promise} Queue object with records array */ async getQueue() { await this.refreshCache(); return { records: [...this.cache.movieRequests, ...this.cache.tvRequests] }; } /** * Get history from Ombi (not applicable, returns empty records) * @param {Object} options - Optional parameters (ignored for Ombi) * @returns {Promise} History object with empty records array */ async getHistory(options = {}) { return { records: [] }; } /** * Test connection to Ombi * @returns {Promise} True if connection is successful */ async testConnection() { return await this.client.testConnection(); } /** * Check if cache is expired * @returns {boolean} True if cache needs refresh */ isCacheExpired() { return Date.now() - this.cache.lastFetch > this.cache.ttl; } /** * Refresh cached data from Ombi API * @param {boolean} force - Whether to force a refresh regardless of TTL * @returns {Promise} */ async refreshCache(force = false) { if (!force && !this.isCacheExpired()) { return; } try { logToFile('[OmbiRetriever] Refreshing cache'); // Fetch requests and users in parallel const [movieRequests, tvRequests, users] = await Promise.all([ this.client.getMovieRequests(), this.client.getTvRequests(), this.client.getUsers() ]); // Update cache this.cache.movieRequests = movieRequests; this.cache.tvRequests = tvRequests; this.cache.users = users; this.cache.lastFetch = Date.now(); // Build lookup maps this.cache.movieMap.clear(); this.cache.tvMap.clear(); this.cache.userMap.clear(); // Build user map (id -> user) if (Array.isArray(users)) { users.forEach(user => { if (user && user.id) { this.cache.userMap.set(user.id, user); } }); } // Build movie map (tmdbId -> request) movieRequests.forEach(request => { if (request.theMovieDbId) { this.cache.movieMap.set(request.theMovieDbId, request); } if (request.imdbId) { this.cache.movieMap.set(request.imdbId, request); } }); // Build TV map (tvdbId -> request, fallback to tmdbId) tvRequests.forEach(request => { if (request.theTvDbId) { this.cache.tvMap.set(request.theTvDbId, request); } if (request.theMovieDbId) { this.cache.tvMap.set(request.theMovieDbId, request); } }); logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows, ${users.length} users`); } catch (error) { logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`); // Don't throw error, continue with stale cache if available } } /** * Hydrates requestedUser on a single request using the userMap cache * @param {Object} req - The request object * @returns {Object} Hydrated request object * @private */ _hydrateRequest(req) { if (!req) return req; let result = req; const reqUserId = req.requestedUserId || req.RequestedUserId; if (reqUserId && this.cache.userMap.has(reqUserId)) { const cachedUser = this.cache.userMap.get(reqUserId); let requestedUser = req.requestedUser || req.RequestedUser; // If requestedUser is not an object or is empty/null, populate it if (!requestedUser || typeof requestedUser !== 'object' || Object.keys(requestedUser).length === 0) { const hydratedUser = { id: cachedUser.id, userName: cachedUser.userName, alias: cachedUser.alias || cachedUser.Alias || '', userAlias: cachedUser.userAlias || cachedUser.UserAlias || '', normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || '' }; result = { ...req, requestedUser: hydratedUser, RequestedUser: hydratedUser }; } } // Hydrate childRequests (common for Ombi TV show requests) if (Array.isArray(result.childRequests) && result.childRequests.length > 0) { const hydratedChildren = result.childRequests.map(child => { if (!child) return child; const childUserId = child.requestedUserId || child.RequestedUserId; if (childUserId && this.cache.userMap.has(childUserId)) { const cachedUser = this.cache.userMap.get(childUserId); let childUser = child.requestedUser || child.RequestedUser; if (!childUser || typeof childUser !== 'object' || Object.keys(childUser).length === 0) { const hydratedUser = { id: cachedUser.id, userName: cachedUser.userName, alias: cachedUser.alias || cachedUser.Alias || '', userAlias: cachedUser.userAlias || cachedUser.UserAlias || '', normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || '' }; return { ...child, requestedUser: hydratedUser, RequestedUser: hydratedUser }; } } return child; }); result = { ...result, childRequests: hydratedChildren }; } // Promote requestedDate from childRequests to top level (common for Ombi TV) if (!result.requestedDate && Array.isArray(result.childRequests) && result.childRequests.length > 0) { const childDate = result.childRequests[0].requestedDate || result.childRequests[0].RequestedDate; if (childDate) { result = { ...result, requestedDate: childDate }; } } return result; } /** * Hydrates requestedUser on a list of requests using the userMap cache * @param {Array} requests - Array of request objects * @returns {Array} Array of hydrated request objects * @private */ _hydrateRequests(requests) { if (!Array.isArray(requests)) return []; return requests.map(req => this._hydrateRequest(req)); } /** * Get all movie requests * @param {boolean} force - Whether to force refresh from API * @returns {Promise} Array of movie request objects */ async getMovieRequests(force = false) { await this.refreshCache(force); return this._hydrateRequests(this.cache.movieRequests); } /** * Get all TV requests * @param {boolean} force - Whether to force refresh from API * @returns {Promise} Array of TV request objects */ async getTvRequests(force = false) { await this.refreshCache(force); return this._hydrateRequests(this.cache.tvRequests); } /** * Find movie request by external ID * @param {string} tmdbId - TheMovieDB ID * @param {string} imdbId - IMDB ID (optional fallback) * @returns {Promise} Request object or null if not found */ async findMovieRequest(tmdbId, imdbId = null) { await this.refreshCache(); // Try TMDB ID first if (tmdbId && this.cache.movieMap.has(tmdbId)) { return this._hydrateRequest(this.cache.movieMap.get(tmdbId)); } // Try IMDB ID as fallback if (imdbId && this.cache.movieMap.has(imdbId)) { return this._hydrateRequest(this.cache.movieMap.get(imdbId)); } return null; } /** * Find TV request by external ID * @param {string} tvdbId - TheTVDB ID * @param {string} tmdbId - TheMovieDB ID (optional fallback) * @returns {Promise} Request object or null if not found */ async findTvRequest(tvdbId, tmdbId = null) { await this.refreshCache(); // Try TVDB ID first if (tvdbId && this.cache.tvMap.has(tvdbId)) { return this._hydrateRequest(this.cache.tvMap.get(tvdbId)); } // Try TMDB ID as fallback if (tmdbId && this.cache.tvMap.has(tmdbId)) { return this._hydrateRequest(this.cache.tvMap.get(tmdbId)); } return null; } /** * Search for movie by external ID (for fallback when no request found) * @param {string} tmdbId - TheMovieDB ID * @param {string} imdbId - IMDB ID (optional fallback) * @returns {Promise} Search result object or null if not found */ async searchMovie(tmdbId, imdbId = null) { if (tmdbId) { const result = await this.client.searchMovieByTmdbId(tmdbId); if (result) return result; } if (imdbId) { const result = await this.client.searchMovieByImdbId(imdbId); if (result) return result; } return null; } /** * Search for TV show by external ID (for fallback when no request found) * @param {string} tvdbId - TheTVDB ID * @param {string} tmdbId - TheMovieDB ID (optional fallback) * @returns {Promise} Search result object or null if not found */ async searchTv(tvdbId, tmdbId = null) { if (tvdbId) { const result = await this.client.searchTvByTvdbId(tvdbId); if (result) return result; } if (tmdbId) { const result = await this.client.searchTvByTmdbId(tmdbId); if (result) return result; } return null; } /** * Get cache statistics * @returns {Object} Cache statistics */ getCacheStats() { return { movieRequests: this.cache.movieRequests.length, tvRequests: this.cache.tvRequests.length, movieMapSize: this.cache.movieMap.size, tvMapSize: this.cache.tvMap.size, lastFetch: this.cache.lastFetch, age: Date.now() - this.cache.lastFetch }; } } module.exports = OmbiRetriever;