Added typescript base

This commit is contained in:
Nicolas Boyer 2025-01-16 02:08:14 +01:00
parent c5e948d30c
commit 386e5b5b09
62 changed files with 2810 additions and 10509 deletions

2
.gitignore vendored
View file

@ -2,4 +2,4 @@
**/node_modules
**.env
.now
/scraper/manual/examples.js
/scraper/manual/examples.js

View file

@ -1,3 +0,0 @@
**/node_modules
**/npm-debug.log
**/.env

View file

@ -1,12 +0,0 @@
FROM node:21-alpine
RUN apk update && apk upgrade && \
apk add --no-cache git
WORKDIR /home/node/app
COPY package*.json ./
RUN npm ci --only-production
COPY . .
CMD [ "node", "--insecure-http-parser", "index.js" ]

View file

@ -1,127 +0,0 @@
import { addonBuilder } from 'stremio-addon-sdk';
import { Type } from './lib/types.js';
import { dummyManifest } from './lib/manifest.js';
import { cacheWrapStream } from './lib/cache.js';
import { toStreamInfo, applyStaticInfo } from './lib/streamInfo.js';
import * as repository from './lib/repository.js';
import applySorting from './lib/sort.js';
import applyFilters from './lib/filter.js';
import { applyMochs, getMochCatalog, getMochItemMeta } from './moch/moch.js';
import StaticLinks from './moch/static.js';
import { createNamedQueue } from "./lib/namedQueue.js";
import pLimit from "p-limit";
const CACHE_MAX_AGE = parseInt(process.env.CACHE_MAX_AGE) || 60 * 60; // 1 hour in seconds
const CACHE_MAX_AGE_EMPTY = 60; // 60 seconds
const CATALOG_CACHE_MAX_AGE = 0; // 0 minutes
const STALE_REVALIDATE_AGE = 4 * 60 * 60; // 4 hours
const STALE_ERROR_AGE = 7 * 24 * 60 * 60; // 7 days
const builder = new addonBuilder(dummyManifest());
const requestQueue = createNamedQueue(Infinity);
const newLimiter = pLimit(30)
builder.defineStreamHandler((args) => {
if (!args.id.match(/tt\d+/i) && !args.id.match(/kitsu:\d+/i)) {
return Promise.resolve({ streams: [] });
}
return requestQueue.wrap(args.id, () => resolveStreams(args))
.then(streams => applyFilters(streams, args.extra))
.then(streams => applySorting(streams, args.extra, args.type))
.then(streams => applyStaticInfo(streams))
.then(streams => applyMochs(streams, args.extra))
.then(streams => enrichCacheParams(streams))
.catch(error => {
return Promise.reject(`Failed request ${args.id}: ${error}`);
});
});
builder.defineCatalogHandler((args) => {
const [_, mochKey, catalogId] = args.id.split('-');
console.log(`Incoming catalog ${args.id} request with skip=${args.extra.skip || 0}`)
return getMochCatalog(mochKey, catalogId, args.extra)
.then(metas => ({
metas: metas,
cacheMaxAge: CATALOG_CACHE_MAX_AGE
}))
.catch(error => {
return Promise.reject(`Failed retrieving catalog ${args.id}: ${JSON.stringify(error.message || error)}`);
});
})
builder.defineMetaHandler((args) => {
const [mochKey, metaId] = args.id.split(':');
console.log(`Incoming debrid meta ${args.id} request`)
return getMochItemMeta(mochKey, metaId, args.extra)
.then(meta => ({
meta: meta,
cacheMaxAge: metaId === 'Downloads' ? 0 : CACHE_MAX_AGE
}))
.catch(error => {
return Promise.reject(`Failed retrieving catalog meta ${args.id}: ${JSON.stringify(error)}`);
});
})
async function resolveStreams(args) {
return cacheWrapStream(args.id, () => newLimiter(() => streamHandler(args)
.then(records => records
.sort((a, b) => b.torrent.seeders - a.torrent.seeders || b.torrent.uploadDate - a.torrent.uploadDate)
.map(record => toStreamInfo(record)))));
}
async function streamHandler(args) {
// console.log(`Pending count: ${newLimiter.pendingCount}, active count: ${newLimiter.activeCount}`, )
if (args.type === Type.MOVIE) {
return movieRecordsHandler(args);
} else if (args.type === Type.SERIES) {
return seriesRecordsHandler(args);
}
return Promise.reject('not supported type');
}
async function seriesRecordsHandler(args) {
if (args.id.match(/^tt\d+:\d+:\d+$/)) {
const parts = args.id.split(':');
const imdbId = parts[0];
const season = parts[1] !== undefined ? parseInt(parts[1], 10) : 1;
const episode = parts[2] !== undefined ? parseInt(parts[2], 10) : 1;
return repository.getImdbIdSeriesEntries(imdbId, season, episode);
} else if (args.id.match(/^kitsu:\d+(?::\d+)?$/i)) {
const parts = args.id.split(':');
const kitsuId = parts[1];
const episode = parts[2] !== undefined ? parseInt(parts[2], 10) : undefined;
return episode !== undefined
? repository.getKitsuIdSeriesEntries(kitsuId, episode)
: repository.getKitsuIdMovieEntries(kitsuId);
}
return Promise.resolve([]);
}
async function movieRecordsHandler(args) {
if (args.id.match(/^tt\d+$/)) {
const parts = args.id.split(':');
const imdbId = parts[0];
return repository.getImdbIdMovieEntries(imdbId);
} else if (args.id.match(/^kitsu:\d+(?::\d+)?$/i)) {
return seriesRecordsHandler(args);
}
return Promise.resolve([]);
}
function enrichCacheParams(streams) {
let cacheAge = CACHE_MAX_AGE;
if (!streams.length) {
cacheAge = CACHE_MAX_AGE_EMPTY;
} else if (streams.every(stream => stream?.url?.endsWith(StaticLinks.FAILED_ACCESS))) {
cacheAge = 0;
}
return {
streams: streams,
cacheMaxAge: cacheAge,
staleRevalidate: STALE_REVALIDATE_AGE,
staleError: STALE_ERROR_AGE
}
}
export default builder.getInterface();

View file

@ -1,25 +0,0 @@
import express from 'express';
import swStats from 'swagger-stats';
import serverless from './serverless.js';
import { manifest } from './lib/manifest.js';
import { initBestTrackers } from './lib/magnetHelper.js';
const app = express();
app.enable('trust proxy');
app.use(swStats.getMiddleware({
name: manifest().name,
version: manifest().version,
timelineBucketDuration: 60 * 60 * 1000,
apdexThreshold: 100,
authentication: true,
onAuthenticate: (req, username, password) => {
return username === process.env.METRICS_USER
&& password === process.env.METRICS_PASSWORD
},
}))
app.use(express.static('static', { maxAge: '1y' }));
app.use((req, res, next) => serverless(req, res, next));
app.listen(process.env.PORT || 7000, () => {
initBestTrackers()
.then(() => console.log(`Started addon at: http://localhost:${process.env.PORT || 7000}`));
});

View file

@ -1,95 +0,0 @@
import KeyvMongo from "@keyv/mongo";
import { KeyvCacheableMemory } from "cacheable";
import { isStaticUrl } from '../moch/static.js';
const GLOBAL_KEY_PREFIX = 'torrentio-addon';
const STREAM_KEY_PREFIX = `${GLOBAL_KEY_PREFIX}|stream`;
const AVAILABILITY_KEY_PREFIX = `${GLOBAL_KEY_PREFIX}|availability`;
const RESOLVED_URL_KEY_PREFIX = `${GLOBAL_KEY_PREFIX}|resolved`;
const STREAM_TTL = 24 * 60 * 60 * 1000; // 24 hours
const STREAM_EMPTY_TTL = 60 * 1000; // 1 minute
const RESOLVED_URL_TTL = 3 * 60 * 60 * 1000; // 3 hours
const AVAILABILITY_TTL = 5 * 24 * 60 * 60 * 1000; // 5 days
const MESSAGE_VIDEO_URL_TTL = 60 * 1000; // 1 minutes
// When the streams are empty we want to cache it for less time in case of timeouts or failures
const MONGO_URI = process.env.MONGODB_URI;
const memoryCache = new KeyvCacheableMemory({ ttl: MESSAGE_VIDEO_URL_TTL, lruSize: Infinity });
const remoteCache = MONGO_URI && new KeyvMongo(MONGO_URI, {
collection: 'torrentio_addon_collection',
minPoolSize: 50,
maxPoolSize: 200,
maxConnecting: 5,
});
async function cacheWrap(cache, key, method, ttl) {
if (!cache) {
return method();
}
const value = await cache.get(key);
if (value !== undefined) {
return value;
}
const result = await method();
const ttlValue = ttl instanceof Function ? ttl(result) : ttl;
await cache.set(key, result, ttlValue);
return result;
}
export function cacheWrapStream(id, method) {
const ttl = (streams) => streams.length ? STREAM_TTL : STREAM_EMPTY_TTL;
return cacheWrap(remoteCache, `${STREAM_KEY_PREFIX}:${id}`, method, ttl);
}
export function cacheWrapResolvedUrl(id, method) {
const ttl = (url) => isStaticUrl(url) ? MESSAGE_VIDEO_URL_TTL : RESOLVED_URL_TTL;
return cacheWrap(remoteCache, `${RESOLVED_URL_KEY_PREFIX}:${id}`, method, ttl);
}
export function cacheAvailabilityResults(infoHash, fileIds) {
const key = `${AVAILABILITY_KEY_PREFIX}:${infoHash}`;
const fileIdsString = fileIds.toString();
const containsFileIds = (array) => array.some(ids => ids.toString() === fileIdsString)
return remoteCache.get(key)
.then(result => {
const newResult = result || [];
if (!containsFileIds(newResult)) {
newResult.push(fileIds);
newResult.sort((a, b) => b.length - a.length);
}
return remoteCache.set(key, newResult, AVAILABILITY_TTL);
});
}
export function removeAvailabilityResults(infoHash, fileIds) {
const key = `${AVAILABILITY_KEY_PREFIX}:${infoHash}`;
const fileIdsString = fileIds.toString();
return remoteCache.get(key)
.then(result => {
const storedIndex = result?.findIndex(ids => ids.toString() === fileIdsString);
if (storedIndex >= 0) {
result.splice(storedIndex, 1);
return remoteCache.set(key, result, AVAILABILITY_TTL);
}
});
}
export function getCachedAvailabilityResults(infoHashes) {
const keys = infoHashes.map(infoHash => `${AVAILABILITY_KEY_PREFIX}:${infoHash}`)
return remoteCache.getMany(keys)
.then(result => {
const availabilityResults = {};
infoHashes.forEach((infoHash, index) => {
if (result[index]) {
availabilityResults[infoHash] = result[index];
}
});
return availabilityResults;
})
.catch(error => {
console.log('Failed retrieve availability cache', error)
return {};
});
}

View file

