diff --git a/src/lib/addon/.dockerignore b/src/lib/addon/.dockerignore
new file mode 100644
index 0000000..5538a38
--- /dev/null
+++ b/src/lib/addon/.dockerignore
@@ -0,0 +1,3 @@
+**/node_modules
+**/npm-debug.log
+**/.env
\ No newline at end of file
diff --git a/src/lib/addon/Dockerfile b/src/lib/addon/Dockerfile
new file mode 100644
index 0000000..14dd96e
--- /dev/null
+++ b/src/lib/addon/Dockerfile
@@ -0,0 +1,12 @@
+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" ]
\ No newline at end of file
diff --git a/src/lib/addon/addon.ts b/src/lib/addon/addon.ts
new file mode 100644
index 0000000..2c2bb1d
--- /dev/null
+++ b/src/lib/addon/addon.ts
@@ -0,0 +1,136 @@
+import { addonBuilder, Stream } 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";
+
+function getMaxAge() {
+ const maxAgeEnv = process.env.CACHE_MAX_AGE;
+ if (maxAgeEnv) {
+ return parseInt(maxAgeEnv);
+ }
+
+ return 60 * 60;
+}
+
+const CACHE_MAX_AGE = getMaxAge(); // 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: Stream[]) => applyFilters(streams, args.extra))
+ .then((streams: Stream[]) => applySorting(streams, args.extra, args.type))
+ .then((streams: Stream[]) => applyStaticInfo(streams))
+ .then((streams: Stream[]) => applyMochs(streams, args.extra))
+ .then((streams: Stream[]) => enrichCacheParams(streams))
+ .catch((error: unknown) => {
+ 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();
diff --git a/src/lib/addon/index.ts b/src/lib/addon/index.ts
new file mode 100644
index 0000000..6bf497a
--- /dev/null
+++ b/src/lib/addon/index.ts
@@ -0,0 +1,27 @@
+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) => serverless(req, res));
+app.listen(process.env.PORT || 7000, () => {
+ initBestTrackers()
+ .then(() => console.log(`Started addon at: http://localhost:${process.env.PORT || 7000}`));
+});
diff --git a/src/lib/addon/lib/cache.ts b/src/lib/addon/lib/cache.ts
new file mode 100644
index 0000000..3b9bc77
--- /dev/null
+++ b/src/lib/addon/lib/cache.ts
@@ -0,0 +1,109 @@
+import KeyvMongo from "@keyv/mongo";
+import { KeyvCacheableMemory } from "cacheable";
+import { isStaticUrl } from '../moch/static.js';
+import { Stream } from "stremio-addon-sdk";
+
+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: string, method: string) {
+ const ttl = (streams: Stream[]) => 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 {};
+ });
+}
+
+/**
+ * Returns the max age for the cache in seconds.
+ * It first tries to parse the `CACHE_MAX_AGE` environment variable, and if it's not set, it defaults to 1 hour.
+ */
+export function getCacheMaxAge() {
+ const maxAgeEnv = process.env.CACHE_MAX_AGE;
+ if (maxAgeEnv) {
+ return parseInt(maxAgeEnv);
+ }
+
+ return 60 * 60;
+}
diff --git a/src/lib/addon/lib/configuration.ts b/src/lib/addon/lib/configuration.ts
new file mode 100644
index 0000000..d646297
--- /dev/null
+++ b/src/lib/addon/lib/configuration.ts
@@ -0,0 +1,82 @@
+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 : {};
+}
\ No newline at end of file
diff --git a/src/lib/addon/lib/extension.ts b/src/lib/addon/lib/extension.ts
new file mode 100644
index 0000000..abd1b72
--- /dev/null
+++ b/src/lib/addon/lib/extension.ts
@@ -0,0 +1,72 @@
+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());
+}
diff --git a/src/lib/addon/lib/filter.ts b/src/lib/addon/lib/filter.ts
new file mode 100644
index 0000000..c8eea6f
--- /dev/null
+++ b/src/lib/addon/lib/filter.ts
@@ -0,0 +1,269 @@
+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;
+ })
+}
diff --git a/src/lib/addon/lib/landingTemplate.ts b/src/lib/addon/lib/landingTemplate.ts
new file mode 100644
index 0000000..59206d7
--- /dev/null
+++ b/src/lib/addon/lib/landingTemplate.ts
@@ -0,0 +1,506 @@
+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 => ``)
+ .join('\n');
+ const sortOptionsHTML = Object.values(SortOptions.options)
+ .map((option, i) => ``)
+ .join('\n');
+ const languagesOptionsHTML = LanguageOptions.options
+ .map((option, i) => ``)
+ .join('\n');
+ const qualityFiltersHTML = Object.values(QualityFilter.options)
+ .map(option => ``)
+ .join('\n');
+ const debridProvidersHTML = Object.values(MochOptions)
+ .map(moch => ``)
+ .join('\n');
+ const debridOptionsHTML = Object.values(DebridOptions.options)
+ .map(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 `
+
+
+
+
+
+ ${manifest.name} - Stremio Addon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
${manifest.name}
+
${manifest.version || '0.0.0'}
+
${manifest.description || ''}
+
+
+
+
This addon has more :
+
+ ${stylizedTypes.map(t => `- ${t}
`).join('')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+}
diff --git a/src/lib/addon/lib/languages.ts b/src/lib/addon/lib/languages.ts
new file mode 100644
index 0000000..1b80ac4
--- /dev/null
+++ b/src/lib/addon/lib/languages.ts
@@ -0,0 +1,76 @@
+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];
+}
diff --git a/src/lib/addon/lib/magnetHelper.ts b/src/lib/addon/lib/magnetHelper.ts
new file mode 100644
index 0000000..4893692
--- /dev/null
+++ b/src/lib/addon/lib/magnetHelper.ts
@@ -0,0 +1,93 @@
+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));
+}
diff --git a/src/lib/addon/lib/manifest.ts b/src/lib/addon/lib/manifest.ts
new file mode 100644
index 0000000..4141f4d
--- /dev/null
+++ b/src/lib/addon/lib/manifest.ts
@@ -0,0 +1,89 @@
+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];
+}
diff --git a/src/lib/addon/lib/namedQueue.ts b/src/lib/addon/lib/namedQueue.ts
new file mode 100644
index 0000000..695ce05
--- /dev/null
+++ b/src/lib/addon/lib/namedQueue.ts
@@ -0,0 +1,11 @@
+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;
+}
\ No newline at end of file
diff --git a/src/lib/addon/lib/promises.ts b/src/lib/addon/lib/promises.ts
new file mode 100644
index 0000000..55094bb
--- /dev/null
+++ b/src/lib/addon/lib/promises.ts
@@ -0,0 +1,20 @@
+/**
+ * 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);
+ })
+ ]);
+}
diff --git a/src/lib/addon/lib/repository.ts b/src/lib/addon/lib/repository.ts
new file mode 100644
index 0000000..ceb8b61
--- /dev/null
+++ b/src/lib/addon/lib/repository.ts
@@ -0,0 +1,132 @@
+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']
+ ]
+ });
+}
diff --git a/src/lib/addon/lib/requestHelper.ts b/src/lib/addon/lib/requestHelper.ts
new file mode 100644
index 0000000..7053ac4
--- /dev/null
+++ b/src/lib/addon/lib/requestHelper.ts
@@ -0,0 +1,6 @@
+import UserAgent from 'user-agents';
+const userAgent = new UserAgent();
+
+export function getRandomUserAgent() {
+ return userAgent.random().toString();
+}
diff --git a/src/lib/addon/lib/sort.ts b/src/lib/addon/lib/sort.ts
new file mode 100644
index 0000000..2430284
--- /dev/null
+++ b/src/lib/addon/lib/sort.ts
@@ -0,0 +1,130 @@
+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;
+}
diff --git a/src/lib/addon/lib/streamInfo.ts b/src/lib/addon/lib/streamInfo.ts
new file mode 100644
index 0000000..4a4b743
--- /dev/null
+++ b/src/lib/addon/lib/streamInfo.ts
@@ -0,0 +1,152 @@
+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));
+}
diff --git a/src/lib/addon/lib/subtitles.ts b/src/lib/addon/lib/subtitles.ts
new file mode 100644
index 0000000..10be676
--- /dev/null
+++ b/src/lib/addon/lib/subtitles.ts
@@ -0,0 +1,99 @@
+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();
+}
diff --git a/src/lib/addon/lib/titleHelper.ts b/src/lib/addon/lib/titleHelper.ts
new file mode 100644
index 0000000..7cb62b9
--- /dev/null
+++ b/src/lib/addon/lib/titleHelper.ts
@@ -0,0 +1,31 @@
+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);
+}
diff --git a/src/lib/addon/lib/types.ts b/src/lib/addon/lib/types.ts
new file mode 100644
index 0000000..cf05502
--- /dev/null
+++ b/src/lib/addon/lib/types.ts
@@ -0,0 +1,6 @@
+export enum Type {
+ MOVIE = 'movie',
+ SERIES = 'series',
+ ANIME = 'anime',
+ OTHER = 'other'
+};
diff --git a/src/lib/addon/moch/alldebrid.ts b/src/lib/addon/moch/alldebrid.ts
new file mode 100644
index 0000000..f58c146
--- /dev/null
+++ b/src/lib/addon/moch/alldebrid.ts
@@ -0,0 +1,199 @@
+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);
+}
diff --git a/src/lib/addon/moch/debridlink.ts b/src/lib/addon/moch/debridlink.ts
new file mode 100644
index 0000000..e0d214e
--- /dev/null
+++ b/src/lib/addon/moch/debridlink.ts
@@ -0,0 +1,156 @@
+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);
+}
diff --git a/src/lib/addon/moch/moch.ts b/src/lib/addon/moch/moch.ts
new file mode 100644
index 0000000..f826843
--- /dev/null
+++ b/src/lib/addon/moch/moch.ts
@@ -0,0 +1,249 @@
+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;
+}
diff --git a/src/lib/addon/moch/mochHelper.ts b/src/lib/addon/moch/mochHelper.ts
new file mode 100644
index 0000000..3d86766
--- /dev/null
+++ b/src/lib/addon/moch/mochHelper.ts
@@ -0,0 +1,64 @@
+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] !== '�') {
+ 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();
+}
diff --git a/src/lib/addon/moch/offcloud.ts b/src/lib/addon/moch/offcloud.ts
new file mode 100644
index 0000000..f928d71
--- /dev/null
+++ b/src/lib/addon/moch/offcloud.ts
@@ -0,0 +1,184 @@
+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'));
+}
diff --git a/src/lib/addon/moch/options.ts b/src/lib/addon/moch/options.ts
new file mode 100644
index 0000000..31567bf
--- /dev/null
+++ b/src/lib/addon/moch/options.ts
@@ -0,0 +1,21 @@
+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);
+}
diff --git a/src/lib/addon/moch/premiumize.ts b/src/lib/addon/moch/premiumize.ts
new file mode 100644
index 0000000..943c784
--- /dev/null
+++ b/src/lib/addon/moch/premiumize.ts
@@ -0,0 +1,208 @@
+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 };
+}
diff --git a/src/lib/addon/moch/putio.ts b/src/lib/addon/moch/putio.ts
new file mode 100644
index 0000000..4dc42dd
--- /dev/null
+++ b/src/lib/addon/moch/putio.ts
@@ -0,0 +1,216 @@
+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);
+}
diff --git a/src/lib/addon/moch/realdebrid.ts b/src/lib/addon/moch/realdebrid.ts
new file mode 100644
index 0000000..4e16b9b
--- /dev/null
+++ b/src/lib/addon/moch/realdebrid.ts
@@ -0,0 +1,366 @@
+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 };
+}
diff --git a/src/lib/addon/moch/static.ts b/src/lib/addon/moch/static.ts
new file mode 100644
index 0000000..6e85015
--- /dev/null
+++ b/src/lib/addon/moch/static.ts
@@ -0,0 +1,19 @@
+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
\ No newline at end of file
diff --git a/src/lib/addon/moch/torbox.ts b/src/lib/addon/moch/torbox.ts
new file mode 100644
index 0000000..e4976fe
--- /dev/null
+++ b/src/lib/addon/moch/torbox.ts
@@ -0,0 +1,298 @@
+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);
+}
diff --git a/src/lib/addon/serverless.ts b/src/lib/addon/serverless.ts
new file mode 100644
index 0000000..21166db
--- /dev/null
+++ b/src/lib/addon/serverless.ts
@@ -0,0 +1,113 @@
+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';
+import qs from 'querystring';
+import { manifest } from '@/lib/manifest';
+import { parseConfiguration, PreConfigurations } from '@/lib/configuration';
+import landingTemplate from '@/lib/landingTemplate';
+import * as moch from '@/moch/moch';
+import { NextFunction, Request, Response } from 'express';
+
+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: Request, res: Response) => {
+ res.redirect(`/${req.params.preconfiguration}/configure`)
+ res.end();
+});
+
+router.get('/:configuration?/configure', (req: Request, res: Response) => {
+ const configValues = parseConfiguration(req.params.configuration || '');
+ const landingHTML = landingTemplate(manifest(configValues), configValues);
+ res.setHeader('content-type', 'text/html');
+ res.end(landingHTML);
+});
+
+router.get('/:configuration?/manifeston', (req: Request, res: Response) => {
+ 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?on', limiter, (req: Request, res: Response, next: NextFunction) => {
+ 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: Request, res: Response) => {
+ 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: Request, res: Response) {
+ router(req, res, function () {
+ res.statusCode = 404;
+ res.end();
+ });
+};
diff --git a/src/lib/addon/static/videos/blocked_access_v1.mp4 b/src/lib/addon/static/videos/blocked_access_v1.mp4
new file mode 100644
index 0000000..ed72c9a
Binary files /dev/null and b/src/lib/addon/static/videos/blocked_access_v1.mp4 differ
diff --git a/src/lib/addon/static/videos/download_failed_v2.mp4 b/src/lib/addon/static/videos/download_failed_v2.mp4
new file mode 100644
index 0000000..0240e64
Binary files /dev/null and b/src/lib/addon/static/videos/download_failed_v2.mp4 differ
diff --git a/src/lib/addon/static/videos/downloading_v2.mp4 b/src/lib/addon/static/videos/downloading_v2.mp4
new file mode 100644
index 0000000..c0e29e8
Binary files /dev/null and b/src/lib/addon/static/videos/downloading_v2.mp4 differ
diff --git a/src/lib/addon/static/videos/failed_access_v2.mp4 b/src/lib/addon/static/videos/failed_access_v2.mp4
new file mode 100644
index 0000000..f5b3c28
Binary files /dev/null and b/src/lib/addon/static/videos/failed_access_v2.mp4 differ
diff --git a/src/lib/addon/static/videos/failed_infringement_v2.mp4 b/src/lib/addon/static/videos/failed_infringement_v2.mp4
new file mode 100644
index 0000000..c538fc0
Binary files /dev/null and b/src/lib/addon/static/videos/failed_infringement_v2.mp4 differ
diff --git a/src/lib/addon/static/videos/failed_opening_v2.mp4 b/src/lib/addon/static/videos/failed_opening_v2.mp4
new file mode 100644
index 0000000..d12247e
Binary files /dev/null and b/src/lib/addon/static/videos/failed_opening_v2.mp4 differ
diff --git a/src/lib/addon/static/videos/failed_rar_v2.mp4 b/src/lib/addon/static/videos/failed_rar_v2.mp4
new file mode 100644
index 0000000..8bd04cb
Binary files /dev/null and b/src/lib/addon/static/videos/failed_rar_v2.mp4 differ
diff --git a/src/lib/addon/static/videos/failed_too_big_v1.mp4 b/src/lib/addon/static/videos/failed_too_big_v1.mp4
new file mode 100644
index 0000000..a479712
Binary files /dev/null and b/src/lib/addon/static/videos/failed_too_big_v1.mp4 differ
diff --git a/src/lib/addon/static/videos/failed_unexpected_v2.mp4 b/src/lib/addon/static/videos/failed_unexpected_v2.mp4
new file mode 100644
index 0000000..3ca2b3a
Binary files /dev/null and b/src/lib/addon/static/videos/failed_unexpected_v2.mp4 differ
diff --git a/src/lib/addon/static/videos/limits_exceeded_v1.mp4 b/src/lib/addon/static/videos/limits_exceeded_v1.mp4
new file mode 100644
index 0000000..3e80a1e
Binary files /dev/null and b/src/lib/addon/static/videos/limits_exceeded_v1.mp4 differ
diff --git a/src/lib/catalogs/.dockerignore b/src/lib/catalogs/.dockerignore
new file mode 100644
index 0000000..5538a38
--- /dev/null
+++ b/src/lib/catalogs/.dockerignore
@@ -0,0 +1,3 @@
+**/node_modules
+**/npm-debug.log
+**/.env
\ No newline at end of file
diff --git a/src/lib/catalogs/Dockerfile b/src/lib/catalogs/Dockerfile
new file mode 100644
index 0000000..57cb66b
--- /dev/null
+++ b/src/lib/catalogs/Dockerfile
@@ -0,0 +1,12 @@
+FROM node:22-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" ]
diff --git a/src/lib/catalogs/addon.ts b/src/lib/catalogs/addon.ts
new file mode 100644
index 0000000..e9e45be
--- /dev/null
+++ b/src/lib/catalogs/addon.ts
@@ -0,0 +1,249 @@
+import Bottleneck from 'bottleneck';
+import moment from 'moment';
+import { addonBuilder, Manifest, ManifestCatalog } 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';
+import { getCacheMaxAge } from '../addon/lib/cache.js'
+
+const CACHE_MAX_AGE = getCacheMaxAge(); // 4 hours in seconds
+const STALE_REVALIDATE_AGE = 4 * 60 * 60; // 4 hours
+const STALE_ERROR_AGE = 7 * 24 * 60 * 60; // 7 days
+
+/**
+ * Returns the max concurrency for the Bottleneck limiter.
+ * This is the max number of concurrent requests that the limiter will allow.
+ * If the LIMIT_MAX_CONCURRENT environment variable is set, it will be used.
+ * Otherwise, the default value is 20.
+ * @returns {number} The max concurrency.
+ */
+function getConcurrency(): number {
+ const val = process.env.LIMIT_MAX_CONCURRENT;
+
+ if (val) {
+ return parseInt(val, 10);
+ }
+
+ return 20;
+}
+
+/**
+ * Returns the queue size for the Bottleneck limiter.
+ * Defaults to 50 if LIMIT_QUEUE_SIZE environment variable is not set.
+ * @returns {number} The queue size.
+ */
+function getQueueSize(): number {
+ const val = process.env.LIMIT_QUEUE_SIZE;
+
+ if (val) {
+ return parseInt(val, 10);
+ }
+
+ return 50;
+}
+
+interface ICreateCacheKey {
+ catalogId: string;
+ providers: string[];
+ genre: string;
+ offset: number
+}
+
+/**
+ * Creates a unique cache key for a given catalog and its options.
+ * @param {ICreateCacheKey} params - The id of the catalog.
+ * @param {string} params.catalogId - The id of the catalog.
+ * @param {MediaProvider[]} params.providers - The list of providers.
+ * @param {string} params.genre - The genre or category of the catalog.
+ * @param {number} params.offset - The offset of the current page.
+ * @returns {string} A unique cache key.
+ */
+function createCacheKey({
+ catalogId,
+ providers,
+ genre,
+ offset
+}: ICreateCacheKey): string {
+ const dateKey = moment().format('YYYY-MM-DD');
+ return [catalogId, providers.join(','), genre, dateKey, offset].filter(x => x !== undefined).join('|');
+}
+
+const manifest: Manifest = createManifest();
+const builder = new addonBuilder(manifest);
+const limiter = new Bottleneck({
+ maxConcurrent: getConcurrency(),
+ highWater: getQueueSize(),
+ strategy: Bottleneck.strategy.OVERFLOW
+});
+const defaultProviders = Providers.options
+ .filter(provider => !provider.foreign)
+ .map(provider => provider.label)
+ .sort();
+
+interface IGetCatalog {
+ catalog: ManifestCatalog;
+ providers: string[];
+ genre: string;
+ offset: number;
+}
+
+/**
+ * Retrieves the cursor for a given catalog, providers, genre, and offset.
+ * The cursor is used to track the last item retrieved from the previous page.
+ * Returns undefined if the offset is 0, indicating the first page.
+ *
+ * @param {Catalog} catalog - The catalog to retrieve the cursor for.
+ * @param {MediaProvider[]} providers - The list of media providers.
+ * @param {string} genre - The genre or category.
+ * @param {number} offset - The offset of the current page.
+ * @returns {Promise} A promise resolving to the cursor ID or undefined.
+ */
+function getCursor({ catalog, providers, genre, offset }: IGetCatalog) {
+ if (offset === 0) {
+ return undefined;
+ }
+ const previousCacheKey = createCacheKey({
+ catalogId: catalog.id,
+ providers,
+ genre,
+ offset
+ });
+ return cacheWrapCatalog({
+ key: previousCacheKey,
+ method: () => Promise.reject(new Error("cursor not found"))
+ })
+ .then((metas) => metas[metas.length - 1])
+ .then((meta) => meta.id.replace('kitsu:', ''))
+}
+
+
+
+/**
+ * Returns the start date of a given genre or category.
+ * @param {string} genre - The genre or category.
+ * @returns {moment.Moment | undefined} The start date of the given genre or undefined if not found.
+ */
+function getStartDate(genre: string): moment.Moment | undefined {
+ 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');
+ }
+}
+
+/**
+ * Returns the end date of a given genre or category.
+ * @param {string} genre - The genre or category.
+ * @returns {moment.Moment | undefined} The end date of the given genre or undefined if not found.
+ */
+
+function getEndDate(genre: string): moment.Moment | undefined {
+ 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');
+ }
+}
+
+
+
+
+/**
+ * Retrieves a page of metas for a given catalog and genre.
+ * @param {Catalog} catalog - The catalog to retrieve.
+ * @param {MediaProvider[]} providers - The list of providers.
+ * @param {string} genre - The genre or category.
+ * @param {number} offset - The offset of the current page.
+ * @returns {Promise} A promise resolving to an array of Stream objects.
+ */
+async function getCatalog({
+ catalog,
+ providers,
+ genre,
+ offset
+}: IGetCatalog): Promise {
+ const cursor = await getCursor({
+ catalog,
+ providers,
+ genre,
+ offset
+ });
+
+ const startDate = getStartDate(genre)?.toISOString();
+ const endDate = getEndDate(genre)?.toISOString();
+
+ const cacheKey = createCacheKey({
+ catalogId: catalog.id,
+ providers,
+ genre,
+ offset
+ });
+
+ return cacheWrapIds({
+ key: cacheKey,
+ method: () => repository.getIds(providers, catalog.type, startDate, endDate)
+ })
+ .then((ids) => ids.slice(ids.indexOf(cursor) + 1))
+ .then((ids) => getMetas(ids, catalog.type));
+}
+
+builder.defineCatalogHandler(async (args) => {
+ const offset = parseInt(args.extra.skip.toString() || '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(new Error(`No catalog found for with id: ${args.id}`));
+ }
+
+ const cacheKey = createCacheKey({
+ catalogId: catalog.id,
+ providers,
+ genre,
+ offset
+ });
+
+
+ try {
+ const metas = await limiter.schedule(() => cacheWrapCatalog({
+ key: cacheKey,
+ method: () => getCatalog({
+ catalog,
+ providers,
+ genre,
+ offset
+ })
+ }));
+
+ return ({
+ metas,
+ cacheMaxAge: CACHE_MAX_AGE,
+ staleRevalidate: STALE_REVALIDATE_AGE,
+ staleError: STALE_ERROR_AGE
+ });
+
+ } catch (error) {
+ if (error instanceof Error) {
+ return await Promise.reject(new Error(`Failed retrieving catalog ${args.id}: ${error.message}`));
+ }
+
+ return await Promise.reject(new Error(`Failed retrieving catalog ${args.id}: ${error}`));
+ }
+})
+
+
+
+export default builder.getInterface();
diff --git a/src/lib/catalogs/eslint.config.mjs b/src/lib/catalogs/eslint.config.mjs
new file mode 100644
index 0000000..502ada2
--- /dev/null
+++ b/src/lib/catalogs/eslint.config.mjs
@@ -0,0 +1,62 @@
+import globals from 'globals';
+import js from '@eslint/js';
+import prettier from 'eslint-config-prettier';
+import tseslint from 'typescript-eslint';
+
+/** @type {import('eslint').Linter.Config[]} */
+export default [
+ js.configs.all,
+ ...tseslint.configs.recommended,
+ prettier,
+ {
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.node
+ }
+ }
+ },
+ {
+ files: ['**/*.ts'],
+ languageOptions: {
+ parser: tseslint.parser
+ },
+ plugins: {
+ ...tseslint.configs.recommended,
+ }
+ },
+ {
+ rules: {
+ camelcase: 'off',
+ 'max-lines-per-function': 'off',
+ 'max-statements': 'off',
+ 'new-cap': 'off',
+ 'no-magic-numbers': 'off',
+ 'one-var': 'off',
+ 'id-length': 'off',
+ 'sort-vars': 'off',
+ 'max-classes-per-file': 'off',
+ 'func-style': 'off',
+ 'no-ternary': 'off',
+ 'sort-imports': 'off',
+ 'sort-keys': 'off',
+ 'max-lines': 'off',
+ 'no-undefined': 'off',
+ complexity: 'warn',
+ 'no-plusplus': 'off',
+ 'prefer-const': 'off',
+ 'prefer-destructuring': 'off',
+ 'require-atomic-updates': 'off',
+ 'capitalized-comments': 'off',
+ 'no-await-in-loop': 'off',
+ 'no-undef-init': 'off',
+ 'init-declarations': 'off',
+ 'dot-notation': 'off',
+ 'no-console': 'off',
+ 'no-inline-comments': 'off'
+ }
+ },
+ {
+ ignores: ['build/', 'dist/', 'express/dist', 'node_modules/', 'src/lib/api-spec.ts']
+ }
+];
diff --git a/src/lib/catalogs/index.ts b/src/lib/catalogs/index.ts
new file mode 100644
index 0000000..ad7c20a
--- /dev/null
+++ b/src/lib/catalogs/index.ts
@@ -0,0 +1,9 @@
+import express from 'express';
+import serverless from './serverless.js';
+
+const app = express();
+
+app.use((req, res) => serverless(req, res));
+app.listen(process.env.PORT ?? 7000, () => {
+ console.log(`Started addon at: http://localhost:${process.env.PORT || 7000}`);
+});
diff --git a/src/lib/catalogs/lib/cache.ts b/src/lib/catalogs/lib/cache.ts
new file mode 100644
index 0000000..1ea9e30
--- /dev/null
+++ b/src/lib/catalogs/lib/cache.ts
@@ -0,0 +1,95 @@
+import KeyvMongo from "@keyv/mongo";
+
+const CATALOG_TTL = 24 * 60 * 60 * 1000; // 24 hours
+
+const MONGO_URI = process.env.MONGODB_URI;
+
+/**
+ * Returns the remote cache instance.
+ * If the MONGO_URI environment variable is not set, will return undefined.
+ * @returns {KeyvMongo | undefined}
+ */
+function getRemoteCache(): KeyvMongo | undefined {
+ if (MONGO_URI) {
+ return new KeyvMongo(MONGO_URI, { collection: 'torrentio_catalog_collection' });
+ }
+}
+
+type Method = () => Promise;
+
+interface ICacheWrap {
+ key: string;
+ method: Method;
+ ttl: number;
+}
+
+/**
+ * Wraps a method with caching. If the cache is not set up, will simply call the method.
+ * Otherwise, will check the cache for the given key. If the key is not present, will call the method,
+ * cache the result, and return it. If the key is present, will return the cached value.
+ *
+ * @param {ICacheWrap} - An object with the following properties:
+ * - key: The key to use for caching.
+ * - method: The method to call if the cache doesn't have the key.
+ * - ttl: The time to live for the cache entry.
+ * @returns {Promise} The cached or newly retrieved value.
+ */
+async function cacheWrap({ key, method, ttl }: ICacheWrap): Promise {
+ const cache = getRemoteCache();
+
+ 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 method();
+}
+
+/**
+ * Wraps a method with caching. If the cache is not set up, will simply call the method.
+ * Otherwise, will check the cache for the given key. If the key is not present, will call the method,
+ * cache the result, and return it. If the key is present, will return the cached value.
+ *
+ * @param {string} key - The key to use for caching.
+ * @param {Method} method - The method to call if the cache doesn't have the key.
+ * @returns {Promise} The cached or newly retrieved value.
+ */
+export function cacheWrapCatalog({ key, method }: {
+ key: string;
+ method: Method;
+}): Promise {
+ return cacheWrap({
+ key,
+ method,
+ ttl: CATALOG_TTL
+ });
+}
+
+/**
+ * Wraps a method with caching, like cacheWrapCatalog, but prefixes the cache key with 'ids|'.
+ * This is used to cache the list of IDs for a given catalog, so that we can avoid calling the
+ * repository's getIds method over and over again.
+ *
+ * @param {string} key - The key to use for caching.
+ * @param {Method} method - The method to call if the cache doesn't have the key.
+ * @returns {Promise} The cached or newly retrieved value.
+ */
+export function cacheWrapIds({ key, method }: {
+ key: string;
+ method: Method;
+}): Promise {
+
+ return cacheWrap({
+ key: `ids|${key}`,
+ method,
+ ttl: CATALOG_TTL
+ });
+}
diff --git a/src/lib/catalogs/lib/landingTemplate.ts b/src/lib/catalogs/lib/landingTemplate.ts
new file mode 100644
index 0000000..14d7953
--- /dev/null
+++ b/src/lib/catalogs/lib/landingTemplate.ts
@@ -0,0 +1,274 @@
+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: { background: string; logo: string; contactEmail: any; name: any; types: any[]; version: any; description: any; }, config: { providers: never[]; }) {
+ 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 ?
+ `` : '';
+ const providersHTML = Providers.options
+ .map(provider => ``)
+ .join('\n');
+ const stylizedTypes = manifest.types
+ .map(t => t[0].toUpperCase() + t.slice(1) + (t !== 'series' ? 's' : ''));
+
+ return `
+
+
+
+
+
+ ${manifest.name} - Stremio Addon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
${manifest.name}
+
${manifest.version || '0.0.0'}
+
${manifest.description || ''}
+
+
+
+
This addon has more :
+
+ ${stylizedTypes.map(t => `- ${t}
`).join('')}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${contactHTML}
+
+
+
+
+ `
+}
diff --git a/src/lib/catalogs/lib/manifest.ts b/src/lib/catalogs/lib/manifest.ts
new file mode 100644
index 0000000..a444b66
--- /dev/null
+++ b/src/lib/catalogs/lib/manifest.ts
@@ -0,0 +1,45 @@
+import { Manifest } from 'stremio-addon-sdk';
+import { Type } from '../../addon/lib/types.js';
+
+export const genres = [
+ 'Yesterday',
+ 'This Week',
+ 'Last Week',
+ 'This Month',
+ 'Last Month',
+ 'All Time'
+]
+
+export function createManifest(config?: Manifest): Manifest {
+ return {
+ id: config?.id ?? 'com.stremio.torrentio.catalog.addon',
+ version: config?.version ?? '1.0.2',
+ name: config?.name ?? 'Torrent Catalogs',
+ description: config?.description ?? 'Provides catalogs for movies/series/anime based on top seeded torrents. Requires Kitsu addon for anime.',
+ logo: config?.logo ?? `https://i.ibb.co/w4BnkC9/GwxAcDV.png`,
+ background: config?.background ?? `https://i.ibb.co/VtSfFP9/t8wVwcg.jpg`,
+ types: config?.types ?? [Type.MOVIE, Type.SERIES],
+ resources: config?.resources ?? ['catalog'],
+ catalogs: config?.catalogs ?? [
+ {
+ id: 'top-movies',
+ type: Type.MOVIE,
+ name: "Top seeded",
+ extra: [{ name: 'genre', options: genres }, { name: 'skip' }],
+ genres
+ },
+ {
+ id: 'top-series',
+ type: Type.SERIES,
+ name: "Top seeded",
+ extra: [{ name: 'genre', options: genres }, { name: 'skip' }],
+ genres
+ }
+ ],
+ behaviorHints: config?.behaviorHints ?? {
+ // @TODO might enable configuration to configure providers
+ configurable: false,
+ configurationRequired: false
+ }
+ };
+}
diff --git a/src/lib/catalogs/lib/metadata.ts b/src/lib/catalogs/lib/metadata.ts
new file mode 100644
index 0000000..d02b27e
--- /dev/null
+++ b/src/lib/catalogs/lib/metadata.ts
@@ -0,0 +1,41 @@
+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: string[], type: any) {
+ if (!ids.length || !type) {
+ return [];
+ }
+
+ return _requestMetadata(ids, type)
+ .catch((error) => {
+ throw new Error(`failed metadata ${type} query due: ${error.message}`);
+ });
+}
+
+async function _requestMetadata(ids: string[], type: any) {
+ const url = _getUrl(ids, type);
+ const response = await axios.get(url, { timeout: TIMEOUT });
+ const metas = response?.data?.metas || response?.data?.metasDetailed || [];
+ const metas_1 = metas.filter((meta: any) => meta);
+ return metas_1.map((meta_1: { videos: any; credits_cast: any; credits_crew: any; }) => _sanitizeMeta(meta_1));
+}
+
+function _getUrl(ids: string[], type: string) {
+ 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: { videos: any; credits_cast: any; credits_crew: any; }) {
+ delete meta.videos;
+ delete meta.credits_cast;
+ delete meta.credits_crew;
+ return meta;
+}
diff --git a/src/lib/catalogs/lib/repository.ts b/src/lib/catalogs/lib/repository.ts
new file mode 100644
index 0000000..edfd4bd
--- /dev/null
+++ b/src/lib/catalogs/lib/repository.ts
@@ -0,0 +1,59 @@
+import { Sequelize, QueryTypes } from 'sequelize';
+import { Type } from '../../addon/lib/types.js';
+import { ContentType } from 'stremio-addon-sdk';
+
+const DATABASE_URI = process.env.DATABASE_URI;
+
+if (!DATABASE_URI) {
+ throw new Error('Missing database URI');
+}
+
+const database = new Sequelize(DATABASE_URI, { logging: false });
+
+/**
+ * Retrieves a list of unique identifiers (IDs) for media content based on specified criteria.
+ * The function queries a database to find IDs of files associated with torrents that meet the given
+ * conditions such as content type, providers, date range, and other filters.
+ *
+ * @param {string[]} providers - An array of provider names to filter the results by.
+ * @param {ContentType} type - The type of content to filter (e.g., movie, series).
+ * @param {string} [startDate] - The start date for filtering based on the upload date of the torrents.
+ * @param {string} [endDate] - The end date for filtering based on the upload date of the torrents.
+ * @returns {Promise} A promise that resolves to an array of IDs that match the query conditions.
+ */
+
+export async function getIds(providers: string[], type: ContentType, startDate?: string, endDate?: string): Promise {
+ const idName = '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}`);
+}
diff --git a/src/lib/catalogs/serverless.ts b/src/lib/catalogs/serverless.ts
new file mode 100644
index 0000000..4be75bd
--- /dev/null
+++ b/src/lib/catalogs/serverless.ts
@@ -0,0 +1,85 @@
+import { getRouter } from 'stremio-addon-sdk';
+import addonInterface from './addon';
+import qs from 'querystring';
+import { parseConfiguration } from '../addon/lib/configuration';
+import { createManifest } from './lib/manifest';
+import { NextFunction, Request, Response } from 'express';
+
+const router = getRouter(addonInterface);
+
+router.get('/:configuration?/manifest.json', (req: Request, res: Response) => {
+ 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: Request, res: Response, next: NextFunction) => {
+ const { configuration, resource, type, id } = req.params;
+
+ const reqUrl = req.url;
+
+ if (!reqUrl) {
+ throw new Error('No request URL found.');
+ }
+
+ const reqUrls = reqUrl.split('/');
+
+ if (reqUrls.length === 0) {
+ throw new Error('No request URL found.')
+ }
+
+ const popedUrls = reqUrls.pop();
+
+ if (!popedUrls) {
+ throw new Error('No request URL found.');
+ }
+
+ const extra = req.params.extra ? qs.parse(popedUrls.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) => Boolean(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' }));
+ }
+ });
+});
+
+/**
+ * Express route handler for the serverless catalog addon.
+ *
+ * @param {Request} req Express request object
+ * @param {Response} res Express response object
+ */
+export default function run(req: Request, res: Response) {
+ router(req, res, () => {
+ res.statusCode = 404;
+ res.end();
+ });
+};