torrentio-scraper/addon/moch/moch.js
2026-08-19 15:57:58 +03:00

304 lines
10 KiB
JavaScript

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 easydebrid from './easydebrid.js';
import * as offcloud from './offcloud.js';
import * as torbox from './torbox.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 { streamFilename, enrichMeta, BadTokenError, AccessDeniedError, AccessBlockedError, NotFoundError } from './mochHelper.js';
import { createNamedQueue } from "../lib/namedQueue.js";
import { mochResolveTimer, mochAvailabilityTimer, mochQueueTimer, recordTokenBlacklist } from "../lib/metrics.js";
const RESOLVE_TIMEOUT = 2 * 60 * 1000; // 2 minutes
const AVAILABILITY_TIMEOUT = 30 * 1000;
const MIN_API_KEY_SYMBOLS = 15;
const TOKEN_BLACKLIST_MAX = 20000;
const tokenBlacklist = new Set();
export const MochOptions = {
realdebrid: {
key: 'realdebrid',
instance: realdebrid,
name: "RealDebrid",
shortName: 'RD',
catalogs: ['']
},
premiumize: {
key: 'premiumize',
instance: premiumize,
name: 'Premiumize',
shortName: 'PM',
catalogs: ['']
},
alldebrid: {
key: 'alldebrid',
instance: alldebrid,
name: 'AllDebrid',
shortName: 'AD',
catalogs: ['']
},
debridlink: {
key: 'debridlink',
instance: debridlink,
name: 'DebridLink',
shortName: 'DL',
catalogs: ['']
},
easydebrid: {
key: 'easydebrid',
instance: easydebrid,
name: 'EasyDebrid',
shortName: 'ED',
catalogs: [],
noDownloads: true
},
offcloud: {
key: 'offcloud',
instance: offcloud,
name: 'Offcloud',
shortName: 'OC',
catalogs: ['']
},
torbox: {
key: 'torbox',
instance: torbox,
name: 'TorBox',
shortName: 'TB',
catalogs: [`Torrents`, `Usenet`, `WebDL`]
},
putio: {
key: 'putio',
instance: putio,
name: 'Put.io',
shortName: 'Putio',
catalogs: ['']
}
};
const unrestrictQueues = {}
Object.values(MochOptions)
.map(moch => moch.key)
.forEach(mochKey => unrestrictQueues[mochKey] = createNamedQueue(100));
export function queueDepths() {
return Object.fromEntries(Object.entries(unrestrictQueues).map(([moch, queue]) => [moch, queue.length()]));
}
export function blacklistSize() {
return tokenBlacklist.size;
}
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 };
}
const end = mochAvailabilityTimer(moch.key);
return timeout(AVAILABILITY_TIMEOUT, moch.instance.getCachedStreams(streams, config[moch.key], config.ip))
.then(mochStreams => { end({ outcome: 'ok' }); return { moch, mochStreams }; })
.catch(rawError => {
end({ outcome: rawError?.message === 'Timed out' ? 'timeout' : 'error' });
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(NotFoundError);
}
if (!parameters.apiKey || !parameters.infoHash || !parameters.cachedEntryInfo) {
return Promise.reject(new Error("No valid parameters passed"));
}
if (isInvalidToken(parameters.apiKey, moch.key)) {
mochResolveTimer(moch.key)({ outcome: resolveOutcome(StaticResponse.FAILED_ACCESS) });
return `${parameters.host}/${StaticResponse.FAILED_ACCESS}`;
}
const id = `${parameters.ip}_${parameters.mochKey}_${parameters.apiKey}_${parameters.infoHash}_${parameters.fileIndex}`;
const resolveApi = () => {
const end = mochResolveTimer(moch.key);
return moch.instance.resolve(parameters)
.then(url => {
end({ outcome: resolveOutcome(url) });
return url;
})
.catch(error => {
end({ outcome: 'failed' });
throw error;
});
};
const queueWaitEnd = mochQueueTimer(moch.key);
const method = () => {
queueWaitEnd();
return timeout(RESOLVE_TIMEOUT, cacheWrapResolvedUrl(id, resolveApi))
.catch(error => {
console.warn(error);
return StaticResponse.FAILED_UNEXPECTED;
})
.then(url => isStaticUrl(url) ? `${parameters.host}/${url}` : url);
};
return unrestrictQueues[moch.key].wrap(id, method);
}
export async function getMochCatalog(mochKey, catalogId, config, ) {
const moch = MochOptions[mochKey];
if (!moch) {
return Promise.reject(NotFoundError);
}
if (isInvalidToken(config[mochKey], mochKey)) {
return Promise.reject(new Error(`Invalid API key for moch provider: ${mochKey}`));
}
return moch.instance.getCatalog(config[moch.key], catalogId, config)
.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(NotFoundError);
}
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}/resolve/${moch.key}/${stream.url}/${streamFilename(video)}`
}
stream.behaviorHints = { bingeGroup: itemId }
}))
return meta;
});
}
function processMochResults(streams, config, results) {
const excludeDownloadLinks = options.excludeDownloadLinks(config);
const cachedStreams = results.reduce((resultStreams, result) => {
if (result?.mochStreams) {
return populateCachedLinks(resultStreams, result, config)
}
const errorStream = errorStreamResponse(result.moch.key, result.error, config);
if (errorStream) {
resultStreams.push(errorStream);
}
return resultStreams;
}, streams);
const resultStreams = excludeDownloadLinks ? cachedStreams : populateDownloadLinks(cachedStreams, results, config);
return resultStreams.filter(stream => stream.url);
}
function populateCachedLinks(streams, mochResult, config) {
return streams.map(stream => {
const cachedEntry = stream.infoHash && mochResult.mochStreams[`${stream.infoHash}@${stream.fileIdx}`];
if (cachedEntry?.cached) {
return {
name: `[${mochResult.moch.shortName}+] ${stream.name}`,
title: stream.title,
url: `${config.host}/resolve/${mochResult.moch.key}/${cachedEntry.url}/${streamFilename(stream)}`,
behaviorHints: { ...stream.behaviorHints, ...cachedEntry.behaviorHints }
};
}
return stream;
});
}
function populateDownloadLinks(streams, results, config) {
const mochResults = results.filter(result => result.mochStreams);
const torrentStreams = streams.filter(stream => stream.infoHash);
const seededStreams = streams.filter(stream => !stream.title.includes('👤 0'));
torrentStreams.forEach(stream => mochResults.forEach(mochResult => {
const supportDownloads = !mochResult.moch.noDownloads;
const cachedEntry = mochResult.mochStreams[`${stream.infoHash}@${stream.fileIdx}`];
const isCached = cachedEntry?.cached;
if (supportDownloads && cachedEntry && !isCached && isHealthyStreamForDebrid(seededStreams, stream)) {
streams.push({
name: `[${mochResult.moch.shortName} download] ${stream.name}`,
title: stream.title,
url: `${config.host}/resolve/${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 || token.length < MIN_API_KEY_SYMBOLS || tokenBlacklist.has(`${mochKey}|${token}`);
}
function blackListToken(token, mochKey) {
const tokenKey = `${mochKey}|${token}`;
console.log(`Blacklisting invalid token: ${tokenKey}`)
recordTokenBlacklist(mochKey);
if (tokenBlacklist.size >= TOKEN_BLACKLIST_MAX) {
tokenBlacklist.delete(tokenBlacklist.values().next().value);
}
tokenBlacklist.add(tokenKey);
}
function resolveOutcome(url) {
if (!isStaticUrl(url)) {
return 'success';
}
const match = Object.entries(StaticResponse).find(([, value]) => url.endsWith(value));
return match?.[0]?.toLowerCase() || 'failed';
}
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}`
};
}
if (error === AccessBlockedError) {
return {
name: `Torrentio\n${MochOptions[mochKey].shortName} error`,
title: `Access to ${MochOptions[mochKey].name} is blocked!\nCheck your account or email.`,
url: `${config.host}/${StaticResponse.FAILED_ACCESS}`
};
}
return undefined;
}