@ -1,82 +0,0 @@
import { DebridOptions } from '../moch/options.js';
import { QualityFilter, Providers, SizeFilter } from './filter.js';
import { LanguageOptions } from './languages.js';
export const PreConfigurations = {
lite: {
config: liteConfig(),
serialized: configValue(liteConfig()),
manifest: {
id: 'com.stremio.torrentio.lite.addon',
name: 'Torrentio Lite',
description: 'Preconfigured Lite version of Torrentio addon.'
+ ' To configure advanced options visit https://torrentio.strem.fun/lite'
}
},
brazuca: {
config: brazucaConfig(),
serialized: configValue(brazucaConfig()),
manifest: {
id: 'com.stremio.torrentio.brazuca.addon',
name: 'Torrentio Brazuca',
description: 'Preconfigured version of Torrentio addon for Brazilian content.'
+ ' To configure advanced options visit https://torrentio.strem.fun/brazuca',
logo: 'https://i.ibb.co/8mgRZPp/GwxAcDV.png'
}
}
}
const keysToSplit = [Providers.key, LanguageOptions.key, QualityFilter.key, SizeFilter.key, DebridOptions.key];
const keysToUppercase = [SizeFilter.key];
export function parseConfiguration(configuration) {
if (!configuration) {
return undefined;
}
if (PreConfigurations[configuration]) {
return PreConfigurations[configuration].config;
}
const configValues = configuration.split('|')
.reduce((map, next) => {
const parameterParts = next.split('=');
if (parameterParts.length === 2) {
map[parameterParts[0].toLowerCase()] = parameterParts[1];
}
return map;
}, {});
keysToSplit
.filter(key => configValues[key])
.forEach(key => configValues[key] = configValues[key].split(',')
.map(value => keysToUppercase.includes(key) ? value.toUpperCase() : value.toLowerCase()))
return configValues;
}
function liteConfig() {
const config = {};
config[Providers.key] = Providers.options
.filter(provider => !provider.foreign)
.map(provider => provider.key);
config[QualityFilter.key] = ['scr', 'cam']
config['limit'] = 1;
return config;
}
function brazucaConfig() {
const config = {};
config[Providers.key] = Providers.options
.filter(provider => !provider.foreign || provider.foreign === '🇵🇹')
.map(provider => provider.key);
config[LanguageOptions.key] = ['portuguese'];
return config;
}
function configValue(config) {
return Object.entries(config)
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(',') : value}`)
.join('|');
}
export function getManifestOverride(config) {
const preConfig = Object.values(PreConfigurations).find(pre => pre.config === config);
return preConfig ? preConfig.manifest : {};
}

View file

@ -1,72 +0,0 @@
const VIDEO_EXTENSIONS = [
"3g2",
"3gp",
"avi",
"flv",
"mkv",
"mk3d",
"mov",
"mp2",
"mp4",
"m4v",
"mpe",
"mpeg",
"mpg",
"mpv",
"webm",
"wmv",
"ogm",
"ts",
"m2ts"
];
const SUBTITLE_EXTENSIONS = [
"aqt",
"gsub",
"jss",
"sub",
"ttxt",
"pjs",
"psb",
"rt",
"smi",
"slt",
"ssf",
"srt",
"ssa",
"ass",
"usf",
"idx",
"vtt"
];
const DISK_EXTENSIONS = [
"iso",
"m2ts",
"ts",
"vob"
]
const ARCHIVE_EXTENSIONS = [
"rar",
"zip"
]
export function isVideo(filename) {
return isExtension(filename, VIDEO_EXTENSIONS);
}
export function isSubtitle(filename) {
return isExtension(filename, SUBTITLE_EXTENSIONS);
}
export function isDisk(filename) {
return isExtension(filename, DISK_EXTENSIONS);
}
export function isArchive(filename) {
return isExtension(filename, ARCHIVE_EXTENSIONS);
}
export function isExtension(filename, extensions) {
const extensionMatch = filename?.match(/\.(\w{2,4})$/);
return extensionMatch && extensions.includes(extensionMatch[1].toLowerCase());
}

View file

@ -1,269 +0,0 @@
import { extractProvider, parseSize, extractSize } from './titleHelper.js';
import { Type } from './types.js';
export const Providers = {
key: 'providers',
options: [
{
key: 'yts',
label: 'YTS'
},
{
key: 'eztv',
label: 'EZTV'
},
{
key: 'rarbg',
label: 'RARBG'
},
{
key: '1337x',
label: '1337x'
},
{
key: 'thepiratebay',
label: 'ThePirateBay'
},
{
key: 'kickasstorrents',
label: 'KickassTorrents'
},
{
key: 'torrentgalaxy',
label: 'TorrentGalaxy'
},
{
key: 'magnetdl',
label: 'MagnetDL'
},
{
key: 'horriblesubs',
label: 'HorribleSubs',
anime: true
},
{
key: 'nyaasi',
label: 'NyaaSi',
anime: true
},
{
key: 'tokyotosho',
label: 'TokyoTosho',
anime: true
},
{
key: 'anidex',
label: 'AniDex',
anime: true
},
{
key: 'rutor',
label: 'Rutor',
foreign: '🇷🇺'
},
{
key: 'rutracker',
label: 'Rutracker',
foreign: '🇷🇺'
},
{
key: 'comando',
label: 'Comando',
foreign: '🇵🇹'
},
{
key: 'bludv',
label: 'BluDV',
foreign: '🇵🇹'
},
{
key: 'torrent9',
label: 'Torrent9',
foreign: '🇫🇷'
},
{
key: 'ilcorsaronero',
label: 'ilCorSaRoNeRo',
foreign: '🇮🇹'
},
{
key: 'mejortorrent',
label: 'MejorTorrent',
foreign: '🇪🇸'
},
{
key: 'wolfmax4k',
label: 'Wolfmax4k',
foreign: '🇪🇸'
},
{
key: 'cinecalidad',
label: 'Cinecalidad',
foreign: '🇲🇽'
},
]
};
export const QualityFilter = {
key: 'qualityfilter',
options: [
{
key: 'brremux',
label: 'BluRay REMUX',
test(quality, bingeGroup) {
return bingeGroup?.includes(this.label);
}
},
{
key: 'hdrall',
label: 'HDR/HDR10+/Dolby Vision',
items: ['HDR', 'HDR10+', 'DV'],
test(quality) {
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
return this.items.some(hdrType => hdrProfiles.includes(hdrType));
}
},
{
key: 'dolbyvision',
label: 'Dolby Vision',
test(quality) {
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
return hdrProfiles === 'DV';
}
},
{
key: 'dolbyvisionwithhdr',
label: 'Dolby Vision + HDR',
test(quality) {
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
return hdrProfiles.includes('DV') && hdrProfiles.includes('HDR');
}
},
{
key: 'threed',
label: '3D',
test(quality) {
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
return hdrProfiles.includes('3D');
}
},
{
key: 'nonthreed',
label: 'Non 3D (DO NOT SELECT IF NOT SURE)',
test(quality) {
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
return !hdrProfiles.includes('3D');
}
},
{
key: '4k',
label: '4k',
items: ['4k'],
test(quality) {
return quality && this.items.includes(quality.split(' ')[0]);
}
},
{
key: '1080p',
label: '1080p',
items: ['1080p'],
test(quality) {
return this.items.includes(quality)
}
},
{
key: '720p',
label: '720p',
items: ['720p'],
test(quality) {
return this.items.includes(quality)
}
},
{
key: '480p',
label: '480p',
items: ['480p'],
test(quality) {
return this.items.includes(quality)
}
},
{
key: 'other',
label: 'Other (DVDRip/HDRip/BDRip...)',
// could be ['DVDRip', 'HDRip', 'BDRip', 'BRRip', 'BluRay', 'WEB-DL', 'WEBRip', 'HDTV', 'DivX', 'XviD']
items: ['4k', '1080p', '720p', '480p', 'SCR', 'CAM', 'TeleSync', 'TeleCine'],
test(quality) {
return quality && !this.items.includes(quality.split(' ')[0]);
}
},
{
key: 'scr',
label: 'Screener',
items: ['SCR'],
test(quality) {
return this.items.includes(quality)
}
},
{
key: 'cam',
label: 'Cam',
items: ['CAM', 'TeleSync', 'TeleCine'],
test(quality) {
return this.items.includes(quality)
}
},
{
key: 'unknown',
label: 'Unknown',
test(quality) {
return !quality
}
}
]
};
export const SizeFilter = {
key: 'sizefilter'
}
const defaultProviderKeys = Providers.options.map(provider => provider.key);
export default function applyFilters(streams, config) {
return [
filterByProvider,
filterByQuality,
filterBySize
].reduce((filteredStreams, filter) => filter(filteredStreams, config), streams);
}
function filterByProvider(streams, config) {
const providers = config.providers || defaultProviderKeys;
if (!providers?.length) {
return streams;
}
return streams.filter(stream => {
const provider = extractProvider(stream.title).toLowerCase();
return providers.includes(provider);
})
}
function filterByQuality(streams, config) {
const filters = config[QualityFilter.key];
if (!filters) {
return streams;
}
const filterOptions = QualityFilter.options.filter(option => filters.includes(option.key));
return streams.filter(stream => {
const streamQuality = stream.name.split('\n')[1];
const bingeGroup = stream.behaviorHints?.bingeGroup;
return !filterOptions.some(option => option.test(streamQuality, bingeGroup));
});
}
function filterBySize(streams, config) {
const sizeFilters = config[SizeFilter.key];
if (!sizeFilters?.length) {
return streams;
}
const sizeLimit = parseSize(config.type === Type.MOVIE ? sizeFilters.shift() : sizeFilters.pop());
return streams.filter(stream => {
const size = extractSize(stream.title)
return size <= sizeLimit;
})
}

View file

@ -1,506 +0,0 @@
const STYLESHEET = `
* {
box-sizing: border-box;
}
body,
html {
margin: 0;
padding: 0;
width: 100%;
height: 100%
}
html {
background-size: auto 100%;
background-size: cover;
background-position: center center;
background-repeat: repeat-y;
}
body {
display: flex;
background-color: transparent;
font-family: 'Open Sans', Arial, sans-serif;
color: white;
}
h1 {
font-size: 4.5vh;
font-weight: 700;
}
h2 {
font-size: 2.2vh;
font-weight: normal;
font-style: italic;
opacity: 0.8;
}
h3 {
font-size: 2.2vh;
}
h1,
h2,
h3,
p,
label {
margin: 0;
text-shadow: 0 0 1vh rgba(0, 0, 0, 0.15);
}
p {
font-size: 1.75vh;
}
ul {
font-size: 1.75vh;
margin: 0;
margin-top: 1vh;
padding-left: 3vh;
}
a {
color: green
}
a.install-link {
text-decoration: none
}
.install-button {
border: 0;
outline: 0;
color: white;
background: #8A5AAB;
padding: 1.2vh 3.5vh;
margin: auto;
text-align: center;
font-family: 'Open Sans', Arial, sans-serif;
font-size: 2.2vh;
font-weight: 600;
cursor: pointer;
display: block;
box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2);
transition: box-shadow 0.1s ease-in-out;
}
.install-button:hover {
box-shadow: none;
}
.install-button:active {
box-shadow: 0 0 0 0.5vh white inset;
}
#addon {
width: 90vh;
margin: auto;
padding-left: 10%;
padding-right: 10%;
background: rgba(0, 0, 0, 0.60);
}
.logo {
height: 14vh;
width: 14vh;
margin: auto;
margin-bottom: 3vh;
}
.logo img {
width: 100%;
}
.name, .version {
display: inline-block;
vertical-align: top;
}
.name {
line-height: 5vh;
}
.version {
position: absolute;
line-height: 5vh;
margin-left: 1vh;
opacity: 0.8;
}
.contact {
left: 0;
bottom: 4vh;
width: 100%;
margin-top: 1vh;
text-align: center;
}
.contact a {
font-size: 1.4vh;
font-style: italic;
}
.separator {
margin-bottom: 4vh;
}
.label {
font-size: 2.2vh;
font-weight: 600;
padding: 0;
line-height: inherit;
}
.btn-group, .multiselect-container {
width: 100%;
}
.btn {
text-align: left;
}
.multiselect-container {
border: 0;
border-radius: 0;
}
.input, .btn {
width: 100%;
margin: auto;
margin-bottom: 10px;
padding: 6px 12px;
border: 0;
border-radius: 0;
outline: 0;
color: #333;
background-color: rgb(255, 255, 255);
box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2);
}
.input:focus, .btn:focus {
outline: none;
box-shadow: 0 0 0 2pt rgb(30, 144, 255, 0.7);
}
`;
import { Providers, QualityFilter, SizeFilter } from './filter.js';
import { SortOptions } from './sort.js';
import { LanguageOptions } from './languages.js';
import { DebridOptions } from '../moch/options.js';
import { MochOptions } from '../moch/moch.js';
import { PreConfigurations } from './configuration.js';
export default function landingTemplate(manifest, config = {}) {
const providers = config[Providers.key] || Providers.options.map(provider => provider.key);
const sort = config[SortOptions.key] || SortOptions.options.qualitySeeders.key;
const languages = config[LanguageOptions.key] || [];
const qualityFilters = config[QualityFilter.key] || [];
const sizeFilter = (config[SizeFilter.key] || []).join(',');
const limit = config.limit || '';
const debridProvider = Object.keys(MochOptions).find(mochKey => config[mochKey]);
const debridOptions = config[DebridOptions.key] || [];
const realDebridApiKey = config[MochOptions.realdebrid.key] || '';
const premiumizeApiKey = config[MochOptions.premiumize.key] || '';
const allDebridApiKey = config[MochOptions.alldebrid.key] || '';
const debridLinkApiKey = config[MochOptions.debridlink.key] || '';
const offcloudApiKey = config[MochOptions.offcloud.key] || '';
const torboxApiKey = config[MochOptions.torbox.key] || '';
const putioKey = config[MochOptions.putio.key] || '';
const putioClientId = putioKey.replace(/@.*/, '');
const putioToken = putioKey.replace(/.*@/, '');
const background = manifest.background || 'https://dl.strem.io/addon-background.jpg';
const logo = manifest.logo || 'https://dl.strem.io/addon-logo.png';
const providersHTML = Providers.options
.map(provider => `<option value="${provider.key}">${provider.foreign ? provider.foreign + ' ' : ''}${provider.label}</option>`)
.join('\n');
const sortOptionsHTML = Object.values(SortOptions.options)
.map((option, i) => `<option value="${option.key}" ${i === 0 ? 'selected' : ''}>${option.description}</option>`)
.join('\n');
const languagesOptionsHTML = LanguageOptions.options
.map((option, i) => `<option value="${option.key}">${option.label}</option>`)
.join('\n');
const qualityFiltersHTML = Object.values(QualityFilter.options)
.map(option => `<option value="${option.key}">${option.label}</option>`)
.join('\n');
const debridProvidersHTML = Object.values(MochOptions)
.map(moch => `<option value="${moch.key}">${moch.name}</option>`)
.join('\n');
const debridOptionsHTML = Object.values(DebridOptions.options)
.map(option => `<option value="${option.key}">${option.description}</option>`)
.join('\n');
const stylizedTypes = manifest.types
.map(t => t[0].toUpperCase() + t.slice(1) + (t !== 'series' ? 's' : ''));
const preConfigurationObject = Object.entries(PreConfigurations)
.map(([key, config]) => `${key}: '${config.serialized}'`)
.join(',');
return `
<!DOCTYPE html>
<html style="background-image: url(${background});">
<head>
<meta charset="utf-8">
<title>${manifest.name} - Stremio Addon</title>
<link rel="shortcut icon" href="${logo}" type="image/x-icon">
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,600,700&display=swap" rel="stylesheet">
<script src="https://code.jquery.com/jquery-3.6.4.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" >
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/1.1.2/js/bootstrap-multiselect.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/1.1.2/css/bootstrap-multiselect.css" rel="stylesheet"/>
<style>${STYLESHEET}</style>
</head>
<body>
<div id="addon">
<div class="logo">
<img src="${logo}">
</div>
<h1 class="name">${manifest.name}</h1>
<h2 class="version">${manifest.version || '0.0.0'}</h2>
<h2 class="description">${manifest.description || ''}</h2>
<div class="separator"></div>
<h3 class="gives">This addon has more :</h3>
<ul>
${stylizedTypes.map(t => `<li>${t}</li>`).join('')}
</ul>
<div class="separator"></div>
<label class="label" for="iProviders">Providers:</label>
<select id="iProviders" class="input" onchange="generateInstallLink()" name="providers[]" multiple="multiple">
${providersHTML}
</select>
<label class="label" for="iSort">Sorting:</label>
<select id="iSort" class="input" onchange="sortModeChange()">
${sortOptionsHTML}
</select>
<label class="label" for="iLanguages">Priority foreign language:</label>
<select id="iLanguages" class="input" onchange="generateInstallLink()" name="languages[]" multiple="multiple" title="Streams with the selected dubs/subs language will be shown on the top">
${languagesOptionsHTML}
</select>
<label class="label" for="iQualityFilter">Exclude qualities/resolutions:</label>
<select id="iQualityFilter" class="input" onchange="generateInstallLink()" name="qualityFilters[]" multiple="multiple">
${qualityFiltersHTML}
</select>
<label class="label" id="iLimitLabel" for="iLimit">Max results per quality:</label>
<input type="text" inputmode="numeric" pattern="[0-9]*" id="iLimit" onchange="generateInstallLink()" class="input" placeholder="All results">
<label class="label" id="iSizeFilterLabel" for="iSizeFilter">Video size limit:</label>
<input type="text" pattern="([0-9.]*(?:MB|GB),?)+" id="iSizeFilter" onchange="generateInstallLink()" class="input" placeholder="No limit" title="Returned videos cannot exceed this size, use comma to have different size for movies and series. Examples: 5GB ; 800MB ; 10GB,2GB">
<label class="label" for="iDebridProviders">Debrid provider:</label>
<select id="iDebridProviders" class="input" onchange="debridProvidersChange()">
<option value="none" selected>None</option>
${debridProvidersHTML}
</select>
<div id="dRealDebrid">
<label class="label" for="iRealDebrid">RealDebrid API Key (Find it <a href='https://real-debrid.com/apitoken' target="_blank">here</a>):</label>
<input type="text" id="iRealDebrid" onchange="generateInstallLink()" class="input">
</div>
<div id="dAllDebrid">
<label class="label" for="iAllDebrid">AllDebrid API Key (Create it <a href='https://alldebrid.com/apikeys' target="_blank">here</a>):</label>
<input type="text" id="iAllDebrid" onchange="generateInstallLink()" class="input">
</div>
<div id="dPremiumize">
<label class="label" for="iPremiumize">Premiumize API Key (Find it <a href='https://www.premiumize.me/account' target="_blank">here</a>):</label>
<input type="text" id="iPremiumize" onchange="generateInstallLink()" class="input">
</div>
<div id="dDebridLink">
<label class="label" for="iDebridLink">DebridLink API Key (Find it <a href='https://debrid-link.fr/webapp/apikey' target="_blank">here</a>):</label>
<input type="text" id="iDebridLink" onchange="generateInstallLink()" class="input">
</div>
<div id="dOffcloud">
<label class="label" for="iOffcloud">Offcloud API Key (Find it <a href='https://offcloud.com/#/account' target="_blank">here</a>):</label>
<input type="text" id="iOffcloud" onchange="generateInstallLink()" class="input">
</div>
<div id="dTorbox">
<label class="label" for="iTorbox">TorBox API Key (Find it <a href='https://torbox.app/settings' target="_blank">here</a>):</label>
<input type="text" id="iTorbox" onchange="generateInstallLink()" class="input">
</div>
<div id="dPutio">
<label class="label" for="iPutio">Put.io ClientId and Token (Create new OAuth App <a href='https://app.put.io/oauth' target="_blank">here</a>):</label>
<input type="text" id="iPutioClientId" placeholder="ClientId" onchange="generateInstallLink()" class="input">
<input type="text" id="iPutioToken" placeholder="Token" onchange="generateInstallLink()" class="input">
</div>
<div id="dDebridOptions">
<label class="label" for="iDebridOptions">Debrid options:</label>
<select id="iDebridOptions" class="input" onchange="generateInstallLink()" name="debridOptions[]" multiple="multiple">
${debridOptionsHTML}
</select>
</div>
<div class="separator"></div>
<a id="installLink" class="install-link" href="#">
<button name="Install" class="install-button">INSTALL</button>
</a>
<div class="contact">
<p>Or paste into Stremio search bar after clicking install</p>
</div>
<div class="separator"></div>
</div>
<script type="text/javascript">
$(document).ready(function() {
const isTvMedia = window.matchMedia("tv").matches;
const isTvAgent = /\\b(?:tv|wv)\\b/i.test(navigator.userAgent)
const isDesktopMedia = window.matchMedia("(pointer:fine)").matches;
if (isDesktopMedia && !isTvMedia && !isTvAgent) {
$('#iProviders').multiselect({
nonSelectedText: 'All providers',
buttonTextAlignment: 'left',
onChange: () => generateInstallLink()
});
$('#iProviders').multiselect('select', [${providers.map(provider => '"' + provider + '"')}]);
$('#iLanguages').multiselect({
nonSelectedText: 'None',
buttonTextAlignment: 'left',
onChange: () => generateInstallLink()
});
$('#iLanguages').multiselect('select', [${languages.map(language => '"' + language + '"')}]);
$('#iQualityFilter').multiselect({
nonSelectedText: 'None',
buttonTextAlignment: 'left',
onChange: () => generateInstallLink()
});
$('#iQualityFilter').multiselect('select', [${qualityFilters.map(filter => '"' + filter + '"')}]);
$('#iDebridOptions').multiselect({
nonSelectedText: 'None',
buttonTextAlignment: 'left',
onChange: () => generateInstallLink()
});
$('#iDebridOptions').multiselect('select', [${debridOptions.map(option => '"' + option + '"')}]);
} else {
$('#iProviders').val([${providers.map(provider => '"' + provider + '"')}]);
$('#iLanguages').val([${languages.map(language => '"' + language + '"')}]);
$('#iQualityFilter').val([${qualityFilters.map(filter => '"' + filter + '"')}]);
$('#iDebridOptions').val([${debridOptions.map(option => '"' + option + '"')}]);
}
$('#iDebridProviders').val("${debridProvider || 'none'}");
$('#iRealDebrid').val("${realDebridApiKey}");
$('#iPremiumize').val("${premiumizeApiKey}");
$('#iAllDebrid').val("${allDebridApiKey}");
$('#iDebridLink').val("${debridLinkApiKey}");
$('#iOffcloud').val("${offcloudApiKey}");
$('#iTorbox').val("${torboxApiKey}");
$('#iPutioClientId').val("${putioClientId}");
$('#iPutioToken').val("${putioToken}");
$('#iSort').val("${sort}");
$('#iLimit').val("${limit}");
$('#iSizeFilter').val("${sizeFilter}");
generateInstallLink();
debridProvidersChange();
});
function sortModeChange() {
if (['${SortOptions.options.seeders.key}', '${SortOptions.options.size.key}'].includes($('#iSort').val())) {
$("#iLimitLabel").text("Max results:");
} else {
$("#iLimitLabel").text("Max results per quality:");
}
generateInstallLink();
}
function debridProvidersChange() {
const provider = $('#iDebridProviders').val()
$('#dDebridOptions').toggle(provider !== 'none');
$('#dRealDebrid').toggle(provider === '${MochOptions.realdebrid.key}');
$('#dPremiumize').toggle(provider === '${MochOptions.premiumize.key}');
$('#dAllDebrid').toggle(provider === '${MochOptions.alldebrid.key}');
$('#dDebridLink').toggle(provider === '${MochOptions.debridlink.key}');
$('#dOffcloud').toggle(provider === '${MochOptions.offcloud.key}');
$('#dTorbox').toggle(provider === '${MochOptions.torbox.key}');
$('#dPutio').toggle(provider === '${MochOptions.putio.key}');
}
function generateInstallLink() {
const providersList = $('#iProviders').val() || [];
const providersValue = providersList.join(',');
const qualityFilterValue = $('#iQualityFilter').val().join(',') || '';
const sortValue = $('#iSort').val() || '';
const languagesValue = $('#iLanguages').val().join(',') || [];
const limitValue = $('#iLimit').val() || '';
const sizeFilterValue = $('#iSizeFilter').val() || '';
const debridOptionsValue = $('#iDebridOptions').val().join(',') || '';
const realDebridValue = $('#iRealDebrid').val() || '';
const allDebridValue = $('#iAllDebrid').val() || '';
const debridLinkValue = $('#iDebridLink').val() || ''
const premiumizeValue = $('#iPremiumize').val() || '';
const offcloudValue = $('#iOffcloud').val() || '';
const torboxValue = $('#iTorbox').val() || '';
const putioClientIdValue = $('#iPutioClientId').val() || '';
const putioTokenValue = $('#iPutioToken').val() || '';
const providers = providersList.length && providersList.length < ${Providers.options.length} && providersValue;
const qualityFilters = qualityFilterValue.length && qualityFilterValue;
const sort = sortValue !== '${SortOptions.options.qualitySeeders.key}' && sortValue;
const languages = languagesValue.length && languagesValue;
const limit = /^[1-9][0-9]{0,2}$/.test(limitValue) && limitValue;
const sizeFilter = sizeFilterValue.length && sizeFilterValue;
const debridOptions = debridOptionsValue.length && debridOptionsValue.trim();
const realDebrid = realDebridValue.length && realDebridValue.trim();
const premiumize = premiumizeValue.length && premiumizeValue.trim();
const allDebrid = allDebridValue.length && allDebridValue.trim();
const debridLink = debridLinkValue.length && debridLinkValue.trim();
const offcloud = offcloudValue.length && offcloudValue.trim();
const torbox = torboxValue.length && torboxValue.trim();
const putio = putioClientIdValue.length && putioTokenValue.length && putioClientIdValue.trim() + '@' + putioTokenValue.trim();
const preConfigurations = {
${preConfigurationObject}
};
let configurationValue = [
['${Providers.key}', providers],
['${SortOptions.key}', sort],
['${LanguageOptions.key}', languages],
['${QualityFilter.key}', qualityFilters],
['limit', limit],
['${SizeFilter.key}', sizeFilter],
['${DebridOptions.key}', debridOptions],
['${MochOptions.realdebrid.key}', realDebrid],
['${MochOptions.premiumize.key}', premiumize],
['${MochOptions.alldebrid.key}', allDebrid],
['${MochOptions.debridlink.key}', debridLink],
['${MochOptions.offcloud.key}', offcloud],
['${MochOptions.torbox.key}', torbox],
['${MochOptions.putio.key}', putio]
].filter(([_, value]) => value.length).map(([key, value]) => key + '=' + value).join('|');
configurationValue = Object.entries(preConfigurations)
.filter(([key, value]) => value === configurationValue)
.map(([key, value]) => key)[0] || configurationValue;
const configuration = configurationValue && configurationValue.length ? '/' + configurationValue : '';
const location = window.location.host + configuration + '/manifest.json'
installLink.href = 'stremio://' + location;
}
installLink.addEventListener('click', function() {
navigator.clipboard.writeText(installLink.href.replace('stremio://', 'https://'));
});
</script>
</body>
</html>`
}

View file

@ -1,76 +0,0 @@
const languageMapping = {
'dubbed': 'Dubbed',
'multi audio': 'Multi Audio',
'multi subs': 'Multi Subs',
'dual audio': 'Dual Audio',
'english': '🇬🇧',
'japanese': '🇯🇵',
'russian': '🇷🇺',
'italian': '🇮🇹',
'portuguese': '🇵🇹',
'spanish': '🇪🇸',
'latino': '🇲🇽',
'korean': '🇰🇷',
'chinese': '🇨🇳',
'taiwanese': '🇹🇼',
'french': '🇫🇷',
'german': '🇩🇪',
'dutch': '🇳🇱',
'hindi': '🇮🇳',
'telugu': '🇮🇳',
'tamil': '🇮🇳',
'polish': '🇵🇱',
'lithuanian': '🇱🇹',
'latvian': '🇱🇻',
'estonian': '🇪🇪',
'czech': '🇨🇿',
'slovakian': '🇸🇰',
'slovenian': '🇸🇮',
'hungarian': '🇭🇺',
'romanian': '🇷🇴',
'bulgarian': '🇧🇬',
'serbian': '🇷🇸 ',
'croatian': '🇭🇷',
'ukrainian': '🇺🇦',
'greek': '🇬🇷',
'danish': '🇩🇰',
'finnish': '🇫🇮',
'swedish': '🇸🇪',
'norwegian': '🇳🇴',
'turkish': '🇹🇷',
'arabic': '🇸🇦',
'persian': '🇮🇷',
'hebrew': '🇮🇱',
'vietnamese': '🇻🇳',
'indonesian': '🇮🇩',
'malay': '🇲🇾',
'thai': '🇹🇭'
}
export const LanguageOptions = {
key: 'language',
options: Object.keys(languageMapping).slice(5).map(lang => ({
key: lang,
label: `${languageMapping[lang]} ${lang.charAt(0).toUpperCase()}${lang.slice(1)}`
}))
}
export function mapLanguages(languages) {
const mapped = languages
.map(language => languageMapping[language])
.filter(language => language)
.sort((a, b) => Object.values(languageMapping).indexOf(a) - Object.values(languageMapping).indexOf(b));
const unmapped = languages
.filter(language => !languageMapping[language])
.sort((a, b) => a.localeCompare(b))
return [...new Set([].concat(mapped).concat(unmapped))];
}
export function containsLanguage(stream, languages) {
return languages.map(lang => languageMapping[lang]).some(lang => stream.title.includes(lang));
}
export function languageFromCode(code) {
const entry = Object.entries(languageMapping).find(entry => entry[1] === code);
return entry?.[0];
}

View file

@ -1,93 +0,0 @@
import axios from 'axios';
import magnet from 'magnet-uri';
import { getRandomUserAgent } from './requestHelper.js';
import { getTorrent } from './repository.js';
import { Type } from './types.js';
import { extractProvider } from "./titleHelper.js";
import { Providers } from "./filter.js";
const TRACKERS_URL = 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt';
const ANIME_TRACKERS = [
"http://nyaa.tracker.wf:7777/announce",
"http://anidex.moe:6969/announce",
"http://tracker.anirena.com:80/announce",
"udp://tracker.uw0.xyz:6969/announce",
"http://share.camoe.cn:8080/announce",
"http://t.nyaatracker.com:80/announce",
];
const RUSSIAN_TRACKERS = [
"udp://opentor.net:6969",
"http://bt.t-ru.org/ann?magnet",
"http://bt2.t-ru.org/ann?magnet",
"http://bt3.t-ru.org/ann?magnet",
"http://bt4.t-ru.org/ann?magnet",
];
// Some trackers have limits on original torrent trackers,
// where downloading ip has to seed the torrents for some amount of time,
// thus it doesn't work on mochs.
// So it's better to exclude them and try to download through DHT,
// as the torrent won't start anyway.
const RUSSIAN_PROVIDERS = Providers.options
.filter(provider => provider.foreign === '🇷🇺')
.map(provider => provider.label);
const ANIME_PROVIDERS = Providers.options
.filter(provider => provider.anime)
.map(provider => provider.label);
let BEST_TRACKERS = [];
let ALL_ANIME_TRACKERS = [];
let ALL_RUSSIAN_TRACKERS = [];
export async function getMagnetLink(infoHash) {
const torrent = await getTorrent(infoHash).catch(() => ({ infoHash }));
const torrentTrackers = torrent?.trackers?.split(',') || [];
const animeTrackers = torrent?.type === Type.ANIME ? ALL_ANIME_TRACKERS : [];
const providerTrackers = RUSSIAN_PROVIDERS.includes(torrent?.provider) && ALL_RUSSIAN_TRACKERS || [];
const trackers = unique([].concat(torrentTrackers).concat(animeTrackers).concat(providerTrackers));
return magnet.encode({ infoHash: infoHash, name: torrent?.title, announce: trackers });
}
export async function initBestTrackers() {
BEST_TRACKERS = await getBestTrackers();
ALL_ANIME_TRACKERS = unique(BEST_TRACKERS.concat(ANIME_TRACKERS));
ALL_RUSSIAN_TRACKERS = unique(BEST_TRACKERS.concat(RUSSIAN_TRACKERS));
console.log('Retrieved best trackers: ', BEST_TRACKERS);
}
async function getBestTrackers(retry = 2) {
const options = { timeout: 30000, headers: { 'User-Agent': getRandomUserAgent() } };
return axios.get(TRACKERS_URL, options)
.then(response => response?.data?.trim()?.split('\n\n') || [])
.catch(error => {
if (retry === 0) {
console.log(`Failed retrieving best trackers: ${error.message}`);
throw error;
}
return getBestTrackers(retry - 1);
});
}
export function getSources(trackersInput, infoHash) {
if (!trackersInput) {
return null;
}
const trackers = Array.isArray(trackersInput) ? trackersInput : trackersInput.split(',');
return trackers.map(tracker => `tracker:${tracker}`).concat(`dht:${infoHash}`);
}
export function enrichStreamSources(stream) {
const provider = extractProvider(stream.title);
if (ANIME_PROVIDERS.includes(provider)) {
const sources = getSources(ALL_ANIME_TRACKERS, stream.infoHash);
return { ...stream, sources };
}
if (RUSSIAN_PROVIDERS.includes(provider)) {
const sources = unique([].concat(stream.sources || []).concat(getSources(ALL_RUSSIAN_TRACKERS, stream.infoHash)));
return { ...stream, sources };
}
return stream;
}
function unique(array) {
return Array.from(new Set(array));
}

View file

@ -1,89 +0,0 @@
import { MochOptions } from '../moch/moch.js';
import { Providers } from './filter.js';
import { showDebridCatalog } from '../moch/options.js';
import { getManifestOverride } from './configuration.js';
import { Type } from './types.js';
const DefaultProviders = Providers.options.map(provider => provider.key);
const MochProviders = Object.values(MochOptions);
export function manifest(config = {}) {
const overrideManifest = getManifestOverride(config);
const baseManifest = {
id: 'com.stremio.torrentio.addon',
version: '0.0.14',
name: getName(overrideManifest, config),
description: getDescription(config),
catalogs: getCatalogs(config),
resources: getResources(config),
types: [Type.MOVIE, Type.SERIES, Type.ANIME, Type.OTHER],
background: `https://i.ibb.co/VtSfFP9/t8wVwcg.jpg`,
logo: `https://i.ibb.co/w4BnkC9/GwxAcDV.png`,
behaviorHints: {
configurable: true,
configurationRequired: false
}
};
return Object.assign(baseManifest, overrideManifest);
}
export function dummyManifest() {
const manifestDefault = manifest();
manifestDefault.catalogs = [{ id: 'dummy', type: Type.OTHER }];
manifestDefault.resources = ['stream', 'meta'];
return manifestDefault;
}
function getName(manifest, config) {
const rootName = manifest?.name || 'Torrentio';
const mochSuffix = MochProviders
.filter(moch => config[moch.key])
.map(moch => moch.shortName)
.join('/');
return [rootName, mochSuffix].filter(v => v).join(' ');
}
function getDescription(config) {
const providersList = config[Providers.key] || DefaultProviders;
const enabledProvidersDesc = Providers.options
.map(provider => `${provider.label}${providersList.includes(provider.key) ? '(+)' : '(-)'}`)
.join(', ')
const enabledMochs = MochProviders
.filter(moch => config[moch.key])
.map(moch => moch.name)
.join(' & ');
const possibleMochs = MochProviders.map(moch => moch.name).join('/')
const mochsDesc = enabledMochs ? ` and ${enabledMochs} enabled` : '';
return 'Provides torrent streams from scraped torrent providers.'
+ ` Currently supports ${enabledProvidersDesc}${mochsDesc}.`
+ ` To configure providers, ${possibleMochs} support and other settings visit https://torrentio.strem.fun`
}
function getCatalogs(config) {
return MochProviders
.filter(moch => showDebridCatalog(config) && config[moch.key])
.map(moch => moch.catalogs.map(catalogName => ({
id: catalogName ? `torrentio-${moch.key}-${catalogName.toLowerCase()}` : `torrentio-${moch.key}`,
name: catalogName ? `${moch.name} ${catalogName}` : `${moch.name}`,
type: 'other',
extra: [{ name: 'skip' }],
})))
.reduce((a, b) => a.concat(b), []);
}
function getResources(config) {
const streamResource = {
name: 'stream',
types: [Type.MOVIE, Type.SERIES],
idPrefixes: ['tt', 'kitsu']
};
const metaResource = {
name: 'meta',
types: [Type.OTHER],
idPrefixes: MochProviders.filter(moch => config[moch.key]).map(moch => moch.key)
};
if (showDebridCatalog(config) && MochProviders.filter(moch => config[moch.key]).length) {
return [streamResource, metaResource];
}
return [streamResource];
}

