const axios = require('axios'); const log = require('../helpers/logger'); const { genresDb, cacheDb } = require('../helpers/db'); const { getCatalogCache, setCatalogCache } = require('../helpers/cache'); const queue = require('../helpers/ratelimit'); const { getFanartPoster } = require('./fanart'); const { getPosterUrl, cachePosters } = require('./rpdb'); const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; const getGenreId = (mediaType, genreName) => new Promise((resolve, reject) => { const query = `SELECT genre_id FROM genres WHERE media_type = ? AND genre_name = ?`; genresDb.get(query, [mediaType, genreName], (err, row) => { if (err) return reject(err); resolve(row ? row.genre_id : null); }); }); const buildQueryParams = (params, mediaType) => { const queryParams = []; if (params.year) { const [startYear, endYear] = params.year.split('-'); if (startYear && endYear) { if (mediaType === 'movie') { queryParams.push(`primary_release_date.gte=${startYear}-01-01`); queryParams.push(`primary_release_date.lte=${endYear}-12-31`); } else if (mediaType === 'tv') { queryParams.push(`first_air_date.gte=${startYear}-01-01`); queryParams.push(`first_air_date.lte=${endYear}-12-31`); } } } if (params.rating) { const [minRating, maxRating] = params.rating.split('-'); if (minRating && maxRating) { queryParams.push(`vote_average.gte=${minRating}`); queryParams.push(`vote_average.lte=${maxRating}`); } } Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null && !['year', 'rating', 'hideNoPoster', 'skip', 'genre'].includes(key)) { queryParams.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); } }); return queryParams.join('&'); }; const fetchData = async (type, id, extra, cacheDuration = '3d', tmdbApiKey, rpdbApiKey, fanartApiKey) => { try { const mediaType = type === 'series' ? 'tv' : type; const language = extra.language || 'default'; const genre = extra.genre || null; const year = extra.year || null; const rating = extra.rating || null; const skip = extra.skip || 0; const cacheKey = `catalog_${mediaType}_${id}_${JSON.stringify(extra)}_lang_${language}_genre_${genre}_year_${year}_rating_${rating}`; log.debug(`Cache key generated: ${cacheKey}`); const cachedData = await getCatalogCache(cacheKey, cacheDuration); if (cachedData) { log.info(`Using cached data for key: ${cacheKey}`); return cachedData.value; } log.debug(`Skip value: ${skip}`); const initialQueryParams = buildQueryParams({ ...extra, page: 1 }, mediaType); const initialUrl = `${TMDB_BASE_URL}/discover/${mediaType}?api_key=${tmdbApiKey}&${initialQueryParams}`; log.debug(`Fetching initial data from TMDB to get total_pages: ${initialUrl}`); const initialResponse = await axios.get(initialUrl); let total_pages = initialResponse.data.total_pages; log.info(`Total pages available: ${total_pages}`); if (total_pages > 500) { total_pages = 500; log.warn(`Capping total pages at 500 (TMDB limitation)`); } const fetchedPages = await getFetchedPages(genre, year, rating, mediaType, cacheDb); log.debug(`Fetched pages: ${fetchedPages}`); let availablePages = Array.from({ length: total_pages }, (_, i) => i + 1).filter(page => !fetchedPages.includes(page)); if (availablePages.length === 0) { log.warn(`All pages have been fetched for the current filters.`); return []; } const randomPage = availablePages[Math.floor(Math.random() * availablePages.length)]; log.debug(`Random page selected: ${randomPage}`); const queryParams = buildQueryParams({ ...extra, page: randomPage }, mediaType); const url = `${TMDB_BASE_URL}/discover/${mediaType}?api_key=${tmdbApiKey}&${queryParams}`; log.debug(`Fetching from TMDB: ${url}`); return new Promise((resolve, reject) => { queue.push({ fn: async () => { try { const response = await axios.get(url); const results = response.data.results; log.info(`Fetched ${results.length} results from TMDB on page ${randomPage}`); const metas = await Promise.all(results.map(async item => { const genreNames = item.genre_ids && item.genre_ids.length > 0 ? await getGenreNames(item.genre_ids, mediaType, language) : []; if (item.genre_ids && item.genre_ids.length === 0) { log.warn(`No genre IDs for item ${item.id}`); } let logo = null; if (fanartApiKey) { logo = await getFanartPoster(item.id, language, fanartApiKey); } const posterUrl = await getPosterUrl(item, rpdbApiKey); log.debug(`Poster URL for item ${item.id}: ${posterUrl}`); return { id: item.id.toString(), name: item.title || item.name, poster: posterUrl, banner: item.backdrop_path ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` : null, logo: logo || null, type: mediaType, description: item.overview, releaseInfo: item.release_date || item.first_air_date, imdbRating: item.vote_average ? item.vote_average.toFixed(1) : null, genres: genreNames }; })); setCatalogCache(cacheKey, metas, cacheDuration, randomPage, skip, genre, year, rating, mediaType); await cachePosters(); resolve(metas); } catch (error) { log.error(`TMDB fetch error: ${error.message}`); reject(new Error('Failed to fetch data from TMDB')); } } }); }); } catch (error) { log.error(`Error in fetchData: ${error.message}`); throw error; } }; const getFetchedPages = (genre, year, rating, mediaType, cacheDb) => { return new Promise((resolve, reject) => { const decodedGenre = decodeURIComponent(genre || 'undefined'); const decodedYear = decodeURIComponent(year || 'undefined'); const decodedRating = decodeURIComponent(rating || 'undefined'); cacheDb.all( `SELECT page FROM cache WHERE genre = ? AND year = ? AND rating = ? AND mediaType = ?`, [decodedGenre, decodedYear, decodedRating, mediaType], (err, rows) => { if (err) { log.error(`Error querying cache for fetched pages: ${err.message}`); reject(err); } else { const fetchedPages = rows.map(row => row.page); resolve(fetchedPages); } } ); }); }; const getGenreNames = (genreIds, mediaType, language) => new Promise((resolve, reject) => { if (!genreIds || genreIds.length === 0) { log.warn(`No genre IDs provided for ${mediaType}`); return resolve([]); } const placeholders = genreIds.map(() => '?').join(','); const sql = `SELECT genre_name FROM genres WHERE genre_id IN (${placeholders}) AND media_type = ? AND language = ?`; genresDb.all(sql, [...genreIds, mediaType, language], (err, rows) => { if (err) { log.error(`Error fetching genre names from database: ${err.message}`); resolve([]); } else { resolve(rows.map(row => row.genre_name)); } }); }); const fetchGenres = async (type, language, tmdbApiKey = TMDB_API_KEY) => { const mediaType = type === 'series' ? 'tv' : 'movie'; const endpoint = `/genre/${mediaType}/list`; try { const response = await axios.get(`${TMDB_BASE_URL}${endpoint}`, { params: { api_key: tmdbApiKey, language } }); log.debug(`Genres retrieved for ${type} (${language})`); return response.data.genres; } catch (error) { log.error(`Error fetching genres from TMDB: ${error.message}`); throw error; } }; const storeGenresInDb = (genres, mediaType, language) => new Promise((resolve, reject) => { genresDb.serialize(() => { genresDb.run('BEGIN TRANSACTION'); const insertGenre = genresDb.prepare(` INSERT INTO genres (genre_id, genre_name, media_type, language) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING; `); genres.forEach((genre, index) => { insertGenre.run(genre.id, genre.name, mediaType, language, (err) => { if (err) { log.error(`Error inserting genre: ${err.message}`); genresDb.run('ROLLBACK'); reject(err); return; } if (index === genres.length - 1) { insertGenre.finalize(); genresDb.run('COMMIT'); log.info(`Genres stored for ${mediaType} (${language})`); resolve(); } }); }); }); }); const checkGenresExistForLanguage = async (language) => new Promise((resolve, reject) => { log.debug(`Checking genres for ${language}`); genresDb.get( `SELECT 1 FROM genres WHERE language = ? LIMIT 1`, [language], (err, row) => err ? reject(err) : resolve(!!row) ); }); const fetchAndStoreGenres = async (language, tmdbApiKey = TMDB_API_KEY) => { try { const movieGenres = await fetchGenres('movie', language, tmdbApiKey); const tvGenres = await fetchGenres('series', language, tmdbApiKey); await storeGenresInDb(movieGenres, 'movie', language); await storeGenresInDb(tvGenres, 'tv', language); log.info(`Genres fetched and stored for ${language}`); } catch (error) { log.error(`Error fetching/storing genres: ${error.message}`); } }; module.exports = { fetchData, getGenreId, checkGenresExistForLanguage, fetchAndStoreGenres };