├── .gitignore ├── .gitattributes ├── .dockerignore ├── public ├── assets │ ├── logo.png │ ├── logoTrakt.png │ └── countdown.webp ├── js │ ├── languages.js │ └── countries.js └── configure.html ├── Dockerfile ├── src ├── helpers │ ├── bottleneck.js │ ├── bottleneck_tmdb.js │ ├── bottleneck_trakt.js │ ├── trakt.js │ ├── redis.js │ ├── logger.js │ ├── cache.js │ ├── db.js │ ├── stream.js │ ├── providers.js │ ├── manifest.js │ └── catalog.js ├── routes │ ├── poster.js │ ├── providers.js │ ├── configure.js │ ├── manifest.js │ ├── index.js │ ├── catalog.js │ ├── stream.js │ └── trakt.js └── api │ ├── fanart.js │ ├── trakt.js │ └── tmdb.js ├── index.js ├── package.json ├── docker-compose.yml ├── .env.example ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /node_modules 3 | /log -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | log 2 | node_modules 3 | .git 4 | .dockerignore 5 | .env 6 | .gitattributes 7 | .gitignore 8 | Dockerfile -------------------------------------------------------------------------------- /public/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redd-ravenn/stremio-catalog-providers/HEAD/public/assets/logo.png -------------------------------------------------------------------------------- /public/assets/logoTrakt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redd-ravenn/stremio-catalog-providers/HEAD/public/assets/logoTrakt.png -------------------------------------------------------------------------------- /public/assets/countdown.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redd-ravenn/stremio-catalog-providers/HEAD/public/assets/countdown.webp -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-slim 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | CMD ["node", "index.js"] 12 | -------------------------------------------------------------------------------- /src/helpers/bottleneck.js: -------------------------------------------------------------------------------- 1 | const Bottleneck = require('bottleneck'); 2 | 3 | const limiter = new Bottleneck({ 4 | reservoir: 50, 5 | reservoirRefreshAmount: 50, 6 | reservoirRefreshInterval: 1000, 7 | maxConcurrent: 20 8 | }); 9 | 10 | const addToQueue = (task) => { 11 | return limiter.schedule(task.fn); 12 | }; 13 | 14 | module.exports = addToQueue; 15 | -------------------------------------------------------------------------------- /src/helpers/bottleneck_tmdb.js: -------------------------------------------------------------------------------- 1 | const Bottleneck = require('bottleneck'); 2 | 3 | const tmdbLimiter = new Bottleneck({ 4 | reservoir: 50, 5 | reservoirRefreshAmount: 50, 6 | reservoirRefreshInterval: 1000, 7 | maxConcurrent: 20 8 | }); 9 | 10 | const addToQueueTMDB = (task) => { 11 | return tmdbLimiter.schedule(task.fn); 12 | }; 13 | 14 | module.exports = addToQueueTMDB; 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | const path = require('path'); 5 | const log = require('./src/helpers/logger'); 6 | const routes = require('./src/routes/index'); 7 | 8 | const PORT = process.env.PORT || 7000; 9 | const app = express(); 10 | 11 | app.use(cors()); 12 | 13 | app.use(express.static(path.join(__dirname, 'public'))); 14 | 15 | app.use(express.json()); 16 | 17 | app.use('/', routes); 18 | 19 | app.listen(PORT, () => { 20 | log.info(`Server running on port ${PORT} - Environment: ${process.env.NODE_ENV || 'development'}`); 21 | }); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stremio-catalogs-providers", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC", 11 | "description": "", 12 | "dependencies": { 13 | "async": "^3.2.6", 14 | "axios": "^1.7.5", 15 | "bottleneck": "^2.19.5", 16 | "cors": "^2.8.5", 17 | "dotenv": "^16.4.5", 18 | "express": "^4.19.2", 19 | "pg": "^8.13.0", 20 | "redis": "^4.7.0", 21 | "winston": "^3.14.2", 22 | "winston-daily-rotate-file": "^5.0.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/helpers/bottleneck_trakt.js: -------------------------------------------------------------------------------- 1 | const Bottleneck = require('bottleneck'); 2 | 3 | const traktLimiterGET = new Bottleneck({ 4 | reservoir: 1000, 5 | reservoirRefreshAmount: 1000, 6 | reservoirRefreshInterval: 300 * 1000, 7 | maxConcurrent: 10 8 | }); 9 | 10 | const traktLimiterPOST = new Bottleneck({ 11 | minTime: 1000, 12 | maxConcurrent: 1 13 | }); 14 | 15 | const addToQueueGET = (task) => { 16 | return traktLimiterGET.schedule(task.fn); 17 | }; 18 | 19 | const addToQueuePOST = (task) => { 20 | return traktLimiterPOST.schedule(task.fn); 21 | }; 22 | 23 | module.exports = { addToQueueGET, addToQueuePOST }; 24 | -------------------------------------------------------------------------------- /src/routes/poster.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const log = require('../helpers/logger'); 5 | 6 | const router = express.Router(); 7 | 8 | router.get('/poster/:filename', (req, res) => { 9 | const filePath = path.join(__dirname, '../../db/rpdbPosters', req.params.filename); 10 | 11 | fs.access(filePath, fs.constants.F_OK, (err) => { 12 | if (err) { 13 | log.error(`Poster not found: ${filePath}`); 14 | res.status(404).send('Poster not found'); 15 | } else { 16 | res.sendFile(filePath); 17 | } 18 | }); 19 | }); 20 | 21 | module.exports = router; 22 | -------------------------------------------------------------------------------- /src/routes/providers.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const log = require('../helpers/logger'); 3 | const { getProviders } = require('../helpers/providers'); 4 | 5 | const router = express.Router(); 6 | 7 | router.post('/fetch-providers', async (req, res) => { 8 | const { apiKey } = req.body; 9 | 10 | if (!apiKey) { 11 | return res.status(400).json({ error: 'API key is required' }); 12 | } 13 | 14 | try { 15 | const providers = await getProviders(apiKey); 16 | return res.json(providers); 17 | } catch (error) { 18 | log.error(`Error fetching providers: ${error.message}`); 19 | return res.status(500).json({ error: 'Failed to fetch providers' }); 20 | } 21 | }); 22 | 23 | module.exports = router; 24 | -------------------------------------------------------------------------------- /src/routes/configure.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const log = require('../helpers/logger'); 4 | 5 | const router = express.Router(); 6 | 7 | router.use(express.static(path.join(__dirname, '../../public'))); 8 | 9 | router.get("/", (req, res) => { 10 | log.info('Redirecting to /configure'); 11 | res.redirect("/configure"); 12 | }); 13 | 14 | router.get("/:configParameters?/configure", (req, res) => { 15 | log.info(`Sending public/configure.html`); 16 | res.sendFile(path.join(__dirname, `../../public/configure.html`)); 17 | }); 18 | 19 | router.get('/env', (req, res) => { 20 | log.info('Sending environment variable TRAKT_CLIENT_ID to client'); 21 | res.json({ 22 | TRAKT_CLIENT_ID: process.env.TRAKT_CLIENT_ID 23 | }); 24 | }); 25 | 26 | module.exports = router; 27 | -------------------------------------------------------------------------------- /src/routes/manifest.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const log = require('../helpers/logger'); 3 | const generateManifest = require('../helpers/manifest'); 4 | 5 | const router = express.Router(); 6 | 7 | router.get("/:configParameters?/manifest.json", async (req, res) => { 8 | const { configParameters } = req.params; 9 | let config = {}; 10 | 11 | if (configParameters) { 12 | try { 13 | config = JSON.parse(decodeURIComponent(configParameters)); 14 | } catch (error) { 15 | log.error(`Failed to decode configParameters: ${error.message}`, error); 16 | return res.status(400).json({ error: 'Invalid config parameters' }); 17 | } 18 | } 19 | 20 | log.debug(`Manifest request for language: ${config.language}`); 21 | 22 | try { 23 | const manifest = await generateManifest(config); 24 | res.json(manifest); 25 | } catch (error) { 26 | log.error(`Error generating manifest: ${error.message}`); 27 | res.status(500).json({ error: 'Error generating manifest' }); 28 | } 29 | }); 30 | 31 | module.exports = router; 32 | -------------------------------------------------------------------------------- /src/api/fanart.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const log = require('../helpers/logger'); 3 | 4 | const getFanartPoster = async (tmdbId, preferredLang, fanartApiKey) => { 5 | try { 6 | const url = `https://webservice.fanart.tv/v3/movies/${tmdbId}/?api_key=${fanartApiKey}`; 7 | 8 | log.debug(`Fetching Fanart logos from: ${url}`); 9 | 10 | const response = await axios.get(url); 11 | const logos = response.data.hdmovielogo || []; 12 | 13 | log.debug(`Logos fetched: ${JSON.stringify(logos)}`); 14 | 15 | const preferredLangLogos = logos.filter(logo => logo.lang === preferredLang); 16 | log.debug(`Logos in preferred language (${preferredLang}): ${JSON.stringify(preferredLangLogos)}`); 17 | 18 | const bestLogoInPreferredLang = preferredLangLogos.sort((a, b) => b.likes - a.likes)[0]; 19 | log.debug(`Best logo in preferred language: ${JSON.stringify(bestLogoInPreferredLang)}`); 20 | 21 | if (!bestLogoInPreferredLang) { 22 | const englishLogos = logos.filter(logo => logo.lang === 'en'); 23 | log.debug(`Logos in English: ${JSON.stringify(englishLogos)}`); 24 | 25 | const bestLogoInEnglish = englishLogos.sort((a, b) => b.likes - a.likes)[0]; 26 | log.debug(`Best logo in English: ${JSON.stringify(bestLogoInEnglish)}`); 27 | 28 | return bestLogoInEnglish ? bestLogoInEnglish.url.replace('http://', 'https://') : ''; 29 | } 30 | 31 | const bestLogoUrl = bestLogoInPreferredLang.url.replace('http://', 'https://'); 32 | log.debug(`Best logo URL: ${bestLogoUrl}`); 33 | return bestLogoUrl; 34 | } catch (error) { 35 | log.error(`Error fetching logos from Fanart.tv for TMDB ID ${tmdbId}:`, error.message); 36 | return ''; 37 | } 38 | }; 39 | 40 | module.exports = { getFanartPoster }; 41 | -------------------------------------------------------------------------------- /src/helpers/trakt.js: -------------------------------------------------------------------------------- 1 | const { fetchUserHistory } = require('../api/trakt'); 2 | const { pool } = require('./db'); 3 | const log = require('./logger'); 4 | 5 | const saveUserTokens = async (username, accessToken, refreshToken) => { 6 | try { 7 | await pool.query( 8 | `INSERT INTO trakt_tokens (username, access_token, refresh_token) 9 | VALUES ($1, $2, $3) 10 | ON CONFLICT (username) DO UPDATE SET access_token = $2, refresh_token = $3`, 11 | [username, accessToken, refreshToken] 12 | ); 13 | log.info(`Tokens saved for user ${username}`); 14 | } catch (err) { 15 | log.error(`Error saving tokens for user ${username}: ${err.message}`); 16 | throw err; 17 | } 18 | }; 19 | 20 | const fetchUserTokens = async (username) => { 21 | try { 22 | const result = await pool.query( 23 | `SELECT access_token, refresh_token FROM trakt_tokens WHERE username = $1`, 24 | [username] 25 | ); 26 | const row = result.rows[0]; 27 | 28 | if (!row) { 29 | log.warn(`No tokens found for user ${username}`); 30 | throw new Error(`No tokens found for user ${username}`); 31 | } 32 | 33 | return { 34 | access_token: row.access_token, 35 | refresh_token: row.refresh_token, 36 | }; 37 | } catch (err) { 38 | log.error(`Error fetching tokens for user ${username}: ${err.message}`); 39 | throw err; 40 | } 41 | }; 42 | 43 | const fetchUserWatchedMovies = async (username, accessToken) => { 44 | return fetchUserHistory(username, 'movies', accessToken); 45 | }; 46 | 47 | const fetchUserWatchedShows = async (username, accessToken) => { 48 | return fetchUserHistory(username, 'shows', accessToken); 49 | }; 50 | 51 | module.exports = { 52 | saveUserTokens, 53 | fetchUserWatchedMovies, 54 | fetchUserWatchedShows, 55 | fetchUserTokens 56 | }; 57 | -------------------------------------------------------------------------------- /src/helpers/redis.js: -------------------------------------------------------------------------------- 1 | const { createClient } = require('redis'); 2 | const log = require('./logger'); 3 | 4 | let redisUnavailable = false; 5 | let hasLoggedError = false; 6 | 7 | const redisClient = createClient({ 8 | socket: { 9 | host: process.env.REDIS_HOST || 'localhost', 10 | port: process.env.REDIS_PORT || 6379, 11 | }, 12 | password: process.env.REDIS_PASSWORD || null, 13 | }); 14 | 15 | redisClient.on('ready', () => { 16 | if (redisUnavailable) { 17 | redisUnavailable = false; 18 | hasLoggedError = false; 19 | log.info('Redis is ready and connected.'); 20 | } 21 | }); 22 | 23 | redisClient.on('end', () => { 24 | if (!redisUnavailable) { 25 | redisUnavailable = true; 26 | log.warn('Redis connection closed. Marking as unavailable.'); 27 | } 28 | }); 29 | 30 | redisClient.on('error', (err) => { 31 | if (!redisUnavailable) { 32 | redisUnavailable = true; 33 | } 34 | if (!hasLoggedError) { 35 | log.error(`Redis error: ${err}. Marking Redis as unavailable.`); 36 | hasLoggedError = true; 37 | } 38 | }); 39 | 40 | redisClient.connect().catch((err) => { 41 | if (!hasLoggedError) { 42 | log.error(`Failed to connect to Redis: ${err}. Disabling Redis cache temporarily.`); 43 | hasLoggedError = true; 44 | } 45 | redisUnavailable = true; 46 | }); 47 | 48 | const safeRedisCall = async (operation, ...args) => { 49 | if (redisUnavailable) { 50 | log.warn('Redis is unavailable, skipping cache operation.'); 51 | return null; 52 | } 53 | 54 | try { 55 | return await redisClient[operation](...args); 56 | } catch (err) { 57 | if (!redisUnavailable) { 58 | redisUnavailable = true; 59 | log.error(`Redis operation failed: ${err}. Marking Redis as unavailable.`); 60 | } 61 | return null; 62 | } 63 | }; 64 | 65 | module.exports = { 66 | redisClient, 67 | safeRedisCall 68 | }; 69 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | stremio-catalog-providers: 5 | container_name: stremio_catalog_providers 6 | image: reddravenn/stremio-catalog-providers 7 | ports: 8 | - "8080:7000" 9 | environment: 10 | PORT: 7000 11 | BASE_URL: http://localhost:7000 12 | DB_USER: postgres_user 13 | DB_HOST: stremio_postgres 14 | DB_NAME: stremio_catalog_db 15 | DB_PASSWORD: postgres_password 16 | DB_PORT: 5432 17 | DB_MAX_CONNECTIONS: 20 18 | DB_IDLE_TIMEOUT: 30000 19 | DB_CONNECTION_TIMEOUT: 2000 20 | REDIS_HOST: stremio_redis 21 | REDIS_PORT: 6379 22 | REDIS_PASSWORD: 23 | TRAKT_CLIENT_ID: your_trakt_client_id 24 | TRAKT_CLIENT_SECRET: your_trakt_client_secret 25 | TRAKT_HISTORY_FETCH_INTERVAL: 1d 26 | CACHE_CATALOG_CONTENT_DURATION_DAYS: 1 27 | CACHE_POSTER_CONTENT_DURATION_DAYS: 7 28 | LOG_LEVEL: info 29 | LOG_INTERVAL_DELETION: 3d 30 | NODE_ENV: production 31 | depends_on: 32 | postgres: 33 | condition: service_healthy 34 | redis: 35 | condition: service_healthy 36 | volumes: 37 | - ./db:/usr/src/app/db 38 | - ./log:/usr/src/app/log 39 | 40 | postgres: 41 | container_name: stremio_postgres 42 | image: postgres:16.4 43 | environment: 44 | POSTGRES_DB: stremio_catalog_db 45 | POSTGRES_USER: postgres_user 46 | POSTGRES_PASSWORD: postgres_password 47 | volumes: 48 | - ./postgres/data:/var/lib/postgresql/data 49 | ports: 50 | - "5432:5432" 51 | healthcheck: 52 | test: ["CMD-SHELL", "pg_isready -U postgres_user -d stremio_catalog_db"] 53 | interval: 30s 54 | timeout: 10s 55 | retries: 5 56 | start_period: 10s 57 | 58 | redis: 59 | container_name: stremio_redis 60 | image: redis:6 61 | ports: 62 | - "6379:6379" 63 | volumes: 64 | - ./redis/data:/data 65 | healthcheck: 66 | test: ["CMD", "redis-cli", "ping"] 67 | interval: 30s 68 | timeout: 10s 69 | retries: 5 70 | -------------------------------------------------------------------------------- /src/helpers/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const DailyRotateFile = require('winston-daily-rotate-file'); 3 | const path = require('path'); 4 | 5 | winston.addColors({ 6 | error: 'red', 7 | warn: 'yellow', 8 | info: 'blue', 9 | debug: 'green' 10 | }); 11 | 12 | const uppercaseLevelFormat = winston.format((info) => { 13 | info.level = info.level.toUpperCase(); 14 | return info; 15 | })(); 16 | 17 | const LOG_LEVEL = process.env.LOG_LEVEL || 'info'; 18 | const LOG_FILE_PATH = path.join(__dirname, '../../log/application-%DATE%.log'); 19 | const MAX_FILES = process.env.LOG_INTERVAL_DELETION || '3d'; 20 | 21 | const log = winston.createLogger({ 22 | level: LOG_LEVEL, 23 | format: winston.format.combine( 24 | uppercaseLevelFormat, 25 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 26 | winston.format.printf(({ level, message, timestamp }) => { 27 | return `[${timestamp}] [${level}]: ${message}`; 28 | }) 29 | ), 30 | transports: [ 31 | new winston.transports.Console({ 32 | format: winston.format.combine( 33 | winston.format.colorize(), 34 | winston.format.printf(({ level, message, timestamp }) => { 35 | return `[${timestamp}] [${level}]: ${message}`; 36 | }) 37 | ) 38 | }), 39 | new DailyRotateFile({ 40 | filename: LOG_FILE_PATH, 41 | datePattern: 'YYYY-MM-DD', 42 | maxSize: '20m', 43 | maxFiles: MAX_FILES 44 | }) 45 | ] 46 | }); 47 | 48 | log.exceptions.handle( 49 | new DailyRotateFile({ 50 | filename: path.join(__dirname, '../../log/exceptions-%DATE%.log'), 51 | datePattern: 'YYYY-MM-DD', 52 | maxSize: '20m', 53 | maxFiles: '30d' 54 | }) 55 | ); 56 | 57 | process.on('unhandledRejection', (reason, promise) => { 58 | log.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`); 59 | }); 60 | 61 | log.on('error', function (err) { 62 | console.error('Erreur dans le logger:', err); 63 | }); 64 | 65 | module.exports = log; 66 | -------------------------------------------------------------------------------- /src/helpers/cache.js: -------------------------------------------------------------------------------- 1 | const log = require('../helpers/logger'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const axios = require('axios'); 5 | const { CACHE_POSTER_CONTENT_DURATION_DAYS } = process.env; 6 | const baseUrl = process.env.BASE_URL || 'http://localhost:7000'; 7 | 8 | const defaultCacheDurationDays = 3; 9 | 10 | const posterCacheDurationDays = parseInt(CACHE_POSTER_CONTENT_DURATION_DAYS, 10) || defaultCacheDurationDays; 11 | const posterCacheDurationMillis = posterCacheDurationDays * 24 * 60 * 60 * 1000; 12 | 13 | log.debug(`Cache duration for posters: ${posterCacheDurationMillis} milliseconds (${posterCacheDurationDays} days)`); 14 | 15 | const posterDirectory = path.join(__dirname, '../../db/rpdbPosters'); 16 | 17 | if (!fs.existsSync(posterDirectory)) { 18 | fs.mkdirSync(posterDirectory, { recursive: true }); 19 | } 20 | 21 | const formatFileName = (posterId) => { 22 | return posterId.replace(/[^a-zA-Z0-9-_]/g, '_'); 23 | }; 24 | 25 | const getCachedPoster = async (posterId) => { 26 | const formattedPosterId = formatFileName(posterId); 27 | const filePath = path.join(posterDirectory, `${formattedPosterId}.jpg`); 28 | const fileStats = fs.existsSync(filePath) ? fs.statSync(filePath) : null; 29 | 30 | if (fileStats && (Date.now() - fileStats.mtimeMs < posterCacheDurationMillis)) { 31 | const posterUrl = `${baseUrl}/poster/${formattedPosterId}.jpg`; 32 | log.debug(`Cache hit for poster id ${posterId}, serving from ${posterUrl}`); 33 | return { poster_url: posterUrl }; 34 | } else { 35 | log.debug(`Cache miss or expired for poster id ${posterId}`); 36 | return null; 37 | } 38 | }; 39 | 40 | const setCachedPoster = async (posterId, posterUrl) => { 41 | const formattedPosterId = formatFileName(posterId); 42 | const filePath = path.join(posterDirectory, `${formattedPosterId}.jpg`); 43 | 44 | try { 45 | const response = await axios.get(posterUrl, { responseType: 'arraybuffer' }); 46 | fs.writeFileSync(filePath, response.data); 47 | log.debug(`Poster id ${posterId} cached at ${filePath}`); 48 | } catch (error) { 49 | log.error(`Error caching poster id ${posterId} from URL ${posterUrl}: ${error.message}`); 50 | throw error; 51 | } 52 | }; 53 | 54 | module.exports = { 55 | getCachedPoster, 56 | setCachedPoster 57 | }; 58 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const catalogRoutes = require('./catalog'); 3 | const configureRoutes = require('./configure'); 4 | const manifestRoutes = require('./manifest'); 5 | const posterRoutes = require('./poster'); 6 | const providersRoutes = require('./providers'); 7 | const streamRoutes = require('./stream'); 8 | const traktRoutes = require('./trakt'); 9 | const log = require('../helpers/logger'); 10 | 11 | const router = express.Router(); 12 | 13 | const isBase64 = (str) => { 14 | try { 15 | return Buffer.from(str, 'base64').toString('base64') === str; 16 | } catch (err) { 17 | return false; 18 | } 19 | }; 20 | 21 | const decodeBase64Middleware = (req, res, next) => { 22 | if (req.path.startsWith('/callback') || req.path.startsWith('/updateWatched')) { 23 | return next(); 24 | } 25 | 26 | 27 | try { 28 | const pathParts = req.path.split('/'); 29 | 30 | const decodedParts = pathParts.map(part => { 31 | if (isBase64(part)) { 32 | try { 33 | const decoded = Buffer.from(part, 'base64').toString('utf8'); 34 | return decoded; 35 | } catch (e) { 36 | log.error(`Error decoding part: ${e.message}`); 37 | return part; 38 | } 39 | } else { 40 | return part; 41 | } 42 | }); 43 | 44 | req.url = decodedParts.join('/'); 45 | 46 | next(); 47 | } catch (error) { 48 | log.error('Base64 decoding error:', error); 49 | res.status(400).send('Bad request: Invalid base64 encoding.'); 50 | } 51 | }; 52 | 53 | router.use(decodeBase64Middleware); 54 | 55 | router.use((req, res, next) => { 56 | log.info(`--- Request received ---`); 57 | log.info(`${req.method} ${req.originalUrl}`); 58 | next(); 59 | }); 60 | 61 | router.use(catalogRoutes); 62 | router.use(configureRoutes); 63 | router.use(manifestRoutes); 64 | router.use(posterRoutes); 65 | router.use(providersRoutes); 66 | router.use(streamRoutes); 67 | router.use(traktRoutes); 68 | 69 | router.use((err, req, res, next) => { 70 | const errorTime = new Date().toISOString(); 71 | log.error(`${errorTime} - Error: ${err.stack}`); 72 | 73 | res.status(500).send(`Something broke! If you need help, please provide this timestamp to the developer : ${errorTime}`); 74 | }); 75 | 76 | module.exports = router; 77 | -------------------------------------------------------------------------------- /src/helpers/db.js: -------------------------------------------------------------------------------- 1 | const { Pool } = require('pg'); 2 | const log = require('../helpers/logger'); 3 | 4 | const pool = new Pool({ 5 | user: process.env.DB_USER, 6 | host: process.env.DB_HOST, 7 | database: process.env.DB_NAME, 8 | password: process.env.DB_PASSWORD, 9 | port: process.env.DB_PORT, 10 | max: process.env.DB_MAX_CONNECTIONS, 11 | idleTimeoutMillis: process.env.DB_IDLE_TIMEOUT, 12 | connectionTimeoutMillis: process.env.DB_CONNECTION_TIMEOUT, 13 | }); 14 | 15 | const createDatabaseAndTable = async (createTableSQL) => { 16 | let client; 17 | try { 18 | client = await pool.connect(); 19 | const commands = createTableSQL.split(';'); 20 | for (let command of commands) { 21 | if (command.trim()) { 22 | await client.query(command); 23 | log.debug('Table created or already exists'); 24 | } 25 | } 26 | } catch (err) { 27 | log.error('Error executing query', err.stack); 28 | } finally { 29 | if (client) client.release(); 30 | } 31 | }; 32 | 33 | const providersDb = createDatabaseAndTable( 34 | `CREATE TABLE IF NOT EXISTS providers ( 35 | provider_id SERIAL PRIMARY KEY, 36 | provider_name TEXT, 37 | logo_path TEXT, 38 | display_priorities TEXT, 39 | last_fetched TIMESTAMP DEFAULT CURRENT_TIMESTAMP 40 | );` 41 | ); 42 | 43 | const genresDb = createDatabaseAndTable( 44 | `CREATE TABLE IF NOT EXISTS genres ( 45 | genre_id INTEGER, 46 | genre_name TEXT, 47 | media_type TEXT, 48 | language TEXT, 49 | PRIMARY KEY (genre_id, media_type, language), 50 | UNIQUE (genre_id, media_type, language) 51 | );` 52 | ); 53 | 54 | const traktDb = createDatabaseAndTable( 55 | `CREATE TABLE IF NOT EXISTS trakt_tokens ( 56 | id SERIAL PRIMARY KEY, 57 | username TEXT UNIQUE, 58 | access_token TEXT NOT NULL, 59 | refresh_token TEXT NOT NULL, 60 | last_fetched_at TIMESTAMP DEFAULT NULL 61 | ); 62 | CREATE TABLE IF NOT EXISTS trakt_history ( 63 | id SERIAL PRIMARY KEY, 64 | username TEXT, 65 | watched_at TIMESTAMP, 66 | type TEXT, 67 | title TEXT, 68 | imdb_id TEXT, 69 | tmdb_id INTEGER, 70 | FOREIGN KEY (username) REFERENCES trakt_tokens(username) ON DELETE CASCADE 71 | ); 72 | CREATE INDEX IF NOT EXISTS idx_trakt_history_username ON trakt_history(username); 73 | ` 74 | ); 75 | 76 | module.exports = { 77 | pool, 78 | providersDb, 79 | genresDb, 80 | traktDb 81 | }; 82 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Exposition port 2 | PORT=7000 3 | 4 | # URL to access the addon 5 | BASE_URL=http://localhost:7000 6 | 7 | # PostgreSQL database connection settings 8 | DB_USER=your_user # Username for PostgreSQL authentication 9 | DB_HOST=your_host # PostgreSQL server hostname or IP 10 | DB_NAME=your_database # Name of the database to connect to 11 | DB_PASSWORD=your_password # Password for the PostgreSQL user 12 | DB_PORT=5432 # Port number where PostgreSQL is running (default 5432) 13 | DB_MAX_CONNECTIONS=20 # Maximum number of active connections allowed to the database 14 | DB_IDLE_TIMEOUT=30000 # Time (in ms) to close idle database connections 15 | DB_CONNECTION_TIMEOUT=2000 # Timeout (in ms) to establish a new database connection 16 | 17 | # Redis cache configuration 18 | REDIS_HOST=your_host # Redis server hostname or IP 19 | REDIS_PORT=6379 # Port number where Redis is running (default 6379) 20 | REDIS_PASSWORD= # Password for Redis authentication (if required) 21 | 22 | # These credentials are required to interact with the Trakt API and access its services. 23 | # To obtain these credentials: 24 | # 1. Create an account on Trakt.tv (https://trakt.tv). 25 | # 2. Go to the applications section (https://trakt.tv/oauth/applications). 26 | # 3. Create a new application by filling in the required information (name, description, etc.). 27 | # - For the "Redirect URL", use the following format: BASE_URL + /callback (e.g., http://localhost:7000/callback). 28 | TRAKT_CLIENT_ID= 29 | TRAKT_CLIENT_SECRET= 30 | 31 | # Allows you to define the interval for synchronizing the Trakt watch history 32 | # The value can be expressed in hours (h) or days (d) 33 | # Default is '1d' 34 | TRAKT_HISTORY_FETCH_INTERVAL=1d 35 | 36 | # The number of pages to prefetch after the currently requested page 37 | # Helps improve performance by anticipating future requests 38 | # Default is '5' 39 | PREFETCH_PAGE_COUNT=5 40 | 41 | # Cache duration settings for different content types 42 | # Default is '3' for both 43 | CACHE_CATALOG_CONTENT_DURATION_DAYS=3 44 | CACHE_POSTER_CONTENT_DURATION_DAYS=7 45 | 46 | # Possible values are: info, debug 47 | # Default is 'info' if not specified; 'debug' provides more detailed logs 48 | LOG_LEVEL=info 49 | 50 | # The value can be expressed in days (d), weeks (w), or months (M) 51 | # For example, '3d' means that log files will be kept for 3 days before being deleted 52 | # If LOG_INTERVAL_DELETION is not defined in the environment variables, the default value is '3d' 53 | LOG_INTERVAL_DELETION=3d 54 | 55 | # The environment in which the Node.js application is running 56 | NODE_ENV=production 57 | -------------------------------------------------------------------------------- /src/helpers/stream.js: -------------------------------------------------------------------------------- 1 | const log = require('./logger'); 2 | const { getContentDetailsById, getImdbId } = require('../api/tmdb'); 3 | 4 | const prepareStreams = async (content, apiKey, language, showRating, showTagline, userAgent = '', type) => { 5 | const today = new Date(); 6 | 7 | if (!Array.isArray(content)) { 8 | throw new TypeError('Expected content to be an array'); 9 | } 10 | 11 | const contentDetails = await Promise.all( 12 | content.map(item => getContentDetailsById(item, type, apiKey, language)) 13 | ); 14 | 15 | const imdbIdResults = await Promise.all(contentDetails.map(async item => { 16 | return (new Date(item.released) <= today) ? getImdbId(item.id, type, apiKey, language) : null; 17 | })); 18 | 19 | const preparedContent = contentDetails.map((item, index) => { 20 | const rating = showRating ? (item.rating?.toFixed(1) || 'N/A') : ''; 21 | const ratingValue = parseFloat(rating); 22 | const emoji = ratingValue > 0 ? ratingToEmoji(ratingValue) : ''; 23 | const ratingText = ratingValue > 0 ? `${rating} ${emoji}` : ''; 24 | const voteCountText = showRating && item.vote_count ? ` (${formatVoteCount(item.vote_count)} 👥)` : ''; 25 | 26 | const externalUrl = item.released 27 | ? (new Date(item.released) > today 28 | ? `https://www.themoviedb.org/${type}/${item.id}` 29 | : userAgent.includes('Stremio') 30 | ? `stremio:///detail/${type}/${imdbIdResults[index] || ''}` 31 | : `https://web.stremio.com/#/detail/${type}/${imdbIdResults[index] || ''}`) 32 | : `https://www.themoviedb.org/${type}/${item.id}`; 33 | 34 | const newLine = '\n'; 35 | const title = `${item.title}${ratingText ? `${newLine}${ratingText}` : ''}${voteCountText ? `${voteCountText}` : ''}${showTagline && item.tagline ? `${newLine}${item.tagline}` : ''}`; 36 | 37 | return { 38 | name: item.released ? item.released.match(/^\d{4}/)?.[0] || 'TMDB' : 'TMDB', 39 | title: title, 40 | externalUrl: externalUrl, 41 | rating: rating, 42 | ratingValue: ratingValue, 43 | emoji: emoji, 44 | ratingText: ratingText, 45 | voteCountText: voteCountText 46 | }; 47 | }); 48 | 49 | return preparedContent; 50 | }; 51 | 52 | 53 | const formatVoteCount = (voteCount) => { 54 | if (voteCount >= 1000000) { 55 | return `${Math.round(voteCount / 1000000)}M`; 56 | } else if (voteCount >= 1000) { 57 | return `${Math.round(voteCount / 1000)}k`; 58 | } 59 | return voteCount.toString(); 60 | }; 61 | 62 | const ratingToEmoji = (rating) => { 63 | if (rating >= 9) return '🏆'; 64 | if (rating >= 8) return '🔥'; 65 | if (rating >= 6) return '⭐'; 66 | if (rating >= 5) return '😐'; 67 | return '🥱'; 68 | }; 69 | 70 | module.exports = { 71 | prepareStreams 72 | }; 73 | -------------------------------------------------------------------------------- /src/routes/catalog.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const log = require('../helpers/logger'); 3 | const { parseConfigParameters, extractCatalogInfo, getGenreId, fetchDiscoverContent, buildMetas } = require('../helpers/catalog'); 4 | const { handleTraktHistory } = require('../api/trakt'); 5 | 6 | const router = express.Router(); 7 | 8 | router.get("/:configParameters?/catalog/:type/:id/:extra?.json", async (req, res, next) => { 9 | const { id, configParameters, type, extra: extraParam } = req.params; 10 | const extra = extraParam ? decodeURIComponent(extraParam) : ''; 11 | let skip = 0; 12 | 13 | const origin = req.get('origin'); 14 | log.debug(`Request Origin: ${origin}`); 15 | 16 | log.debug(`Received parameters: id=${id}, type=${type}, configParameters=${configParameters}, extra=${extra}`); 17 | 18 | const parsedConfig = await parseConfigParameters(configParameters); 19 | const { catalogType, providerId } = extractCatalogInfo(id); 20 | const providers = [providerId.toString()]; 21 | 22 | if (extra.startsWith('skip=')) { 23 | const skipValue = parseInt(extra.split('=')[1], 10); 24 | skip = isNaN(skipValue) ? 0 : skipValue; 25 | } 26 | 27 | const yearMatch = extra.match(/year=([^&]+)/); 28 | const ratingMatch = extra.match(/rating=([^&]+)/); 29 | const genreMatch = extra.match(/genre=([^&]+)/); 30 | 31 | let year = yearMatch ? yearMatch[1] : null; 32 | let rating = ratingMatch ? ratingMatch[1] : null; 33 | let genre = genreMatch ? genreMatch[1] : null; 34 | 35 | if (genre) { 36 | genre = await getGenreId(genre, type); 37 | } 38 | 39 | try { 40 | const sortBy = catalogType === 'movies' 41 | ? (id.includes('-new') ? 'primary_release_date.desc' : 'popularity.desc') 42 | : (id.includes('-new') ? 'first_air_date.desc' : 'popularity.desc'); 43 | 44 | const discoverResults = await fetchDiscoverContent( 45 | catalogType, 46 | providers, 47 | parsedConfig.ageRange, 48 | sortBy, 49 | genre, 50 | parsedConfig.tmdbApiKey, 51 | parsedConfig.language, 52 | skip, 53 | parsedConfig.regions, 54 | year, 55 | rating 56 | ); 57 | 58 | let filteredResults = discoverResults.results; 59 | 60 | if (parsedConfig.filterContentWithoutPoster === 'true') { 61 | filteredResults = filteredResults.filter(content => content.poster_path); 62 | } 63 | 64 | if (parsedConfig.hideTraktHistory === 'true' && parsedConfig.traktUsername) { 65 | filteredResults = await handleTraktHistory(parsedConfig, filteredResults, catalogType); 66 | } 67 | 68 | const metas = await buildMetas(filteredResults, catalogType, parsedConfig.language, parsedConfig.rpdbApiKey, parsedConfig.fanartApiKey, parsedConfig.addWatchedTraktBtn, parsedConfig.hideTraktHistory, parsedConfig.traktUsername, origin); 69 | 70 | res.json({ metas }); 71 | } catch (error) { 72 | log.error(`Error processing request: ${error.message}`); 73 | if (!res.headersSent) { 74 | res.status(500).json({ error: 'Internal Server Error' }); 75 | } 76 | } 77 | }); 78 | 79 | module.exports = router; 80 | -------------------------------------------------------------------------------- /src/routes/stream.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const log = require('../helpers/logger'); 3 | const { parseConfigParameters } = require('../helpers/catalog'); 4 | const { prepareStreams } = require('../helpers/stream'); 5 | const { getRecommendationsFromTmdb, getSimilarContentFromTmdb, getContentFromImdbId } = require('../api/tmdb'); 6 | 7 | const router = express.Router(); 8 | 9 | router.get("/:configParameters?/stream/:type/:id.json", async (req, res, next) => { 10 | const { id, configParameters, type } = req.params; 11 | 12 | const origin = req.get('origin'); 13 | const userAgent = req.headers['user-agent'] || ''; 14 | log.debug(`Request Origin: ${origin}`); 15 | 16 | log.debug(`Received parameters: id=${id}, type=${type}, configParameters=${configParameters}`); 17 | 18 | const parsedConfig = await parseConfigParameters(configParameters); 19 | const additionalContent = parsedConfig.additionalContent || ''; 20 | 21 | const recommendationsTitle = parsedConfig.recommendationsTitle || 'Recommendations'; 22 | const similarTitle = parsedConfig.similarTitle || 'Similar'; 23 | 24 | try { 25 | const content = await getContentFromImdbId(id, parsedConfig.tmdbApiKey, parsedConfig.language); 26 | if (!content) { 27 | log.warn(`Content not found for IMDb ID: ${id}`); 28 | return res.json({ streams: [] }); 29 | } 30 | 31 | let recommendations = []; 32 | let similar = []; 33 | 34 | if (additionalContent === 'recommendations-similar') { 35 | recommendations = await getRecommendationsFromTmdb(content.tmdbId, type, parsedConfig.tmdbApiKey, parsedConfig.language); 36 | similar = await getSimilarContentFromTmdb(content.tmdbId, type, parsedConfig.tmdbApiKey, parsedConfig.language); 37 | } else if (additionalContent === 'recommendations') { 38 | recommendations = await getRecommendationsFromTmdb(content.tmdbId, type, parsedConfig.tmdbApiKey, parsedConfig.language); 39 | } else if (additionalContent === 'similar') { 40 | similar = await getSimilarContentFromTmdb(content.tmdbId, type, parsedConfig.tmdbApiKey, parsedConfig.language); 41 | } 42 | 43 | let streams = []; 44 | 45 | if (recommendations.length > 0) { 46 | streams.push({ 47 | name: '📢', 48 | title: recommendationsTitle, 49 | externalUrl: 'https://web.stremio.com' 50 | }); 51 | const recommendationStreams = await prepareStreams(recommendations, parsedConfig.tmdbApiKey, parsedConfig.language, true, true, userAgent, type); 52 | streams = streams.concat(recommendationStreams); 53 | } 54 | 55 | if (similar.length > 0) { 56 | streams.push({ 57 | name: '🔍', 58 | title: similarTitle, 59 | externalUrl: 'https://web.stremio.com' 60 | }); 61 | const similarStreams = await prepareStreams(similar, parsedConfig.tmdbApiKey, parsedConfig.language, true, true, userAgent, type); 62 | streams = streams.concat(similarStreams); 63 | } 64 | 65 | res.json({ streams }); 66 | } catch (error) { 67 | log.error(`Error processing request: ${error.message}`, error); 68 | if (!res.headersSent) { 69 | res.status(500).json({ error: 'Internal Server Error' }); 70 | } 71 | } 72 | }); 73 | 74 | module.exports = router; 75 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.3.0] - 2024-09-24 4 | ### Added 5 | - Enabled recommendations and similar content display on content pages. 6 | - Option to rename and translate catalog titles in the addon settings page. 7 | 8 | ## [1.2.0] - 2024-09-23 9 | ### Added 10 | - Redesigned the search bar to display all platforms matching the query, not just the currently displayed platforms. 11 | - Automatically adds associated regional catalogs when a platform is selected. 12 | 13 | ### Fixed 14 | - Resolved issue with Trakt history retrieval by adding `type` to database queries, preventing false positives due to non-unique TMDB IDs for movies and shows. 15 | 16 | ## [1.1.2] - 2024-09-22 17 | ### Added 18 | - Stremio config button now manages all Trakt-related parameters. 19 | 20 | ### Fixed 21 | - Genres now display correctly in the defined language when using multiple language configurations. 22 | - Resolved issue with catalogs not fetching correctly from the cache in multi-language configurations. 23 | - Fixed broken Stremio config button caused by Base64 encoding of URL parameters. 24 | 25 | ## [1.1.1] - 2024-09-21 26 | ### Added 27 | - API key for TMDB was added to the requests. 28 | - Replaced content title with logo in the selected language or English by default. 29 | - Updated the addon ID to match the prototype name. 30 | - Added prototype logo. 31 | 32 | ## [1.0.0] - 2024-09-20 33 | ### Migration 34 | - Migrated SQLite databases to PostgreSQL. 35 | - Migrated SQLite caching system to Redis. 36 | 37 | ### Improved 38 | - Enhanced user interface for better provider display and management. 39 | - Fixed multiple region handling in catalog results. 40 | - Resolved issues with genre display and the "Mark as Watched" button for Trakt integration. 41 | 42 | ### Removed 43 | - Dropped redundant country selection dropdown, replaced with region-based catalog fetch logic. 44 | 45 | ## [0.5.0] - 2024-09-19 46 | ### Added 47 | - Automatic Trakt token refresh to avoid manual re-authentication every 3 months. 48 | - Button to mark content as watched on Trakt directly from Stremio with customizable text and translation options. 49 | - Environment variable to configure the history sync interval. 50 | - Compliance with Trakt rate limits. 51 | 52 | ## [0.4.0] - 2024-09-18 53 | ### Added 54 | - Trakt integration for syncing watch history automatically every 24 hours (configurable via environment variable). 55 | - Option to add an emoji next to watched content synced from Trakt. 56 | 57 | ## [0.3.0] - 2024-09-17 58 | ### Added 59 | - New filters for rating range and year. 60 | - Prefetch system for faster page loading. 61 | - Base64 URL parameter obfuscation for security. 62 | 63 | ## [0.2.0] - 2024-09-16 64 | ### Refactored 65 | - TMDB API handling with improved rate limiting. 66 | - Restructured project for better maintainability. 67 | 68 | ### Added 69 | - Enhanced UI elements. 70 | - Stremio config button. 71 | 72 | ### Removed 73 | - Removed TMDB token, language, and watch region environment variables. 74 | 75 | ## [0.1.0] - 2024-09-09 76 | ### Added 77 | - Multi-region instance support. 78 | - RPDB integration with a fallback to TMDB posters if unavailable in RPDB. 79 | 80 | ### Fixed 81 | - Improved performance with RPDB poster handling and caching. 82 | - Addressed issues with free RPDB key tier. 83 | - Fixed RPDB cache handling, with cache duration configurable via environment variables. 84 | - Resolved issues with multiple `ageRange` catalog handling. 85 | - Corrected database path, missing languages, and error messages on the configuration page. -------------------------------------------------------------------------------- /src/routes/trakt.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { saveUserTokens, fetchUserWatchedMovies, fetchUserWatchedShows, fetchUserTokens } = require('../helpers/trakt'); 3 | const { fetchUserProfile, exchangeCodeForToken, markContentAsWatched, saveUserWatchedHistory, lookupTraktId } = require('../api/trakt'); 4 | const log = require('../helpers/logger'); 5 | const router = express.Router(); 6 | 7 | const { TRAKT_CLIENT_ID } = process.env; 8 | 9 | if (!TRAKT_CLIENT_ID) { 10 | log.warn('Environment variables TRAKT_CLIENT_ID is not set.'); 11 | } 12 | 13 | router.get('/callback', async (req, res) => { 14 | const code = req.query.code; 15 | 16 | if (!code) { 17 | log.error('Authorization code is missing.'); 18 | return res.status(400).send('Error: Authorization code is missing.'); 19 | } 20 | 21 | try { 22 | const { access_token, refresh_token } = await exchangeCodeForToken(code); 23 | 24 | if (!access_token || !refresh_token) { 25 | log.error('Received tokens are invalid or missing.'); 26 | return res.status(500).send('Error receiving tokens.'); 27 | } 28 | 29 | const userProfile = await fetchUserProfile(access_token); 30 | const username = userProfile.username; 31 | 32 | if (!username) { 33 | log.error('Received username is invalid or missing.'); 34 | return res.status(500).send('Error receiving username.'); 35 | } 36 | 37 | const now = new Date(); 38 | 39 | await saveUserTokens(username, access_token, refresh_token); 40 | log.info(`Successfully saved tokens and username for user ${username}.`); 41 | 42 | const [movieHistory, showHistory] = await Promise.all([ 43 | fetchUserWatchedMovies(username, access_token), 44 | fetchUserWatchedShows(username, access_token) 45 | ]); 46 | 47 | log.info(`Successfully fetched watched movies and shows for user ${username}.`); 48 | 49 | await Promise.all([ 50 | saveUserWatchedHistory(username, movieHistory), 51 | saveUserWatchedHistory(username, showHistory) 52 | ]); 53 | 54 | log.info(`Successfully saved watched history for user ${username} in the database.`); 55 | 56 | res.redirect(`/configure?username=${encodeURIComponent(username)}`); 57 | } catch (error) { 58 | log.error(`Error during token exchange: ${error.response ? error.response.data : error.message}`); 59 | res.status(500).send('Error connecting to Trakt'); 60 | } 61 | }); 62 | 63 | router.get('/:configParameters?/updateWatched/:username/:type/:tmdbId', async (req, res) => { 64 | const { username, type, tmdbId } = req.params; 65 | 66 | if (!username) { 67 | return res.status(400).send('Invalid parameter: username is required'); 68 | } 69 | 70 | if (!['movies', 'series'].includes(type)) { 71 | return res.status(400).send(`Invalid parameter: type must be 'movies' or 'series', received '${type}'`); 72 | } 73 | 74 | if (!tmdbId) { 75 | return res.status(400).send('Invalid parameter: tmdbId is required'); 76 | } 77 | 78 | try { 79 | const { access_token, refresh_token } = await fetchUserTokens(username); 80 | 81 | if (!access_token || !refresh_token) { 82 | log.error(`Tokens missing for user ${username}`); 83 | return res.status(500).send('Error retrieving tokens'); 84 | } 85 | 86 | const traktId = await lookupTraktId(tmdbId, type.slice(0, -1), access_token); 87 | 88 | const watched_at = new Date().toISOString(); 89 | 90 | const response = await markContentAsWatched(access_token, type, traktId, watched_at); 91 | 92 | if (!response) { 93 | log.error(`Failed to mark content as watched for user ${username}`); 94 | return res.status(500).send('Error marking content as watched'); 95 | } 96 | 97 | log.info(`Content ID ${traktId} of type ${type} marked as watched for user ${username}.`); 98 | 99 | const traktUrl = `https://trakt.tv/users/${username}/history/${type === 'movies' ? 'movies' : 'shows'}`; 100 | return res.redirect(traktUrl); 101 | } catch (error) { 102 | log.error(`Error in /updateWatched: ${error.message}`); 103 | return res.status(500).send('Internal server error'); 104 | } 105 | }); 106 | 107 | module.exports = router; 108 | -------------------------------------------------------------------------------- /src/helpers/providers.js: -------------------------------------------------------------------------------- 1 | const log = require('../helpers/logger'); 2 | const { pool } = require('../helpers/db'); 3 | const { makeRequest } = require('../api/tmdb'); 4 | 5 | async function fetchProvidersFromTMDB(apiKey) { 6 | try { 7 | const [movieData, tvData] = await Promise.all([ 8 | makeRequest(`https://api.themoviedb.org/3/watch/providers/movie`, apiKey), 9 | makeRequest(`https://api.themoviedb.org/3/watch/providers/tv`, apiKey) 10 | ]); 11 | 12 | if (!movieData || !tvData) { 13 | throw new Error('Failed to fetch providers from TMDB'); 14 | } 15 | 16 | return [...movieData.results, ...tvData.results]; 17 | } catch (error) { 18 | log.error(`Error fetching providers from TMDB: ${error.message}`); 19 | throw error; 20 | } 21 | } 22 | 23 | async function fetchProvidersFromDatabase() { 24 | try { 25 | const result = await pool.query(`SELECT * FROM providers`); 26 | const rows = result.rows; 27 | 28 | log.debug(`Fetched ${rows.length} providers from the database.`); 29 | 30 | if (rows.length > 0) { 31 | const now = new Date(); 32 | const lastFetched = new Date(rows[0].last_fetched); 33 | 34 | log.debug(`Current time: ${now.toISOString()}, Last fetched: ${lastFetched.toISOString()}`); 35 | 36 | if (!isNaN(lastFetched.getTime())) { 37 | const timeDifference = now.getTime() - lastFetched.getTime(); 38 | const twentyFourHoursInMillis = 24 * 60 * 60 * 1000; 39 | 40 | log.debug(`Time difference: ${timeDifference} ms (${(timeDifference / (1000 * 60)).toFixed(2)} minutes)`); 41 | 42 | if (timeDifference < twentyFourHoursInMillis) { 43 | log.info('Providers fetched from the database (less than 24 hours old).'); 44 | return rows; 45 | } else { 46 | log.info(`Providers are older than 24 hours. Time since last fetch: ${(timeDifference / (1000 * 60 * 60)).toFixed(2)} hours.`); 47 | } 48 | } else { 49 | log.error('Invalid date format for last_fetched.'); 50 | } 51 | } else { 52 | log.info('No providers found in the database.'); 53 | } 54 | 55 | return null; 56 | } catch (err) { 57 | log.error(`Error fetching providers from the database: ${err.message}`); 58 | throw err; 59 | } 60 | } 61 | 62 | async function updateProvidersInDatabase(providers) { 63 | const mergedProviders = {}; 64 | 65 | providers.forEach(provider => { 66 | const { provider_id, provider_name, logo_path, display_priorities } = provider; 67 | if (!mergedProviders[provider_name]) { 68 | mergedProviders[provider_name] = { 69 | provider_id, 70 | provider_name, 71 | logo_path, 72 | display_priorities: JSON.stringify(display_priorities) 73 | }; 74 | } 75 | }); 76 | 77 | const uniqueProviders = Object.values(mergedProviders); 78 | 79 | const insertOrUpdateProvider = ` 80 | INSERT INTO providers (provider_id, provider_name, logo_path, display_priorities, last_fetched) 81 | VALUES ($1, $2, $3, $4, $5) 82 | ON CONFLICT(provider_id) DO UPDATE SET 83 | provider_name = EXCLUDED.provider_name, 84 | logo_path = EXCLUDED.logo_path, 85 | display_priorities = EXCLUDED.display_priorities, 86 | last_fetched = EXCLUDED.last_fetched; 87 | `; 88 | 89 | const currentTimestamp = new Date().toISOString(); 90 | 91 | try { 92 | for (const provider of uniqueProviders) { 93 | await pool.query(insertOrUpdateProvider, [ 94 | provider.provider_id, 95 | provider.provider_name, 96 | provider.logo_path, 97 | provider.display_priorities, 98 | currentTimestamp 99 | ]); 100 | log.debug(`Inserted/Updated provider: ${provider.provider_name} (ID: ${provider.provider_id})`); 101 | } 102 | 103 | log.info('Providers successfully updated in the database.'); 104 | return uniqueProviders; 105 | } catch (err) { 106 | log.error('Error updating providers in the database:', err.message); 107 | throw err; 108 | } 109 | } 110 | 111 | async function getProviders(apiKey) { 112 | const providersFromDb = await fetchProvidersFromDatabase(); 113 | if (providersFromDb) { 114 | return providersFromDb; 115 | } 116 | 117 | const providersFromApi = await fetchProvidersFromTMDB(apiKey); 118 | const updatedProviders = await updateProvidersInDatabase(providersFromApi); 119 | return updatedProviders; 120 | } 121 | 122 | module.exports = { getProviders }; 123 | -------------------------------------------------------------------------------- /src/helpers/manifest.js: -------------------------------------------------------------------------------- 1 | const { pool } = require('./db'); 2 | const log = require('../helpers/logger'); 3 | const { checkGenresExistForLanguage, fetchAndStoreGenres } = require('../api/tmdb'); 4 | 5 | const addonLogoUrl = `${process.env.BASE_URL}/assets/logo.png`; 6 | 7 | const manifestTemplate = { 8 | id: 'community.streamingcatalogproviders', 9 | version: '1.3.0', 10 | logo: addonLogoUrl, 11 | name: 'Streaming Catalog Providers', 12 | description: 'Catalog from TMDB streaming providers.', 13 | resources: ['catalog'], 14 | types: ['movie', 'series'], 15 | idPrefixes: ['tt'], 16 | catalogs: [], 17 | behaviorHints: { 18 | configurable: true, 19 | configurationRequired: false, 20 | } 21 | }; 22 | 23 | const getProvider = async (providerId) => { 24 | try { 25 | const result = await pool.query("SELECT * FROM providers WHERE provider_id = $1", [providerId]); 26 | const row = result.rows[0]; 27 | return row || null; 28 | } catch (err) { 29 | throw err; 30 | } 31 | }; 32 | 33 | const getGenres = async (type, language) => { 34 | try { 35 | const result = await pool.query( 36 | "SELECT genre_name FROM genres WHERE media_type = $1 AND language = $2", 37 | [type, language] 38 | ); 39 | return result.rows.map(row => row.genre_name); 40 | } catch (err) { 41 | throw err; 42 | } 43 | }; 44 | 45 | const getCurrentYear = () => new Date().getFullYear(); 46 | 47 | const generateYearIntervals = (startYear = 1880, endYear = getCurrentYear(), interval = 4) => { 48 | const intervals = []; 49 | endYear = Math.max(endYear, startYear); 50 | 51 | for (let year = endYear; year >= startYear; year -= interval) { 52 | const nextYear = Math.max(year - interval + 1, startYear); 53 | intervals.push(`${nextYear}-${year}`); 54 | } 55 | 56 | const [firstStart, firstEnd] = intervals.length 57 | ? intervals[intervals.length - 1].split('-').map(Number) 58 | : [startYear, endYear]; 59 | 60 | if (firstStart > startYear) { 61 | intervals[intervals.length - 1] = `${startYear}-${firstEnd}`; 62 | } 63 | 64 | return intervals.length ? intervals : [`${startYear}-${endYear}`]; 65 | }; 66 | 67 | const generateManifest = async (config) => { 68 | try { 69 | const { providers, language, tmdbApiKey, ageRange, additionalContent, popularCatalogTitle, newCatalogTitle } = config; 70 | 71 | if (!Array.isArray(providers) || !providers.length) throw new Error('No providers specified.'); 72 | 73 | if (language && !(await checkGenresExistForLanguage(language))) { 74 | log.debug(`Fetching genres for language: ${language}`); 75 | await fetchAndStoreGenres(language, tmdbApiKey); 76 | } 77 | 78 | const [movieGenres, seriesGenres] = await Promise.all([ 79 | getGenres('movie', language), 80 | getGenres('tv', language) 81 | ]); 82 | 83 | const genreOptions = (genres) => genres.map(genre => genre); 84 | const yearIntervals = generateYearIntervals(); 85 | const isKidsMode = ageRange && ageRange !== '18+'; 86 | 87 | const providerInfo = await Promise.all(providers.map(providerId => getProvider(providerId))); 88 | const catalogs = providerInfo.flatMap(provider => { 89 | if (!provider) return []; 90 | 91 | const baseCatalogs = [ 92 | { type: 'movie', idSuffix: 'movies', namePrefix: 'Movies' }, 93 | { type: 'series', idSuffix: 'series', namePrefix: 'Series' } 94 | ]; 95 | 96 | return baseCatalogs.flatMap(base => { 97 | return [ 98 | { 99 | type: base.type, 100 | id: `tmdb-discover-${base.idSuffix}-popular-${provider.provider_id}`, 101 | name: `${popularCatalogTitle || 'Popular'} - ${provider.provider_name}`, 102 | extra: [ 103 | { name: 'genre', isRequired: false, options: genreOptions(base.type === 'movie' ? movieGenres : seriesGenres) }, 104 | { name: "rating", options: ["8-10", "6-8", "4-6", "2-4", "0-2"], isRequired: false }, 105 | { name: "year", options: yearIntervals, isRequired: false }, 106 | { name: 'skip', isRequired: false }, 107 | { name: 'ageRange', value: isKidsMode ? ageRange : '18+' } 108 | ] 109 | }, 110 | { 111 | type: base.type, 112 | id: `tmdb-discover-${base.idSuffix}-new-${provider.provider_id}`, 113 | name: `${newCatalogTitle || 'New'} - ${provider.provider_name}`, 114 | extra: [ 115 | { name: 'genre', isRequired: false, options: genreOptions(base.type === 'movie' ? movieGenres : seriesGenres) }, 116 | { name: "rating", options: ["8-10", "6-8", "4-6", "2-4", "0-2"], isRequired: false }, 117 | { name: "year", options: yearIntervals, isRequired: false }, 118 | { name: 'skip', isRequired: false }, 119 | { name: 'ageRange', value: isKidsMode ? ageRange : '18+' } 120 | ] 121 | } 122 | ]; 123 | }); 124 | }); 125 | 126 | const resources = ['catalog']; 127 | if (additionalContent && additionalContent.trim() !== '') { 128 | resources.push('stream'); 129 | } 130 | 131 | const manifest = { 132 | ...manifestTemplate, 133 | catalogs: catalogs, 134 | resources: resources 135 | }; 136 | 137 | return manifest; 138 | } catch (error) { 139 | console.error('Error generating manifest:', error); 140 | throw error; 141 | } 142 | }; 143 | 144 | 145 | module.exports = generateManifest; 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stremio Catalog Providers 2 | 3 | ## Key features 4 | 5 | - **Dynamic streaming platform management**: Manage over **600 streaming platforms**, adapting to user configurations on the addon's settings page. 6 | 7 | - **Popular and new movies and series**: Retrieve and organize content into popular and recent catalogs for easy access. The content, including titles and posters, is fetched in the language configured by the user. 8 | 9 | - **Region-specific content**: Aggregate region-specific content (e.g., Netflix FR, Netflix US) into a unified catalog, ensuring users have access to localized content. 10 | 11 | - **Age-based filtering (Kids catalog)**: Filter content based on set age ranges using US certifications, excluding inappropriate genres. Detailed guidelines are accessible via the "?" icon in the settings. 12 | 13 | - **Advanced catalog filtering**: Filter catalogs by genre, rating, and release year. 14 | 15 | - **Customizable catalog display**: Arrange catalog displays in the preferred order through the addon's configuration page. 16 | 17 | - **Content recommendations and similar titles**: View recommended and similar content directly on the content's page. 18 | 19 | - **Trakt Integration**: 20 | - **Sync Trakt history**: Synchronize your Trakt watch history with Stremio, ensuring your watched items are marked in your catalogs with a custom emoji of your choice. 21 | - **Automatic synchronization**: Trakt history sync occurs automatically everyday, interval can be customized through an environment variable. 22 | - **Token refresh**: Automatic token refresh, avoiding the need for reauthentication every three months. 23 | - **Mark content as watched**: Users can manually mark content as watched on Trakt directly from Stremio, with the flexibility to rename or translate the action button text according to their language. 24 | 25 | - **RPDB integration**: A web service that provides movie and series posters along with ratings, enhancing the visual and informational aspects of the catalogs. 26 | 27 | - **Fanart integration**: Enhance the visual appeal of content by replacing titles with logos in the selected language or English by default, when available. 28 | 29 | - **Progressive scraping**: Prefetch upcoming content pages as you scroll to enhance loading times. Ensuring smooth and reliable performance. 30 | 31 | - **Customizable cache management**: 32 | - **Catalog cache duration**: Adjust cache duration through an environment variable to balance performance with content freshness. 33 | - **RPDB poster caching**: Customize cache duration to reduce RPDB API load, also managed via an environment variable. 34 | 35 | - **Data sourced from TMDB**: All catalog data is sourced from TMDB, adhering to their Terms of Service. This product uses the TMDB API but is not endorsed or certified by TMDB. 36 | 37 | ## Docker Compose 38 | 39 | ```yaml 40 | version: '3.8' 41 | 42 | services: 43 | stremio-catalog-providers: 44 | image: reddravenn/stremio-catalog-providers 45 | ports: 46 | # Map port 8080 on the host to port 7000 in the container 47 | - "8080:7000" 48 | environment: 49 | # Port to listen on inside the container 50 | PORT: 7000 51 | 52 | # URL to access the addon 53 | BASE_URL: http://localhost:7000 54 | 55 | # PostgreSQL database connection settings 56 | DB_USER: your_user # Username for PostgreSQL authentication 57 | DB_HOST: your_host # PostgreSQL server hostname or IP 58 | DB_NAME: your_database # Name of the database to connect to 59 | DB_PASSWORD: your_password # Password for the PostgreSQL user 60 | DB_PORT: 5432 # Port number where PostgreSQL is running (default 5432) 61 | DB_MAX_CONNECTIONS: 20 # Maximum number of active connections allowed to the database 62 | DB_IDLE_TIMEOUT: 30000 # Time (in ms) to close idle database connections 63 | DB_CONNECTION_TIMEOUT: 2000 # Timeout (in ms) to establish a new database connection 64 | 65 | # Redis cache configuration 66 | REDIS_HOST: your_host # Redis server hostname or IP 67 | REDIS_PORT: 6379 # Port number where Redis is running (default 6379) 68 | REDIS_PASSWORD: # Password for Redis authentication (if required) 69 | 70 | # These credentials are required to interact with the Trakt API and access its services. 71 | # To obtain these credentials: 72 | # 1. Create an account on Trakt.tv (https://trakt.tv). 73 | # 2. Go to the applications section (https://trakt.tv/oauth/applications). 74 | # 3. Create a new application by filling in the required information (name, description, etc.). 75 | # - For the "Redirect URL", use the following format: BASE_URL + /callback (e.g., http://localhost:7000/callback). 76 | TRAKT_CLIENT_ID: 77 | TRAKT_CLIENT_SECRET: 78 | 79 | # Allows you to define the interval for synchronizing the Trakt watch history 80 | # The value can be expressed in hours (h) or days (d) 81 | # Default is '1d' 82 | TRAKT_HISTORY_FETCH_INTERVAL: 1d 83 | 84 | # Cache duration for catalog content in days 85 | CACHE_CATALOG_CONTENT_DURATION_DAYS: 1 86 | 87 | # Cache duration for RPDB poster content in days 88 | CACHE_POSTER_CONTENT_DURATION_DAYS: 7 89 | 90 | # Possible values: 'info' or 'debug' 91 | # Default is 'info' if not specified; 'debug' provides more detailed logs 92 | LOG_LEVEL: info 93 | 94 | # Can be expressed in days (d), weeks (w), or months (M) 95 | # For example, '3d' means logs will be kept for 3 days before being deleted 96 | # If not specified, the default value is '3d' 97 | LOG_INTERVAL_DELETION: 3d 98 | 99 | # The environment in which the Node.js application is running 100 | NODE_ENV: production 101 | volumes: 102 | # Defines a volume for storing data from the container on the host. 103 | # Replace /your/path/to/* with the path of your choice on the host where you want to store the data. 104 | - /your/path/to/db:/usr/src/app/db 105 | - /your/path/to/log:/usr/src/app/log 106 | ``` 107 | 108 | ## Build 109 | 110 | To set up and run the project using a classic Node.js environment: 111 | 112 | 1. Clone the repository: 113 | ```bash 114 | git clone https://github.com/redd-ravenn/stremio-catalog-providers.git 115 | ``` 116 | 117 | 2. Navigate into the project directory: 118 | ```bash 119 | cd stremio-catalog-providers 120 | ``` 121 | 122 | 3. Install the required dependencies: 123 | ```bash 124 | npm install 125 | ``` 126 | 127 | 4. Run the application: 128 | ```bash 129 | node index.js 130 | ``` 131 | 132 | ## Docker build 133 | 134 | To build and run the project using Docker: 135 | 136 | 1. Clone the repository: 137 | ```bash 138 | git clone https://github.com/redd-ravenn/stremio-catalog-providers.git 139 | ``` 140 | 141 | 2. Navigate into the project directory: 142 | ```bash 143 | cd stremio-catalog-providers 144 | ``` 145 | 146 | 3. Build the Docker image: 147 | ```bash 148 | docker build -t yourusername/stremio-catalog-providers . 149 | ``` 150 | 151 | 4. Run the Docker container: 152 | ```bash 153 | docker run -p 8080:7000 yourusername/stremio-catalog-providers 154 | ``` 155 | 156 | Make sure to replace `yourusername` with your Docker Hub username or preferred image name. 157 | 158 | ## Contributing 159 | Contribtutions are welcome and appreciated! This project is currently in its very early stages of development, and we welcome any and all contributions. Whether you want to [report an issue](https://github.com/redd-ravenn/stremio-catalog-providers/issues), suggest a new feature, or submit a pull request, your involvement is greatly appreciated. 160 | -------------------------------------------------------------------------------- /src/helpers/catalog.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const { pool } = require('./db'); 3 | const { discoverContent } = require('../api/tmdb'); 4 | const { getFanartPoster } = require('../api/fanart'); 5 | const { getCachedPoster, setCachedPoster } = require('../helpers/cache'); 6 | const log = require('../helpers/logger'); 7 | 8 | const baseUrl = process.env.BASE_URL || 'http://localhost:7000'; 9 | 10 | async function parseConfigParameters(configParameters) { 11 | let parsedConfig = {}; 12 | if (configParameters) { 13 | try { 14 | parsedConfig = JSON.parse(decodeURIComponent(configParameters)); 15 | } catch (error) { 16 | log.error(`Error parsing configParameters: ${error.message}`); 17 | } 18 | } 19 | return parsedConfig; 20 | } 21 | 22 | function extractCatalogInfo(id) { 23 | const match = id.match(/^tmdb-discover-(movies|series)(-new|-popular)?-(\d+)$/); 24 | if (!match) { 25 | throw new Error('Invalid catalog id'); 26 | } 27 | return { 28 | catalogType: match[1], 29 | providerId: parseInt(match[3], 10) 30 | }; 31 | } 32 | 33 | async function getGenreId(genreName, type) { 34 | try { 35 | const result = await pool.query( 36 | "SELECT genre_id FROM genres WHERE genre_name = $1 AND media_type = $2", 37 | [genreName, type === 'series' ? 'tv' : 'movie'] 38 | ); 39 | const row = result.rows[0]; 40 | return row ? row.genre_id : null; 41 | } catch (err) { 42 | throw err; 43 | } 44 | } 45 | 46 | async function fetchDiscoverContent(catalogType, providers, ageRange, sortBy, genre, tmdbApiKey, language, skip, regions, year = null, rating = null) { 47 | return await discoverContent(catalogType, providers, ageRange, sortBy, genre, tmdbApiKey, language, skip, regions, year, rating); 48 | } 49 | 50 | function getRpdbPoster(type, id, language, rpdbkey) { 51 | const tier = rpdbkey.split("-")[0]; 52 | const lang = language.split("-")[0]; 53 | const baseUrl = `https://api.ratingposterdb.com/${rpdbkey}/tmdb/poster-default/${type}-${id}.jpg?fallback=true`; 54 | return (tier === "t0" || tier === "t1") ? baseUrl : `${baseUrl}&lang=${lang}`; 55 | } 56 | 57 | async function getPosterUrl(content, catalogType, language, rpdbApiKey) { 58 | const posterId = `poster:${content.id}`; 59 | let posterUrl; 60 | if (rpdbApiKey) { 61 | const cachedPoster = await getCachedPoster(posterId); 62 | if (cachedPoster) { 63 | log.debug(`Using cached poster URL for id ${posterId}`); 64 | return cachedPoster.poster_url; 65 | } 66 | 67 | const rpdbImage = getRpdbPoster(catalogType, content.id, language, rpdbApiKey); 68 | try { 69 | const response = await axios.head(rpdbImage); 70 | if (response.status === 200) { 71 | log.debug(`RPDB poster found for id ${posterId}`); 72 | await setCachedPoster(posterId, rpdbImage); 73 | return rpdbImage; 74 | } 75 | } catch (error) { 76 | log.warn(`Error fetching RPDB poster: ${error.message}. Falling back to TMDB poster.`); 77 | } 78 | } 79 | posterUrl = `https://image.tmdb.org/t/p/w500${content.poster_path}`; 80 | return posterUrl; 81 | } 82 | 83 | async function buildMetas(filteredResults, catalogType, language, rpdbApiKey, fanartApiKey, addWatchedTraktBtn, hideTraktHistory, traktUsername, origin) { 84 | return await Promise.all(filteredResults.map(async (content) => { 85 | const posterUrl = await getPosterUrl(content, catalogType, language, rpdbApiKey); 86 | 87 | let releaseInfo = catalogType === 'movies' 88 | ? content.release_date ? content.release_date.split('-')[0] : '' 89 | : content.first_air_date 90 | ? content.last_air_date 91 | ? `${content.first_air_date.split('-')[0]}-${content.last_air_date.split('-')[0]}` 92 | : content.first_air_date.split('-')[0] 93 | : ''; 94 | 95 | let links = null; 96 | let imdbRating = null; 97 | let genres = null; 98 | let logo = null; 99 | 100 | if (fanartApiKey) { 101 | logo = await getFanartPoster(content.id, language, fanartApiKey); 102 | } 103 | 104 | if (origin === 'https://web.stremio.com') { 105 | 106 | links = await buildLinks(content, catalogType, addWatchedTraktBtn, hideTraktHistory, traktUsername, language); 107 | } else { 108 | imdbRating = content.vote_average ? content.vote_average.toFixed(1) : null; 109 | 110 | genres = await Promise.all( 111 | content.genre_ids.map(async (genreId) => { 112 | 113 | const genreName = await getGenreName(genreId, catalogType, language); 114 | return genreName; 115 | }) 116 | ); 117 | 118 | genres = genres.filter(Boolean); 119 | } 120 | 121 | return { 122 | id: `tmdb:${content.id}`, 123 | type: catalogType === 'movies' ? 'movie' : 'series', 124 | name: catalogType === 'movies' ? content.title : content.name, 125 | poster: posterUrl, 126 | logo: logo || null, 127 | background: `https://image.tmdb.org/t/p/w1280${content.backdrop_path}`, 128 | description: content.overview, 129 | releaseInfo: releaseInfo || null, 130 | ...(links && { links }), 131 | ...(imdbRating && { imdbRating }), 132 | ...(genres && genres.length > 0 && { genres }) 133 | }; 134 | })); 135 | } 136 | 137 | async function buildLinks(content, catalogType, addWatchedTraktBtn, hideTraktHistory, traktUsername, language) { 138 | const links = []; 139 | 140 | if (content.genre_ids) { 141 | for (const genreId of content.genre_ids) { 142 | try { 143 | const genreName = await getGenreName(genreId, content.type, language); 144 | if (genreName) { 145 | links.push({ 146 | name: genreName, 147 | category: 'Genres', 148 | url: `stremio:///discover` 149 | }); 150 | } 151 | } catch (error) { 152 | console.error(`Error fetching genre name for ID ${genreId}: ${error.message}`); 153 | } 154 | } 155 | } 156 | 157 | if (content.vote_average && content.id) { 158 | links.push({ 159 | name: content.vote_average.toFixed(1), 160 | category: 'imdb', 161 | url: `https://imdb.com/title/tt${content.id}` 162 | }); 163 | } 164 | 165 | if (addWatchedTraktBtn && addWatchedTraktBtn.trim() !== '' && hideTraktHistory === 'true' && traktUsername) { 166 | links.push({ 167 | name: addWatchedTraktBtn, 168 | category: 'Trakt', 169 | url: `${baseUrl}/updateWatched/${traktUsername}/${catalogType}/${content.id}` 170 | }); 171 | } 172 | 173 | return links; 174 | } 175 | 176 | async function getGenreName(genreId, type, language) { 177 | try { 178 | const result = await pool.query( 179 | "SELECT genre_name FROM genres WHERE genre_id = $1 AND media_type = $2 AND language = $3", 180 | [genreId, type === 'series' ? 'tv' : 'movie', language] 181 | ); 182 | const row = result.rows[0]; 183 | return row ? row.genre_name : null; 184 | } catch (err) { 185 | throw err; 186 | } 187 | } 188 | 189 | module.exports = { 190 | parseConfigParameters, 191 | extractCatalogInfo, 192 | getGenreId, 193 | fetchDiscoverContent, 194 | getPosterUrl, 195 | buildMetas 196 | }; 197 | -------------------------------------------------------------------------------- /public/js/languages.js: -------------------------------------------------------------------------------- 1 | const languages = [ 2 | {iso_639_1: "bi", english_name: "Bislama", name: "" }, 3 | {iso_639_1: "cs", english_name: "Czech", name: "Český" }, 4 | {iso_639_1: "ba", english_name: "Bashkir", name: "" }, 5 | {iso_639_1: "ae", english_name: "Avestan", name: "" }, 6 | {iso_639_1: "av", english_name: "Avaric", name: "" }, 7 | {iso_639_1: "de", english_name: "German", name: "Deutsch" }, 8 | {iso_639_1: "mt", english_name: "Maltese", name: "Malti" }, 9 | {iso_639_1: "om", english_name: "Oromo", name: "" }, 10 | {iso_639_1: "rm", english_name: "Raeto-Romance", name: "" }, 11 | {iso_639_1: "so", english_name: "Somali", name: "Somali" }, 12 | {iso_639_1: "ts", english_name: "Tsonga", name: "" }, 13 | {iso_639_1: "vi", english_name: "Vietnamese", name: "Tiếng Việt" }, 14 | {iso_639_1: "gn", english_name: "Guarani", name: "" }, 15 | {iso_639_1: "ig", english_name: "Igbo", name: "" }, 16 | {iso_639_1: "it", english_name: "Italian", name: "Italiano" }, 17 | {iso_639_1: "ki", english_name: "Kikuyu", name: "" }, 18 | {iso_639_1: "ku", english_name: "Kurdish", name: "" }, 19 | {iso_639_1: "la", english_name: "Latin", name: "Latin" }, 20 | {iso_639_1: "ln", english_name: "Lingala", name: "" }, 21 | {iso_639_1: "lb", english_name: "Letzeburgesch", name: "" }, 22 | {iso_639_1: "ny", english_name: "Chichewa; Nyanja", name: "" }, 23 | {iso_639_1: "pl", english_name: "Polish", name: "Polski" }, 24 | {iso_639_1: "si", english_name: "Sinhalese", name: "සිංහල" }, 25 | {iso_639_1: "to", english_name: "Tonga", name: "" }, 26 | {iso_639_1: "az", english_name: "Azerbaijani", name: "Azərbaycan" }, 27 | {iso_639_1: "ce", english_name: "Chechen", name: "" }, 28 | {iso_639_1: "cu", english_name: "Slavic", name: "" }, 29 | {iso_639_1: "da", english_name: "Danish", name: "Dansk" }, 30 | {iso_639_1: "hz", english_name: "Herero", name: "" }, 31 | {iso_639_1: "ie", english_name: "Interlingue", name: "" }, 32 | {iso_639_1: "rw", english_name: "Kinyarwanda", name: "Kinyarwanda" }, 33 | {iso_639_1: "mi", english_name: "Maori", name: "" }, 34 | {iso_639_1: "no", english_name: "Norwegian", name: "Norsk" }, 35 | {iso_639_1: "pi", english_name: "Pali", name: "" }, 36 | {iso_639_1: "sk", english_name: "Slovak", name: "Slovenčina" }, 37 | {iso_639_1: "se", english_name: "Northern Sami", name: "" }, 38 | {iso_639_1: "sm", english_name: "Samoan", name: "" }, 39 | {iso_639_1: "uk", english_name: "Ukrainian", name: "Український" }, 40 | {iso_639_1: "en", english_name: "English", name: "English" }, 41 | {iso_639_1: "ay", english_name: "Aymara", name: "" }, 42 | {iso_639_1: "ca", english_name: "Catalan", name: "Català" }, 43 | {iso_639_1: "eo", english_name: "Esperanto", name: "Esperanto" }, 44 | {iso_639_1: "ha", english_name: "Hausa", name: "Hausa" }, 45 | {iso_639_1: "ho", english_name: "Hiri Motu", name: "" }, 46 | {iso_639_1: "hu", english_name: "Hungarian", name: "Magyar" }, 47 | {iso_639_1: "io", english_name: "Ido", name: "" }, 48 | {iso_639_1: "ii", english_name: "Yi", name: "" }, 49 | {iso_639_1: "kn", english_name: "Kannada", name: "?????" }, 50 | {iso_639_1: "kv", english_name: "Komi", name: "" }, 51 | {iso_639_1: "li", english_name: "Limburgish", name: "" }, 52 | {iso_639_1: "oj", english_name: "Ojibwa", name: "" }, 53 | {iso_639_1: "ru", english_name: "Russian", name: "Pусский" }, 54 | {iso_639_1: "sr", english_name: "Serbian", name: "Srpski" }, 55 | {iso_639_1: "sv", english_name: "Swedish", name: "svenska" }, 56 | {iso_639_1: "ty", english_name: "Tahitian", name: "" }, 57 | {iso_639_1: "zu", english_name: "Zulu", name: "isiZulu" }, 58 | {iso_639_1: "ka", english_name: "Georgian", name: "ქართული" }, 59 | {iso_639_1: "ch", english_name: "Chamorro", name: "Finu' Chamorro" }, 60 | {iso_639_1: "be", english_name: "Belarusian", name: "беларуская мова" }, 61 | {iso_639_1: "br", english_name: "Breton", name: "" }, 62 | {iso_639_1: "kw", english_name: "Cornish", name: "" }, 63 | {iso_639_1: "fi", english_name: "Finnish", name: "suomi" }, 64 | {iso_639_1: "sh", english_name: "Serbo-Croatian", name: "" }, 65 | {iso_639_1: "nn", english_name: "Norwegian Nynorsk", name: "" }, 66 | {iso_639_1: "tt", english_name: "Tatar", name: "" }, 67 | {iso_639_1: "tg", english_name: "Tajik", name: "" }, 68 | {iso_639_1: "vo", english_name: "Volapük", name: "" }, 69 | {iso_639_1: "ps", english_name: "Pushto", name: "پښتو" }, 70 | {iso_639_1: "mk", english_name: "Macedonian", name: "" }, 71 | {iso_639_1: "fr", english_name: "French", name: "Français" }, 72 | {iso_639_1: "bm", english_name: "Bambara", name: "Bamanankan" }, 73 | {iso_639_1: "eu", english_name: "Basque", name: "euskera" }, 74 | {iso_639_1: "fj", english_name: "Fijian", name: "" }, 75 | {iso_639_1: "id", english_name: "Indonesian", name: "Bahasa indonesia" }, 76 | {iso_639_1: "mg", english_name: "Malagasy", name: "" }, 77 | {iso_639_1: "na", english_name: "Nauru", name: "" }, 78 | {iso_639_1: "xx", english_name: "No Language", name: "No Language" }, 79 | {iso_639_1: "qu", english_name: "Quechua", name: "" }, 80 | {iso_639_1: "sq", english_name: "Albanian", name: "shqip" }, 81 | {iso_639_1: "ti", english_name: "Tigrinya", name: "" }, 82 | {iso_639_1: "tw", english_name: "Twi", name: "" }, 83 | {iso_639_1: "wa", english_name: "Walloon", name: "" }, 84 | {iso_639_1: "ab", english_name: "Abkhazian", name: "" }, 85 | {iso_639_1: "bs", english_name: "Bosnian", name: "Bosanski" }, 86 | {iso_639_1: "af", english_name: "Afrikaans", name: "Afrikaans" }, 87 | {iso_639_1: "an", english_name: "Aragonese", name: "" }, 88 | {iso_639_1: "fy", english_name: "Frisian", name: "" }, 89 | {iso_639_1: "gu", english_name: "Gujarati", name: "" }, 90 | {iso_639_1: "ik", english_name: "Inupiaq", name: "" }, 91 | {iso_639_1: "ja", english_name: "Japanese", name: "日本語" }, 92 | {iso_639_1: "ko", english_name: "Korean", name: "한국어/조선말" }, 93 | {iso_639_1: "lg", english_name: "Ganda", name: "" }, 94 | {iso_639_1: "nl", english_name: "Dutch", name: "Nederlands" }, 95 | {iso_639_1: "os", english_name: "Ossetian; Ossetic", name: "" }, 96 | {iso_639_1: "el", english_name: "Greek", name: "ελληνικά" }, 97 | {iso_639_1: "bn", english_name: "Bengali", name: "বাংলা" }, 98 | {iso_639_1: "cr", english_name: "Cree", name: "" }, 99 | {iso_639_1: "km", english_name: "Khmer", name: "" }, 100 | {iso_639_1: "lo", english_name: "Lao", name: "" }, 101 | {iso_639_1: "nd", english_name: "Ndebele", name: "" }, 102 | {iso_639_1: "ne", english_name: "Nepali", name: "" }, 103 | {iso_639_1: "sc", english_name: "Sardinian", name: "" }, 104 | {iso_639_1: "sw", english_name: "Swahili", name: "Kiswahili" }, 105 | {iso_639_1: "tl", english_name: "Tagalog", name: "" }, 106 | {iso_639_1: "ur", english_name: "Urdu", name: "اردو" }, 107 | {iso_639_1: "ee", english_name: "Ewe", name: "Èʋegbe" }, 108 | {iso_639_1: "aa", english_name: "Afar", name: "" }, 109 | {iso_639_1: "co", english_name: "Corsican", name: "" }, 110 | {iso_639_1: "et", english_name: "Estonian", name: "Eesti" }, 111 | {iso_639_1: "is", english_name: "Icelandic", name: "Íslenska" }, 112 | {iso_639_1: "ks", english_name: "Kashmiri", name: "" }, 113 | {iso_639_1: "kr", english_name: "Kanuri", name: "" }, 114 | {iso_639_1: "ky", english_name: "Kirghiz", name: "??????" }, 115 | {iso_639_1: "kj", english_name: "Kuanyama", name: "" }, 116 | {iso_639_1: "nr", english_name: "Ndebele", name: "" }, 117 | {iso_639_1: "or", english_name: "Oriya", name: "" }, 118 | {iso_639_1: "wo", english_name: "Wolof", name: "Wolof" }, 119 | {iso_639_1: "za", english_name: "Zhuang", name: "" }, 120 | {iso_639_1: "ar", english_name: "Arabic", name: "العربية" }, 121 | {iso_639_1: "cv", english_name: "Chuvash", name: "" }, 122 | {iso_639_1: "fo", english_name: "Faroese", name: "" }, 123 | {iso_639_1: "hr", english_name: "Croatian", name: "Hrvatski" }, 124 | {iso_639_1: "ms", english_name: "Malay", name: "Bahasa melayu" }, 125 | {iso_639_1: "nb", english_name: "Norwegian Bokmål", name: "Bokmål" }, 126 | {iso_639_1: "rn", english_name: "Rundi", name: "Kirundi" }, 127 | {iso_639_1: "sn", english_name: "Shona", name: "" }, 128 | {iso_639_1: "st", english_name: "Sotho", name: "" }, 129 | {iso_639_1: "tr", english_name: "Turkish", name: "Türkçe" }, 130 | {iso_639_1: "am", english_name: "Amharic", name: "" }, 131 | {iso_639_1: "fa", english_name: "Persian", name: "فارسی" }, 132 | {iso_639_1: "hy", english_name: "Armenian", name: "" }, 133 | {iso_639_1: "pa", english_name: "Punjabi", name: "ਪੰਜਾਬੀ" }, 134 | {iso_639_1: "as", english_name: "Assamese", name: "" }, 135 | {iso_639_1: "ia", english_name: "Interlingua", name: "" }, 136 | {iso_639_1: "lv", english_name: "Latvian", name: "Latviešu" }, 137 | {iso_639_1: "lu", english_name: "Luba-Katanga", name: "" }, 138 | {iso_639_1: "mr", english_name: "Marathi", name: "" }, 139 | {iso_639_1: "mn", english_name: "Mongolian", name: "" }, 140 | {iso_639_1: "pt", english_name: "Portuguese", name: "Português" }, 141 | {iso_639_1: "th", english_name: "Thai", name: "ภาษาไทย" }, 142 | {iso_639_1: "tk", english_name: "Turkmen", name: "" }, 143 | {iso_639_1: "ve", english_name: "Venda", name: "" }, 144 | {iso_639_1: "dv", english_name: "Divehi", name: "" }, 145 | {iso_639_1: "gv", english_name: "Manx", name: "" }, 146 | {iso_639_1: "kl", english_name: "Kalaallisut", name: "" }, 147 | {iso_639_1: "kk", english_name: "Kazakh", name: "қазақ" }, 148 | {iso_639_1: "lt", english_name: "Lithuanian", name: "Lietuvių" }, 149 | {iso_639_1: "my", english_name: "Burmese", name: "" }, 150 | {iso_639_1: "sl", english_name: "Slovenian", name: "Slovenščina" }, 151 | {iso_639_1: "sd", english_name: "Sindhi", name: "" }, 152 | {iso_639_1: "cn", english_name: "Cantonese", name: "广州话 / 廣州話" }, 153 | {iso_639_1: "hi", english_name: "Hindi", name: "हिन्दी" }, 154 | {iso_639_1: "cy", english_name: "Welsh", name: "Cymraeg" }, 155 | {iso_639_1: "ht", english_name: "Haitian; Haitian Creole", name: "" }, 156 | {iso_639_1: "iu", english_name: "Inuktitut", name: "" }, 157 | {iso_639_1: "jv", english_name: "Javanese", name: "" }, 158 | {iso_639_1: "mh", english_name: "Marshall", name: "" }, 159 | {iso_639_1: "sa", english_name: "Sanskrit", name: "" }, 160 | {iso_639_1: "ss", english_name: "Swati", name: "" }, 161 | {iso_639_1: "te", english_name: "Telugu", name: "తెలుగు" }, 162 | {iso_639_1: "kg", english_name: "Kongo", name: "" }, 163 | {iso_639_1: "ml", english_name: "Malayalam", name: "" }, 164 | {iso_639_1: "uz", english_name: "Uzbek", name: "ozbek" }, 165 | {iso_639_1: "sg", english_name: "Sango", name: "" }, 166 | {iso_639_1: "xh", english_name: "Xhosa", name: "" }, 167 | {iso_639_1: "es", english_name: "Spanish", name: "Español" }, 168 | {iso_639_1: "su", english_name: "Sundanese", name: "" }, 169 | {iso_639_1: "ug", english_name: "Uighur", name: "" }, 170 | {iso_639_1: "yi", english_name: "Yiddish", name: "" }, 171 | {iso_639_1: "yo", english_name: "Yoruba", name: "Èdè Yorùbá" }, 172 | {iso_639_1: "zh", english_name: "Mandarin", name: "普通话" }, 173 | {iso_639_1: "he", english_name: "Hebrew", name: "עִבְרִית" }, 174 | {iso_639_1: "bo", english_name: "Tibetan", name: "" }, 175 | {iso_639_1: "ak", english_name: "Akan", name: "" }, 176 | {iso_639_1: "mo", english_name: "Moldavian", name: "" }, 177 | {iso_639_1: "ng", english_name: "Ndonga", name: "" }, 178 | {iso_639_1: "dz", english_name: "Dzongkha", name: "" }, 179 | {iso_639_1: "ff", english_name: "Fulah", name: "Fulfulde" }, 180 | {iso_639_1: "gd", english_name: "Gaelic", name: "" }, 181 | {iso_639_1: "ga", english_name: "Irish", name: "Gaeilge" }, 182 | {iso_639_1: "gl", english_name: "Galician", name: "Galego" }, 183 | {iso_639_1: "nv", english_name: "Navajo", name: "" }, 184 | {iso_639_1: "oc", english_name: "Occitan", name: "" }, 185 | {iso_639_1: "ro", english_name: "Romanian", name: "Română" }, 186 | {iso_639_1: "ta", english_name: "Tamil", name: "தமிழ்" }, 187 | {iso_639_1: "tn", english_name: "Tswana", name: "" }, 188 | {iso_639_1: "bg", english_name: "Bulgarian", name: "български език" 189 | } 190 | ]; 191 | 192 | languages.sort((a, b) => { 193 | const nameA = a.english_name.toUpperCase(); 194 | const nameB = b.english_name.toUpperCase(); 195 | if (nameA < nameB) { 196 | return -1; 197 | } 198 | if (nameA > nameB) { 199 | return 1; 200 | } 201 | return 0; 202 | }); -------------------------------------------------------------------------------- /src/api/trakt.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const { pool } = require('../helpers/db'); 3 | const log = require('../helpers/logger'); 4 | const { addToQueueGET, addToQueuePOST } = require('../helpers/bottleneck_trakt'); 5 | 6 | const TRAKT_BASE_URL = 'https://api.trakt.tv'; 7 | const TRAKT_API_VERSION = '2'; 8 | const TRAKT_API_KEY = process.env.TRAKT_CLIENT_ID; 9 | const TRAKT_CLIENT_SECRET = process.env.TRAKT_CLIENT_SECRET; 10 | const TRAKT_REDIRECT_URI = `${process.env.BASE_URL}/callback`; 11 | 12 | const makeGetRequest = (url, accessToken = null) => { 13 | const headers = { 14 | 'trakt-api-version': TRAKT_API_VERSION, 15 | 'trakt-api-key': TRAKT_API_KEY, 16 | }; 17 | 18 | if (accessToken) { 19 | headers.Authorization = `Bearer ${accessToken}`; 20 | } else { 21 | log.debug(`No access token provided, making unauthenticated request.`); 22 | } 23 | 24 | return new Promise((resolve, reject) => { 25 | addToQueueGET({ 26 | fn: () => axios.get(url, { headers }) 27 | .then(response => { 28 | log.debug(`API GET request successful for URL: ${url}`); 29 | resolve(response.data); 30 | }) 31 | .catch(error => { 32 | if (error.response && error.response.status === 401) { 33 | log.warn(`Unauthorized request (401) during API GET request for URL: ${url} - ${error.message}`); 34 | } else { 35 | log.error(`Error during API GET request for URL: ${url} - ${error.message}`); 36 | } 37 | reject(error); 38 | }) 39 | }); 40 | }); 41 | }; 42 | 43 | const makePostRequest = (url, data, accessToken = null) => { 44 | const headers = { 45 | 'trakt-api-version': TRAKT_API_VERSION, 46 | 'trakt-api-key': TRAKT_API_KEY, 47 | 'Content-Type': 'application/json', 48 | }; 49 | 50 | if (accessToken) { 51 | headers.Authorization = `Bearer ${accessToken}`; 52 | } 53 | 54 | return new Promise((resolve, reject) => { 55 | addToQueuePOST({ 56 | fn: () => axios.post(url, data, { headers }) 57 | .then(response => { 58 | log.debug(`API POST request successful for URL: ${url}`); 59 | resolve(response.data); 60 | }) 61 | .catch(error => { 62 | log.error(`Error during API POST request for URL: ${url} - ${error.message}`); 63 | reject(error); 64 | }) 65 | }); 66 | }); 67 | }; 68 | 69 | const exchangeCodeForToken = async (code) => { 70 | try { 71 | const response = await makePostRequest(`${TRAKT_BASE_URL}/oauth/token`, { 72 | code: code, 73 | client_id: TRAKT_API_KEY, 74 | client_secret: TRAKT_CLIENT_SECRET, 75 | redirect_uri: TRAKT_REDIRECT_URI, 76 | grant_type: 'authorization_code', 77 | }); 78 | 79 | return response; 80 | } catch (error) { 81 | log.error(`Error exchanging authorization code for token: ${error.message}`); 82 | throw error; 83 | } 84 | }; 85 | 86 | const fetchData = async (endpoint, params = {}, accessToken = null) => { 87 | const queryString = new URLSearchParams(params).toString(); 88 | const url = `${TRAKT_BASE_URL}${endpoint}?${queryString}`; 89 | 90 | try { 91 | const data = await makeGetRequest(url, accessToken); 92 | log.debug(`Data successfully retrieved from URL: ${url}`); 93 | return data; 94 | } catch (error) { 95 | throw error; 96 | } 97 | }; 98 | 99 | const refreshTraktToken = async (refreshToken) => { 100 | const payload = { 101 | refresh_token: refreshToken, 102 | client_id: TRAKT_API_KEY, 103 | client_secret: TRAKT_CLIENT_SECRET, 104 | redirect_uri: TRAKT_REDIRECT_URI, 105 | grant_type: 'refresh_token' 106 | }; 107 | 108 | try { 109 | const response = await axios.post('https://api.trakt.tv/oauth/token', payload, { 110 | headers: { 'Content-Type': 'application/json' } 111 | }); 112 | 113 | return response.data; 114 | } catch (error) { 115 | if (error.response) { 116 | log.error(`Failed to refresh token: ${JSON.stringify(error.response.data)}`); 117 | } else { 118 | log.error(`Failed to refresh token: ${error.message}`); 119 | } 120 | throw error; 121 | } 122 | }; 123 | 124 | const updateTokensInDb = async (username, newAccessToken, newRefreshToken) => { 125 | await pool.query( 126 | 'UPDATE trakt_tokens SET access_token = $1, refresh_token = $2 WHERE username = $3', 127 | [newAccessToken, newRefreshToken, username] 128 | ); 129 | }; 130 | 131 | const fetchUserHistory = async (username, type, accessToken) => { 132 | const endpoint = `/users/${username}/watched/${type}`; 133 | 134 | try { 135 | return await fetchData(endpoint, {}, accessToken); 136 | } catch (error) { 137 | if (error.response && error.response.status === 401) { 138 | throw new Error('token_expired'); 139 | } else { 140 | throw error; 141 | } 142 | } 143 | }; 144 | 145 | async function handleTraktHistory(parsedConfig, filteredResults, type) { 146 | const traktUsername = parsedConfig.traktUsername; 147 | const watchedEmoji = parsedConfig.watchedEmoji || '✔️'; 148 | const fetchInterval = process.env.TRAKT_HISTORY_FETCH_INTERVAL || '24h'; 149 | 150 | const dbType = type === 'movies' ? 'movie' : type === 'series' ? 'show' : type; 151 | 152 | const intervalInMs = (() => { 153 | const intervalValue = parseInt(fetchInterval.slice(0, -1), 10); 154 | const intervalUnit = fetchInterval.slice(-1); 155 | 156 | switch (intervalUnit) { 157 | case 'h': 158 | return intervalValue * 60 * 60 * 1000; 159 | case 'd': 160 | return intervalValue * 24 * 60 * 60 * 1000; 161 | default: 162 | throw new Error(`Invalid time unit in TRAKT_HISTORY_FETCH_INTERVAL: ${fetchInterval}`); 163 | } 164 | })(); 165 | 166 | const result = await pool.query( 167 | `SELECT last_fetched_at FROM trakt_tokens WHERE username = $1`, 168 | [traktUsername] 169 | ); 170 | 171 | const lastFetchedRow = result.rows[0]; 172 | const lastFetchedAt = lastFetchedRow ? new Date(lastFetchedRow.last_fetched_at) : null; 173 | const now = new Date(); 174 | 175 | if (!lastFetchedAt || (now - lastFetchedAt) >= intervalInMs) { 176 | try { 177 | const tokensResult = await pool.query( 178 | `SELECT access_token, refresh_token FROM trakt_tokens WHERE username = $1`, 179 | [traktUsername] 180 | ); 181 | 182 | const tokensRow = tokensResult.rows[0]; 183 | if (tokensRow) { 184 | let { access_token, refresh_token } = tokensRow; 185 | 186 | try { 187 | const [movieHistory, showHistory] = await Promise.all([ 188 | fetchUserHistory(traktUsername, 'movies', access_token), 189 | fetchUserHistory(traktUsername, 'shows', access_token) 190 | ]); 191 | 192 | await Promise.all([ 193 | saveUserWatchedHistory(traktUsername, movieHistory), 194 | saveUserWatchedHistory(traktUsername, showHistory) 195 | ]); 196 | } catch (error) { 197 | if (error.message === 'token_expired') { 198 | log.warn(`Token expired for user ${traktUsername}, refreshing token...`); 199 | 200 | const newTokens = await refreshTraktToken(refresh_token); 201 | access_token = newTokens.access_token; 202 | refresh_token = newTokens.refresh_token; 203 | 204 | await updateTokensInDb(traktUsername, newTokens.access_token, newTokens.refresh_token); 205 | 206 | const [movieHistory, showHistory] = await Promise.all([ 207 | fetchUserHistory(traktUsername, 'movies', newTokens.access_token), 208 | fetchUserHistory(traktUsername, 'shows', newTokens.access_token) 209 | ]); 210 | 211 | await Promise.all([ 212 | saveUserWatchedHistory(traktUsername, movieHistory), 213 | saveUserWatchedHistory(traktUsername, showHistory) 214 | ]); 215 | } else { 216 | throw error; 217 | } 218 | } 219 | 220 | await pool.query( 221 | `UPDATE trakt_tokens SET last_fetched_at = $1 WHERE username = $2`, 222 | [now.toISOString(), traktUsername] 223 | ); 224 | } 225 | } catch (error) { 226 | log.error(`Error fetching Trakt history for user ${traktUsername}: ${error.message}`); 227 | } 228 | } 229 | 230 | const traktIdsResult = await pool.query( 231 | `SELECT tmdb_id FROM trakt_history WHERE username = $1 AND type = $2 AND tmdb_id IS NOT NULL`, 232 | [traktUsername, dbType] 233 | ); 234 | 235 | log.debug(`Fetching Trakt history for user ${traktUsername} with type ${dbType}. Result: ${traktIdsResult.rows.length} items found.`); 236 | 237 | const traktIds = traktIdsResult.rows.map(row => `tmdb:${row.tmdb_id}`); 238 | 239 | return filteredResults.map(content => { 240 | const contentId = `tmdb:${content.id}`; 241 | if (traktIds.includes(contentId)) { 242 | content.title = `${watchedEmoji} ${content.title || content.name}`; 243 | } 244 | return content; 245 | }); 246 | } 247 | 248 | const saveUserWatchedHistory = async (username, history) => { 249 | if (!history || history.length === 0) { 250 | log.warn(`No history to save for user ${username}.`); 251 | return; 252 | } 253 | 254 | const client = await pool.connect(); 255 | try { 256 | await client.query('BEGIN'); 257 | 258 | for (const item of history) { 259 | const media = item.movie || item.show; 260 | const mediaId = media.ids.imdb || media.ids.tmdb; 261 | const mediaType = item.movie ? 'movie' : 'show'; 262 | const watchedAt = item.last_watched_at; 263 | const title = media.title; 264 | 265 | const historyResult = await client.query( 266 | `SELECT id FROM trakt_history WHERE username = $1 AND imdb_id = $2`, 267 | [username, media.ids.imdb] 268 | ); 269 | 270 | if (historyResult.rows.length > 0) { 271 | await client.query( 272 | `UPDATE trakt_history 273 | SET watched_at = $1, title = $2, tmdb_id = $3, type = $4 274 | WHERE id = $5`, 275 | [watchedAt, title, media.ids.tmdb, mediaType, historyResult.rows[0].id] 276 | ); 277 | } else { 278 | await client.query( 279 | `INSERT INTO trakt_history (username, imdb_id, tmdb_id, type, watched_at, title) 280 | VALUES ($1, $2, $3, $4, $5, $6)`, 281 | [username, media.ids.imdb, media.ids.tmdb, mediaType, watchedAt, title] 282 | ); 283 | } 284 | } 285 | 286 | await client.query('COMMIT'); 287 | log.info(`History saved for user ${username}`); 288 | } catch (err) { 289 | await client.query('ROLLBACK'); 290 | log.error(`Error committing transaction for user ${username}: ${err.message}`); 291 | throw err; 292 | } finally { 293 | client.release(); 294 | } 295 | }; 296 | 297 | const fetchUserProfile = async (accessToken) => { 298 | const endpoint = '/users/me'; 299 | return await fetchData(endpoint, {}, accessToken); 300 | }; 301 | 302 | async function lookupTraktId(tmdbId, type, accessToken) { 303 | const url = `${TRAKT_BASE_URL}/search/tmdb/${tmdbId}?type=${type}`; 304 | 305 | try { 306 | const response = await makeGetRequest(url, accessToken); 307 | if (response.length > 0 && response[0].type === type && response[0][type]) { 308 | const traktId = response[0][type].ids.trakt; 309 | return traktId; 310 | } else { 311 | throw new Error(`No Trakt ID found for TMDB ID ${tmdbId}`); 312 | } 313 | } catch (error) { 314 | log.error(`Error fetching Trakt ID for TMDB ID ${tmdbId}: ${error.message}`); 315 | throw error; 316 | } 317 | } 318 | 319 | 320 | const markContentAsWatched = async (access_token, type, id, watched_at) => { 321 | const url = `${TRAKT_BASE_URL}/sync/history`; 322 | 323 | let data = {}; 324 | if (type === 'movies') { 325 | data = { 326 | movies: [{ ids: { trakt: id }, watched_at }] 327 | }; 328 | } else if (type === 'series') { 329 | data = { 330 | shows: [{ ids: { trakt: id }, watched_at }] 331 | }; 332 | } 333 | 334 | try { 335 | const response = await makePostRequest(url, data, access_token); 336 | return response; 337 | } catch (error) { 338 | log.error(`Error marking content as watched: ${error.message}`); 339 | throw error; 340 | } 341 | }; 342 | 343 | module.exports = { makeGetRequest, makePostRequest, fetchUserHistory, fetchUserProfile, exchangeCodeForToken, handleTraktHistory, markContentAsWatched, lookupTraktId, saveUserWatchedHistory }; 344 | -------------------------------------------------------------------------------- /public/js/countries.js: -------------------------------------------------------------------------------- 1 | const countries = [ 2 | { 3 | "iso_3166_1": "AD", 4 | "english_name": "Andorra", 5 | "native_name": "Andorra" 6 | }, 7 | { 8 | "iso_3166_1": "AE", 9 | "english_name": "United Arab Emirates", 10 | "native_name": "United Arab Emirates" 11 | }, 12 | { 13 | "iso_3166_1": "AG", 14 | "english_name": "Antigua and Barbuda", 15 | "native_name": "Antigua & Barbuda" 16 | }, 17 | { 18 | "iso_3166_1": "AL", 19 | "english_name": "Albania", 20 | "native_name": "Albania" 21 | }, 22 | { 23 | "iso_3166_1": "AO", 24 | "english_name": "Angola", 25 | "native_name": "Angola" 26 | }, 27 | { 28 | "iso_3166_1": "AR", 29 | "english_name": "Argentina", 30 | "native_name": "Argentina" 31 | }, 32 | { 33 | "iso_3166_1": "AT", 34 | "english_name": "Austria", 35 | "native_name": "Austria" 36 | }, 37 | { 38 | "iso_3166_1": "AU", 39 | "english_name": "Australia", 40 | "native_name": "Australia" 41 | }, 42 | { 43 | "iso_3166_1": "AZ", 44 | "english_name": "Azerbaijan", 45 | "native_name": "Azerbaijan" 46 | }, 47 | { 48 | "iso_3166_1": "BA", 49 | "english_name": "Bosnia and Herzegovina", 50 | "native_name": "Bosnia & Herzegovina" 51 | }, 52 | { 53 | "iso_3166_1": "BB", 54 | "english_name": "Barbados", 55 | "native_name": "Barbados" 56 | }, 57 | { 58 | "iso_3166_1": "BE", 59 | "english_name": "Belgium", 60 | "native_name": "Belgium" 61 | }, 62 | { 63 | "iso_3166_1": "BF", 64 | "english_name": "Burkina Faso", 65 | "native_name": "Burkina Faso" 66 | }, 67 | { 68 | "iso_3166_1": "BG", 69 | "english_name": "Bulgaria", 70 | "native_name": "Bulgaria" 71 | }, 72 | { 73 | "iso_3166_1": "BH", 74 | "english_name": "Bahrain", 75 | "native_name": "Bahrain" 76 | }, 77 | { 78 | "iso_3166_1": "BM", 79 | "english_name": "Bermuda", 80 | "native_name": "Bermuda" 81 | }, 82 | { 83 | "iso_3166_1": "BO", 84 | "english_name": "Bolivia", 85 | "native_name": "Bolivia" 86 | }, 87 | { 88 | "iso_3166_1": "BR", 89 | "english_name": "Brazil", 90 | "native_name": "Brazil" 91 | }, 92 | { 93 | "iso_3166_1": "BS", 94 | "english_name": "Bahamas", 95 | "native_name": "Bahamas" 96 | }, 97 | { 98 | "iso_3166_1": "BY", 99 | "english_name": "Belarus", 100 | "native_name": "Belarus" 101 | }, 102 | { 103 | "iso_3166_1": "BZ", 104 | "english_name": "Belize", 105 | "native_name": "Belize" 106 | }, 107 | { 108 | "iso_3166_1": "CA", 109 | "english_name": "Canada", 110 | "native_name": "Canada" 111 | }, 112 | { 113 | "iso_3166_1": "CD", 114 | "english_name": "Congo", 115 | "native_name": "Democratic Republic of the Congo (Kinshasa)" 116 | }, 117 | { 118 | "iso_3166_1": "CH", 119 | "english_name": "Switzerland", 120 | "native_name": "Switzerland" 121 | }, 122 | { 123 | "iso_3166_1": "CI", 124 | "english_name": "Cote D'Ivoire", 125 | "native_name": "Côte d’Ivoire" 126 | }, 127 | { 128 | "iso_3166_1": "CL", 129 | "english_name": "Chile", 130 | "native_name": "Chile" 131 | }, 132 | { 133 | "iso_3166_1": "CM", 134 | "english_name": "Cameroon", 135 | "native_name": "Cameroon" 136 | }, 137 | { 138 | "iso_3166_1": "CO", 139 | "english_name": "Colombia", 140 | "native_name": "Colombia" 141 | }, 142 | { 143 | "iso_3166_1": "CR", 144 | "english_name": "Costa Rica", 145 | "native_name": "Costa Rica" 146 | }, 147 | { 148 | "iso_3166_1": "CU", 149 | "english_name": "Cuba", 150 | "native_name": "Cuba" 151 | }, 152 | { 153 | "iso_3166_1": "CV", 154 | "english_name": "Cape Verde", 155 | "native_name": "Cape Verde" 156 | }, 157 | { 158 | "iso_3166_1": "CY", 159 | "english_name": "Cyprus", 160 | "native_name": "Cyprus" 161 | }, 162 | { 163 | "iso_3166_1": "CZ", 164 | "english_name": "Czech Republic", 165 | "native_name": "Czech Republic" 166 | }, 167 | { 168 | "iso_3166_1": "DE", 169 | "english_name": "Germany", 170 | "native_name": "Germany" 171 | }, 172 | { 173 | "iso_3166_1": "DK", 174 | "english_name": "Denmark", 175 | "native_name": "Denmark" 176 | }, 177 | { 178 | "iso_3166_1": "DO", 179 | "english_name": "Dominican Republic", 180 | "native_name": "Dominican Republic" 181 | }, 182 | { 183 | "iso_3166_1": "DZ", 184 | "english_name": "Algeria", 185 | "native_name": "Algeria" 186 | }, 187 | { 188 | "iso_3166_1": "EC", 189 | "english_name": "Ecuador", 190 | "native_name": "Ecuador" 191 | }, 192 | { 193 | "iso_3166_1": "EE", 194 | "english_name": "Estonia", 195 | "native_name": "Estonia" 196 | }, 197 | { 198 | "iso_3166_1": "EG", 199 | "english_name": "Egypt", 200 | "native_name": "Egypt" 201 | }, 202 | { 203 | "iso_3166_1": "ES", 204 | "english_name": "Spain", 205 | "native_name": "Spain" 206 | }, 207 | { 208 | "iso_3166_1": "FI", 209 | "english_name": "Finland", 210 | "native_name": "Finland" 211 | }, 212 | { 213 | "iso_3166_1": "FJ", 214 | "english_name": "Fiji", 215 | "native_name": "Fiji" 216 | }, 217 | { 218 | "iso_3166_1": "FR", 219 | "english_name": "France", 220 | "native_name": "France" 221 | }, 222 | { 223 | "iso_3166_1": "GB", 224 | "english_name": "United Kingdom", 225 | "native_name": "United Kingdom" 226 | }, 227 | { 228 | "iso_3166_1": "GF", 229 | "english_name": "French Guiana", 230 | "native_name": "French Guiana" 231 | }, 232 | { 233 | "iso_3166_1": "GH", 234 | "english_name": "Ghana", 235 | "native_name": "Ghana" 236 | }, 237 | { 238 | "iso_3166_1": "GI", 239 | "english_name": "Gibraltar", 240 | "native_name": "Gibraltar" 241 | }, 242 | { 243 | "iso_3166_1": "GP", 244 | "english_name": "Guadaloupe", 245 | "native_name": "Guadeloupe" 246 | }, 247 | { 248 | "iso_3166_1": "GQ", 249 | "english_name": "Equatorial Guinea", 250 | "native_name": "Equatorial Guinea" 251 | }, 252 | { 253 | "iso_3166_1": "GR", 254 | "english_name": "Greece", 255 | "native_name": "Greece" 256 | }, 257 | { 258 | "iso_3166_1": "GT", 259 | "english_name": "Guatemala", 260 | "native_name": "Guatemala" 261 | }, 262 | { 263 | "iso_3166_1": "GY", 264 | "english_name": "Guyana", 265 | "native_name": "Guyana" 266 | }, 267 | { 268 | "iso_3166_1": "HK", 269 | "english_name": "Hong Kong", 270 | "native_name": "Hong Kong SAR China" 271 | }, 272 | { 273 | "iso_3166_1": "HN", 274 | "english_name": "Honduras", 275 | "native_name": "Honduras" 276 | }, 277 | { 278 | "iso_3166_1": "HR", 279 | "english_name": "Croatia", 280 | "native_name": "Croatia" 281 | }, 282 | { 283 | "iso_3166_1": "HU", 284 | "english_name": "Hungary", 285 | "native_name": "Hungary" 286 | }, 287 | { 288 | "iso_3166_1": "ID", 289 | "english_name": "Indonesia", 290 | "native_name": "Indonesia" 291 | }, 292 | { 293 | "iso_3166_1": "IE", 294 | "english_name": "Ireland", 295 | "native_name": "Ireland" 296 | }, 297 | { 298 | "iso_3166_1": "IL", 299 | "english_name": "Israel", 300 | "native_name": "Israel" 301 | }, 302 | { 303 | "iso_3166_1": "IN", 304 | "english_name": "India", 305 | "native_name": "India" 306 | }, 307 | { 308 | "iso_3166_1": "IQ", 309 | "english_name": "Iraq", 310 | "native_name": "Iraq" 311 | }, 312 | { 313 | "iso_3166_1": "IS", 314 | "english_name": "Iceland", 315 | "native_name": "Iceland" 316 | }, 317 | { 318 | "iso_3166_1": "IT", 319 | "english_name": "Italy", 320 | "native_name": "Italy" 321 | }, 322 | { 323 | "iso_3166_1": "JM", 324 | "english_name": "Jamaica", 325 | "native_name": "Jamaica" 326 | }, 327 | { 328 | "iso_3166_1": "JO", 329 | "english_name": "Jordan", 330 | "native_name": "Jordan" 331 | }, 332 | { 333 | "iso_3166_1": "JP", 334 | "english_name": "Japan", 335 | "native_name": "Japan" 336 | }, 337 | { 338 | "iso_3166_1": "KE", 339 | "english_name": "Kenya", 340 | "native_name": "Kenya" 341 | }, 342 | { 343 | "iso_3166_1": "KR", 344 | "english_name": "South Korea", 345 | "native_name": "South Korea" 346 | }, 347 | { 348 | "iso_3166_1": "KW", 349 | "english_name": "Kuwait", 350 | "native_name": "Kuwait" 351 | }, 352 | { 353 | "iso_3166_1": "LB", 354 | "english_name": "Lebanon", 355 | "native_name": "Lebanon" 356 | }, 357 | { 358 | "iso_3166_1": "LC", 359 | "english_name": "St. Lucia", 360 | "native_name": "St. Lucia" 361 | }, 362 | { 363 | "iso_3166_1": "LI", 364 | "english_name": "Liechtenstein", 365 | "native_name": "Liechtenstein" 366 | }, 367 | { 368 | "iso_3166_1": "LT", 369 | "english_name": "Lithuania", 370 | "native_name": "Lithuania" 371 | }, 372 | { 373 | "iso_3166_1": "LU", 374 | "english_name": "Luxembourg", 375 | "native_name": "Luxembourg" 376 | }, 377 | { 378 | "iso_3166_1": "LV", 379 | "english_name": "Latvia", 380 | "native_name": "Latvia" 381 | }, 382 | { 383 | "iso_3166_1": "LY", 384 | "english_name": "Libyan Arab Jamahiriya", 385 | "native_name": "Libya" 386 | }, 387 | { 388 | "iso_3166_1": "MA", 389 | "english_name": "Morocco", 390 | "native_name": "Morocco" 391 | }, 392 | { 393 | "iso_3166_1": "MC", 394 | "english_name": "Monaco", 395 | "native_name": "Monaco" 396 | }, 397 | { 398 | "iso_3166_1": "MD", 399 | "english_name": "Moldova", 400 | "native_name": "Moldova" 401 | }, 402 | { 403 | "iso_3166_1": "ME", 404 | "english_name": "Montenegro", 405 | "native_name": "Montenegro" 406 | }, 407 | { 408 | "iso_3166_1": "MG", 409 | "english_name": "Madagascar", 410 | "native_name": "Madagascar" 411 | }, 412 | { 413 | "iso_3166_1": "MK", 414 | "english_name": "Macedonia", 415 | "native_name": "Macedonia" 416 | }, 417 | { 418 | "iso_3166_1": "ML", 419 | "english_name": "Mali", 420 | "native_name": "Mali" 421 | }, 422 | { 423 | "iso_3166_1": "MT", 424 | "english_name": "Malta", 425 | "native_name": "Malta" 426 | }, 427 | { 428 | "iso_3166_1": "MU", 429 | "english_name": "Mauritius", 430 | "native_name": "Mauritius" 431 | }, 432 | { 433 | "iso_3166_1": "MW", 434 | "english_name": "Malawi", 435 | "native_name": "Malawi" 436 | }, 437 | { 438 | "iso_3166_1": "MX", 439 | "english_name": "Mexico", 440 | "native_name": "Mexico" 441 | }, 442 | { 443 | "iso_3166_1": "MY", 444 | "english_name": "Malaysia", 445 | "native_name": "Malaysia" 446 | }, 447 | { 448 | "iso_3166_1": "MZ", 449 | "english_name": "Mozambique", 450 | "native_name": "Mozambique" 451 | }, 452 | { 453 | "iso_3166_1": "NE", 454 | "english_name": "Niger", 455 | "native_name": "Niger" 456 | }, 457 | { 458 | "iso_3166_1": "NG", 459 | "english_name": "Nigeria", 460 | "native_name": "Nigeria" 461 | }, 462 | { 463 | "iso_3166_1": "NI", 464 | "english_name": "Nicaragua", 465 | "native_name": "Nicaragua" 466 | }, 467 | { 468 | "iso_3166_1": "NL", 469 | "english_name": "Netherlands", 470 | "native_name": "Netherlands" 471 | }, 472 | { 473 | "iso_3166_1": "NO", 474 | "english_name": "Norway", 475 | "native_name": "Norway" 476 | }, 477 | { 478 | "iso_3166_1": "NZ", 479 | "english_name": "New Zealand", 480 | "native_name": "New Zealand" 481 | }, 482 | { 483 | "iso_3166_1": "OM", 484 | "english_name": "Oman", 485 | "native_name": "Oman" 486 | }, 487 | { 488 | "iso_3166_1": "PA", 489 | "english_name": "Panama", 490 | "native_name": "Panama" 491 | }, 492 | { 493 | "iso_3166_1": "PE", 494 | "english_name": "Peru", 495 | "native_name": "Peru" 496 | }, 497 | { 498 | "iso_3166_1": "PF", 499 | "english_name": "French Polynesia", 500 | "native_name": "French Polynesia" 501 | }, 502 | { 503 | "iso_3166_1": "PG", 504 | "english_name": "Papua New Guinea", 505 | "native_name": "Papua New Guinea" 506 | }, 507 | { 508 | "iso_3166_1": "PH", 509 | "english_name": "Philippines", 510 | "native_name": "Philippines" 511 | }, 512 | { 513 | "iso_3166_1": "PK", 514 | "english_name": "Pakistan", 515 | "native_name": "Pakistan" 516 | }, 517 | { 518 | "iso_3166_1": "PL", 519 | "english_name": "Poland", 520 | "native_name": "Poland" 521 | }, 522 | { 523 | "iso_3166_1": "PS", 524 | "english_name": "Palestinian Territory", 525 | "native_name": "Palestinian Territories" 526 | }, 527 | { 528 | "iso_3166_1": "PT", 529 | "english_name": "Portugal", 530 | "native_name": "Portugal" 531 | }, 532 | { 533 | "iso_3166_1": "PY", 534 | "english_name": "Paraguay", 535 | "native_name": "Paraguay" 536 | }, 537 | { 538 | "iso_3166_1": "QA", 539 | "english_name": "Qatar", 540 | "native_name": "Qatar" 541 | }, 542 | { 543 | "iso_3166_1": "RO", 544 | "english_name": "Romania", 545 | "native_name": "Romania" 546 | }, 547 | { 548 | "iso_3166_1": "RS", 549 | "english_name": "Serbia", 550 | "native_name": "Serbia" 551 | }, 552 | { 553 | "iso_3166_1": "RU", 554 | "english_name": "Russia", 555 | "native_name": "Russia" 556 | }, 557 | { 558 | "iso_3166_1": "SA", 559 | "english_name": "Saudi Arabia", 560 | "native_name": "Saudi Arabia" 561 | }, 562 | { 563 | "iso_3166_1": "SC", 564 | "english_name": "Seychelles", 565 | "native_name": "Seychelles" 566 | }, 567 | { 568 | "iso_3166_1": "SE", 569 | "english_name": "Sweden", 570 | "native_name": "Sweden" 571 | }, 572 | { 573 | "iso_3166_1": "SG", 574 | "english_name": "Singapore", 575 | "native_name": "Singapore" 576 | }, 577 | { 578 | "iso_3166_1": "SI", 579 | "english_name": "Slovenia", 580 | "native_name": "Slovenia" 581 | }, 582 | { 583 | "iso_3166_1": "SK", 584 | "english_name": "Slovakia", 585 | "native_name": "Slovakia" 586 | }, 587 | { 588 | "iso_3166_1": "SM", 589 | "english_name": "San Marino", 590 | "native_name": "San Marino" 591 | }, 592 | { 593 | "iso_3166_1": "SN", 594 | "english_name": "Senegal", 595 | "native_name": "Senegal" 596 | }, 597 | { 598 | "iso_3166_1": "SV", 599 | "english_name": "El Salvador", 600 | "native_name": "El Salvador" 601 | }, 602 | { 603 | "iso_3166_1": "TC", 604 | "english_name": "Turks and Caicos Islands", 605 | "native_name": "Turks & Caicos Islands" 606 | }, 607 | { 608 | "iso_3166_1": "TD", 609 | "english_name": "Chad", 610 | "native_name": "Chad" 611 | }, 612 | { 613 | "iso_3166_1": "TH", 614 | "english_name": "Thailand", 615 | "native_name": "Thailand" 616 | }, 617 | { 618 | "iso_3166_1": "TN", 619 | "english_name": "Tunisia", 620 | "native_name": "Tunisia" 621 | }, 622 | { 623 | "iso_3166_1": "TR", 624 | "english_name": "Turkey", 625 | "native_name": "Turkey" 626 | }, 627 | { 628 | "iso_3166_1": "TT", 629 | "english_name": "Trinidad and Tobago", 630 | "native_name": "Trinidad & Tobago" 631 | }, 632 | { 633 | "iso_3166_1": "TW", 634 | "english_name": "Taiwan", 635 | "native_name": "Taiwan" 636 | }, 637 | { 638 | "iso_3166_1": "TZ", 639 | "english_name": "Tanzania", 640 | "native_name": "Tanzania" 641 | }, 642 | { 643 | "iso_3166_1": "UA", 644 | "english_name": "Ukraine", 645 | "native_name": "Ukraine" 646 | }, 647 | { 648 | "iso_3166_1": "UG", 649 | "english_name": "Uganda", 650 | "native_name": "Uganda" 651 | }, 652 | { 653 | "iso_3166_1": "US", 654 | "english_name": "United States of America", 655 | "native_name": "United States" 656 | }, 657 | { 658 | "iso_3166_1": "UY", 659 | "english_name": "Uruguay", 660 | "native_name": "Uruguay" 661 | }, 662 | { 663 | "iso_3166_1": "VA", 664 | "english_name": "Holy See", 665 | "native_name": "Vatican City" 666 | }, 667 | { 668 | "iso_3166_1": "VE", 669 | "english_name": "Venezuela", 670 | "native_name": "Venezuela" 671 | }, 672 | { 673 | "iso_3166_1": "XK", 674 | "english_name": "Kosovo", 675 | "native_name": "Kosovo" 676 | }, 677 | { 678 | "iso_3166_1": "YE", 679 | "english_name": "Yemen", 680 | "native_name": "Yemen" 681 | }, 682 | { 683 | "iso_3166_1": "ZA", 684 | "english_name": "South Africa", 685 | "native_name": "South Africa" 686 | }, 687 | { 688 | "iso_3166_1": "ZM", 689 | "english_name": "Zambia", 690 | "native_name": "Zambia" 691 | }, 692 | { 693 | "iso_3166_1": "ZW", 694 | "english_name": "Zimbabwe", 695 | "native_name": "Zimbabwe" 696 | } 697 | ]; -------------------------------------------------------------------------------- /src/api/tmdb.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const { safeRedisCall } = require('../helpers/redis'); 3 | const log = require('../helpers/logger'); 4 | const addToQueueTMDB = require('../helpers/bottleneck_tmdb'); 5 | const { pool } = require('../helpers/db'); 6 | 7 | const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; 8 | 9 | const PREFETCH_PAGE_COUNT = process.env.PREFETCH_PAGE_COUNT ? parseInt(process.env.PREFETCH_PAGE_COUNT, 10) : 5; 10 | const CACHE_CATALOG_CONTENT_DURATION_DAYS = process.env.CACHE_CATALOG_CONTENT_DURATION_DAYS ? parseInt(process.env.CACHE_CATALOG_CONTENT_DURATION_DAYS, 10) : 1; 11 | const CACHE_DURATION_SECONDS = CACHE_CATALOG_CONTENT_DURATION_DAYS * 86400; 12 | 13 | const makeRequest = (url, tmdbApiKey = null) => { 14 | if (tmdbApiKey) { 15 | url = `${url}${url.includes('?') ? '&' : '?'}api_key=${tmdbApiKey}`; 16 | } 17 | 18 | return new Promise((resolve, reject) => { 19 | addToQueueTMDB({ 20 | fn: () => axios.get(url) 21 | .then(response => { 22 | log.debug(`API request successful for URL: ${url}`); 23 | resolve(response.data); 24 | }) 25 | .catch(error => { 26 | log.error(`Error during API request for URL: ${url} - ${error.message}`); 27 | reject(error); 28 | }) 29 | }); 30 | }); 31 | }; 32 | 33 | const determinePageFromSkip = async (providerId, skip, type, sortBy, ageRange, rating = null, genre = null, year = null, watchRegion = 'no-region', language = 'en') => { 34 | try { 35 | if (skip === 0 || skip === null || skip === '') { 36 | log.debug('Skip is 0 or null, returning page 1'); 37 | return 1; 38 | } 39 | 40 | const keyPattern = `discover:${providerId}:${type}:${sortBy}:${ageRange}:${rating || 'no-rating'}:${genre || 'no-genre'}:${year || 'no-year'}:${watchRegion}:${language}:page:*:skip:*`; 41 | 42 | const keys = await safeRedisCall('keys', keyPattern); 43 | 44 | if (keys && keys.length > 0) { 45 | const filteredKeys = keys.filter(key => { 46 | const skipMatch = key.match(/skip:(\d+)/); 47 | return skipMatch && parseInt(skipMatch[1], 10) <= skip; 48 | }); 49 | 50 | if (filteredKeys.length > 0) { 51 | filteredKeys.sort((a, b) => { 52 | const skipA = parseInt(a.match(/skip:(\d+)/)[1], 10); 53 | const skipB = parseInt(b.match(/skip:(\d+)/)[1], 10); 54 | return skipB - skipA; 55 | }); 56 | 57 | const bestMatchKey = filteredKeys[0]; 58 | const cachedEntry = await safeRedisCall('get', bestMatchKey); 59 | 60 | if (cachedEntry) { 61 | const parsedEntry = JSON.parse(cachedEntry); 62 | log.debug(`Cached Entry: Page ${parsedEntry.page}, Skip ${parsedEntry.skip}`); 63 | return parsedEntry.page + 1; 64 | } 65 | } 66 | } 67 | 68 | log.debug(`No cached entry found for skip=${skip}, returning default page`); 69 | return 1; 70 | 71 | } catch (error) { 72 | log.error('Error in determinePageFromSkip:', error); 73 | return 1; 74 | } 75 | }; 76 | 77 | const fetchData = async (endpoint, params = {}, tmdbApiKey = null, providerId = null, ageRange = null, rating = null, genre = null, year = null, language = 'en') => { 78 | if (tmdbApiKey) { 79 | params.api_key = tmdbApiKey; 80 | } 81 | 82 | const { skip, type, sort_by: sortBy, watch_region: watchRegion = 'no-region' } = params; 83 | 84 | const page = providerId ? await determinePageFromSkip(providerId, skip, type, sortBy, ageRange, rating, genre, year, watchRegion, language) : 1; 85 | 86 | const { skip: _skip, type: _type, ...queryParamsWithoutSkipAndType } = params; 87 | const queryParamsWithPage = { 88 | ...queryParamsWithoutSkipAndType, 89 | page, 90 | }; 91 | 92 | const queryString = new URLSearchParams(queryParamsWithPage).toString(); 93 | const url = `${TMDB_BASE_URL}${endpoint}?${queryString}`; 94 | 95 | log.debug(`Request URL: ${url}`); 96 | 97 | const cacheKey = `discover:${providerId}:${type}:${sortBy}:${ageRange}:${rating || 'no-rating'}:${genre || 'no-genre'}:${year || 'no-year'}:${watchRegion}:${language}:page:${page}:skip:${skip}`; 98 | 99 | const cachedData = await safeRedisCall('get', cacheKey); 100 | 101 | if (cachedData) { 102 | log.debug(`Redis cache hit for key: ${cacheKey}`); 103 | return JSON.parse(cachedData); 104 | } 105 | 106 | const data = await makeRequest(url); 107 | 108 | if (data.total_pages >= page) { 109 | await safeRedisCall('setEx', cacheKey, CACHE_DURATION_SECONDS, JSON.stringify(data)); 110 | log.debug(`Data stored in Redis cache for key: ${cacheKey}`); 111 | } else { 112 | log.debug(`Skipping cache: Page ${page} exceeds total_pages ${data.total_pages}`); 113 | } 114 | 115 | if (data.total_pages > page) { 116 | prefetchNextPages(endpoint, queryParamsWithPage, page, data.total_pages, providerId, ageRange, rating, genre, year, watchRegion, language); 117 | } 118 | 119 | return data; 120 | }; 121 | 122 | const prefetchNextPages = async (endpoint, queryParamsWithPage, currentPage, totalPages, providerId, ageRange, rating = 'all', genre = 'all', year = 'all', watchRegion = 'no-region', language = 'en') => { 123 | const prefetchPromises = []; 124 | 125 | for (let i = 1; i <= PREFETCH_PAGE_COUNT; i++) { 126 | const nextPage = currentPage + i; 127 | const nextSkip = (nextPage - 1) * 20; 128 | 129 | const cacheKey = `discover:${providerId}:${queryParamsWithPage.type}:${queryParamsWithPage.sort_by}:${ageRange}:${rating}:${genre}:${year}:${watchRegion}:${language}:page:${nextPage}:skip:${nextSkip}`; 130 | 131 | const cachedData = await safeRedisCall('get', cacheKey); 132 | if (cachedData) { 133 | log.debug(`Prefetch skipped for URL: ${nextPage}, data already in cache`); 134 | } else { 135 | log.debug(`Preparing to prefetch page ${nextPage}`); 136 | prefetchPromises.push( 137 | (async () => { 138 | try { 139 | const nextQueryParamsWithPage = { ...queryParamsWithPage, page: nextPage }; 140 | delete nextQueryParamsWithPage.skip; 141 | 142 | const nextQueryString = new URLSearchParams(nextQueryParamsWithPage).toString(); 143 | const nextUrl = `${TMDB_BASE_URL}${endpoint}?${nextQueryString}`; 144 | 145 | const nextData = await makeRequest(nextUrl); 146 | await safeRedisCall('setEx', cacheKey, CACHE_DURATION_SECONDS, JSON.stringify(nextData)); 147 | 148 | log.debug(`Prefetched and stored data for URL: ${nextUrl} with page: ${nextPage}`); 149 | } catch (error) { 150 | log.warn(`Error prefetching URL: ${nextUrl} - ${error.message}`); 151 | } 152 | })() 153 | ); 154 | } 155 | 156 | if (nextPage > totalPages) { 157 | log.debug(`Stopping prefetch: nextPage (${nextPage}) exceeds totalPages (${totalPages})`); 158 | break; 159 | } 160 | } 161 | 162 | await Promise.all(prefetchPromises); 163 | log.debug(`Finished prefetching pages after page ${currentPage}`); 164 | }; 165 | 166 | const discoverContent = async (type, watchProviders = [], ageRange = null, sortBy = 'popularity.desc', genre = null, tmdbApiKey = null, language = 'en', skip = 0, regions = [], year = null, rating = null) => { 167 | const mediaType = type === 'series' ? 'tv' : 'movie'; 168 | const endpoint = `/discover/${mediaType}`; 169 | 170 | regions = regions && regions.length > 0 ? regions : []; 171 | 172 | const providerId = watchProviders[0]; 173 | 174 | const params = { 175 | with_watch_providers: watchProviders.join(','), 176 | sort_by: sortBy, 177 | language, 178 | skip, 179 | type 180 | }; 181 | 182 | if (year) { 183 | const [startYear, endYear] = year.split('-'); 184 | if (startYear && endYear) { 185 | if (mediaType === 'movie') { 186 | params['primary_release_date.gte'] = `${startYear}-01-01`; 187 | params['primary_release_date.lte'] = `${endYear}-12-31`; 188 | } else if (mediaType === 'tv') { 189 | params['first_air_date.gte'] = `${startYear}-01-01`; 190 | params['first_air_date.lte'] = `${endYear}-12-31`; 191 | } 192 | } 193 | } 194 | 195 | if (rating) { 196 | const [minRating, maxRating] = rating.split('-'); 197 | if (minRating && maxRating) { 198 | params['vote_average.gte'] = minRating; 199 | params['vote_average.lte'] = maxRating; 200 | } 201 | } 202 | 203 | if (ageRange) { 204 | switch(ageRange) { 205 | case '0-5': 206 | if (mediaType === 'movie') { 207 | params.certification_country = 'US'; 208 | params.certification = 'G'; 209 | params.without_genres = '27,18,53,80,10752,37,10749,10768,10767,10766,10764,10763,9648,99,36'; 210 | } 211 | if (mediaType === 'tv') { 212 | params.with_genres = '10762'; // Kids only 213 | } 214 | break; 215 | 216 | case '6-11': 217 | if (mediaType === 'movie') { 218 | params.certification_country = 'US'; 219 | params.certification = 'G'; 220 | params.without_genres = '27,18,53,80,10752,37,10749,10768,10767,10766,10764,10763,9648,99,36'; 221 | } 222 | if (mediaType === 'tv') { 223 | params.with_genres = '10762'; // Kids only 224 | } 225 | break; 226 | 227 | case '12-15': 228 | if (mediaType === 'movie') { 229 | params.certification_country = 'US'; 230 | params.certification = 'PG'; 231 | } 232 | if (mediaType === 'tv') { 233 | params.with_genres = '16'; // Animation only 234 | } 235 | break; 236 | 237 | case '16-17': 238 | if (mediaType === 'movie') { 239 | params.certification_country = 'US'; 240 | params.certification = 'PG-13'; 241 | } 242 | break; 243 | 244 | case '18+': 245 | if (mediaType === 'movie') { 246 | params.include_adult = true; 247 | } 248 | break; 249 | 250 | default: 251 | log.warn(`Unknown ageRange: ${ageRange}`); 252 | break; 253 | } 254 | } 255 | 256 | if (genre) { 257 | params.with_genres = genre; 258 | } 259 | 260 | const fetchForRegion = async (region) => { 261 | const clonedParams = { ...params }; 262 | 263 | if (region) { 264 | clonedParams.watch_region = region; 265 | } else { 266 | delete clonedParams.watch_region; 267 | } 268 | 269 | return await fetchData(endpoint, clonedParams, tmdbApiKey, providerId, ageRange, rating, genre, year, language); 270 | }; 271 | 272 | const results = await Promise.all(regions.map(region => fetchForRegion(region))); 273 | 274 | const combinedResults = results.reduce((acc, result) => acc.concat(result.results), []); 275 | 276 | const uniqueResults = Array.from(new Map(combinedResults.map(item => [item.id, item])).values()); 277 | 278 | return { 279 | ...results[0], 280 | results: uniqueResults 281 | }; 282 | }; 283 | 284 | const getRecommendationsFromTmdb = async (tmdbId, type, apiKey, language) => { 285 | try { 286 | const tmdbType = type === 'series' ? 'tv' : type; 287 | const cacheKey = `recommendations:${tmdbId}:${tmdbType}:${language}`; 288 | 289 | const cachedData = await safeRedisCall('get', cacheKey); 290 | if (cachedData) { 291 | log.debug(`Redis cache hit for recommendations key: ${cacheKey}`); 292 | return JSON.parse(cachedData); 293 | } 294 | 295 | const url = `https://api.themoviedb.org/3/${tmdbType}/${tmdbId}/recommendations?language=${language}`; 296 | const data = await makeRequest(url, apiKey); 297 | 298 | if (data.results) { 299 | await safeRedisCall('setEx', cacheKey, CACHE_DURATION_SECONDS, JSON.stringify(data.results)); 300 | } 301 | 302 | return data.results; 303 | } catch (error) { 304 | if (error.response && error.response.status === 404) { 305 | log.error(`TMDB ID ${tmdbId} not found or no recommendations available for type: ${tmdbType}`); 306 | return []; 307 | } 308 | throw new Error(`Failed to fetch recommendations from TMDB: ${error.message}`); 309 | } 310 | }; 311 | 312 | const getSimilarContentFromTmdb = async (tmdbId, type, apiKey, language) => { 313 | try { 314 | const tmdbType = type === 'series' ? 'tv' : type; 315 | const cacheKey = `similar:${tmdbId}:${tmdbType}:${language}`; 316 | 317 | const cachedData = await safeRedisCall('get', cacheKey); 318 | if (cachedData) { 319 | log.debug(`Redis cache hit for similar content key: ${cacheKey}`); 320 | return JSON.parse(cachedData); 321 | } 322 | 323 | const url = `https://api.themoviedb.org/3/${tmdbType}/${tmdbId}/similar?language=${language}`; 324 | const data = await makeRequest(url, apiKey); 325 | 326 | if (data.results) { 327 | await safeRedisCall('setEx', cacheKey, CACHE_DURATION_SECONDS, JSON.stringify(data.results)); 328 | } 329 | 330 | return data.results; 331 | } catch (error) { 332 | if (error.response && error.response.status === 404) { 333 | log.error(`TMDB ID ${tmdbId} not found or no similar content available for type: ${tmdbType}`); 334 | return []; 335 | } 336 | throw new Error(`Failed to fetch similar content from TMDB: ${error.message}`); 337 | } 338 | }; 339 | 340 | const getContentFromImdbId = async (imdbId, apiKey, language) => { 341 | const cleanedImdbId = imdbId.split(':')[0]; 342 | const cacheKey = `imdb:${cleanedImdbId}:${language}`; 343 | 344 | log.info(`Checking content from IMDb ID: ${cleanedImdbId}`); 345 | 346 | const cachedData = await safeRedisCall('get', cacheKey); 347 | if (cachedData) { 348 | log.debug(`Redis cache hit for IMDb key: ${cacheKey}`); 349 | return JSON.parse(cachedData); 350 | } 351 | 352 | const url = `https://api.themoviedb.org/3/find/${cleanedImdbId}?external_source=imdb_id&language=${language}`; 353 | const data = await makeRequest(url, apiKey); 354 | 355 | const content = data.movie_results?.[0] || data.tv_results?.[0]; 356 | if (content) { 357 | const result = { 358 | tmdbId: content.id, 359 | title: content.title || content.name, 360 | type: data.movie_results?.length ? 'movie' : 'tv' 361 | }; 362 | await safeRedisCall('setEx', cacheKey, CACHE_DURATION_SECONDS, JSON.stringify(result)); 363 | return result; 364 | } 365 | 366 | return null; 367 | }; 368 | 369 | const getContentDetails = async (tmdbId, type, apiKey, language) => { 370 | const tmdbType = type === 'series' ? 'tv' : type; 371 | const cacheKey = `details:${tmdbId}:${tmdbType}:${language}`; 372 | 373 | log.debug(`Checking content details for TMDB ID: ${tmdbId}`); 374 | 375 | const cachedData = await safeRedisCall('get', cacheKey); 376 | if (cachedData) { 377 | log.debug(`Redis cache hit for details key: ${cacheKey}`); 378 | return JSON.parse(cachedData); 379 | } 380 | 381 | const url = `https://api.themoviedb.org/3/${tmdbType}/${tmdbId}?language=${language}`; 382 | const data = await makeRequest(url, apiKey); 383 | 384 | if (data) { 385 | await safeRedisCall('setEx', cacheKey, CACHE_DURATION_SECONDS, JSON.stringify(data)); 386 | } 387 | 388 | return data; 389 | }; 390 | 391 | const getContentDetailsById = async (item, type, apiKey, language) => { 392 | try { 393 | log.debug(`Fetching details for item ID: ${item.id}`); 394 | 395 | const tmdbType = type === 'series' ? 'tv' : type; 396 | 397 | const details = await getContentDetails(item.id, tmdbType, apiKey, language); 398 | return { 399 | ...item, 400 | title: details.title || details.name, 401 | tagline: details.tagline || '', 402 | rating: details.vote_average, 403 | vote_count: details.vote_count, 404 | released: details.release_date || details.first_air_date 405 | }; 406 | } catch (error) { 407 | log.error(`Error fetching details for item ID: ${item.id}`, error); 408 | throw new Error('Failed to get content details'); 409 | } 410 | }; 411 | 412 | const getImdbId = async (tmdbId, type, apiKey, language) => { 413 | log.debug(`Checking IMDb ID for TMDB ID: ${tmdbId}`); 414 | 415 | const tmdbType = type === 'series' ? 'tv' : type; 416 | const cacheKey = `imdbId:${tmdbId}:${tmdbType}:${language}`; 417 | 418 | const cachedData = await safeRedisCall('get', cacheKey); 419 | if (cachedData) { 420 | log.debug(`Redis cache hit for IMDb ID key: ${cacheKey}`); 421 | return cachedData; 422 | } 423 | 424 | const url = `https://api.themoviedb.org/3/${tmdbType}/${tmdbId}?language=${language}`; 425 | const data = await makeRequest(url, apiKey); 426 | 427 | const imdbId = data?.imdb_id || null; 428 | 429 | if (imdbId) { 430 | await safeRedisCall('setEx', cacheKey, CACHE_DURATION_SECONDS, imdbId); 431 | } 432 | 433 | return imdbId; 434 | }; 435 | 436 | const fetchGenres = async (type, language, tmdbApiKey) => { 437 | const mediaType = type === 'series' ? 'tv' : 'movie'; 438 | const endpoint = `/genre/${mediaType}/list`; 439 | 440 | try { 441 | const params = { 442 | language, 443 | api_key: tmdbApiKey 444 | }; 445 | 446 | const genresData = await fetchData(endpoint, params, tmdbApiKey); 447 | log.debug(`Genres retrieved for ${type} (${language})`); 448 | return genresData.genres; 449 | } catch (error) { 450 | log.error(`Error fetching genres from TMDB: ${error.message}`); 451 | throw error; 452 | } 453 | }; 454 | 455 | const storeGenresInDb = async (genres, mediaType, language) => { 456 | const client = await pool.connect(); 457 | try { 458 | await client.query('BEGIN'); 459 | 460 | const insertGenreText = ` 461 | INSERT INTO genres (genre_id, genre_name, media_type, language) 462 | VALUES ($1, $2, $3, $4) 463 | ON CONFLICT DO NOTHING 464 | `; 465 | 466 | for (const genre of genres) { 467 | await client.query(insertGenreText, [genre.id, genre.name, mediaType, language]); 468 | } 469 | 470 | await client.query('COMMIT'); 471 | log.info(`Genres stored for ${mediaType} (${language})`); 472 | } catch (err) { 473 | await client.query('ROLLBACK'); 474 | log.error(`Error inserting genre: ${err.message}`); 475 | throw err; 476 | } finally { 477 | client.release(); 478 | } 479 | }; 480 | 481 | const checkGenresExistForLanguage = async (language) => { 482 | try { 483 | log.debug(`Checking genres for ${language}`); 484 | const result = await pool.query( 485 | `SELECT 1 FROM genres WHERE language = $1 LIMIT 1`, 486 | [language] 487 | ); 488 | return result.rows.length > 0; 489 | } catch (err) { 490 | log.error(`Error checking genres: ${err.message}`); 491 | throw err; 492 | } 493 | }; 494 | 495 | const fetchAndStoreGenres = async (language, tmdbApiKey) => { 496 | try { 497 | const movieGenres = await fetchGenres('movie', language, tmdbApiKey); 498 | const tvGenres = await fetchGenres('series', language, tmdbApiKey); 499 | 500 | await storeGenresInDb(movieGenres, 'movie', language); 501 | await storeGenresInDb(tvGenres, 'tv', language); 502 | 503 | log.info(`Genres fetched and stored for ${language}`); 504 | } catch (error) { 505 | log.error(`Error fetching/storing genres: ${error.message}`); 506 | } 507 | }; 508 | 509 | module.exports = { makeRequest, fetchData, discoverContent, checkGenresExistForLanguage, fetchAndStoreGenres, getRecommendationsFromTmdb, getContentFromImdbId, getContentDetailsById, getImdbId, getSimilarContentFromTmdb }; 510 | -------------------------------------------------------------------------------- /public/configure.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |
836 |