View file

@ -1,11 +0,0 @@
import namedQueue from "named-queue";
export function createNamedQueue(concurrency) {
const queue = new namedQueue((task, callback) => task.method()
.then(result => callback(false, result))
.catch((error => callback(error))), 200);
queue.wrap = (id, method) => new Promise(((resolve, reject) => {
queue.push({ id, method }, (error, result) => result ? resolve(result) : reject(error));
}));
return queue;
}

View file

@ -1,20 +0,0 @@
/**
* Delay promise
*/
export async function delay(duration) {
return new Promise((resolve) => setTimeout(resolve, duration));
}
/**
* Timeout promise after a set time in ms
*/
export async function timeout(timeoutMs, promise, message = 'Timed out') {
return Promise.race([
promise,
new Promise(function (resolve, reject) {
setTimeout(function () {
reject(message);
}, timeoutMs);
})
]);
}

View file

@ -1,131 +0,0 @@
import { Sequelize } from 'sequelize';
const Op = Sequelize.Op;
const DATABASE_URI = process.env.DATABASE_URI;
const database = new Sequelize(DATABASE_URI, { logging: false, pool: { max: 30, min: 5, idle: 20 * 60 * 1000 } });
const Torrent = database.define('torrent',
{
infoHash: { type: Sequelize.STRING(64), primaryKey: true },
provider: { type: Sequelize.STRING(32), allowNull: false },
torrentId: { type: Sequelize.STRING(128) },
title: { type: Sequelize.STRING(256), allowNull: false },
size: { type: Sequelize.BIGINT },
type: { type: Sequelize.STRING(16), allowNull: false },
uploadDate: { type: Sequelize.DATE, allowNull: false },
seeders: { type: Sequelize.SMALLINT },
trackers: { type: Sequelize.STRING(4096) },
languages: { type: Sequelize.STRING(4096) },
resolution: { type: Sequelize.STRING(16) }
}
);
const File = database.define('file',
{
id: { type: Sequelize.BIGINT, autoIncrement: true, primaryKey: true },
infoHash: {
type: Sequelize.STRING(64),
allowNull: false,
references: { model: Torrent, key: 'infoHash' },
onDelete: 'CASCADE'
},
fileIndex: { type: Sequelize.INTEGER },
title: { type: Sequelize.STRING(256), allowNull: false },
size: { type: Sequelize.BIGINT },
imdbId: { type: Sequelize.STRING(32) },
imdbSeason: { type: Sequelize.INTEGER },
imdbEpisode: { type: Sequelize.INTEGER },
kitsuId: { type: Sequelize.INTEGER },
kitsuEpisode: { type: Sequelize.INTEGER }
},
);
const Subtitle = database.define('subtitle',
{
infoHash: {
type: Sequelize.STRING(64),
allowNull: false,
references: { model: Torrent, key: 'infoHash' },
onDelete: 'CASCADE'
},
fileIndex: { type: Sequelize.INTEGER, allowNull: false },
fileId: {
type: Sequelize.BIGINT,
allowNull: true,
references: { model: File, key: 'id' },
onDelete: 'SET NULL'
},
title: { type: Sequelize.STRING(512), allowNull: false },
size: { type: Sequelize.BIGINT, allowNull: false },
},
{ timestamps: false }
);
Torrent.hasMany(File, { foreignKey: 'infoHash', constraints: false });
File.belongsTo(Torrent, { foreignKey: 'infoHash', constraints: false });
File.hasMany(Subtitle, { foreignKey: 'fileId', constraints: false });
Subtitle.belongsTo(File, { foreignKey: 'fileId', constraints: false });
export function getTorrent(infoHash) {
return Torrent.findOne({ where: { infoHash: infoHash } });
}
export function getFiles(infoHashes) {
return File.findAll({ where: { infoHash: { [Op.in]: infoHashes} } });
}
export function getImdbIdMovieEntries(imdbId) {
return File.findAll({
where: {
imdbId: { [Op.eq]: imdbId }
},
include: [Torrent],
limit: 500,
order: [
[Torrent, 'seeders', 'DESC']
]
});
}
export function getImdbIdSeriesEntries(imdbId, season, episode) {
return File.findAll({
where: {
imdbId: { [Op.eq]: imdbId },
imdbSeason: { [Op.eq]: season },
imdbEpisode: { [Op.eq]: episode }
},
include: [Torrent],
limit: 500,
order: [
[Torrent, 'seeders', 'DESC']
]
});
}
export function getKitsuIdMovieEntries(kitsuId) {
return File.findAll({
where: {
kitsuId: { [Op.eq]: kitsuId }
},
include: [Torrent],
limit: 500,
order: [
[Torrent, 'seeders', 'DESC']
]
});
}
export function getKitsuIdSeriesEntries(kitsuId, episode) {
return File.findAll({
where: {
kitsuId: { [Op.eq]: kitsuId },
kitsuEpisode: { [Op.eq]: episode }
},
include: [Torrent],
limit: 500,
order: [
[Torrent, 'seeders', 'DESC']
]
});
}

View file

@ -1,6 +0,0 @@
import UserAgent from 'user-agents';
const userAgent = new UserAgent();
export function getRandomUserAgent() {
return userAgent.random().toString();
}

View file

@ -1,130 +0,0 @@
import { QualityFilter } from './filter.js';
import { containsLanguage, LanguageOptions } from './languages.js';
import { Type } from './types.js';
import { hasMochConfigured } from '../moch/moch.js';
import { extractSeeders, extractSize } from './titleHelper.js';
const OTHER_QUALITIES = QualityFilter.options.find(option => option.key === 'other');
const CAM_QUALITIES = QualityFilter.options.find(option => option.key === 'cam');
const HEALTHY_SEEDERS = 5;
const SEEDED_SEEDERS = 1;
const MIN_HEALTHY_COUNT = 50;
const MAX_UNHEALTHY_COUNT = 5;
export const SortOptions = {
key: 'sort',
options: {
qualitySeeders: {
key: 'quality',
description: 'By quality then seeders'
},
qualitySize: {
key: 'qualitysize',
description: 'By quality then size'
},
seeders: {
key: 'seeders',
description: 'By seeders'
},
size: {
key: 'size',
description: 'By size'
},
}
}
export default function sortStreams(streams, config, type) {
const languages = config[LanguageOptions.key];
if (languages?.length && languages[0] !== 'english') {
// No need to filter english since it's hard to predict which entries are english
const streamsWithLanguage = streams.filter(stream => containsLanguage(stream, languages));
const streamsNoLanguage = streams.filter(stream => !streamsWithLanguage.includes(stream));
return _sortStreams(streamsWithLanguage, config, type).concat(_sortStreams(streamsNoLanguage, config, type));
}
return _sortStreams(streams, config, type);
}
function _sortStreams(streams, config, type) {
const sort = config?.sort?.toLowerCase() || undefined;
const limit = /^[1-9][0-9]*$/.test(config.limit) && parseInt(config.limit) || undefined;
const sortedStreams = sortBySeeders(streams, config, type);
if (sort === SortOptions.options.seeders.key) {
return sortedStreams.slice(0, limit);
} else if (sort === SortOptions.options.size.key) {
return sortBySize(sortedStreams, limit);
}
const nestedSort = sort === SortOptions.options.qualitySize.key ? sortBySize : noopSort;
return sortByVideoQuality(sortedStreams, nestedSort, limit)
}
function noopSort(streams) {
return streams;
}
function sortBySeeders(streams, config, type) {
// streams are already presorted by seeders and upload date
const healthy = streams.filter(stream => extractSeeders(stream.title) >= HEALTHY_SEEDERS);
const seeded = streams.filter(stream => extractSeeders(stream.title) >= SEEDED_SEEDERS);
if (type === Type.SERIES && hasMochConfigured(config)) {
return streams;
} else if (healthy.length >= MIN_HEALTHY_COUNT) {
return healthy;
} else if (seeded.length >= MAX_UNHEALTHY_COUNT) {
return seeded.slice(0, MIN_HEALTHY_COUNT);
}
return streams.slice(0, MAX_UNHEALTHY_COUNT);
}
function sortBySize(streams, limit) {
return streams
.sort((a, b) => {
const aSize = extractSize(a.title);
const bSize = extractSize(b.title);
return bSize - aSize;
}).slice(0, limit);
}
function sortByVideoQuality(streams, nestedSort, limit) {
const qualityMap = streams
.reduce((map, stream) => {
const quality = extractQuality(stream.name);
map[quality] = (map[quality] || []).concat(stream);
return map;
}, {});
const sortedQualities = Object.keys(qualityMap)
.sort((a, b) => {
const aResolution = a?.match(/\d+p/) && parseInt(a, 10);
const bResolution = b?.match(/\d+p/) && parseInt(b, 10);
if (aResolution && bResolution) {
return bResolution - aResolution; // higher resolution first;
} else if (aResolution) {
return -1; // remain higher if resolution is there
} else if (bResolution) {
return 1; // move downward if other stream has resolution
}
return a < b ? -1 : b < a ? 1 : 0; // otherwise sort by alphabetic order
});
return sortedQualities
.map(quality => nestedSort(qualityMap[quality]).slice(0, limit))
.reduce((a, b) => a.concat(b), []);
}
function extractQuality(title) {
const qualityDesc = title.split('\n')[1];
const resolutionMatch = qualityDesc?.match(/\d+p/);
const isHDR = qualityDesc?.match(/HDR|DV/);
const withHDRScore = resolution => isHDR ? resolution.replace('0p', '1p') : resolution;
if (resolutionMatch) {
return withHDRScore(resolutionMatch[0]);
} else if (/8k/i.test(qualityDesc)) {
return withHDRScore('4320p');
} else if (/4k|uhd/i.test(qualityDesc)) {
return withHDRScore('2060p');
} else if (CAM_QUALITIES.test(qualityDesc)) {
return CAM_QUALITIES.label;
} else if (OTHER_QUALITIES.test(qualityDesc)) {
return OTHER_QUALITIES.label;
}
return qualityDesc;
}

View file

