import namedQueue from 'named-queue'; import * as options from './options.js'; import * as realdebrid from './realdebrid.js'; import * as premiumize from './premiumize.js'; import * as alldebrid from './alldebrid.js'; import * as debridlink from './debridlink.js'; import * as offcloud from './offcloud.js'; import * as putio from './putio.js'; import StaticResponse, { isStaticUrl } from './static.js'; import { cacheWrapResolvedUrl } from '../lib/cache.js'; import { timeout } from '../lib/promises.js'; import { BadTokenError, streamFilename, AccessDeniedError, enrichMeta } from './mochHelper.js'; const RESOLVE_TIMEOUT = 2 * 60 * 1000; // 2 minutes const MIN_API_KEY_SYMBOLS = 15; const TOKEN_BLACKLIST = []; export const MochOptions = { realdebrid: { key: 'realdebrid', instance: realdebrid, name: "RealDebrid", shortName: 'RD', catalog: true }, premiumize: { key: 'premiumize', instance: premiumize, name: 'Premiumize', shortName: 'PM', catalog: true }, alldebrid: { key: 'alldebrid', instance: alldebrid, name: 'AllDebrid', shortName: 'AD', catalog: true }, debridlink: { key: 'debridlink', instance: debridlink, name: 'DebridLink', shortName: 'DL', catalog: true }, offcloud: { key: 'offcloud', instance: offcloud, name: 'Offcloud', shortName: 'OC', catalog: true }, putio: { key: 'putio', instance: putio, name: 'Put.io', shortName: 'Putio', catalog: true } }; const unrestrictQueues = {} Object.values(MochOptions) .map(moch => moch.key) .forEach(mochKey => unrestrictQueues[mochKey] = new namedQueue((task, callback) => task.method() .then(result => callback(false, result)) .catch((error => callback(error))), 200)); export function hasMochConfigured(config) { return Object.keys(MochOptions).find(moch => config?.[moch]) } export async function applyMochs(streams, config) { if (!streams?.length || !hasMochConfigured(config)) { return streams; } return Promise.all(Object.keys(config) .filter(configKey => MochOptions[configKey]) .map(configKey => MochOptions[configKey]) .map(moch => { if (isInvalidToken(config[moch.key], moch.key)) { return { moch, error: BadTokenError }; } return moch.instance.getCachedStreams(streams, config[moch.key]) .then(mochStreams => ({ moch, mochStreams })) .catch(rawError => { const error = moch.instance.toCommonError(rawError) || rawError; if (error === BadTokenError) { blackListToken(config[moch.key], moch.key); } return { moch, error }; }) })) .then(results => processMochResults(streams, config, results)); } export async function resolve(parameters) { const moch = MochOptions[parameters.mochKey]; if (!moch) { return Promise.reject(new Error(`Not a valid moch provider: ${parameters.mochKey}`)); } if (!parameters.apiKey || !parameters.infoHash || !parameters.cachedEntryInfo) { return Promise.reject(new Error("No valid parameters passed")); } const id = `${parameters.ip}_${parameters.mochKey}_${parameters.apiKey}_${parameters.infoHash}_${parameters.fileIndex}`; const method = () => timeout(RESOLVE_TIMEOUT, cacheWrapResolvedUrl(id, () => moch.instance.resolve(parameters))) .catch(error => { console.warn(error); return StaticResponse.FAILED_UNEXPECTED; }) .then(url => isStaticUrl(url) ? `${parameters.host}/${url}` : url); const unrestrictQueue = unrestrictQueues[moch.key]; return new Promise(((resolve, reject) => { unrestrictQueue.push({ id, method }, (error, result) => result ? resolve(result) : reject(error)); })); } export async function getMochCatalog(mochKey, config) { const moch = MochOptions[mochKey]; if (!moch) { return Promise.reject(new Error(`Not a valid moch provider: ${mochKey}`)); } if (isInvalidToken(config[mochKey], mochKey)) { return Promise.reject(new Error(`Invalid API key for moch provider: ${mochKey}`)); } return moch.instance.getCatalog(config[moch.key], config.skip, config.ip) .catch(rawError => { const commonError = moch.instance.toCommonError(rawError); if (commonError === BadTokenError) { blackListToken(config[moch.key], moch.key); } return commonError ? [] : Promise.reject(rawError); }); } export async function getMochItemMeta(mochKey, itemId, config) { const moch = MochOptions[mochKey]; if (!moch) { return Promise.reject(new Error(`Not a valid moch provider: ${mochKey}`)); } return moch.instance.getItemMeta(itemId, config[moch.key], config.ip) .then(meta => enrichMeta(meta)) .then(meta => { meta.videos.forEach(video => video.streams.forEach(stream => { if (!stream.url.startsWith('http')) { stream.url = `${config.host}/${moch.key}/${stream.url}/${streamFilename(video)}` } stream.behaviorHints = { bingeGroup: itemId } })) return meta; }); } function processMochResults(streams, config, results) { const errorResults = results .map(result => errorStreamResponse(result.moch.key, result.error, config)) .filter(errorResponse => errorResponse); if (errorResults.length) { return errorResults; } const includeTorrentLinks = options.includeTorrentLinks(config); const excludeDownloadLinks = options.excludeDownloadLinks(config); const mochResults = results.filter(result => result?.mochStreams); const cachedStreams = mochResults .reduce((resultStreams, mochResult) => populateCachedLinks(resultStreams, mochResult, config), streams); const resultStreams = excludeDownloadLinks ? cachedStreams : populateDownloadLinks(cachedStreams, mochResults, config); return includeTorrentLinks ? resultStreams : resultStreams.filter(stream => stream.url); } function populateCachedLinks(streams, mochResult, config) { return streams.map(stream => { const cachedEntry = stream.infoHash && mochResult.mochStreams[stream.infoHash]; if (cachedEntry?.cached) { return { name: `[${mochResult.moch.shortName}+] ${stream.name}`, title: stream.title, url: `${config.host}/${mochResult.moch.key}/${cachedEntry.url}/${streamFilename(stream)}`, behaviorHints: stream.behaviorHints }; } return stream; }); } function populateDownloadLinks(streams, mochResults, config) { const torrentStreams = streams.filter(stream => stream.infoHash); const seededStreams = streams.filter(stream => !stream.title.includes('👤 0')); torrentStreams.forEach(stream => mochResults.forEach(mochResult => { const cachedEntry = mochResult.mochStreams[stream.infoHash]; const isCached = cachedEntry?.cached; if (!isCached && isHealthyStreamForDebrid(seededStreams, stream)) { streams.push({ name: `[${mochResult.moch.shortName} download] ${stream.name}`, title: stream.title, url: `${config.host}/${mochResult.moch.key}/${cachedEntry.url}/${streamFilename(stream)}`, behaviorHints: stream.behaviorHints }) } })); return streams; } function isHealthyStreamForDebrid(streams, stream) { const isZeroSeeders = stream.title.includes('👤 0'); const is4kStream = stream.name.includes('4k'); const isNotEnoughOptions = streams.length <= 5; return !isZeroSeeders || is4kStream || isNotEnoughOptions; } function isInvalidToken(token, mochKey) { return token.length < MIN_API_KEY_SYMBOLS || TOKEN_BLACKLIST.includes(`${mochKey}|${token}`); } function blackListToken(token, mochKey) { const tokenKey = `${mochKey}|${token}`; console.log(`Blacklisting invalid token: ${tokenKey}`) TOKEN_BLACKLIST.push(tokenKey); } function errorStreamResponse(mochKey, error, config) { if (error === BadTokenError) { return { name: `Torrentio\n${MochOptions[mochKey].shortName} error`, title: `Invalid ${MochOptions[mochKey].name} ApiKey/Token!`, url: `${config.host}/${StaticResponse.FAILED_ACCESS}` }; } if (error === AccessDeniedError) { return { name: `Torrentio\n${MochOptions[mochKey].shortName} error`, title: `Expired/invalid ${MochOptions[mochKey].name} subscription!`, url: `${config.host}/${StaticResponse.FAILED_ACCESS}` }; } return undefined; }