import showdown from 'showdown'; import compression from 'compression'; import express from 'express'; import localtunnel from 'localtunnel'; import { rateLimit } from 'express-rate-limit'; import {readFileSync} from "fs"; import config from './lib/config.js'; import cache, {vacuum as vacuumCache, clean as cleanCache} from './lib/cache.js'; import path from 'path'; import * as meta from './lib/meta.js'; import * as icon from './lib/icon.js'; import * as debrid from './lib/debrid.js'; import {getIndexers} from './lib/jackett.js'; import * as jackettio from "./lib/jackettio.js"; import {cleanTorrentFolder, createTorrentFolder} from './lib/torrentInfos.js'; const converter = new showdown.Converter(); const welcomeMessageHtml = config.welcomeMessage ? `${converter.makeHtml(config.welcomeMessage)}
` : ''; const addon = JSON.parse(readFileSync(`./package.json`)); const app = express(); const respond = (res, data) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Headers', '*') res.setHeader('Content-Type', 'application/json') res.send(data) }; const limiter = rateLimit({ windowMs: config.rateLimitWindow * 1000, max: config.rateLimitRequest, legacyHeaders: false, standardHeaders: 'draft-7', keyGenerator: (req) => req.clientIp || req.ip, handler: (req, res, next, options) => { if(req.route.path == '/:userConfig/stream/:type/:id.json'){ const resetInMs = new Date(req.rateLimit.resetTime) - new Date(); return res.json({streams: [{ name: `${config.addonName}`, title: `🛑 Too many requests, please try in ${Math.ceil(resetInMs / 1000 / 60)} minute(s).`, url: '#' }]}) }else{ return res.status(options.statusCode).send(options.message); } } }); app.set('trust proxy', config.trustProxy); app.use((req, res, next) => { req.clientIp = req.ip; if(req.get('CF-Connecting-IP')){ req.clientIp = req.get('CF-Connecting-IP'); } next(); }); app.use(compression()); app.use(express.static(path.join(import.meta.dirname, 'static'), {maxAge: 86400e3})); app.get('/', (req, res) => { res.redirect('/configure') res.end(); }); app.get('/icon', async (req, res) => { const filePath = await icon.getLocation(); res.contentType(path.basename(filePath)); res.setHeader('Cache-Control', `public, max-age=${3600}`); return res.sendFile(filePath); }); app.use((req, res, next) => { console.log(`${req.method} ${req.path.replace(/\/eyJ[\w\=]+/g, '/*******************')}`); next(); }); app.get('/:userConfig?/configure', async(req, res) => { let indexers = (await getIndexers().catch(() => [])) .map(indexer => ({ value: indexer.id, label: indexer.title, types: ['movie', 'series'].filter(type => indexer.searching[type].available) })); const templateConfig = { debrids: await debrid.list(), addon: { version: addon.version, name: config.addonName }, userConfig: req.params.userConfig || '', defaultUserConfig: config.defaultUserConfig, qualities: config.qualities, languages: config.languages.map(l => ({value: l.value, label: l.label})).filter(v => v.value != 'multi'), metaLanguages: await meta.getLanguages(), sorts: config.sorts, indexers, passkey: {enabled: false}, immulatableUserConfigKeys: config.immulatableUserConfigKeys }; if(config.replacePasskey){ templateConfig.passkey = { enabled: true, infoUrl: config.replacePasskeyInfoUrl, pattern: config.replacePasskeyPattern } } let template = readFileSync(`./src/template/configure.html`).toString() .replace('/** import-config */', `const config = ${JSON.stringify(templateConfig, null, 2)}`) .replace('', welcomeMessageHtml); return res.send(template); }); // https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/advanced.md#using-user-data-in-addons app.get("/:userConfig?/manifest.json", async(req, res) => { const manifest = { id: config.addonId, version: addon.version, name: config.addonName, description: config.addonDescription, icon: `${req.hostname == 'localhost' ? 'http' : 'https'}://${req.hostname}/icon`, resources: ["stream"], types: ["movie", "series"], idPrefixes: ["tt","tmdb"], catalogs: [], behaviorHints: {configurable: true} }; if(req.params.userConfig){ const userConfig = JSON.parse(atob(req.params.userConfig)); const debridInstance = debrid.instance(userConfig); manifest.name += ` ${debridInstance.shortName}`; } respond(res, manifest); }); app.get("/:userConfig/stream/:type/:id.json", limiter, async(req, res) => { try { const streams = await jackettio.getStreams( Object.assign(JSON.parse(atob(req.params.userConfig)), {ip: req.clientIp}), req.params.type, req.params.id, `${req.hostname == 'localhost' ? 'http' : 'https'}://${req.hostname}` ); return respond(res, {streams}); }catch(err){ console.log(req.params.id, err); return respond(res, {streams: []}); } }); app.get("/stream/:type/:id.json", async(req, res) => { return respond(res, {streams: [{ name: config.addonName, title: `ℹ Kindly configure this addon to access streams.`, url: '#' }]}); }); app.use('/:userConfig/download/:type/:id/:torrentId', async(req, res, next) => { if (req.method !== 'GET' && req.method !== 'HEAD'){ return next(); } try { const url = await jackettio.getDownload( Object.assign(JSON.parse(atob(req.params.userConfig)), {ip: req.clientIp}), req.params.type, req.params.id, req.params.torrentId ); const parsed = new URL(url); const cut = (value) => value ? `${value.substr(0, 5)}******${value.substr(-5)}` : ''; console.log(`${req.params.id} : Redirect: ${parsed.protocol}//${parsed.host}${cut(parsed.pathname)}${cut(parsed.search)}`); res.status(302); res.set('location', url); res.send(''); }catch(err){ console.log(req.params.id, err); switch(err.message){ case debrid.ERROR.NOT_READY: res.status(302); res.set('location', `/videos/not_ready.mp4`); res.send(''); break; case debrid.ERROR.EXPIRED_API_KEY: res.status(302); res.set('location', `/videos/expired_api_key.mp4`); res.send(''); break; case debrid.ERROR.NOT_PREMIUM: res.status(302); res.set('location', `/videos/not_premium.mp4`); res.send(''); break; case debrid.ERROR.ACCESS_DENIED: res.status(302); res.set('location', `/videos/access_denied.mp4`); res.send(''); break; case debrid.ERROR.TWO_FACTOR_AUTH: res.status(302); res.set('location', `/videos/two_factor_auth.mp4`); res.send(''); break; default: res.status(302); res.set('location', `/videos/error.mp4`); res.send(''); } } }); app.use((req, res) => { if (req.xhr) { res.status(404).send({ error: 'Page not found!' }) } else { res.status(404).send('Page not found!'); } }); app.use((err, req, res, next) => { console.error(err.stack) if (req.xhr) { res.status(500).send({ error: 'Something broke!' }) } else { res.status(500).send('Something broke!'); } }) const server = app.listen(config.port, async () => { console.log('───────────────────────────────────────'); console.log(`Started addon ${addon.name} v${addon.version}`); console.log(`Server listen at: http://localhost:${config.port}`); console.log('───────────────────────────────────────'); let tunnel; if(config.localtunnel){ let subdomain = await cache.get('localtunnel:subdomain'); tunnel = await localtunnel({port: config.port, subdomain}); await cache.set('localtunnel:subdomain', tunnel.clientId, {ttl: 86400*365}); console.log(`Your addon is available on the following address: ${tunnel.url}/configure`); tunnel.on('close', () => console.log("tunnels are closed")); } icon.download().catch(err => console.log(`Failed to download icon: ${err}`)); const intervals = []; createTorrentFolder(); intervals.push(setInterval(cleanTorrentFolder, 3600e3)); vacuumCache().catch(err => console.log(`Failed to vacuum cache: ${err}`)); intervals.push(setInterval(() => vacuumCache(), 86400e3*7)); cleanCache().catch(err => console.log(`Failed to clean cache: ${err}`)); intervals.push(setInterval(() => cleanCache(), 3600e3)); function closeGracefully(signal) { console.log(`Received signal to terminate: ${signal}`); if(tunnel)tunnel.close(); intervals.forEach(interval => clearInterval(interval)); server.close(() => { console.log('Server closed'); process.kill(process.pid, signal); }); } process.once('SIGINT', closeGracefully); process.once('SIGTERM', closeGracefully); });