@ -1,152 +0,0 @@
import titleParser from 'parse-torrent-title';
import { Type } from './types.js';
import { mapLanguages } from './languages.js';
import { enrichStreamSources, getSources } from './magnetHelper.js';
import { getSubtitles } from './subtitles.js';
const ADDON_NAME = 'Torrentio';
const SIZE_DELTA = 0.05;
const UNKNOWN_SIZE = 300000000;
const CAM_SOURCES = ['CAM', 'TeleSync', 'TeleCine', 'SCR'];
export function toStreamInfo(record) {
const torrentInfo = titleParser.parse(record.torrent.title);
const fileInfo = titleParser.parse(record.title);
const sameInfo = !Number.isInteger(record.fileIndex)
|| Math.abs(record.size / record.torrent.size - 1) < SIZE_DELTA
|| record.title.includes(record.torrent.title);
const quality = getQuality(record, torrentInfo, fileInfo);
const three3Quality = fileInfo.threeD || torrentInfo.threeD;
const hdrProfiles = torrentInfo.hdr || fileInfo.hdr || [];
const title = joinDetailParts(
[
joinDetailParts([record.torrent.title.replace(/[, ]+/g, ' ')]),
joinDetailParts([!sameInfo && record.title || undefined]),
joinDetailParts([
joinDetailParts([record.torrent.seeders], '👤 '),
joinDetailParts([formatSize(record.size)], '💾 '),
joinDetailParts([record.torrent.provider], '⚙️ ')
]),
joinDetailParts(getLanguages(record, torrentInfo, fileInfo), '', ' / '),
],
'',
'\n'
);
const name = joinDetailParts(
[
joinDetailParts([ADDON_NAME]),
joinDetailParts([quality, three3Quality, joinDetailParts(hdrProfiles, '', ' | ')])
],
'',
'\n'
);
const bingeGroupParts = getBingeGroupParts(record, sameInfo, quality, torrentInfo, fileInfo);
const bingeGroup = joinDetailParts(bingeGroupParts, "torrentio|", "|")
const filename = Number.isInteger(record.fileIndex) ? record.title.split('/').pop() : undefined;
const behaviorHints = bingeGroup || filename ? cleanOutputObject({ bingeGroup, filename }) : undefined;
return cleanOutputObject({
name: name,
title: title,
infoHash: record.infoHash,
fileIdx: record.fileIndex,
behaviorHints: behaviorHints,
sources: getSources(record.torrent.trackers, record.infoHash),
subtitles: getSubtitles(record)
});
}
function getQuality(record, torrentInfo, fileInfo) {
if (CAM_SOURCES.includes(fileInfo.source)) {
return fileInfo.source;
}
if (CAM_SOURCES.includes(torrentInfo.source)) {
return torrentInfo.source;
}
const resolution = fileInfo.resolution || torrentInfo.resolution || record.torrent.resolution;
const source = fileInfo.source || torrentInfo.source;
return resolution || source;
}
function getLanguages(record, torrentInfo, fileInfo) {
const providerLanguages = record.torrent.languages && titleParser.parse(record.torrent.languages + '.srt').languages || [];
const torrentLanguages = torrentInfo.languages || [];
const fileLanguages = fileInfo.languages || [];
const dubbed = torrentInfo.dubbed || fileInfo.dubbed;
let languages = Array.from(new Set([].concat(torrentLanguages).concat(fileLanguages).concat(providerLanguages)));
if (record.kitsuId || record.torrent.type === Type.ANIME) {
// no need to display japanese for anime
languages = languages.concat(dubbed ? ['dubbed'] : [])
.filter(lang => lang !== 'japanese');
}
if (languages.length === 1 && languages.includes('english')) {
// no need to display languages if only english is present
languages = [];
}
if (languages.length === 0 && dubbed) {
// display dubbed only if there are no other languages defined for non anime
languages = ['dubbed'];
}
return mapLanguages(languages);
}
function joinDetailParts(parts, prefix = '', delimiter = ' ') {
const filtered = parts.filter((part) => part !== undefined && part !== null).join(delimiter);
return filtered.length > 0 ? `${prefix}${filtered}` : undefined;
}
function formatSize(size) {
if (!size) {
return undefined;
}
if (size === UNKNOWN_SIZE) {
return undefined;
}
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return Number((size / Math.pow(1024, i)).toFixed(2)) + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
}
export function applyStaticInfo(streams) {
return streams.map(stream => enrichStaticInfo(stream));
}
function enrichStaticInfo(stream) {
return enrichSubtitles(enrichStreamSources({ ...stream }));
}
function enrichSubtitles(stream) {
if (stream.subtitles?.length) {
stream.subtitles = stream.subtitles.map(subtitle =>{
if (subtitle.url) {
return subtitle;
}
return {
id: `${subtitle.fileIndex}`,
lang: subtitle.lang,
url: `http://localhost:11470/${subtitle.infoHash}/${subtitle.fileIndex}/${subtitle.title.split('/').pop()}`
};
});
}
return stream;
}
function getBingeGroupParts(record, sameInfo, quality, torrentInfo, fileInfo) {
if (record.torrent.type === Type.MOVIE) {
const source = torrentInfo.source || fileInfo.source
return [quality]
.concat(source !== quality ? source : [])
.concat(torrentInfo.codec || fileInfo.codec)
.concat(torrentInfo.bitDepth || fileInfo.bitDepth)
.concat(torrentInfo.hdr || fileInfo.hdr);
} else if (sameInfo) {
return [quality]
.concat(fileInfo.hdr)
.concat(fileInfo.group);
}
return [record.infoHash];
}
function cleanOutputObject(object) {
return Object.fromEntries(Object.entries(object).filter(([_, v]) => v != null));
}

View file

@ -1,99 +0,0 @@
import { parse } from 'parse-torrent-title';
import { isExtension } from './extension.js';
import { Providers } from './filter.js';
import { languageFromCode } from './languages.js';
const languageMapping = {
'english': 'eng',
'japanese': 'jpn',
'russian': 'rus',
'italian': 'ita',
'portuguese': 'por',
'spanish': 'spa',
'latino': 'lat',
'korean': 'kor',
'chinese': 'zho',
'taiwanese': 'zht',
'french': 'fre',
'german': 'ger',
'dutch': 'dut',
'hindi': 'hin ',
'telugu': 'tel',
'tamil': 'tam',
'polish': 'pol',
'lithuanian': 'lit',
'latvian': 'lav',
'estonian': 'est',
'czech': 'cze',
'slovakian': 'slo',
'slovenian': 'slv',
'hungarian': 'hun',
'romanian': 'rum',
'bulgarian': 'bul',
'serbian': 'scc',
'croatian': 'hrv',
'ukrainian': 'ukr',
'greek': 'ell',
'danish': 'dan',
'finnish': 'fin',
'swedish': 'swe',
'norwegian': 'nor',
'turkish': 'tur',
'arabic': 'ara',
'persian': 'per',
'hebrew': 'heb',
'vietnamese': 'vie',
'indonesian': 'ind',
'thai': 'tha'
}
const ignoreSet = new Set(['dubbed', 'multi audio', 'multi subs', 'dual audio']);
const allowedExtensions = ['srt', 'vtt', 'ass', 'ssa'];
export function getSubtitles(record) {
if (!record?.subtitles?.length) {
return null;
}
return record.subtitles
.filter(subtitle => isExtension(subtitle.title, allowedExtensions))
.sort((a, b) => b.size - a.size)
.map(subtitle => ({
infoHash: subtitle.infoHash,
fileIndex: subtitle.fileIndex,
title: subtitle.title,
lang: parseLanguage(subtitle.title, record),
}));
}
function parseLanguage(title, record) {
const subtitlePathParts = title.split('/');
const subtitleFileName = subtitlePathParts.pop();
const subtitleTitleNoExt = title.replace(/\.\w{2,5}$/, '');
const videoFileName = record.title.split('/').pop().replace(/\.\w{2,5}$/, '');
const fileNameLanguage = getSingleLanguage(subtitleFileName.replace(videoFileName, ''));
if (fileNameLanguage) {
return fileNameLanguage;
}
const videoTitleNoExt = record.title.replace(/\.\w{2,5}$/, '');
if (subtitleTitleNoExt === record.title || subtitleTitleNoExt === videoTitleNoExt) {
const provider = Providers.options.find(provider => provider.label === record.torrent.provider);
return provider?.foreign && languageFromCode(provider.foreign) || 'eng';
}
const folderName = subtitlePathParts.join('/');
const folderNameLanguage = getSingleLanguage(folderName.replace(videoFileName, ''));
if (folderNameLanguage) {
return folderNameLanguage
}
return getFileNameLanguageCode(subtitleFileName) || 'Unknown';
}
function getSingleLanguage(title) {
const parsedInfo = parse(title);
const languages = (parsedInfo.languages || []).filter(language => !ignoreSet.has(language));
return languages.length === 1 ? languageMapping[languages[0]] : undefined;
}
function getFileNameLanguageCode(fileName) {
const match = fileName.match(/(?:(?:^|[._ ])([A-Za-z][a-z]{1,2})|\[([a-z]{2,3})])\.\w{3,4}$/);
return match?.[1]?.toLowerCase();
}

View file

@ -1,31 +0,0 @@
export function extractSeeders(title) {
const seedersMatch = title.match(/👤 (\d+)/);
return seedersMatch && parseInt(seedersMatch[1]) || 0;
}
export function extractSize(title) {
const seedersMatch = title.match(/💾 ([\d.]+ \w+)/);
return seedersMatch && parseSize(seedersMatch[1]) || 0;
}
export function extractProvider(title) {
const match = title.match(/⚙.* ([^ \n]+)/);
return match?.[1];
}
export function parseSize(sizeText) {
if (!sizeText) {
return 0;
}
let scale = 1;
if (sizeText.includes('TB')) {
scale = 1024 * 1024 * 1024 * 1024
} else if (sizeText.includes('GB')) {
scale = 1024 * 1024 * 1024
} else if (sizeText.includes('MB')) {
scale = 1024 * 1024;
} else if (sizeText.includes('kB')) {
scale = 1024;
}
return Math.floor(parseFloat(sizeText.replace(/,/g, '')) * scale);
}

View file

@ -1,6 +0,0 @@
export const Type = {
MOVIE: 'movie',
SERIES: 'series',
ANIME: 'anime',
OTHER: 'other'
};

View file

@ -1,199 +0,0 @@
import AllDebridClient from 'all-debrid-api';
import { Type } from '../lib/types.js';
import { isVideo, isArchive } from '../lib/extension.js';
import StaticResponse from './static.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { BadTokenError, AccessDeniedError, sameFilename, streamFilename, AccessBlockedError } from './mochHelper.js';
const KEY = 'alldebrid';
const AGENT = 'torrentio';
export async function getCachedStreams(streams, apiKey, ip) {
return streams
.reduce((mochStreams, stream) => {
const filename = streamFilename(stream);
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
url: `${apiKey}/${stream.infoHash}/${filename}/${stream.fileIdx}`,
cached: false
}
return mochStreams;
}, {})
}
export async function getCatalog(apiKey, catalogId, config) {
if (config.skip > 0) {
return [];
}
const options = await getDefaultOptions(config.ip);
const AD = new AllDebridClient(apiKey, options);
return AD.magnet.status()
.then(response => response.data.magnets)
.then(torrents => (torrents || [])
.filter(torrent => torrent && statusReady(torrent.statusCode))
.map(torrent => ({
id: `${KEY}:${torrent.id}`,
type: Type.OTHER,
name: torrent.filename
})));
}
export async function getItemMeta(itemId, apiKey, ip) {
const options = await getDefaultOptions(ip);
const AD = new AllDebridClient(apiKey, options);
return AD.magnet.status(itemId)
.then(response => response.data.magnets)
.then(torrent => ({
id: `${KEY}:${torrent.id}`,
type: Type.OTHER,
name: torrent.filename,
infoHash: torrent.hash.toLowerCase(),
videos: torrent.links
.filter(file => isVideo(file.filename))
.map((file, index) => ({
id: `${KEY}:${torrent.id}:${index}`,
title: file.filename,
released: new Date(torrent.uploadDate * 1000 - index).toISOString(),
streams: [{ url: `${apiKey}/${torrent.hash.toLowerCase()}/${encodeURIComponent(file.filename)}/${index}` }]
}))
}))
}
export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
console.log(`Unrestricting AllDebrid ${infoHash} [${fileIndex}]`);
const options = await getDefaultOptions(ip);
const AD = new AllDebridClient(apiKey, options);
return _resolve(AD, infoHash, cachedEntryInfo, fileIndex)
.catch(error => {
if (isExpiredSubscriptionError(error)) {
console.log(`Access denied to AllDebrid ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
if (isBlockedAccessError(error)) {
console.log(`Access blocked to AllDebrid ${infoHash} [${fileIndex}]`);
return StaticResponse.BLOCKED_ACCESS;
}
if (error.code === 'MAGNET_TOO_MANY') {
console.log(`Deleting and retrying adding to AllDebrid ${infoHash} [${fileIndex}]...`);
return _deleteAndRetry(AD, infoHash, cachedEntryInfo, fileIndex);
}
return Promise.reject(`Failed AllDebrid adding torrent ${JSON.stringify(error)}`);
});
}
async function _resolve(AD, infoHash, cachedEntryInfo, fileIndex) {
const torrent = await _createOrFindTorrent(AD, infoHash);
if (statusReady(torrent?.statusCode)) {
return _unrestrictLink(AD, torrent, cachedEntryInfo, fileIndex);
} else if (statusDownloading(torrent?.statusCode)) {
console.log(`Downloading to AllDebrid ${infoHash} [${fileIndex}]...`);
return StaticResponse.DOWNLOADING;
} else if (statusHandledError(torrent?.statusCode)) {
console.log(`Retrying downloading to AllDebrid ${infoHash} [${fileIndex}]...`);
return _retryCreateTorrent(AD, infoHash, cachedEntryInfo, fileIndex);
} else if (statusTooBigEntry(torrent?.statusCode)) {
console.log(`Torrent too big for AllDebrid ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_TOO_BIG;
}
return Promise.reject(`Failed AllDebrid adding torrent ${JSON.stringify(torrent)}`);
}
async function _createOrFindTorrent(AD, infoHash) {
return _findTorrent(AD, infoHash)
.catch(() => _createTorrent(AD, infoHash));
}
async function _retryCreateTorrent(AD, infoHash, encodedFileName, fileIndex) {
const newTorrent = await _createTorrent(AD, infoHash);
return newTorrent && statusReady(newTorrent.statusCode)
? _unrestrictLink(AD, newTorrent, encodedFileName, fileIndex)
: StaticResponse.FAILED_DOWNLOAD;
}
async function _deleteAndRetry(AD, infoHash, encodedFileName, fileIndex) {
const torrents = await AD.magnet.status().then(response => response.data.magnets);
const lastTorrent = torrents[torrents.length - 1];
return AD.magnet.delete(lastTorrent.id)
.then(() => _retryCreateTorrent(AD, infoHash, encodedFileName, fileIndex));
}
async function _findTorrent(AD, infoHash) {
const torrents = await AD.magnet.status().then(response => response.data.magnets);
const foundTorrents = torrents.filter(torrent => torrent.hash.toLowerCase() === infoHash);
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent.statusCode));
const foundTorrent = nonFailedTorrent || foundTorrents[0];
return foundTorrent || Promise.reject('No recent torrent found');
}
async function _createTorrent(AD, infoHash) {
const magnetLink = await getMagnetLink(infoHash);
const uploadResponse = await AD.magnet.upload(magnetLink);
const torrentId = uploadResponse.data.magnets[0].id;
return AD.magnet.status(torrentId).then(statusResponse => statusResponse.data.magnets);
}
async function _unrestrictLink(AD, torrent, encodedFileName, fileIndex) {
const targetFileName = decodeURIComponent(encodedFileName);
const videos = torrent.links.filter(link => isVideo(link.filename)).sort((a, b) => b.size - a.size);
const targetVideo = Number.isInteger(fileIndex)
&& videos.find(video => sameFilename(targetFileName, video.filename))
|| videos[0];
if (!targetVideo && torrent.links.every(link => isArchive(link.filename))) {
console.log(`Only AllDebrid archive is available for [${torrent.hash}] ${encodedFileName}`)
return StaticResponse.FAILED_RAR;
}
if (!targetVideo || !targetVideo.link || !targetVideo.link.length) {
return Promise.reject(`No AllDebrid links found for [${torrent.hash}] ${encodedFileName}`);
}
const unrestrictedLink = await AD.link.unlock(targetVideo.link).then(response => response.data.link);
console.log(`Unrestricted AllDebrid ${torrent.hash} [${fileIndex}] to ${unrestrictedLink}`);
return unrestrictedLink;
}
async function getDefaultOptions(ip) {
return { ip, base_agent: AGENT, timeout: 10000 };
}
export function toCommonError(error) {
if (error && error.code === 'AUTH_BAD_APIKEY') {
return BadTokenError;
}
if (error && error.code === 'AUTH_USER_BANNED') {
return AccessDeniedError;
}
if (error && error.code === 'AUTH_BLOCKED') {
return AccessBlockedError;
}
return undefined;
}
function statusError(statusCode) {
return [5, 6, 7, 8, 9, 10, 11].includes(statusCode);
}
function statusHandledError(statusCode) {
return [5, 7, 9, 10, 11].includes(statusCode);
}
function statusDownloading(statusCode) {
return [0, 1, 2, 3].includes(statusCode);
}
function statusReady(statusCode) {
return statusCode === 4;
}
function statusTooBigEntry(statusCode) {
return statusCode === 8;
}
function isExpiredSubscriptionError(error) {
return ['AUTH_BAD_APIKEY', 'MUST_BE_PREMIUM', 'MAGNET_MUST_BE_PREMIUM', 'FREE_TRIAL_LIMIT_REACHED', 'AUTH_USER_BANNED']
.includes(error.code);
}
function isBlockedAccessError(error) {
return ['AUTH_BLOCKED'].includes(error.code);
}

View file

@ -1,156 +0,0 @@
import DebridLinkClient from 'debrid-link-api';
import { Type } from '../lib/types.js';
import { isVideo, isArchive } from '../lib/extension.js';
import StaticResponse from './static.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { BadTokenError } from './mochHelper.js';
const KEY = 'debridlink';
export async function getCachedStreams(streams, apiKey) {
return streams
.reduce((mochStreams, stream) => {
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
url: `${apiKey}/${stream.infoHash}/null/${stream.fileIdx}`,
cached: false
};
return mochStreams;
}, {})
}
export async function getCatalog(apiKey, catalogId, config) {
if (config.skip > 0) {
return [];
}
const options = await getDefaultOptions();
const DL = new DebridLinkClient(apiKey, options);
return DL.seedbox.list()
.then(response => response.value)
.then(torrents => (torrents || [])
.filter(torrent => torrent && statusReady(torrent))
.map(torrent => ({
id: `${KEY}:${torrent.id}`,
type: Type.OTHER,
name: torrent.name
})));
}
export async function getItemMeta(itemId, apiKey, ip) {
const options = await getDefaultOptions(ip);
const DL = new DebridLinkClient(apiKey, options);
return DL.seedbox.list(itemId)
.then(response => response.value[0])
.then(torrent => ({
id: `${KEY}:${torrent.id}`,
type: Type.OTHER,
name: torrent.name,
infoHash: torrent.hashString.toLowerCase(),
videos: torrent.files
.filter(file => isVideo(file.name))
.map((file, index) => ({
id: `${KEY}:${torrent.id}:${index}`,
title: file.name,
released: new Date(torrent.created * 1000 - index).toISOString(),
streams: [{ url: file.downloadUrl }]
}))
}))
}
export async function resolve({ ip, apiKey, infoHash, fileIndex }) {
console.log(`Unrestricting DebridLink ${infoHash} [${fileIndex}]`);
const options = await getDefaultOptions(ip);
const DL = new DebridLinkClient(apiKey, options);
return _resolve(DL, infoHash, fileIndex)
.catch(error => {
if (isAccessDeniedError(error)) {
console.log(`Access denied to DebridLink ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
if (isLimitsExceededError(error)) {
console.log(`Limits exceeded in DebridLink ${infoHash} [${fileIndex}]`);
return StaticResponse.LIMITS_EXCEEDED;
}
if (isTorrentTooBigError(error)) {
console.log(`Torrent too big for DebridLink ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_TOO_BIG;
}
return Promise.reject(`Failed DebridLink adding torrent ${JSON.stringify(error)}`);
});
}
async function _resolve(DL, infoHash, fileIndex) {
const torrent = await _createOrFindTorrent(DL, infoHash);
if (torrent && statusReady(torrent)) {
return _unrestrictLink(DL, torrent, fileIndex);
} else if (torrent && statusDownloading(torrent)) {
console.log(`Downloading to DebridLink ${infoHash} [${fileIndex}]...`);
return StaticResponse.DOWNLOADING;
}
return Promise.reject(`Failed DebridLink adding torrent ${JSON.stringify(torrent)}`);
}
async function _createOrFindTorrent(DL, infoHash) {
return _findTorrent(DL, infoHash)
.catch(() => _createTorrent(DL, infoHash));
}
async function _findTorrent(DL, infoHash) {
const torrents = await DL.seedbox.list().then(response => response.value);
const foundTorrents = torrents.filter(torrent => torrent.hashString.toLowerCase() === infoHash);
return foundTorrents[0] || Promise.reject('No recent torrent found');
}
async function _createTorrent(DL, infoHash) {
const magnetLink = await getMagnetLink(infoHash);
const uploadResponse = await DL.seedbox.add(magnetLink, null, true);
return uploadResponse.value;
}
async function _unrestrictLink(DL, torrent, fileIndex) {
const targetFile = Number.isInteger(fileIndex)
? torrent.files[fileIndex]
: torrent.files.filter(file => file.downloadPercent === 100).sort((a, b) => b.size - a.size)[0];
if (!targetFile && torrent.files.every(file => isArchive(file.downloadUrl))) {
console.log(`Only DebridLink archive is available for [${torrent.hashString}] ${fileIndex}`)
return StaticResponse.FAILED_RAR;
}
if (!targetFile || !targetFile.downloadUrl) {
return Promise.reject(`No DebridLink links found for index ${fileIndex} in: ${JSON.stringify(torrent)}`);
}
console.log(`Unrestricted DebridLink ${torrent.hashString} [${fileIndex}] to ${targetFile.downloadUrl}`);
return targetFile.downloadUrl;
}
async function getDefaultOptions(ip) {
return { ip, timeout: 10000 };
}
export function toCommonError(error) {
if (error === 'badToken') {
return BadTokenError;
}
return undefined;
}
function statusDownloading(torrent) {
return torrent.downloadPercent < 100
}
function statusReady(torrent) {
return torrent.downloadPercent === 100;
}
function isAccessDeniedError(error) {
return ['badToken', 'accountLocked'].includes(error);
}
function isLimitsExceededError(error) {
return ['freeServerOverload', 'maxTorrent', 'maxLink', 'maxLinkHost', 'maxData', 'maxDataHost', 'floodDetected'].includes(error);
}
function isTorrentTooBigError(error) {
return ['torrentTooBig'].includes(error);
}

View file

@ -1,249 +0,0 @@
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 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 { BadTokenError, streamFilename, AccessDeniedError, enrichMeta, AccessBlockedError } from './mochHelper.js';
import { createNamedQueue } from "../lib/namedQueue.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',
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: ['']
},
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(50));
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], config.ip)
.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);
return unrestrictQueues[moch.key].wrap(id, method);
}
export async function getMochCatalog(mochKey, catalogId, 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], 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(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 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 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}/${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}@${stream.fileIdx}`];
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}`
};
}
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;
}

