Added typescript base

This commit is contained in:
Nicolas Boyer 2025-01-16 02:06:37 +01:00
parent b8651bc868
commit c5e948d30c
54 changed files with 5088 additions and 0 deletions

View file

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

12
src/lib/addon/Dockerfile Normal file
View file

@ -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" ]

136
src/lib/addon/addon.ts Normal file
View file

@ -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();

27
src/lib/addon/index.ts Normal file
View file

@ -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}`));
});

109
src/lib/addon/lib/cache.ts Normal file
View file

@ -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;
}

View file

@ -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 : {};
}

View file

@ -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());
}

269
src/lib/addon/lib/filter.ts Normal file
View file

@ -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;
})
}

View file

@ -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 => `<option value="${provider.key}">${provider.foreign ? provider.foreign + ' ' : ''}${provider.label}</option>`)
.join('\n');
const sortOptionsHTML = Object.values(SortOptions.options)
.map((option, i) => `<option value="${option.key}" ${i === 0 ? 'selected' : ''}>${option.description}</option>`)
.join('\n');
const languagesOptionsHTML = LanguageOptions.options
.map((option, i) => `<option value="${option.key}">${option.label}</option>`)
.join('\n');
const qualityFiltersHTML = Object.values(QualityFilter.options)
.map(option => `<option value="${option.key}">${option.label}</option>`)
.join('\n');
const debridProvidersHTML = Object.values(MochOptions)
.map(moch => `<option value="${moch.key}">${moch.name}</option>`)
.join('\n');
const debridOptionsHTML = Object.values(DebridOptions.options)
.map(option => `<option value="${option.key}">${option.description}</option>`)
.join('\n');
const stylizedTypes = manifest.types
.map(t => t[0].toUpperCase() + t.slice(1) + (t !== 'series' ? 's' : ''));
const preConfigurationObject = Object.entries(PreConfigurations)
.map(([key, config]) => `${key}: '${config.serialized}'`)
.join(',');
return `
<!DOCTYPE html>
<html style="background-image: url(${background});">
<head>
<meta charset="utf-8">
<title>${manifest.name} - Stremio Addon</title>
<link rel="shortcut icon" href="${logo}" type="image/x-icon">
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,600,700&display=swap" rel="stylesheet">
<script src="https://code.jquery.com/jquery-3.6.4.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" >
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/1.1.2/js/bootstrap-multiselect.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/1.1.2/css/bootstrap-multiselect.css" rel="stylesheet"/>
<style>${STYLESHEET}</style>
</head>
<body>
<div id="addon">
<div class="logo">
<img src="${logo}">
</div>
<h1 class="name">${manifest.name}</h1>
<h2 class="version">${manifest.version || '0.0.0'}</h2>
<h2 class="description">${manifest.description || ''}</h2>
<div class="separator"></div>
<h3 class="gives">This addon has more :</h3>
<ul>
${stylizedTypes.map(t => `<li>${t}</li>`).join('')}
</ul>
<div class="separator"></div>
<label class="label" for="iProviders">Providers:</label>
<select id="iProviders" class="input" onchange="generateInstallLink()" name="providers[]" multiple="multiple">
${providersHTML}
</select>
<label class="label" for="iSort">Sorting:</label>
<select id="iSort" class="input" onchange="sortModeChange()">
${sortOptionsHTML}
</select>
<label class="label" for="iLanguages">Priority foreign language:</label>
<select id="iLanguages" class="input" onchange="generateInstallLink()" name="languages[]" multiple="multiple" title="Streams with the selected dubs/subs language will be shown on the top">
${languagesOptionsHTML}
</select>
<label class="label" for="iQualityFilter">Exclude qualities/resolutions:</label>
<select id="iQualityFilter" class="input" onchange="generateInstallLink()" name="qualityFilters[]" multiple="multiple">
${qualityFiltersHTML}
</select>
<label class="label" id="iLimitLabel" for="iLimit">Max results per quality:</label>
<input type="text" inputmode="numeric" pattern="[0-9]*" id="iLimit" onchange="generateInstallLink()" class="input" placeholder="All results">
<label class="label" id="iSizeFilterLabel" for="iSizeFilter">Video size limit:</label>
<input type="text" pattern="([0-9.]*(?:MB|GB),?)+" id="iSizeFilter" onchange="generateInstallLink()" class="input" placeholder="No limit" title="Returned videos cannot exceed this size, use comma to have different size for movies and series. Examples: 5GB ; 800MB ; 10GB,2GB">
<label class="label" for="iDebridProviders">Debrid provider:</label>
<select id="iDebridProviders" class="input" onchange="debridProvidersChange()">
<option value="none" selected>None</option>
${debridProvidersHTML}
</select>
<div id="dRealDebrid">
<label class="label" for="iRealDebrid">RealDebrid API Key (Find it <a href='https://real-debrid.com/apitoken' target="_blank">here</a>):</label>
<input type="text" id="iRealDebrid" onchange="generateInstallLink()" class="input">
</div>
<div id="dAllDebrid">
<label class="label" for="iAllDebrid">AllDebrid API Key (Create it <a href='https://alldebrid.com/apikeys' target="_blank">here</a>):</label>
<input type="text" id="iAllDebrid" onchange="generateInstallLink()" class="input">
</div>
<div id="dPremiumize">
<label class="label" for="iPremiumize">Premiumize API Key (Find it <a href='https://www.premiumize.me/account' target="_blank">here</a>):</label>
<input type="text" id="iPremiumize" onchange="generateInstallLink()" class="input">
</div>
<div id="dDebridLink">
<label class="label" for="iDebridLink">DebridLink API Key (Find it <a href='https://debrid-link.fr/webapp/apikey' target="_blank">here</a>):</label>
<input type="text" id="iDebridLink" onchange="generateInstallLink()" class="input">
</div>
<div id="dOffcloud">
<label class="label" for="iOffcloud">Offcloud API Key (Find it <a href='https://offcloud.com/#/account' target="_blank">here</a>):</label>
<input type="text" id="iOffcloud" onchange="generateInstallLink()" class="input">
</div>
<div id="dTorbox">
<label class="label" for="iTorbox">TorBox API Key (Find it <a href='https://torbox.app/settings' target="_blank">here</a>):</label>
<input type="text" id="iTorbox" onchange="generateInstallLink()" class="input">
</div>
<div id="dPutio">
<label class="label" for="iPutio">Put.io ClientId and Token (Create new OAuth App <a href='https://app.put.io/oauth' target="_blank">here</a>):</label>
<input type="text" id="iPutioClientId" placeholder="ClientId" onchange="generateInstallLink()" class="input">
<input type="text" id="iPutioToken" placeholder="Token" onchange="generateInstallLink()" class="input">
</div>
<div id="dDebridOptions">
<label class="label" for="iDebridOptions">Debrid options:</label>
<select id="iDebridOptions" class="input" onchange="generateInstallLink()" name="debridOptions[]" multiple="multiple">
${debridOptionsHTML}
</select>
</div>
<div class="separator"></div>
<a id="installLink" class="install-link" href="#">
<button name="Install" class="install-button">INSTALL</button>
</a>
<div class="contact">
<p>Or paste into Stremio search bar after clicking install</p>
</div>
<div class="separator"></div>
</div>
<script type="text/javascript">
$(document).ready(function() {
const isTvMedia = window.matchMedia("tv").matches;
const isTvAgent = /\\b(?:tv|wv)\\b/i.test(navigator.userAgent)
const isDesktopMedia = window.matchMedia("(pointer:fine)").matches;
if (isDesktopMedia && !isTvMedia && !isTvAgent) {
$('#iProviders').multiselect({
nonSelectedText: 'All providers',
buttonTextAlignment: 'left',
onChange: () => generateInstallLink()
});
$('#iProviders').multiselect('select', [${providers.map(provider => '"' + provider + '"')}]);
$('#iLanguages').multiselect({
nonSelectedText: 'None',
buttonTextAlignment: 'left',
onChange: () => generateInstallLink()
});
$('#iLanguages').multiselect('select', [${languages.map(language => '"' + language + '"')}]);
$('#iQualityFilter').multiselect({
nonSelectedText: 'None',
buttonTextAlignment: 'left',
onChange: () => generateInstallLink()
});
$('#iQualityFilter').multiselect('select', [${qualityFilters.map(filter => '"' + filter + '"')}]);
$('#iDebridOptions').multiselect({
nonSelectedText: 'None',
buttonTextAlignment: 'left',
onChange: () => generateInstallLink()
});
$('#iDebridOptions').multiselect('select', [${debridOptions.map(option => '"' + option + '"')}]);
} else {
$('#iProviders').val([${providers.map(provider => '"' + provider + '"')}]);
$('#iLanguages').val([${languages.map(language => '"' + language + '"')}]);
$('#iQualityFilter').val([${qualityFilters.map(filter => '"' + filter + '"')}]);
$('#iDebridOptions').val([${debridOptions.map(option => '"' + option + '"')}]);
}
$('#iDebridProviders').val("${debridProvider || 'none'}");
$('#iRealDebrid').val("${realDebridApiKey}");
$('#iPremiumize').val("${premiumizeApiKey}");
$('#iAllDebrid').val("${allDebridApiKey}");
$('#iDebridLink').val("${debridLinkApiKey}");
$('#iOffcloud').val("${offcloudApiKey}");
$('#iTorbox').val("${torboxApiKey}");
$('#iPutioClientId').val("${putioClientId}");
$('#iPutioToken').val("${putioToken}");
$('#iSort').val("${sort}");
$('#iLimit').val("${limit}");
$('#iSizeFilter').val("${sizeFilter}");
generateInstallLink();
debridProvidersChange();
});
function sortModeChange() {
if (['${SortOptions.options.seeders.key}', '${SortOptions.options.size.key}'].includes($('#iSort').val())) {
$("#iLimitLabel").text("Max results:");
} else {
$("#iLimitLabel").text("Max results per quality:");
}
generateInstallLink();
}
function debridProvidersChange() {
const provider = $('#iDebridProviders').val()
$('#dDebridOptions').toggle(provider !== 'none');
$('#dRealDebrid').toggle(provider === '${MochOptions.realdebrid.key}');
$('#dPremiumize').toggle(provider === '${MochOptions.premiumize.key}');
$('#dAllDebrid').toggle(provider === '${MochOptions.alldebrid.key}');
$('#dDebridLink').toggle(provider === '${MochOptions.debridlink.key}');
$('#dOffcloud').toggle(provider === '${MochOptions.offcloud.key}');
$('#dTorbox').toggle(provider === '${MochOptions.torbox.key}');
$('#dPutio').toggle(provider === '${MochOptions.putio.key}');
}
function generateInstallLink() {
const providersList = $('#iProviders').val() || [];
const providersValue = providersList.join(',');
const qualityFilterValue = $('#iQualityFilter').val().join(',') || '';
const sortValue = $('#iSort').val() || '';
const languagesValue = $('#iLanguages').val().join(',') || [];
const limitValue = $('#iLimit').val() || '';
const sizeFilterValue = $('#iSizeFilter').val() || '';
const debridOptionsValue = $('#iDebridOptions').val().join(',') || '';
const realDebridValue = $('#iRealDebrid').val() || '';
const allDebridValue = $('#iAllDebrid').val() || '';
const debridLinkValue = $('#iDebridLink').val() || ''
const premiumizeValue = $('#iPremiumize').val() || '';
const offcloudValue = $('#iOffcloud').val() || '';
const torboxValue = $('#iTorbox').val() || '';
const putioClientIdValue = $('#iPutioClientId').val() || '';
const putioTokenValue = $('#iPutioToken').val() || '';
const providers = providersList.length && providersList.length < ${Providers.options.length} && providersValue;
const qualityFilters = qualityFilterValue.length && qualityFilterValue;
const sort = sortValue !== '${SortOptions.options.qualitySeeders.key}' && sortValue;
const languages = languagesValue.length && languagesValue;
const limit = /^[1-9][0-9]{0,2}$/.test(limitValue) && limitValue;
const sizeFilter = sizeFilterValue.length && sizeFilterValue;
const debridOptions = debridOptionsValue.length && debridOptionsValue.trim();
const realDebrid = realDebridValue.length && realDebridValue.trim();
const premiumize = premiumizeValue.length && premiumizeValue.trim();
const allDebrid = allDebridValue.length && allDebridValue.trim();
const debridLink = debridLinkValue.length && debridLinkValue.trim();
const offcloud = offcloudValue.length && offcloudValue.trim();
const torbox = torboxValue.length && torboxValue.trim();
const putio = putioClientIdValue.length && putioTokenValue.length && putioClientIdValue.trim() + '@' + putioTokenValue.trim();
const preConfigurations = {
${preConfigurationObject}
};
let configurationValue = [
['${Providers.key}', providers],
['${SortOptions.key}', sort],
['${LanguageOptions.key}', languages],
['${QualityFilter.key}', qualityFilters],
['limit', limit],
['${SizeFilter.key}', sizeFilter],
['${DebridOptions.key}', debridOptions],
['${MochOptions.realdebrid.key}', realDebrid],
['${MochOptions.premiumize.key}', premiumize],
['${MochOptions.alldebrid.key}', allDebrid],
['${MochOptions.debridlink.key}', debridLink],
['${MochOptions.offcloud.key}', offcloud],
['${MochOptions.torbox.key}', torbox],
['${MochOptions.putio.key}', putio]
].filter(([_, value]) => value.length).map(([key, value]) => key + '=' + value).join('|');
configurationValue = Object.entries(preConfigurations)
.filter(([key, value]) => value === configurationValue)
.map(([key, value]) => key)[0] || configurationValue;
const configuration = configurationValue && configurationValue.length ? '/' + configurationValue : '';
const location = window.location.host + configuration + '/manifest.json'
installLink.href = 'stremio://' + location;
}
installLink.addEventListener('click', function() {
navigator.clipboard.writeText(installLink.href.replace('stremio://', 'https://'));
});
</script>
</body>
</html>`
}

View file

@ -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];
}

View file

@ -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));
}

View file

@ -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];
}

View file

@ -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;
}

View file

@ -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);
})
]);
}

View file

@ -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']
]
});
}

View file

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

130
src/lib/addon/lib/sort.ts Normal file
View file

@ -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;
}

View file

@ -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));
}

View file

@ -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();
}

View file

@ -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);
}

View file

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

View file

@ -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);
}

View file

@ -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);
}

249
src/lib/addon/moch/moch.ts Normal file
View file

@ -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;
}

View file

@ -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] !== '<27>') {
return false;
}
}
return true;
}
function mostCommonValue(array) {
return array.sort((a, b) => array.filter(v => v === a).length - array.filter(v => v === b).length).pop();
}

View file

@ -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'));
}

View file

@ -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);
}

View file

@ -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 };
}

216
src/lib/addon/moch/putio.ts Normal file
View file

@ -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);
}

View file

@ -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 };
}

View file

@ -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

View file

@ -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);
}

113
src/lib/addon/serverless.ts Normal file
View file

@ -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();
});
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

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

View file

@ -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" ]

249
src/lib/catalogs/addon.ts Normal file
View file

@ -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<string | undefined>} 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<Stream[]>} A promise resolving to an array of Stream objects.
*/
async function getCatalog({
catalog,
providers,
genre,
offset
}: IGetCatalog): Promise<string[]> {
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();

View file

@ -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']
}
];

View file

@ -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}`);
});