View file

@ -1,64 +0,0 @@
import * as repository from '../lib/repository.js';
const METAHUB_URL = 'https://images.metahub.space'
export const BadTokenError = { code: 'BAD_TOKEN' }
export const AccessDeniedError = { code: 'ACCESS_DENIED' }
export const AccessBlockedError = { code: 'ACCESS_BLOCKED' }
export function chunkArray(arr, size) {
return arr.length > size
? [arr.slice(0, size), ...chunkArray(arr.slice(size), size)]
: [arr];
}
export function streamFilename(stream) {
const filename = stream?.behaviorHints?.filename
|| stream.title.replace(/\n👤.*/s, '').split('\n').pop().split('/').pop();
return encodeURIComponent(filename)
}
export async function enrichMeta(itemMeta) {
const infoHashes = [...new Set([itemMeta.infoHash]
.concat(itemMeta.videos.map(video => video.infoHash))
.filter(infoHash => infoHash))];
const files = infoHashes.length ? await repository.getFiles(infoHashes).catch(() => []) : [];
const commonImdbId = itemMeta.infoHash && mostCommonValue(files.map(file => file.imdbId));
if (files.length) {
return {
...itemMeta,
logo: commonImdbId && `${METAHUB_URL}/logo/medium/${commonImdbId}/img`,
poster: commonImdbId && `${METAHUB_URL}/poster/medium/${commonImdbId}/img`,
background: commonImdbId && `${METAHUB_URL}/background/medium/${commonImdbId}/img`,
videos: itemMeta.videos.map(video => {
const file = files.find(file => sameFilename(video.title, file.title));
if (file?.imdbId) {
if (file.imdbSeason && file.imdbEpisode) {
video.id = `${file.imdbId}:${file.imdbSeason}:${file.imdbEpisode}`;
video.season = file.imdbSeason;
video.episode = file.imdbEpisode;
video.thumbnail = `https://episodes.metahub.space/${file.imdbId}/${video.season}/${video.episode}/w780.jpg`
} else {
video.id = file.imdbId;
video.thumbnail = `${METAHUB_URL}/background/small/${file.imdbId}/img`;
}
}
return video;
})
}
}
return itemMeta
}
export function sameFilename(filename, expectedFilename) {
const offset = filename.length - expectedFilename.length;
for (let i = 0; i < expectedFilename.length; i++) {
if (filename[offset + i] !== expectedFilename[i] && expectedFilename[i] !== '<27>') {
return false;
}
}
return true;
}
function mostCommonValue(array) {
return array.sort((a, b) => array.filter(v => v === a).length - array.filter(v => v === b).length).pop();
}

View file

@ -1,184 +0,0 @@
import OffcloudClient from 'offcloud-api';
import magnet from 'magnet-uri';
import { Type } from '../lib/types.js';
import { isVideo } from '../lib/extension.js';
import StaticResponse from './static.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { chunkArray, BadTokenError, sameFilename, streamFilename } from './mochHelper.js';
const KEY = 'offcloud';
export async function getCachedStreams(streams, apiKey) {
const options = await getDefaultOptions();
const OC = new OffcloudClient(apiKey, options);
const hashBatches = chunkArray(streams.map(stream => stream.infoHash), 100);
const available = await Promise.all(hashBatches.map(hashes => OC.instant.cache(hashes)))
.then(results => results.map(result => result.cachedItems))
.then(results => results.reduce((all, result) => all.concat(result), []))
.catch(error => {
if (toCommonError(error)) {
return Promise.reject(error);
}
console.warn('Failed Offcloud cached torrent availability request:', error);
return undefined;
});
return available && streams
.reduce((mochStreams, stream) => {
const isCached = available.includes(stream.infoHash);
const fileName = streamFilename(stream);
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
url: `${apiKey}/${stream.infoHash}/${fileName}/${stream.fileIdx}`,
cached: isCached
};
return mochStreams;
}, {})
}
export async function getCatalog(apiKey, catalogId, config) {
if (config.skip > 0) {
return [];
}
const options = await getDefaultOptions();
const OC = new OffcloudClient(apiKey, options);
return OC.cloud.history()
.then(torrents => torrents)
.then(torrents => (torrents || [])
.map(torrent => ({
id: `${KEY}:${torrent.requestId}`,
type: Type.OTHER,
name: torrent.fileName
})));
}
export async function getItemMeta(itemId, apiKey, ip) {
const options = await getDefaultOptions(ip);
const OC = new OffcloudClient(apiKey, options);
const torrents = await OC.cloud.history();
const torrent = torrents.find(torrent => torrent.requestId === itemId)
const infoHash = torrent && magnet.decode(torrent.originalLink).infoHash
const createDate = torrent ? new Date(torrent.createdOn) : new Date();
return _getFileUrls(OC, torrent)
.then(files => ({
id: `${KEY}:${itemId}`,
type: Type.OTHER,
name: torrent.name,
infoHash: infoHash,
videos: files
.filter(file => isVideo(file))
.map((file, index) => ({
id: `${KEY}:${itemId}:${index}`,
title: file.split('/').pop(),
released: new Date(createDate.getTime() - index).toISOString(),
streams: [{ url: file }]
}))
}))
}
export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
console.log(`Unrestricting Offcloud ${infoHash} [${fileIndex}]`);
const options = await getDefaultOptions(ip);
const OC = new OffcloudClient(apiKey, options);
return _resolve(OC, infoHash, cachedEntryInfo, fileIndex)
.catch(error => {
if (errorExpiredSubscriptionError(error)) {
console.log(`Access denied to Offcloud ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
return Promise.reject(`Failed Offcloud adding torrent ${JSON.stringify(error)}`);
});
}
async function _resolve(OC, infoHash, cachedEntryInfo, fileIndex) {
const torrent = await _createOrFindTorrent(OC, infoHash)
.then(info => info.requestId ? OC.cloud.status(info.requestId) : Promise.resolve(info))
.then(info => info.status || info);
if (torrent && statusReady(torrent)) {
return _unrestrictLink(OC, infoHash, torrent, cachedEntryInfo, fileIndex);
} else if (torrent && statusDownloading(torrent)) {
console.log(`Downloading to Offcloud ${infoHash} [${fileIndex}]...`);
return StaticResponse.DOWNLOADING;
} else if (torrent && statusError(torrent)) {
console.log(`Retry failed download in Offcloud ${infoHash} [${fileIndex}]...`);
return _retryCreateTorrent(OC, infoHash, cachedEntryInfo, fileIndex);
}
return Promise.reject(`Failed Offcloud adding torrent ${JSON.stringify(torrent)}`);
}
async function _createOrFindTorrent(OC, infoHash) {
return _findTorrent(OC, infoHash)
.catch(() => _createTorrent(OC, infoHash));
}
async function _findTorrent(OC, infoHash) {
const torrents = await OC.cloud.history();
const foundTorrents = torrents.filter(torrent => torrent.originalLink.toLowerCase().includes(infoHash));
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent));
const foundTorrent = nonFailedTorrent || foundTorrents[0];
return foundTorrent || Promise.reject('No recent torrent found');
}
async function _createTorrent(OC, infoHash) {
const magnetLink = await getMagnetLink(infoHash);
return OC.cloud.download(magnetLink)
}
async function _retryCreateTorrent(OC, infoHash, cachedEntryInfo, fileIndex) {
const newTorrent = await _createTorrent(OC, infoHash);
return newTorrent && statusReady(newTorrent.status)
? _unrestrictLink(OC, infoHash, newTorrent, cachedEntryInfo, fileIndex)
: StaticResponse.FAILED_DOWNLOAD;
}
async function _unrestrictLink(OC, infoHash, torrent, cachedEntryInfo, fileIndex) {
const targetFileName = decodeURIComponent(cachedEntryInfo);
const files = await _getFileUrls(OC, torrent)
const targetFile = files.find(file => sameFilename(targetFileName, file.split('/').pop()))
|| files.find(file => file.includes(`/${torrent.requestId}/${fileIndex + 1}/`) && isVideo(file))
|| files.find(file => isVideo(file))
|| files.pop();
if (!targetFile) {
return Promise.reject(`No Offcloud links found for index ${fileIndex} in: ${JSON.stringify(torrent)}`);
}
console.log(`Unrestricted Offcloud ${infoHash} [${fileIndex}] to ${targetFile}`);
return targetFile;
}
async function _getFileUrls(OC, torrent) {
return OC.cloud.explore(torrent.requestId)
.catch(error => {
if (error === 'Bad archive') {
return [`https://${torrent.server}.offcloud.com/cloud/download/${torrent.requestId}/${torrent.fileName}`];
}
throw error;
})
}
async function getDefaultOptions(ip) {
return { ip, timeout: 10000 };
}
export function toCommonError(error) {
if (error?.error === 'NOAUTH' || error?.message?.startsWith('Cannot read property')) {
return BadTokenError;
}
return undefined;
}
function statusDownloading(torrent) {
return ['downloading', 'created', 'queued'].includes(torrent.status);
}
function statusError(torrent) {
return ['error', 'canceled'].includes(torrent.status);
}
function statusReady(torrent) {
return torrent.status === 'downloaded';
}
function errorExpiredSubscriptionError(error) {
return error?.includes && (error.includes('not_available') || error.includes('NOAUTH') || error.includes('premium membership'));
}

View file

@ -1,21 +0,0 @@
export const DebridOptions = {
key: 'debridoptions',
options: {
noDownloadLinks: {
key: 'nodownloadlinks',
description: 'Don\'t show download to debrid links'
},
noCatalog: {
key: 'nocatalog',
description: 'Don\'t show debrid catalog'
},
}
}
export function excludeDownloadLinks(config) {
return config[DebridOptions.key]?.includes(DebridOptions.options.noDownloadLinks.key);
}
export function showDebridCatalog(config) {
return !config[DebridOptions.key]?.includes(DebridOptions.options.noCatalog.key);
}

View file

@ -1,208 +0,0 @@
import PremiumizeClient from 'premiumize-api';
import magnet from 'magnet-uri';
import { Type } from '../lib/types.js';
import { isVideo, isArchive } from '../lib/extension.js';
import StaticResponse from './static.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { BadTokenError, chunkArray, sameFilename, streamFilename } from './mochHelper.js';
const KEY = 'premiumize';
export async function getCachedStreams(streams, apiKey) {
const options = await getDefaultOptions();
const PM = new PremiumizeClient(apiKey, options);
return Promise.all(chunkArray(streams, 100)
.map(chunkedStreams => _getCachedStreams(PM, apiKey, chunkedStreams)))
.then(results => results.reduce((all, result) => Object.assign(all, result), {}));
}
async function _getCachedStreams(PM, apiKey, streams) {
const hashes = streams.map(stream => stream.infoHash);
return PM.cache.check(hashes)
.catch(error => {
if (toCommonError(error)) {
return Promise.reject(error);
}
console.warn('Failed Premiumize cached torrent availability request:', error);
return undefined;
})
.then(available => streams
.reduce((mochStreams, stream, index) => {
const filename = streamFilename(stream);
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
url: `${apiKey}/${stream.infoHash}/${filename}/${stream.fileIdx}`,
cached: available?.response[index]
};
return mochStreams;
}, {}));
}
export async function getCatalog(apiKey, catalogId, config) {
if (config.skip > 0) {
return [];
}
const options = await getDefaultOptions();
const PM = new PremiumizeClient(apiKey, options);
return PM.folder.list()
.then(response => response.content)
.then(torrents => (torrents || [])
.filter(torrent => torrent && torrent.type === 'folder')
.map(torrent => ({
id: `${KEY}:${torrent.id}`,
type: Type.OTHER,
name: torrent.name
})));
}
export async function getItemMeta(itemId, apiKey, ip) {
const options = await getDefaultOptions();
const PM = new PremiumizeClient(apiKey, options);
const rootFolder = await PM.folder.list(itemId, null);
const infoHash = await _findInfoHash(PM, itemId);
return getFolderContents(PM, itemId, ip)
.then(contents => ({
id: `${KEY}:${itemId}`,
type: Type.OTHER,
name: rootFolder.name,
infoHash: infoHash,
videos: contents
.map((file, index) => ({
id: `${KEY}:${file.id}:${index}`,
title: file.name,
released: new Date(file.created_at * 1000 - index).toISOString(),
streams: [{ url: file.link || file.stream_link }]
}))
}))
}
async function getFolderContents(PM, itemId, ip, folderPrefix = '') {
return PM.folder.list(itemId, null, ip)
.then(response => response.content)
.then(contents => Promise.all(contents
.filter(content => content.type === 'folder')
.map(content => getFolderContents(PM, content.id, ip, [folderPrefix, content.name].join('/'))))
.then(otherContents => otherContents.reduce((a, b) => a.concat(b), []))
.then(otherContents => contents
.filter(content => content.type === 'file' && isVideo(content.name))
.map(content => ({ ...content, name: [folderPrefix, content.name].join('/') }))
.concat(otherContents)));
}
export async function resolve({ ip, isBrowser, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
console.log(`Unrestricting Premiumize ${infoHash} [${fileIndex}] for IP ${ip} from browser=${isBrowser}`);
const options = await getDefaultOptions();
const PM = new PremiumizeClient(apiKey, options);
return _getCachedLink(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser)
.catch(() => _resolve(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser))
.catch(error => {
if (isAccessDeniedError(error)) {
console.log(`Access denied to Premiumize ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
if (isLimitExceededError(error)) {
console.log(`Limits exceeded in Premiumize ${infoHash} [${fileIndex}]`);
return StaticResponse.LIMITS_EXCEEDED;
}
return Promise.reject(`Failed Premiumize adding torrent ${JSON.stringify(error)}`);
});
}
async function _resolve(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser) {
const torrent = await _createOrFindTorrent(PM, infoHash);
if (torrent && statusReady(torrent.status)) {
return _getCachedLink(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser);
} else if (torrent && statusDownloading(torrent.status)) {
console.log(`Downloading to Premiumize ${infoHash} [${fileIndex}]...`);
return StaticResponse.DOWNLOADING;
} else if (torrent && statusError(torrent.status)) {
console.log(`Retrying downloading to Premiumize ${infoHash} [${fileIndex}]...`);
return _retryCreateTorrent(PM, infoHash, cachedEntryInfo, fileIndex);
}
return Promise.reject(`Failed Premiumize adding torrent ${JSON.stringify(torrent)}`);
}
async function _getCachedLink(PM, infoHash, encodedFileName, fileIndex, ip, isBrowser) {
const cachedTorrent = await PM.transfer.directDownload(magnet.encode({ infoHash }), ip);
if (cachedTorrent?.content?.length) {
const targetFileName = decodeURIComponent(encodedFileName);
const videos = cachedTorrent.content.filter(file => isVideo(file.path)).sort((a, b) => b.size - a.size);
const targetVideo = Number.isInteger(fileIndex)
&& videos.find(video => sameFilename(video.path, targetFileName))
|| videos[0];
if (!targetVideo && videos.every(video => isArchive(video.path))) {
console.log(`Only Premiumize archive is available for [${infoHash}] ${fileIndex}`)
return StaticResponse.FAILED_RAR;
}
const streamLink = isBrowser && targetVideo.transcode_status === 'finished' && targetVideo.stream_link;
const unrestrictedLink = streamLink || targetVideo.link;
console.log(`Unrestricted Premiumize ${infoHash} [${fileIndex}] to ${unrestrictedLink}`);
return unrestrictedLink;
}
return Promise.reject('No cached entry found');
}
async function _createOrFindTorrent(PM, infoHash) {
return _findTorrent(PM, infoHash)
.catch(() => _createTorrent(PM, infoHash));
}
async function _findTorrent(PM, infoHash) {
const torrents = await PM.transfer.list().then(response => response.transfers);
const foundTorrents = torrents.filter(torrent => torrent.src.toLowerCase().includes(infoHash));
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent.statusCode));
const foundTorrent = nonFailedTorrent || foundTorrents[0];
return foundTorrent || Promise.reject('No recent torrent found');
}
async function _findInfoHash(PM, itemId) {
const torrents = await PM.transfer.list().then(response => response.transfers);
const foundTorrent = torrents.find(torrent => `${torrent.file_id}` === itemId || `${torrent.folder_id}` === itemId);
return foundTorrent?.src ? magnet.decode(foundTorrent.src).infoHash : undefined;
}
async function _createTorrent(PM, infoHash) {
const magnetLink = await getMagnetLink(infoHash);
return PM.transfer.create(magnetLink).then(() => _findTorrent(PM, infoHash));
}
async function _retryCreateTorrent(PM, infoHash, encodedFileName, fileIndex) {
const newTorrent = await _createTorrent(PM, infoHash).then(() => _findTorrent(PM, infoHash));
return newTorrent && statusReady(newTorrent.status)
? _getCachedLink(PM, infoHash, encodedFileName, fileIndex)
: StaticResponse.FAILED_DOWNLOAD;
}
export function toCommonError(error) {
if (error && error.message === 'Not logged in.') {
return BadTokenError;
}
return undefined;
}
function statusError(status) {
return ['deleted', 'error', 'timeout'].includes(status);
}
function statusDownloading(status) {
return ['waiting', 'queued', 'running'].includes(status);
}
function statusReady(status) {
return ['finished', 'seeding'].includes(status);
}
function isAccessDeniedError(error) {
return ['Account not premium.'].some(value => error?.message?.includes(value));
}
function isLimitExceededError(error) {
return [
'Fair use limit reached!',
'You already have a maximum of 25 active downloads in progress!',
'Your space is full! Please delete old files first!'
].some(value => error?.message?.includes(value));
}
async function getDefaultOptions(ip) {
return { timeout: 5000 };
}

View file

@ -1,216 +0,0 @@
import PutioClient from '@putdotio/api-client'
import { isVideo } from '../lib/extension.js';
import { delay } from '../lib/promises.js';
import StaticResponse from './static.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { Type } from "../lib/types.js";
import { decode } from "magnet-uri";
import { sameFilename, streamFilename } from "./mochHelper.js";
const PutioAPI = PutioClient.default;
const KEY = 'putio';
export async function getCachedStreams(streams, apiKey) {
return streams
.reduce((mochStreams, stream) => {
const filename = streamFilename(stream);
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
url: `${apiKey}/${stream.infoHash}/${filename}/${stream.fileIdx}`,
cached: false
};
return mochStreams;
}, {});
}
export async function getCatalog(apiKey, catalogId, config) {
if (config.skip > 0) {
return [];
}
const Putio = createPutioAPI(apiKey)
return Putio.Files.Query(0)
.then(response => response?.body?.files)
.then(files => (files || [])
.map(file => ({
id: `${KEY}:${file.id}`,
type: Type.OTHER,
name: file.name
})));
}
export async function getItemMeta(itemId, apiKey) {
const Putio = createPutioAPI(apiKey)
const infoHash = await _findInfoHash(Putio, itemId)
return getFolderContents(Putio, itemId)
.then(contents => ({
id: `${KEY}:${itemId}`,
type: Type.OTHER,
name: contents.name,
infoHash: infoHash,
videos: contents
.map((file, index) => ({
id: `${KEY}:${file.id}:${index}`,
title: file.name,
released: new Date(file.created_at).toISOString(),
streams: [{ url: `${apiKey}/null/null/${file.id}` }]
}))
}))
}
async function getFolderContents(Putio, itemId, folderPrefix = '') {
return await Putio.Files.Query(itemId)
.then(response => response?.body)
.then(body => body?.files?.length ? body.files : [body?.parent].filter(x => x))
.then(contents => Promise.all(contents
.filter(content => content.file_type === 'FOLDER')
.map(content => getFolderContents(Putio, content.id, [folderPrefix, content.name].join('/'))))
.then(otherContents => otherContents.reduce((a, b) => a.concat(b), []))
.then(otherContents => contents
.filter(content => content.file_type === 'VIDEO')
.map(content => ({ ...content, name: [folderPrefix, content.name].join('/') }))
.concat(otherContents)));
}
export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
console.log(`Unrestricting Putio ${infoHash} [${fileIndex}]`);
const Putio = createPutioAPI(apiKey)
return _resolve(Putio, infoHash, cachedEntryInfo, fileIndex)
.catch(error => {
if (error?.data?.status_code === 401) {
console.log(`Access denied to Putio ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
return Promise.reject(`Failed Putio adding torrent ${JSON.stringify(error.data || error)}`);
});
}
async function _resolve(Putio, infoHash, cachedEntryInfo, fileIndex) {
if (infoHash === 'null') {
return _unrestrictVideo(Putio, fileIndex);
}
const torrent = await _createOrFindTorrent(Putio, infoHash);
if (torrent && statusReady(torrent.status)) {
return _unrestrictLink(Putio, torrent, cachedEntryInfo, fileIndex);
} else if (torrent && statusDownloading(torrent.status)) {
console.log(`Downloading to Putio ${infoHash} [${fileIndex}]...`);
return StaticResponse.DOWNLOADING;
} else if (torrent && statusError(torrent.status)) {
console.log(`Retrying downloading to Putio ${infoHash} [${fileIndex}]...`);
return _retryCreateTorrent(Putio, infoHash, cachedEntryInfo, fileIndex);
}
return Promise.reject("Failed Putio adding torrent");
}
async function _createOrFindTorrent(Putio, infoHash) {
return _findTorrent(Putio, infoHash)
.catch(() => _createTorrent(Putio, infoHash));
}
async function _retryCreateTorrent(Putio, infoHash, encodedFileName, fileIndex) {
const newTorrent = await _createTorrent(Putio, infoHash);
return newTorrent && statusReady(newTorrent.status)
? _unrestrictLink(Putio, newTorrent, encodedFileName, fileIndex)
: StaticResponse.FAILED_DOWNLOAD;
}
async function _findTorrent(Putio, infoHash) {
const torrents = await Putio.Transfers.Query().then(response => response.data.transfers);
const foundTorrents = torrents.filter(torrent => torrent.source.toLowerCase().includes(infoHash));
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent.status));
const foundTorrent = nonFailedTorrent || foundTorrents[0];
if (foundTorrents && !foundTorrents.userfile_exists) {
return await Putio.Transfers.Cancel(foundTorrents.id).then(() => Promise.reject())
}
return foundTorrent || Promise.reject('No recent torrent found in Putio');
}
async function _findInfoHash(Putio, fileId) {
const torrents = await Putio.Transfers.Query().then(response => response?.data?.transfers);
const foundTorrent = torrents.find(torrent => `${torrent.file_id}` === fileId);
return foundTorrent?.source ? decode(foundTorrent.source).infoHash : undefined;
}
async function _createTorrent(Putio, infoHash) {
const magnetLink = await getMagnetLink(infoHash);
// Add the torrent and then delay for 3 secs for putio to process it and then check it's status.
return Putio.Transfers.Add({ url: magnetLink })
.then(response => _getNewTorrent(Putio, response.data.transfer.id));
}
async function _getNewTorrent(Putio, torrentId, pollCounter = 0, pollRate = 2000, maxPollNumber = 15) {
return Putio.Transfers.Get(torrentId)
.then(response => response.data.transfer)
.then(torrent => statusProcessing(torrent.status) && pollCounter < maxPollNumber
? delay(pollRate).then(() => _getNewTorrent(Putio, torrentId, pollCounter + 1))
: torrent);
}
async function _unrestrictLink(Putio, torrent, encodedFileName, fileIndex) {
const targetVideo = await _getTargetFile(Putio, torrent, encodedFileName, fileIndex);
return _unrestrictVideo(Putio, targetVideo.id);
}
async function _unrestrictVideo(Putio, videoId) {
const response = await Putio.File.GetStorageURL(videoId);
const downloadUrl = response.data.url
console.log(`Unrestricted Putio [${videoId}] to ${downloadUrl}`);
return downloadUrl;
}
async function _getTargetFile(Putio, torrent, encodedFileName, fileIndex) {
const targetFileName = decodeURIComponent(encodedFileName);
let targetFile;
let files = await _getFiles(Putio, torrent.file_id);
let videos = [];
while (!targetFile && files.length) {
const folders = files.filter(file => file.file_type === 'FOLDER');
videos = videos.concat(files.filter(file => isVideo(file.name))).sort((a, b) => b.size - a.size);
// when specific file index is defined search by filename
// when it's not defined find all videos and take the largest one
targetFile = Number.isInteger(fileIndex)
&& videos.find(video => sameFilename(targetFileName, video.name))
|| !folders.length && videos[0];
files = !targetFile
? await Promise.all(folders.map(folder => _getFiles(Putio, folder.id)))
.then(results => results.reduce((a, b) => a.concat(b), []))
: [];
}
return targetFile || Promise.reject(`No target file found for Putio [${torrent.hash}] ${targetFileName}`);
}
async function _getFiles(Putio, fileId) {
const response = await Putio.Files.Query(fileId)
.catch(error => Promise.reject({ ...error.data, path: error.request.path }));
return response.data.files.length
? response.data.files
: [response.data.parent];
}
function createPutioAPI(apiKey) {
const clientId = apiKey.replace(/@.*/, '');
const token = apiKey.replace(/.*@/, '');
const Putio = new PutioAPI({ clientID: clientId });
Putio.setToken(token);
return Putio;
}
export function toCommonError(error) {
return undefined;
}
function statusError(status) {
return ['ERROR'].includes(status);
}
function statusDownloading(status) {
return ['WAITING', 'IN_QUEUE', 'DOWNLOADING'].includes(status);
}
function statusProcessing(status) {
return ['WAITING', 'IN_QUEUE', 'COMPLETING'].includes(status);
}
function statusReady(status) {
return ['COMPLETED', 'SEEDING'].includes(status);
}

View file

@ -1,366 +0,0 @@
import RealDebridClient from 'real-debrid-api';
import { Type } from '../lib/types.js';
import { isVideo, isArchive } from '../lib/extension.js';
import { delay } from '../lib/promises.js';
import { cacheAvailabilityResults, getCachedAvailabilityResults, removeAvailabilityResults } from '../lib/cache.js';
import StaticResponse from './static.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { BadTokenError, AccessDeniedError } from './mochHelper.js';
const MIN_SIZE = 5 * 1024 * 1024; // 5 MB
const CATALOG_MAX_PAGE = 1;
const CATALOG_PAGE_SIZE = 100;
const KEY = 'realdebrid';
const DEBRID_DOWNLOADS = 'Downloads';
export async function getCachedStreams(streams, apiKey) {
const hashes = streams.map(stream => stream.infoHash);
const available = await getCachedAvailabilityResults(hashes);
return available && streams
.reduce((mochStreams, stream) => {
const cachedEntry = available[stream.infoHash];
const cachedIds = _getCachedFileIds(stream.fileIdx, cachedEntry);
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
url: `${apiKey}/${stream.infoHash}/null/${stream.fileIdx}`,
cached: !!cachedIds.length
};
return mochStreams;
}, {})
}
function _getCachedFileIds(fileIndex, cachedResults) {
if (!cachedResults || !Array.isArray(cachedResults)) {
return [];
}
const cachedIds = Number.isInteger(fileIndex)
? cachedResults.find(ids => Array.isArray(ids) && ids.includes(fileIndex + 1))
: cachedResults[0];
return cachedIds || [];
}
export async function getCatalog(apiKey, catalogId, config) {
const options = await getDefaultOptions(config.ip);
const RD = new RealDebridClient(apiKey, options);
const page = Math.floor((config.skip || 0) / 100) + 1;
const downloadsMeta = page === 1 ? [{
id: `${KEY}:${DEBRID_DOWNLOADS}`,
type: Type.OTHER,
name: DEBRID_DOWNLOADS
}] : [];
const torrentMetas = await _getAllTorrents(RD, page)
.then(torrents => Array.isArray(torrents) ? torrents : [])
.then(torrents => torrents
.filter(torrent => torrent && statusReady(torrent.status))
.map(torrent => ({
id: `${KEY}:${torrent.id}`,
type: Type.OTHER,
name: torrent.filename
})));
return downloadsMeta.concat(torrentMetas)
}
export async function getItemMeta(itemId, apiKey, ip) {
const options = await getDefaultOptions(ip);
const RD = new RealDebridClient(apiKey, options);
if (itemId === DEBRID_DOWNLOADS) {
const videos = await _getAllDownloads(RD)
.then(downloads => downloads
.map(download => ({
id: `${KEY}:${DEBRID_DOWNLOADS}:${download.id}`,
// infoHash: allTorrents
// .filter(torrent => (torrent.links || []).find(link => link === download.link))
// .map(torrent => torrent.hash.toLowerCase())[0],
title: download.filename,
released: new Date(download.generated).toISOString(),
streams: [{ url: download.download }]
})));
return {
id: `${KEY}:${DEBRID_DOWNLOADS}`,
type: Type.OTHER,
name: DEBRID_DOWNLOADS,
videos: videos
};
}
return _getTorrentInfo(RD, itemId)
.then(torrent => ({
id: `${KEY}:${torrent.id}`,
type: Type.OTHER,
name: torrent.filename,
infoHash: torrent.hash.toLowerCase(),
videos: torrent.files
.filter(file => file.selected)
.filter(file => isVideo(file.path))
.map((file, index) => ({
id: `${KEY}:${torrent.id}:${file.id}`,
title: file.path,
released: new Date(new Date(torrent.added).getTime() - index).toISOString(),
streams: [{ url: `${apiKey}/${torrent.hash.toLowerCase()}/null/${file.id - 1}` }]
}))
}))
}
async function _getAllTorrents(RD, page = 1) {
return RD.torrents.get(page - 1, page, CATALOG_PAGE_SIZE)
.then(torrents => torrents && torrents.length === CATALOG_PAGE_SIZE && page < CATALOG_MAX_PAGE
? _getAllTorrents(RD, page + 1)
.then(nextTorrents => torrents.concat(nextTorrents))
.catch(() => torrents)
: torrents)
}
async function _getAllDownloads(RD, page = 1) {
return RD.downloads.get(page - 1, page, CATALOG_PAGE_SIZE);
}
export async function resolve({ ip, isBrowser, apiKey, infoHash, fileIndex }) {
console.log(`Unrestricting RealDebrid ${infoHash} [${fileIndex}]`);
const options = await getDefaultOptions(ip);
const RD = new RealDebridClient(apiKey, options);
return _resolve(RD, infoHash, fileIndex, isBrowser)
.catch(error => {
if (isAccessDeniedError(error)) {
console.log(`Access denied to RealDebrid ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
if (isInfringingFileError(error)) {
console.log(`Infringing file removed from RealDebrid ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_INFRINGEMENT;
}
if (isLimitExceededError(error)) {
console.log(`Limits exceeded in RealDebrid ${infoHash} [${fileIndex}]`);
return StaticResponse.LIMITS_EXCEEDED;
}
if (isTorrentTooBigError(error)) {
console.log(`Torrent too big for RealDebrid ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_TOO_BIG;
}
return Promise.reject(`Failed RealDebrid adding torrent ${JSON.stringify(error)}`);
});
}
async function _resolveCachedFileIds(infoHash, fileIndex) {
const available = await getCachedAvailabilityResults([infoHash]);
const cachedEntry = available?.[infoHash];
const cachedIds = _getCachedFileIds(fileIndex, cachedEntry);
return cachedIds?.join(',');
}
async function _resolve(RD, infoHash, fileIndex, isBrowser) {
const torrentId = await _createOrFindTorrentId(RD, infoHash, fileIndex);
const torrent = await _getTorrentInfo(RD, torrentId);
if (torrent && statusReady(torrent.status)) {
return _unrestrictLink(RD, torrent, fileIndex, isBrowser);
} else if (torrent && statusDownloading(torrent.status)) {
console.log(`Downloading to RealDebrid ${infoHash} [${fileIndex}]...`);
const cachedFileIds = torrent.files.filter(file => file.selected).map(file => file.id);
removeAvailabilityResults(infoHash, cachedFileIds);
return StaticResponse.DOWNLOADING;
} else if (torrent && statusMagnetError(torrent.status)) {
console.log(`Failed RealDebrid opening torrent ${infoHash} [${fileIndex}] due to magnet error`);
return StaticResponse.FAILED_OPENING;
} else if (torrent && statusError(torrent.status)) {
return _retryCreateTorrent(RD, infoHash, fileIndex);
} else if (torrent && (statusWaitingSelection(torrent.status) || statusOpening(torrent.status))) {
console.log(`Trying to select files on RealDebrid ${infoHash} [${fileIndex}]...`);
return _selectTorrentFiles(RD, torrent)
.then(() => {
console.log(`Downloading to RealDebrid ${infoHash} [${fileIndex}]...`);
return StaticResponse.DOWNLOADING
})
.catch(error => {
console.log(`Failed RealDebrid opening torrent ${infoHash} [${fileIndex}]:`, error);
return StaticResponse.FAILED_OPENING;
});
}
return Promise.reject(`Failed RealDebrid adding torrent ${JSON.stringify(torrent)}`);
}
async function _createOrFindTorrentId(RD, infoHash, fileIndex) {
return _findTorrent(RD, infoHash, fileIndex)
.catch(() => _createTorrentId(RD, infoHash, fileIndex));
}
async function _findTorrent(RD, infoHash, fileIndex) {
const torrents = await RD.torrents.get(0, 1) || [];
const foundTorrents = torrents
.filter(torrent => torrent.hash.toLowerCase() === infoHash)
.filter(torrent => !statusError(torrent.status));
const foundTorrent = await _findBestFitTorrent(RD, foundTorrents, fileIndex);
return foundTorrent?.id || Promise.reject('No recent torrent found');
}
async function _findBestFitTorrent(RD, torrents, fileIndex) {
if (torrents.length === 1) {
return torrents[0];
}
const torrentInfos = await Promise.all(torrents.map(torrent => _getTorrentInfo(RD, torrent.id)));
const bestFitTorrents = torrentInfos
.filter(torrent => torrent.files.find(f => f.id === fileIndex + 1 && f.selected))
.sort((a, b) => b.links.length - a.links.length);
return bestFitTorrents[0] || torrents[0];
}
async function _getTorrentInfo(RD, torrentId) {
if (!torrentId || typeof torrentId === 'object') {
return torrentId || Promise.reject('No RealDebrid torrentId provided')
}
return RD.torrents.info(torrentId);
}
async function _createTorrentId(RD, infoHash, fileIndex, force = false) {
const magnetLink = await getMagnetLink(infoHash);
const addedMagnet = await RD.torrents.addMagnet(magnetLink);
const cachedFileIds = !force && await _resolveCachedFileIds(infoHash, fileIndex);
if (cachedFileIds && !['null', 'undefined'].includes(cachedFileIds)) {
await RD.torrents.selectFiles(addedMagnet.id, cachedFileIds);
} else if (!force) {
await _selectTorrentFiles(RD, { id: addedMagnet.id });
}
return addedMagnet.id;
}
async function _recreateTorrentId(RD, infoHash, fileIndex, force = false) {
const newTorrentId = await _createTorrentId(RD, infoHash, fileIndex, force);
await _selectTorrentFiles(RD, { id: newTorrentId }, fileIndex);
return newTorrentId;
}
async function _retryCreateTorrent(RD, infoHash, fileIndex, shouldRetry = false) {
console.log(`Retry failed download in RealDebrid ${infoHash} [${fileIndex}]...`);
const newTorrentId = await _recreateTorrentId(RD, infoHash, fileIndex, true);
const newTorrent = await _getTorrentInfo(RD, newTorrentId);
return newTorrent && statusReady(newTorrent.status)
? _unrestrictLink(RD, newTorrent, fileIndex, false, shouldRetry)
: StaticResponse.FAILED_DOWNLOAD;
}
async function _selectTorrentFiles(RD, torrent, fileIndex) {
torrent = statusWaitingSelection(torrent.status) ? torrent : await _openTorrent(RD, torrent.id);
if (torrent?.files && statusWaitingSelection(torrent.status)) {
const videoFileIds = Number.isInteger(fileIndex) ? `${fileIndex + 1}` : torrent.files
.filter(file => isVideo(file.path))
.filter(file => file.bytes > MIN_SIZE)
.map(file => file.id)
.join(',');
return RD.torrents.selectFiles(torrent.id, videoFileIds);
} else if (statusReady(torrent.status) || statusDownloading(torrent.status)) {
return torrent;
}
return Promise.reject('Failed RealDebrid torrent file selection')
}
async function _openTorrent(RD, torrentId, pollCounter = 0, pollRate = 2000, maxPollNumber = 15) {
return _getTorrentInfo(RD, torrentId)
.then(torrent => torrent && statusOpening(torrent.status) && pollCounter < maxPollNumber
? delay(pollRate).then(() => _openTorrent(RD, torrentId, pollCounter + 1))
: torrent);
}
async function _unrestrictLink(RD, torrent, fileIndex, isBrowser, shouldRetry = true) {
const targetFile = torrent.files.find(file => file.id === fileIndex + 1)
|| torrent.files.filter(file => file.selected).sort((a, b) => b.bytes - a.bytes)[0];
if (!targetFile.selected) {
console.log(`Target RealDebrid file is not downloaded: ${JSON.stringify(targetFile)}`);
await _recreateTorrentId(RD, torrent.hash.toLowerCase(), fileIndex);
return StaticResponse.DOWNLOADING;
}
const selectedFiles = torrent.files.filter(file => file.selected);
const fileLink = torrent.links.length === 1
? torrent.links[0]
: torrent.links[selectedFiles.indexOf(targetFile)];
if (shouldRetry && !fileLink?.length) {
console.log(`No RealDebrid links found for ${torrent.hash} [${fileIndex}]`);
return _retryCreateTorrent(RD, torrent.hash, fileIndex)
}
return _unrestrictFileLink(RD, fileLink, torrent, fileIndex, isBrowser, shouldRetry);
}
async function _unrestrictFileLink(RD, fileLink, torrent, fileIndex, isBrowser, shouldRetry) {
return RD.unrestrict.link(fileLink)
.then(response => {
if (isArchive(response.download)) {
if (shouldRetry && Number.isInteger(fileIndex) && torrent.files.filter(file => file.selected).length > 1) {
console.log(`Only archive is available, try to download single file for ${torrent.hash} [${fileIndex}]`);
return _retryCreateTorrent(RD, torrent.hash, fileIndex)
}
return StaticResponse.FAILED_RAR;
}
// if (isBrowser && response.streamable) {
// return RD.streaming.transcode(response.id)
// .then(streamResponse => streamResponse.apple.full)
// }
return response.download;
})
.then(unrestrictedLink => {
console.log(`Unrestricted RealDebrid ${torrent.hash} [${fileIndex}] to ${unrestrictedLink}`);
const cachedFileIds = torrent.files.filter(file => file.selected).map(file => file.id);
cacheAvailabilityResults(torrent.hash.toLowerCase(), cachedFileIds); // no need to await can happen async
return unrestrictedLink;
})
.catch(error => {
if (shouldRetry && error.code === 19) {
console.log(`Retry download as hoster is unavailable for ${torrent.hash} [${fileIndex}]`);
return _retryCreateTorrent(RD, torrent.hash.toLowerCase(), fileIndex);
}
return Promise.reject(error);
});
}
export function toCommonError(error) {
if (error && error.code === 8) {
return BadTokenError;
}
if (error && isAccessDeniedError(error)) {
return AccessDeniedError;
}
return undefined;
}
function statusError(status) {
return ['error', 'magnet_error'].includes(status);
}
function statusMagnetError(status) {
return status === 'magnet_error';
}
function statusOpening(status) {
return status === 'magnet_conversion';
}
function statusWaitingSelection(status) {
return status === 'waiting_files_selection';
}
function statusDownloading(status) {
return ['downloading', 'uploading', 'queued'].includes(status);
}
function statusReady(status) {
return ['downloaded', 'dead'].includes(status);
}
function isAccessDeniedError(error) {
return [8, 9, 20].includes(error?.code);
}
function isInfringingFileError(error) {
return [35].includes(error?.code);
}
function isLimitExceededError(error) {
return [21, 23, 26, 36].includes(error?.code);
}
function isTorrentTooBigError(error) {
return [29].includes(error?.code);
}
async function getDefaultOptions(ip) {
return { ip, timeout: 15000 };
}

View file

@ -1,19 +0,0 @@
const staticVideoUrls = {
DOWNLOADING: `videos/downloading_v2.mp4`,
FAILED_DOWNLOAD: `videos/download_failed_v2.mp4`,
FAILED_ACCESS: `videos/failed_access_v2.mp4`,
FAILED_RAR: `videos/failed_rar_v2.mp4`,
FAILED_TOO_BIG: 'failed_too_big_v1.mp4',
FAILED_OPENING: `videos/failed_opening_v2.mp4`,
FAILED_UNEXPECTED: `videos/failed_unexpected_v2.mp4`,
FAILED_INFRINGEMENT: `videos/failed_infringement_v2.mp4`,
LIMITS_EXCEEDED: `videos/limits_exceeded_v1.mp4`,
BLOCKED_ACCESS: `videos/blocked_access_v1.mp4`,
}
export function isStaticUrl(url) {
return Object.values(staticVideoUrls).some(videoUrl => url?.endsWith(videoUrl));
}
export default staticVideoUrls

View file

@ -1,298 +0,0 @@
import axios from 'axios';
import { Type } from '../lib/types.js';
import { isVideo } from '../lib/extension.js';
import StaticResponse from './static.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { chunkArray, BadTokenError, sameFilename, streamFilename } from './mochHelper.js';
const KEY = 'torbox';
const timeout = 30000;
const baseUrl = 'https://api.torbox.app/v1'
export async function getCachedStreams(streams, apiKey, ip) {
const hashBatches = chunkArray(streams.map(stream => stream.infoHash), 150)
.map(hashes => getAvailabilityResponse(apiKey, hashes));
const available = await Promise.all(hashBatches)
.then(results => results
.map(data => data.map(entry => entry.hash))
.reduce((all, result) => all.concat(result), []))
.catch(error => {
if (toCommonError(error)) {
return Promise.reject(error);
}
const message = error.message || error;
console.warn('Failed TorBox cached torrent availability request:', message);
return undefined;
});
return available && streams
.reduce((mochStreams, stream) => {
const isCached = available.includes(stream.infoHash);
const fileName = streamFilename(stream);
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
url: `${apiKey}/${stream.infoHash}/${fileName}/${stream.fileIdx}`,
cached: isCached
};
return mochStreams;
}, {})
}
export async function getCatalog(apiKey, type, config) {
return getItemList(apiKey, type, null, config.skip)
.then(items => (items || [])
.filter(item => statusReady(item))
.map(item => ({
id: `${KEY}:${type}-${item.id}`,
type: Type.OTHER,
name: item.name
})));
}
export async function getItemMeta(itemId, apiKey) {
const [type, id] = itemId.split('-');
const item = await getItemList(apiKey, type, id);
const createDate = item ? new Date(item.created_at) : new Date();
return {
id: `${KEY}:${itemId}`,
type: Type.OTHER,
name: item.name,
infoHash: item.hash,
videos: item.files
.filter(file => isVideo(file.short_name))
.map((file, index) => ({
id: `${KEY}:${itemId}:${file.id}`,
title: file.name,
released: new Date(createDate.getTime() - index).toISOString(),
streams: [{ url: `${apiKey}/${itemId}-${file.id}/null/null` }]
}))
}
}
export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
console.log(`Unrestricting TorBox ${infoHash} [${fileIndex}]`);
return _resolve(apiKey, infoHash, cachedEntryInfo, fileIndex, ip)
.catch(error => {
if (isAccessDeniedError(error)) {
console.log(`Access denied to TorBox ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
if (isLimitExceededError(error)) {
console.log(`Limits exceeded to TorBox ${infoHash} [${fileIndex}]`);
return StaticResponse.LIMITS_EXCEEDED;
}
if (isTorrentTooBigError(error)) {
console.log(`Torrent too big for TorBox ${infoHash} [${fileIndex}]`);
return StaticResponse.FAILED_TOO_BIG;
}
return Promise.reject(`Failed TorBox adding torrent: ${JSON.stringify(error.message || error)}`);
});
}
async function _resolve(apiKey, infoHash, cachedEntryInfo, fileIndex, ip) {
if (infoHash?.includes('-')) {
const [type, rootId, fileId] = infoHash.split('-');
return getDownloadLink(apiKey, type, rootId, fileId, ip);
}
const torrent = await _createOrFindTorrent(apiKey, infoHash);
if (torrent && statusReady(torrent)) {
return _unrestrictLink(apiKey, infoHash, torrent, cachedEntryInfo, fileIndex, ip);
} else if (torrent && statusDownloading(torrent)) {
console.log(`Downloading to TorBox ${infoHash} [${fileIndex}]...`);
return StaticResponse.DOWNLOADING;
} else if (torrent && statusError(torrent)) {
console.log(`Retry failed download in TorBox ${infoHash} [${fileIndex}]...`);
return controlTorrent(apiKey, torrent.id, 'delete')
.then(() => _retryCreateTorrent(apiKey, infoHash, cachedEntryInfo, fileIndex));
}
return Promise.reject(`Failed TorBox adding torrent ${JSON.stringify(torrent)}`);
}
async function _createOrFindTorrent(apiKey, infoHash) {
return _findTorrent(apiKey, infoHash)
.catch(() => _createTorrent(apiKey, infoHash));
}
async function _findTorrent(apiKey, infoHash) {
const torrents = await getTorrentList(apiKey);
const foundTorrents = torrents.filter(torrent => torrent.hash === infoHash);
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent));
const foundTorrent = nonFailedTorrent || foundTorrents[0];
return foundTorrent || Promise.reject('No recent torrent found');
}
async function _createTorrent(apiKey, infoHash, attempts = 1) {
const magnetLink = await getMagnetLink(infoHash);
return createTorrent(apiKey, magnetLink)
.then(data => {
if (data.torrent_id) {
return getTorrentList(apiKey, data.torrent_id);
}
if (data.queued_id) {
return Promise.resolve({ ...data, download_state: 'metaDL' })
}
if (data?.error === 'ACTIVE_LIMIT' && attempts > 0) {
return freeLastActiveTorrent(apiKey)
.then(() => _createTorrent(apiKey, infoHash, attempts - 1));
}
return Promise.reject(`Unexpected create data: ${JSON.stringify(data)}`);
});
}
async function _retryCreateTorrent(apiKey, infoHash, cachedEntryInfo, fileIndex) {
const newTorrent = await _createTorrent(apiKey, infoHash);
return newTorrent && statusReady(newTorrent)
? _unrestrictLink(apiKey, infoHash, newTorrent, cachedEntryInfo, fileIndex)
: StaticResponse.FAILED_DOWNLOAD;
}
async function freeLastActiveTorrent(apiKey) {
const torrents = await getTorrentList(apiKey);
const seedingTorrent = torrents.filter(statusSeeding).pop();
if (seedingTorrent) {
console.log(`Stopping seeded item in TorBox to make space...`);
return controlTorrent(apiKey, seedingTorrent.id, 'stop_seeding');
}
const downloadingTorrent = torrents.filter(statusDownloading).pop();
if (downloadingTorrent) {
console.log(`Deleting downloading item in TorBox to make space...`);
return controlTorrent(apiKey, downloadingTorrent.id, 'delete');
}
return Promise.reject({ detail: 'No torrent to pause found' });
}
async function _unrestrictLink(apiKey, infoHash, torrent, cachedEntryInfo, fileIndex, ip) {
const targetFileName = decodeURIComponent(cachedEntryInfo);
const videos = torrent.files
.filter(file => isVideo(file.short_name))
.sort((a, b) => b.size - a.size);
const targetVideo = Number.isInteger(fileIndex)
&& videos.find(video => sameFilename(video.name, targetFileName))
|| videos[0];
if (!targetVideo) {
if (torrent.files.every(file => file.zipped)) {
return StaticResponse.FAILED_RAR;
}
return Promise.reject(`No TorBox file found for index ${fileIndex} in: ${JSON.stringify(torrent)}`);
}
return getDownloadLink(apiKey, 'torrents', torrent.id, targetVideo.id, ip);
}
async function getAvailabilityResponse(apiKey, hashes) {
const url = `${baseUrl}/api/torrents/checkcached`;
const headers = getHeaders(apiKey);
const params = { hash: hashes.join(','), format: 'list' };
return axios.get(url, { params, headers, timeout })
.then(response => {
if (response.data?.success) {
return Promise.resolve(response.data.data || []);
}
return Promise.reject(response.data);
})
.catch(error => Promise.reject(error.response?.data || error));
}
async function createTorrent(apiKey, magnetLink){
const url = `${baseUrl}/api/torrents/createtorrent`
const headers = getHeaders(apiKey);
const data = new URLSearchParams();
data.append('magnet', magnetLink);
data.append('allow_zip', 'false');
return axios.post(url, data, { headers, timeout })
.then(response => {
if (response.data?.success) {
return Promise.resolve(response.data.data);
}
return Promise.reject(response.data);
})
.catch(error => Promise.reject(error.response?.data || error));
}
async function controlTorrent(apiKey, torrent_id, operation){
const url = `${baseUrl}/api/torrents/controltorrent`
const headers = getHeaders(apiKey);
const data = { torrent_id, operation}
return axios.post(url, data, { headers, timeout })
.then(response => {
if (response.data?.success) {
return Promise.resolve(response.data.data);
}
return Promise.reject(response.data);
})
.catch(error => Promise.reject(error.response?.data || error));
}
async function getTorrentList(apiKey, id = undefined, offset = 0) {
return getItemList(apiKey, 'torrents', id, offset);
}
async function getItemList(apiKey, type, id = undefined, offset = 0) {
const url = `${baseUrl}/api/${type}/mylist`;
const headers = getHeaders(apiKey);
const params = { id, offset };
return axios.get(url, { params, headers, timeout })
.then(response => {
if (response.data?.success) {
if (Array.isArray(response.data.data)) {
response.data.data.sort((a, b) => b.id - a.id);
}
return Promise.resolve(response.data.data);
}
return Promise.reject(response.data);
})
.catch(error => Promise.reject(error.response?.data || error));
}
async function getDownloadLink(token, type, rootId, file_id, user_ip) {
const url = `${baseUrl}/api/${type}/requestdl`;
const params = { token, torrent_id: rootId, usenet_id: rootId, web_id: rootId, file_id, user_ip };
return axios.get(url, { params, timeout })
.then(response => {
if (response.data?.success) {
console.log(`Unrestricted TorBox ${type} [${rootId}] to ${response.data.data}`);
return Promise.resolve(response.data.data);
}
return Promise.reject(response.data);
})
.catch(error => Promise.reject(error.response?.data || error));
}
function getHeaders(apiKey) {
return { Authorization: `Bearer ${apiKey}` };
}
export function toCommonError(data) {
const error = data?.response?.data || data;
if (['AUTH_ERROR', 'BAD_TOKEN'].includes(error?.error)) {
return BadTokenError;
}
return undefined;
}
function statusDownloading(torrent) {
return !statusReady(torrent) && !statusError(torrent);
}
function statusError(torrent) {
return (!torrent?.active && !torrent?.download_finished) || torrent?.download_state === 'error';
}
function statusReady(torrent) {
return torrent?.download_present;
}
function statusSeeding(torrent) {
return ['seeding', 'uploading', 'uploading (no peers)'].includes(torrent?.download_state);
}
function isAccessDeniedError(error) {
return ['AUTH_ERROR', 'BAD_TOKEN', 'PLAN_RESTRICTED_FEATURE'].includes(error?.error);
}
function isLimitExceededError(error) {
return ['MONTHLY_LIMIT', 'COOLDOWN_LIMIT', 'ACTIVE_LIMIT'].includes(error?.error);
}
function isTorrentTooBigError(error) {
return ['DOWNLOAD_TOO_LARGE'].includes(error?.error);
}

2574
addon/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,38 +0,0 @@
{
"name": "stremio-torrentio",
"version": "1.0.14",
"exports": "./index.js",
"type": "module",
"scripts": {
"start": "node index.js"
},
"author": "TheBeastLT <pauliox@beyond.lt>",
"license": "MIT",
"dependencies": {
"@keyv/mongo": "^3.0.1",
"@putdotio/api-client": "^8.42.0",
"all-debrid-api": "^1.2.0",
"axios": "^1.7.7",
"cacheable": "^1.8.4",
"cors": "^2.8.5",
"debrid-link-api": "^1.0.1",
"express-rate-limit": "^6.7.0",
"magnet-uri": "^6.2.0",
"name-to-imdb": "^3.0.4",
"named-queue": "^2.2.1",
"offcloud-api": "^1.0.2",
"p-limit": "^5.0.0",
"parse-torrent-title": "git://github.com/TheBeastLT/parse-torrent-title.git#1bc3539e0d7c6686655ede649e6be8da53e4c217",
"pg": "^8.8.0",
"premiumize-api": "^1.0.3",
"prom-client": "^14.2.0",
"real-debrid-api": "git://github.com/TheBeastLT/node-real-debrid.git#d1f7eaa8593b947edbfbc8a92a176448b48ef445",
"request-ip": "^3.3.0",
"router": "^1.3.8",
"sequelize": "^6.29.0",
"stremio-addon-sdk": "^1.6.10",
"swagger-stats": "^0.99.7",
"ua-parser-js": "^1.0.36",
"user-agents": "^1.0.1444"
}
}

View file

@ -1,112 +0,0 @@
import Router from 'router';
import cors from 'cors';
import rateLimit from "express-rate-limit";
import requestIp from 'request-ip';
import userAgentParser from 'ua-parser-js';
import addonInterface from './addon.js';
import qs from 'querystring';
import { manifest } from './lib/manifest.js';
import { parseConfiguration, PreConfigurations } from './lib/configuration.js';
import landingTemplate from './lib/landingTemplate.js';
import * as moch from './moch/moch.js';
const router = new Router();
const limiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 300, // limit each IP to 300 requests per windowMs
headers: false,
keyGenerator: (req) => requestIp.getClientIp(req)
})
router.use(cors())
router.get('/', (_, res) => {
res.redirect('/configure')
res.end();
});
router.get(`/:preconfiguration(${Object.keys(PreConfigurations).join('|')})`, (req, res) => {
res.redirect(`/${req.params.preconfiguration}/configure`)
res.end();
});
router.get('/:configuration?/configure', (req, res) => {
const configValues = parseConfiguration(req.params.configuration || '');
const landingHTML = landingTemplate(manifest(configValues), configValues);
res.setHeader('content-type', 'text/html');
res.end(landingHTML);
});
router.get('/:configuration?/manifest.json', (req, res) => {
const configValues = parseConfiguration(req.params.configuration || '');
const manifestBuf = JSON.stringify(manifest(configValues));
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(manifestBuf)
});
router.get('/:configuration?/:resource/:type/:id/:extra?.json', limiter, (req, res, next) => {
const { configuration, resource, type, id } = req.params;
const extra = req.params.extra ? qs.parse(req.url.split('/').pop().slice(0, -5)) : {}
const ip = requestIp.getClientIp(req);
const host = `${req.protocol}://${req.headers.host}`;
const configValues = { ...extra, ...parseConfiguration(configuration), id, type, ip, host };
addonInterface.get(resource, type, id, configValues)
.then(resp => {
const cacheHeaders = {
cacheMaxAge: 'max-age',
staleRevalidate: 'stale-while-revalidate',
staleError: 'stale-if-error'
};
const cacheControl = Object.keys(cacheHeaders)
.map(prop => Number.isInteger(resp[prop]) && cacheHeaders[prop] + '=' + resp[prop])
.filter(val => !!val).join(', ');
res.setHeader('Cache-Control', `${cacheControl}, public`);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(resp));
})
.catch(err => {
if (err.noHandler) {
if (next) {
next()
} else {
res.writeHead(404);
res.end(JSON.stringify({ err: 'not found' }));
}
} else {
console.error(err);
res.writeHead(500);
res.end(JSON.stringify({ err: 'handler error' }));
}
});
});
router.get('/:moch/:apiKey/:infoHash/:cachedEntryInfo/:fileIndex/:filename?', (req, res) => {
const userAgent = req.headers['user-agent'] || '';
const parameters = {
mochKey: req.params.moch,
apiKey: req.params.apiKey,
infoHash: req.params.infoHash.toLowerCase(),
fileIndex: isNaN(req.params.fileIndex) ? undefined : parseInt(req.params.fileIndex),
cachedEntryInfo: req.params.cachedEntryInfo,
ip: requestIp.getClientIp(req),
host: `${req.protocol}://${req.headers.host}`,
isBrowser: !userAgent.includes('Stremio') && !!userAgentParser(userAgent).browser.name
}
moch.resolve(parameters)
.then(url => {
res.writeHead(302, { Location: url });
res.end();
})
.catch(error => {
console.log(error);
res.statusCode = 404;
res.end();
});
});
export default function (req, res) {
router(req, res, function () {
res.statusCode = 404;
res.end();
});
};