View file

@ -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<string[]>;
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<Stream[]>} The cached or newly retrieved value.
*/
async function cacheWrap({ key, method, ttl }: ICacheWrap): Promise<string[]> {
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<Stream[]>} The cached or newly retrieved value.
*/
export function cacheWrapCatalog({ key, method }: {
key: string;
method: Method;
}): Promise<string[]> {
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<Stream[]>} The cached or newly retrieved value.
*/
export function cacheWrapIds({ key, method }: {
key: string;
method: Method;
}): Promise<string[]> {
return cacheWrap({
key: `ids|${key}`,
method,
ttl: CATALOG_TTL
});
}

View file

@ -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 ?
`<div class="contact">
<p>Contact ${manifest.name} creator:</p>
<a href="mailto:${manifest.contactEmail}">${manifest.contactEmail}</a>
</div>` : '<div class="separator"></div>';
const providersHTML = Providers.options
.map(provider => `<option value="${provider.key}">${provider.foreign || ''}${provider.label}</option>`)
.join('\n');
const stylizedTypes = manifest.types
.map(t => t[0].toUpperCase() + t.slice(1) + (t !== 'series' ? 's' : ''));
return `
<!DOCTYPE html>
<html style="background-image: url(${background});">
<head>
<meta charset="utf-8">
<title>${manifest.name} - Stremio Addon</title>
<link rel="shortcut icon" href="${logo}" type="image/x-icon">
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,600,700&display=swap" rel="stylesheet">
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" >
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/js/bootstrap-multiselect.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/css/bootstrap-multiselect.css" rel="stylesheet"/>
<style>${STYLESHEET}</style>
</head>
<body>
<div id="addon">
<div class="logo">
<img src="${logo}">
</div>
<h1 class="name">${manifest.name}</h1>
<h2 class="version">${manifest.version || '0.0.0'}</h2>
<h2 class="description">${manifest.description || ''}</h2>
<div class="separator"></div>
<h3 class="gives">This addon has more :</h3>
<ul>
${stylizedTypes.map(t => `<li>${t}</li>`).join('')}
</ul>
<div class="separator"></div>
<label class="label" for="iProviders">Providers:</label>
<select id="iProviders" class="input" name="providers[]" multiple="multiple">
${providersHTML}
</select>
<div class="separator"></div>
<a id="installLink" class="install-link" href="#">
<button name="Install">INSTALL</button>
</a>
${contactHTML}
</div>
<script type="text/javascript">
$(document).ready(function() {
$('#iProviders').multiselect({
nonSelectedText: 'All providers',
onChange: () => generateInstallLink()
});
$('#iProviders').multiselect('select', [${providers.map(provider => `"${provider}"`)}]);
generateInstallLink();
});
function generateInstallLink() {
const providersValue = $('#iProviders').val().join(',') || '';
const providers = providersValue.length && providersValue;
const configurationValue = [
['${Providers.key}', providers],
]
.filter(([_, value]) => value.length)
.map(([key, value]) => key + '=' + value).join('|');
const configuration = configurationValue && configurationValue.length ? '/' + configurationValue : '';
installLink.href = 'stremio://' + window.location.host + configuration + '/manifest.json';
}
</script>
</body>
</html>`
}

View file

@ -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
}
};
}

View file

@ -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;
}

View file

@ -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<string[]>} 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<string[]> {
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}`);
}

View file

@ -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();
});
};