View file

@ -1,3 +0,0 @@
**/node_modules
**/npm-debug.log
**/.env

View file

@ -1,12 +0,0 @@
FROM node:16-alpine
RUN apk update && apk upgrade && \
apk add --no-cache git
WORKDIR /home/node/app
COPY ./catalogs .
COPY ./addon ../addon
RUN npm ci --only-production
CMD [ "node", "index.js" ]

View file

@ -1,99 +0,0 @@
import Bottleneck from 'bottleneck';
import moment from 'moment';
import { addonBuilder } from 'stremio-addon-sdk';
import { Providers } from '../addon/lib/filter.js';
import { createManifest, genres } from './lib/manifest.js';
import { getMetas } from './lib/metadata.js';
import { cacheWrapCatalog, cacheWrapIds } from './lib/cache.js';
import * as repository from './lib/repository.js';
const CACHE_MAX_AGE = parseInt(process.env.CACHE_MAX_AGE) || 4 * 60 * 60; // 4 hours in seconds
const STALE_REVALIDATE_AGE = 4 * 60 * 60; // 4 hours
const STALE_ERROR_AGE = 7 * 24 * 60 * 60; // 7 days
const manifest = createManifest();
const builder = new addonBuilder(manifest);
const limiter = new Bottleneck({
maxConcurrent: process.env.LIMIT_MAX_CONCURRENT || 20,
highWater: process.env.LIMIT_QUEUE_SIZE || 50,
strategy: Bottleneck.strategy.OVERFLOW
});
const defaultProviders = Providers.options
.filter(provider => !provider.foreign)
.map(provider => provider.label)
.sort();
builder.defineCatalogHandler((args) => {
const offset = parseInt(args.extra.skip || '0', 10);
const genre = args.extra.genre || 'default';
const catalog = manifest.catalogs.find(c => c.id === args.id);
const providers = defaultProviders;
console.log(`Incoming catalog ${args.id} request with genre=${genre} and skip=${offset}`);
if (!catalog) {
return Promise.reject(`No catalog found for with id: ${args.id}`);
}
const cacheKey = createCacheKey(catalog.id, providers, genre, offset);
return limiter.schedule(() => cacheWrapCatalog(cacheKey, () => getCatalog(catalog, providers, genre, offset)))
.then(metas => ({
metas: metas,
cacheMaxAge: CACHE_MAX_AGE,
staleRevalidate: STALE_REVALIDATE_AGE,
staleError: STALE_ERROR_AGE
}))
.catch(error => Promise.reject(`Failed retrieving catalog ${args.id}: ${error.message || error}`));
})
async function getCursor(catalog, providers, genre, offset) {
if (offset === 0) {
return undefined;
}
const previousOffset = offset - catalog.pageSize;
const previousCacheKey = createCacheKey(catalog.id, providers, genre, previousOffset);
return cacheWrapCatalog(previousCacheKey, () => Promise.reject("cursor not found"))
.then(metas => metas[metas.length - 1])
.then(meta => meta.id.replace('kitsu:', ''))
}
async function getCatalog(catalog, providers, genre, offset) {
const cursor = await getCursor(catalog, providers, genre, offset)
const startDate = getStartDate(genre)?.toISOString();
const endDate = getEndDate(genre)?.toISOString();
const cacheKey = createCacheKey(catalog.id, providers, genre);
return cacheWrapIds(cacheKey, () => repository.getIds(providers, catalog.type, startDate, endDate))
.then(ids => ids.slice(ids.indexOf(cursor) + 1))
.then(ids => getMetas(ids, catalog.type))
.then(metas => metas.slice(0, catalog.pageSize));
}
function getStartDate(genre) {
switch (genre) {
case genres[0]: return moment().utc().subtract(1, 'day').startOf('day');
case genres[1]: return moment().utc().startOf('isoWeek');
case genres[2]: return moment().utc().subtract(7, 'day').startOf('isoWeek');
case genres[3]: return moment().utc().startOf('month');
case genres[4]: return moment().utc().subtract(30, 'day').startOf('month');
case genres[5]: return undefined;
default: return moment().utc().subtract(30, 'day').startOf('day');
}
}
function getEndDate(genre) {
switch (genre) {
case genres[0]: return moment().utc().subtract(1, 'day').endOf('day');
case genres[1]: return moment().utc().endOf('isoWeek');
case genres[2]: return moment().utc().subtract(7, 'day').endOf('isoWeek');
case genres[3]: return moment().utc().endOf('month');
case genres[4]: return moment().utc().subtract(30, 'day').endOf('month');
case genres[5]: return undefined;
default: return moment().utc().subtract(1, 'day').endOf('day');
}
}
function createCacheKey(catalogId, providers, genre, offset) {
const dateKey = moment().format('YYYY-MM-DD');
return [catalogId, providers.join(','), genre, dateKey, offset].filter(x => x !== undefined).join('|');
}
export default builder.getInterface();

View file

@ -1,9 +0,0 @@
import express from 'express';
import serverless from './serverless.js';
const app = express();
app.use((req, res, next) => serverless(req, res, next));
app.listen(process.env.PORT || 7000, () => {
console.log(`Started addon at: http://localhost:${process.env.PORT || 7000}`);
});

View file

@ -1,28 +0,0 @@
import KeyvMongo from "@keyv/mongo";
const CATALOG_TTL = 24 * 60 * 60 * 1000; // 24 hours
const MONGO_URI = process.env.MONGODB_URI;
const remoteCache = MONGO_URI && new KeyvMongo(MONGO_URI, { collection: 'torrentio_catalog_collection' });
async function cacheWrap(cache, key, method, ttl) {
if (!cache) {
return method();
}
const value = await cache.get(key);
if (value !== undefined) {
return value;
}
const result = await method();
await cache.set(key, result, ttl);
return result;
}
export function cacheWrapCatalog(key, method) {
return cacheWrap(remoteCache, key, method, CATALOG_TTL);
}
export function cacheWrapIds(key, method) {
return cacheWrap(remoteCache, `ids|${key}`, method, CATALOG_TTL);
}

View file

@ -1,274 +0,0 @@
const STYLESHEET = `
* {
box-sizing: border-box;
}
body,
html {
margin: 0;
padding: 0;
width: 100%;
height: 100%
}
html {
background-size: auto 100%;
background-size: cover;
background-position: center center;
background-repeat: repeat-y;
}
body {
display: flex;
background-color: transparent;
font-family: 'Open Sans', Arial, sans-serif;
color: white;
}
h1 {
font-size: 4.5vh;
font-weight: 700;
}
h2 {
font-size: 2.2vh;
font-weight: normal;
font-style: italic;
opacity: 0.8;
}
h3 {
font-size: 2.2vh;
}
h1,
h2,
h3,
p,
label {
margin: 0;
text-shadow: 0 0 1vh rgba(0, 0, 0, 0.15);
}
p {
font-size: 1.75vh;
}
ul {
font-size: 1.75vh;
margin: 0;
margin-top: 1vh;
padding-left: 3vh;
}
a {
color: green
}
a.install-link {
text-decoration: none
}
button {
border: 0;
outline: 0;
color: white;
background: #8A5AAB;
padding: 1.2vh 3.5vh;
margin: auto;
text-align: center;
font-family: 'Open Sans', Arial, sans-serif;
font-size: 2.2vh;
font-weight: 600;
cursor: pointer;
display: block;
box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2);
transition: box-shadow 0.1s ease-in-out;
}
button:hover {
box-shadow: none;
}
button:active {
box-shadow: 0 0 0 0.5vh white inset;
}
#addon {
width: 90vh;
margin: auto;
padding-left: 10%;
padding-right: 10%;
background: rgba(0, 0, 0, 0.60);
}
.logo {
height: 14vh;
width: 14vh;
margin: auto;
margin-bottom: 3vh;
}
.logo img {
width: 100%;
}
.name, .version {
display: inline-block;
vertical-align: top;
}
.name {
line-height: 5vh;
}
.version {
position: absolute;
line-height: 5vh;
margin-left: 1vh;
opacity: 0.8;
}
.contact {
position: absolute;
left: 0;
bottom: 4vh;
width: 100%;
text-align: center;
}
.contact a {
font-size: 1.4vh;
font-style: italic;
}
.separator {
margin-bottom: 4vh;
}
.label {
font-size: 2.2vh;
font-weight: 600;
padding: 0;
line-height: inherit;
}
.btn-group, .multiselect-container {
width: 100%;
}
.btn {
text-align: left;
}
.multiselect-container {
border: 0;
border-radius: 0;
}
.input, .btn {
height: 3.8vh;
width: 100%;
margin: auto;
margin-bottom: 10px;
padding: 6px 12px;
border: 0;
border-radius: 0;
outline: 0;
color: #333;
background-color: rgb(255, 255, 255);
box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2);
}
`;
import { Providers } from '../../addon/lib/filter.js';
export default function landingTemplate(manifest, config = {}) {
const providers = config.providers || [];
const background = manifest.background || 'https://dl.strem.io/addon-background.jpg';
const logo = manifest.logo || 'https://dl.strem.io/addon-logo.png';
const contactHTML = manifest.contactEmail ?
`<div class="contact">
<p>Contact ${manifest.name} creator:</p>
<a href="mailto:${manifest.contactEmail}">${manifest.contactEmail}</a>
</div>` : '<div class="separator"></div>';
const providersHTML = Providers.options
.map(provider => `<option value="${provider.key}">${provider.foreign || ''}${provider.label}</option>`)
.join('\n');
const stylizedTypes = manifest.types
.map(t => t[0].toUpperCase() + t.slice(1) + (t !== 'series' ? 's' : ''));
return `
<!DOCTYPE html>
<html style="background-image: url(${background});">
<head>
<meta charset="utf-8">
<title>${manifest.name} - Stremio Addon</title>
<link rel="shortcut icon" href="${logo}" type="image/x-icon">
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,600,700&display=swap" rel="stylesheet">
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" >
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/js/bootstrap-multiselect.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/css/bootstrap-multiselect.css" rel="stylesheet"/>
<style>${STYLESHEET}</style>
</head>
<body>
<div id="addon">
<div class="logo">
<img src="${logo}">
</div>
<h1 class="name">${manifest.name}</h1>
<h2 class="version">${manifest.version || '0.0.0'}</h2>
<h2 class="description">${manifest.description || ''}</h2>
<div class="separator"></div>
<h3 class="gives">This addon has more :</h3>
<ul>
${stylizedTypes.map(t => `<li>${t}</li>`).join('')}
</ul>
<div class="separator"></div>
<label class="label" for="iProviders">Providers:</label>
<select id="iProviders" class="input" name="providers[]" multiple="multiple">
${providersHTML}
</select>
<div class="separator"></div>
<a id="installLink" class="install-link" href="#">
<button name="Install">INSTALL</button>
</a>
${contactHTML}
</div>
<script type="text/javascript">
$(document).ready(function() {
$('#iProviders').multiselect({
nonSelectedText: 'All providers',
onChange: () => generateInstallLink()
});
$('#iProviders').multiselect('select', [${providers.map(provider => '"' + provider + '"')}]);
generateInstallLink();
});
function generateInstallLink() {
const providersValue = $('#iProviders').val().join(',') || '';
const providers = providersValue.length && providersValue;
const configurationValue = [
['${Providers.key}', providers],
]
.filter(([_, value]) => value.length)
.map(([key, value]) => key + '=' + value).join('|');
const configuration = configurationValue && configurationValue.length ? '/' + configurationValue : '';
installLink.href = 'stremio://' + window.location.host + configuration + '/manifest.json';
}
</script>
</body>
</html>`
}

View file

@ -1,54 +0,0 @@
import { Type } from '../../addon/lib/types.js';
export const genres = [
'Yesterday',
'This Week',
'Last Week',
'This Month',
'Last Month',
'All Time'
]
export function createManifest() {
return {
id: 'com.stremio.torrentio.catalog.addon',
version: '1.0.2',
name: 'Torrent Catalogs',
description: 'Provides catalogs for movies/series/anime based on top seeded torrents. Requires Kitsu addon for anime.',
logo: `https://i.ibb.co/w4BnkC9/GwxAcDV.png`,
background: `https://i.ibb.co/VtSfFP9/t8wVwcg.jpg`,
types: [Type.MOVIE, Type.SERIES, Type.ANIME],
resources: ['catalog'],
catalogs: [
{
id: 'top-movies',
type: Type.MOVIE,
name: "Top seeded",
pageSize: 20,
extra: [{ name: 'genre', options: genres }, { name: 'skip' }],
genres: genres
},
{
id: 'top-series',
type: Type.SERIES,
name: "Top seeded",
pageSize: 20,
extra: [{ name: 'genre', options: genres }, { name: 'skip' }],
genres: genres
},
{
id: 'top-anime',
type: Type.ANIME,
name: "Top seeded",
pageSize: 20,
extra: [{ name: 'genre', options: genres }, { name: 'skip' }],
genres: genres
}
],
behaviorHints: {
// @TODO might enable configuration to configure providers
configurable: false,
configurationRequired: false
}
};
}

View file

@ -1,41 +0,0 @@
import axios from 'axios';
import { Type } from '../../addon/lib/types.js';
const CINEMETA_URL = 'https://v3-cinemeta.strem.io';
const KITSU_URL = 'https://anime-kitsu.strem.fun';
const TIMEOUT = 30000;
const MAX_SIZE = 40;
export async function getMetas(ids, type) {
if (!ids.length || !type) {
return [];
}
return _requestMetadata(ids, type)
.catch((error) => {
throw new Error(`failed metadata ${type} query due: ${error.message}`);
});
}
function _requestMetadata(ids, type) {
const url = _getUrl(ids, type);
return axios.get(url, { timeout: TIMEOUT })
.then(response => response?.data?.metas || response?.data?.metasDetailed || [])
.then(metas => metas.filter(meta => meta))
.then(metas => metas.map(meta => _sanitizeMeta(meta)));
}
function _getUrl(ids, type) {
const joinedIds = ids.slice(0, MAX_SIZE).join(',');
if (type === Type.ANIME) {
return `${KITSU_URL}/catalog/${type}/kitsu-anime-list/lastVideosIds=${joinedIds}.json`;
}
return `${CINEMETA_URL}/catalog/${type}/last-videos/lastVideosIds=${joinedIds}.json`;
}
function _sanitizeMeta(meta) {
delete meta.videos;
delete meta.credits_cast;
delete meta.credits_crew;
return meta;
}

View file

@ -1,34 +0,0 @@
import { Sequelize, QueryTypes } from 'sequelize';
import { Type } from '../../addon/lib/types.js';
const DATABASE_URI = process.env.DATABASE_URI;
const database = new Sequelize(DATABASE_URI, { logging: false });
export async function getIds(providers, type, startDate, endDate) {
const idName = type === Type.ANIME ? 'kitsuId' : 'imdbId';
const episodeCondition = type === Type.SERIES
? 'AND files."imdbSeason" IS NOT NULL AND files."imdbEpisode" IS NOT NULL'
: '';
const dateCondition = startDate && endDate
? `AND "uploadDate" BETWEEN '${startDate}' AND '${endDate}'`
: '';
const providersCondition = providers && providers.length
? `AND provider in (${providers.map(it => `'${it}'`).join(',')})`
: '';
const titleCondition = type === Type.MOVIE
? 'AND torrents.title NOT LIKE \'%[Erotic]%\''
: '';
const sortCondition = type === Type.MOVIE ? 'sum(torrents.seeders)' : 'max(torrents.seeders)';
const query = `SELECT files."${idName}"
FROM (SELECT torrents."infoHash", torrents.seeders FROM torrents
WHERE seeders > 0 AND type = '${type}' ${providersCondition} ${dateCondition} ${titleCondition}
) as torrents
JOIN files ON torrents."infoHash" = files."infoHash"
WHERE files."${idName}" IS NOT NULL ${episodeCondition}
GROUP BY files."${idName}"
ORDER BY ${sortCondition} DESC
LIMIT 5000`
const results = await database.query(query, { type: QueryTypes.SELECT });
return results.map(result => `${result.imdbId || result.kitsuId}`);
}

File diff suppressed because it is too large Load diff

View file

@ -1,68 +0,0 @@
import getRouter from 'stremio-addon-sdk/src/getRouter.js';
import addonInterface from './addon.js';
import qs from 'querystring';
import { parseConfiguration } from '../addon/lib/configuration.js';
import { createManifest } from './lib/manifest.js';
const router = getRouter(addonInterface);
// router.get('/', (_, res) => {
// res.redirect('/configure')
// res.end();
// });
//
// router.get('/:configuration?/configure', (req, res) => {
// const configValues = parseConfiguration(req.params.configuration || '');
// const landingHTML = landingTemplate(createManifest(configValues), configValues);
// res.setHeader('content-type', 'text/html');
// res.end(landingHTML);
// });
router.get('/:configuration?/manifest.json', (req, res) => {
const configValues = parseConfiguration(req.params.configuration || '');
const manifestBuf = JSON.stringify(createManifest(configValues));
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(manifestBuf)
});
router.get('/:configuration/:resource/:type/:id/:extra?.json', (req, res, next) => {
const { configuration, resource, type, id } = req.params;
const extra = req.params.extra ? qs.parse(req.url.split('/').pop().slice(0, -5)) : {}
const configValues = { ...extra, ...parseConfiguration(configuration) };
addonInterface.get(resource, type, id, configValues)
.then(resp => {
const cacheHeaders = {
cacheMaxAge: 'max-age',
staleRevalidate: 'stale-while-revalidate',
staleError: 'stale-if-error'
};
const cacheControl = Object.keys(cacheHeaders)
.map(prop => Number.isInteger(resp[prop]) && cacheHeaders[prop] + '=' + resp[prop])
.filter(val => !!val).join(', ');
res.setHeader('Cache-Control', `${cacheControl}, public`);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(resp));
})
.catch(err => {
if (err.noHandler) {
if (next) {
next()
} else {
res.writeHead(404);
res.end(JSON.stringify({ err: 'not found' }));
}
} else {
console.error(err);
res.writeHead(500);
res.end(JSON.stringify({ err: 'handler error' }));
}
});
});
export default function (req, res) {
router(req, res, function () {
res.statusCode = 404;
res.end();
});
};

View file

@ -4,15 +4,21 @@
"exports": "./index.js",
"type": "module",
"scripts": {
"start": "node index.js"
"start": "tsx index.ts",
"dev": "tsx --watch index.ts",
"lint": "eslint **/*.ts",
"lint:fix": "eslint --fix **/*.ts",
"check": "tsc --noEmit",
"build": "tsc"
},
"engines": {
"node": ">=16.x"
"node": ">=22.x"
},
"author": "TheBeastLT <pauliox@beyond.lt>",
"license": "MIT",
"dependencies": {
"@keyv/mongo": "^3.0.1",
"@types/express": "^5.0.0",
"axios": "^1.7.7",
"bottleneck": "^2.19.5",
"moment": "^2.30.1",
@ -21,5 +27,14 @@
"request-ip": "^3.3.0",
"sequelize": "^6.29.0",
"stremio-addon-sdk": "^1.6.10"
},
"devDependencies": {
"@types/stremio-addon-sdk": "^1.6.11",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"globals": "^15.14.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
}
}

2767
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

3
src/addon.ts Normal file
View file

@ -0,0 +1,3 @@
import addon from '@/lib/addon/index'
export default addon;

3
src/catalog.ts Normal file
View file

@ -0,0 +1,3 @@
import catalogs from '@/lib/catalogs'
export default catalogs;

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "commonjs" /* Specify what module code is generated. */,
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
"baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
"paths": {
"@/*": ["./src/*"]
} /* Specify a set of entries that re-map imports to additional lookup locations. */,
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
"noEmit": true /* Disable emitting files from a compilation. */,
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}