add the jackett backend alternate addon "jackettio"

This commit is contained in:
iPromKnight
2024-02-02 13:27:41 +00:00
parent 188ffd10f3
commit 0644fe7720
47 changed files with 9150 additions and 0 deletions

View File

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

View File

@@ -0,0 +1 @@
*.ts

View File

@@ -0,0 +1,39 @@
/** @type {import("eslint").ESLint.Options} */
module.exports = {
env: {
es2024: true,
node: true,
},
globals: {
Atomics: "readonly",
SharedArrayBuffer: "readonly",
},
parserOptions: {
sourceType: "module",
},
plugins: ["import", "import-helpers"],
rules: {
"default-case": "off",
"import/no-duplicates": "off",
"import/no-extraneous-dependencies": ["off", { devDependencies: ["backend", "frontend", "mobile"] }],
"import/order": "off",
"import-helpers/order-imports": [
"warn",
{
alphabetize: {
order: "asc",
},
},
],
"lines-between-class-members": ["error", "always", { exceptAfterSingleLine: true }],
"no-continue": "off",
"no-param-reassign": "off",
"no-plusplus": ["error", { allowForLoopAfterthoughts: true }],
"no-restricted-syntax": "off",
"no-unused-expressions": ["off", { allowShortCircuit: true }],
"no-unused-vars": "off",
"no-use-before-define": "off",
"one-var": ["error", { uninitialized: "consecutive" }],
"prefer-destructuring": "warn",
},
};

View File

@@ -0,0 +1,29 @@
# --- Build Stage ---
FROM node:lts-alpine AS builder
RUN apk update && apk upgrade && \
apk add --no-cache git
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# --- Runtime Stage ---
FROM node:lts-alpine
# Install pm2
RUN npm install pm2 -g
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app ./
RUN npm prune --omit=dev
EXPOSE 7001
ENTRYPOINT [ "pm2-runtime", "start", "ecosystem.config.cjs"]

View File

@@ -0,0 +1 @@
# addon-jackett

View File

@@ -0,0 +1,4 @@
#!/bin/bash
docker build -t ippexdeploymentscr.azurecr.io/dave/stremio-addon-jackett:latest . --platform linux/amd64
docker push ippexdeploymentscr.azurecr.io/dave/stremio-addon-jackett:latest

View File

@@ -0,0 +1,14 @@
module.exports = {
apps: [
{
name: 'stremio-jackett',
script: 'npm start',
cwd: '/app',
watch: ['./dist/index.js'],
autorestart: true,
env: {
...process.env,
},
},
],
};

View File

@@ -0,0 +1,68 @@
import { build } from 'esbuild';
import { copy } from 'esbuild-plugin-copy';
import { readFileSync, rmSync } from 'fs';
const { devDependencies } = JSON.parse(readFileSync('./package.json', 'utf8'));
const start = Date.now();
try {
const outdir = 'dist';
rmSync(outdir, { recursive: true, force: true });
build({
bundle: true,
entryPoints: [
'./src/index.js',
// "./src/**/*.css",
// "./src/**/*.hbs",
// "./src/**/*.html"
],
external: [...(devDependencies && Object.keys(devDependencies))],
keepNames: true,
loader: {
'.css': 'copy',
'.hbs': 'copy',
'.html': 'copy',
},
minify: true,
outbase: './src',
outdir,
outExtension: {
'.js': '.cjs',
},
platform: 'node',
plugins: [
{
name: 'populate-import-meta',
setup: ({ onLoad }) => {
onLoad({ filter: new RegExp(`${import.meta.dirname}/src/.*\.(js|ts)$`) }, args => {
const contents = readFileSync(args.path, 'utf8');
const transformedContents = contents
.replace(/import\.meta/g, `{dirname:__dirname,filename:__filename}`)
.replace(/import\.meta\.filename/g, '__filename')
.replace(/import\.meta\.dirname/g, '__dirname');
return { contents: transformedContents, loader: 'default' };
});
},
},
copy({
assets: [
{
from: ['./static/**'],
to: ['./static'],
},
],
}),
],
}).then(() => {
// biome-ignore lint/style/useTemplate: <explanation>
console.log('⚡ ' + '\x1b[32m' + `Done in ${Date.now() - start}ms`);
});
} catch (e) {
console.log(e);
process.exit(1);
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"baseUrl": "./src",
"checkJs": true,
"isolatedModules": true,
"lib": ["es6"],
"module": "ESNext",
"moduleResolution": "node",
"outDir": "./dist",
"pretty": true,
"removeComments": true,
"resolveJsonModule": true,
"rootDir": "./src",
"skipLibCheck": true,
"sourceMap": true,
"target": "ES6",
"types": ["node"],
"typeRoots": ["node_modules/@types", "src/@types"]
},
"exclude": ["node_modules"]
}

6024
src/node/addon-jackett/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
{
"name": "jackettio-addon",
"version": "0.0.1",
"exports": "./index.js",
"type": "module",
"scripts": {
"build": "node esbuild.js",
"dev": "tsx watch --ignore node_modules src/index.js",
"start": "node dist/index.cjs",
"lint": "eslint . --ext .ts,.js"
},
"dependencies": {
"@putdotio/api-client": "^8.42.0",
"all-debrid-api": "^1.1.0",
"axios": "^1.6.1",
"bottleneck": "^2.19.5",
"cache-manager": "^3.4.4",
"cache-manager-mongodb": "^0.3.0",
"cors": "^2.8.5",
"debrid-link-api": "^1.0.1",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"magnet-uri": "^6.2.0",
"named-queue": "^2.2.1",
"offcloud-api": "^1.0.2",
"parse-torrent-title": "git://github.com/TheBeastLT/parse-torrent-title.git#022408972c2a040f846331a912a6a8487746a654",
"premiumize-api": "^1.0.3",
"real-debrid-api": "git://github.com/TheBeastLT/node-real-debrid.git#d1f7eaa8593b947edbfbc8a92a176448b48ef445",
"request-ip": "^3.3.0",
"router": "^1.3.8",
"stremio-addon-sdk": "^1.6.10",
"ua-parser-js": "^1.0.36",
"url-join": "^5.0.0",
"user-agents": "^1.0.1444",
"video-name-parser": "^1.4.6",
"xml-js": "^1.6.11",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@types/node": "^20.11.6",
"@types/stremio-addon-sdk": "^1.6.10",
"esbuild": "^0.19.12",
"esbuild-plugin-copy": "^2.1.1",
"eslint": "^8.56.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-import-helpers": "^1.3.1",
"tsx": "^4.7.0"
}
}

View File

@@ -0,0 +1,126 @@
import Bottleneck from 'bottleneck';
import {addonBuilder} from 'stremio-addon-sdk';
import {searchJackett} from "./jackett/jackett.js";
import {cacheWrapStream} from './lib/cache.js';
import {getMetaData} from "./lib/cinemetaProvider.js";
import {dummyManifest} from './lib/manifest.js';
import {cacheConfig, processConfig} from "./lib/settings.js";
import applySorting from './lib/sort.js';
import {toStreamInfo} from './lib/streamInfo.js';
import {Type} from './lib/types.js';
import {applyMochs, getMochCatalog, getMochItemMeta} from './moch/moch.js';
import StaticLinks from './moch/static.js';
const builder = new addonBuilder(dummyManifest());
const limiter = new Bottleneck({
maxConcurrent: 200,
highWater: 220,
strategy: Bottleneck.strategy.OVERFLOW
});
builder.defineStreamHandler((args) => {
if (!args.id.match(/tt\d+/i) && !args.id.match(/kitsu:\d+/i)) {
return Promise.resolve({ streams: [] });
}
if (processConfig.DEBUG) {
console.log(`Incoming stream ${args.id} request`)
console.log('args', args);
}
return cacheWrapStream(args.id, () => limiter.schedule(() =>
streamHandler(args)
.then(records => records.map(record => toStreamInfo(record, args.type))))
.then(streams => applySorting(streams, args.extra))
.then(streams => applyMochs(streams, args.extra))
.then(streams => enrichCacheParams(streams))
.catch(error => {
console.log(`Failed request ${args.id}: ${error}`);
return Promise.reject(`Failed request ${args.id}: ${error}`);
}));
});
builder.defineCatalogHandler((args) => {
const mochKey = args.id.replace("jackettio-", '');
console.log(`Incoming catalog ${args.id} request with skip=${args.extra.skip || 0}`)
return getMochCatalog(mochKey, args.extra)
.then(metas => ({
metas: metas,
cacheMaxAge: cacheConfig.CATALOG_CACHE_MAX_AGE
}))
.catch(error => {
return Promise.reject(`Failed retrieving catalog ${args.id}: ${JSON.stringify(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 : cacheConfig.CACHE_MAX_AGE
}))
.catch(error => {
return Promise.reject(`Failed retrieving catalog meta ${args.id}: ${JSON.stringify(error)}`);
});
})
async function streamHandler(args) {
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 season = parts[1] !== undefined ? parseInt(parts[1], 10) : 1;
const episode = parts[2] !== undefined ? parseInt(parts[2], 10) : 1;
const metaData = await getMetaData(args);
return await searchJackett({
type: Type.SERIES,
season: season,
episode: episode,
name: metaData.name,
});
}
return [];
}
async function movieRecordsHandler(args) {
if (args.id.match(/^tt\d+$/)) {
const metaData = await getMetaData(args);
return await searchJackett({
type: Type.MOVIE,
name: metaData.name,
year: metaData.year,
});
}
return [];
}
function enrichCacheParams(streams) {
let cacheAge = cacheConfig.CACHE_MAX_AGE;
if (!streams.length) {
cacheAge = cacheConfig.CACHE_MAX_AGE_EMPTY;
} else if (streams.every(stream => stream?.url?.endsWith(StaticLinks.FAILED_ACCESS))) {
cacheAge = 0;
}
return {
streams: streams,
cacheMaxAge: cacheAge,
staleRevalidate: cacheConfig.STALE_REVALIDATE_AGE,
staleError: cacheConfig.STALE_ERROR_AGE
}
}
export default builder.getInterface();

View File

@@ -0,0 +1,13 @@
import express from 'express';
import { initBestTrackers } from './lib/magnetHelper.js';
import {processConfig} from "./lib/settings.js";
import serverless from './serverless.js';
const app = express();
app.enable('trust proxy');
app.use(express.static('static', { maxAge: '1y' }));
app.use((req, res) => serverless(req, res));
app.listen(processConfig.PORT, () => {
initBestTrackers()
.then(() => console.log(`Started addon at: http://localhost:${processConfig.PORT}`));
});

View File

@@ -0,0 +1,151 @@
import videoNameParser from "video-name-parser";
import {parseStringPromise as parseString} from "xml2js";
import {processConfig, jackettConfig} from "../lib/settings.js";
export function extractSize(title) {
const seedersMatch = title.match(/💾 ([\d.]+ \w+)/);
return seedersMatch && parseSize(seedersMatch[1]) || 0;
}
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);
}
export const parseVideo = (name) => {
return videoNameParser(name + '.mp4');
};
export const episodeTag = (season, episode) => {
const paddedSeason = season < 10 ? `0${season}` : season;
const paddedEpisode = episode < 10 ? `0${episode}` : episode;
return `S${paddedSeason}E${paddedEpisode}`;
};
export const cleanName = (name) => {
name = name.replace(/[._\-()\[\]:,]/g, ' ');
name = name.replace(/\s+/g, ' ');
name = name.replace(/'/g, '');
name = name.replace(/\\\\/g, '\\').replace(/\\\\'|\\'|\\\\"|\\"/g, '');
return name;
};
export const insertIntoSortedArray = (sortedArray, newObject, sortingProperty, maxSize) => {
const indexToInsert = sortedArray.findIndex(item => item[sortingProperty] < newObject[sortingProperty]);
if (indexToInsert === -1) {
if (sortedArray.length < maxSize) {
sortedArray.push(newObject);
return true;
}
return false;
} else {
// Insert the new object at the correct position to maintain the sorted order (descending)
sortedArray.splice(indexToInsert, 0, newObject);
// Trim the array if it exceeds maxSize
if (sortedArray.length > maxSize) {
sortedArray.pop();
}
return true;
}
};
export const extraTag = (name, searchQuery) => {
const parsedName = parseVideo(name + '.mp4');
let extraTag = cleanName(name);
searchQuery = cleanName(searchQuery);
extraTag = extraTag.replace(new RegExp(searchQuery, 'gi'), '');
extraTag = extraTag.replace(new RegExp(parsedName.name, 'gi'), '');
if (parsedName.year) {
extraTag = extraTag.replace(parsedName.year.toString(), '');
}
if (parsedName.season && parsedName.episode && parsedName.episode.length) {
extraTag = extraTag.replace(new RegExp(episodeTag(parsedName.season, parsedName.episode[0]), 'gi'), '');
}
extraTag = extraTag.trim();
let extraParts = extraTag.split(' ');
if (parsedName.season && parsedName.episode && parsedName.episode.length) {
if (extraParts[0] && extraParts[0].length === 2 && !isNaN(extraParts[0])) {
const possibleEpTag = `${episodeTag(parsedName.season, parsedName.episode[0])}-${extraParts[0]}`;
if (name.toLowerCase().includes(possibleEpTag.toLowerCase())) {
extraParts[0] = possibleEpTag;
}
}
}
const foundPart = name.toLowerCase().indexOf(extraParts[0].toLowerCase());
if (foundPart > -1) {
extraTag = name.substring(foundPart).replace(/[_()\[\],]/g, ' ');
if ((extraTag.match(/\./g) || []).length > 1) {
extraTag = extraTag.replace(/\./g, ' ');
}
extraTag = extraTag.replace(/\s+/g, ' ');
}
return extraTag;
};
export const transformData = async (data, query) => {
console.log("Transforming data for query " + data);
let results = [];
const parsedData = await parseString(data);
if (!parsedData.rss.channel[0]?.item) {
return [];
}
for (const rssItem of parsedData.rss.channel[0].item) {
let torznabData = {};
rssItem["torznab:attr"].forEach((torznabDataItem) =>
Object.assign(torznabData, {
[torznabDataItem.$.name]: torznabDataItem.$.value,
})
);
if (torznabData.infohash) {
const [title, pubDate, category, size] = [rssItem.title[0], rssItem.pubDate[0], rssItem.category[0], rssItem.size[0]];
torznabData = {
...torznabData,
title,
pubDate,
category,
size,
extraTag: extraTag(title, query.name)
};
if (insertIntoSortedArray(results, torznabData, 'size', jackettConfig.MAXIMUM_RESULTS)) {
processConfig.DEBUG && console.log(torznabData);
}
}
}
return results;
};

View File

@@ -0,0 +1,41 @@
import axios from 'axios';
import {jackettConfig, processConfig} from "../lib/settings.js";
import {cleanName, transformData} from "./jacketParser.js";
import {jackettSearchQueries} from "./jackettQueries.js";
const JACKETT_SEARCH_URI = `${jackettConfig.URI}/api/v2.0/indexers/!status:failing,test:passed/results/torznab?apikey=${jackettConfig.API_KEY}`;
const performRequest = async (url) => {
const response = await axios.get(url, { timeout: jackettConfig.TIMEOUT, responseType: 'text' });
return !response.data ? null : response.data;
};
export const searchJackett = async (query) => {
if (processConfig.DEBUG) {
console.log('Beginning jackett query construction for', query);
}
const name = encodeURIComponent(cleanName(query.name));
const queries = jackettSearchQueries(name, query.type, query.year, query.season, query.episode);
const flatQueries = [].concat(...Object.values(queries));
const fetchPromises = flatQueries.map(searchQuery => {
const url = JACKETT_SEARCH_URI + searchQuery;
return performRequest(url);
});
const responses = await Promise.all(fetchPromises);
let sortedResults = [];
for (const response of responses) {
if (response) {
const transformedData = await transformData(response, query);
sortedResults = sortedResults.concat(transformedData);
}
}
return sortedResults;
};

View File

@@ -0,0 +1,34 @@
import {Type} from "../lib/types.js";
const moviesByYear = (cleanName, year) => `&cat=2000,2010,2020,2030,2040,2045,2050,2080&t=movie&q=${cleanName}&year=${year}`;
const movies = (cleanName) => `&cat=2000,2010,2020,2030,2040,2045,2050,2080&t=movie&q=${cleanName}`;
const seriesByEpisode = (cleanName, season, episode) => `&cat=5000,5010,5020,5030,5040,5045,5050,5060,5070,5080&t=tvsearch&q=${cleanName}&season=${season}&ep=${episode}`;
const getMovieSearchQueries = (cleanName, year) => {
if (year) {
return {
moviesByYear: moviesByYear(cleanName, year),
};
}
return {
movies: movies(cleanName),
};
}
const getSeriesSearchQueries = (cleanName, year, season, episode) => {
return {
seriesByEpisode: seriesByEpisode(cleanName, season, episode),
};
}
export const jackettSearchQueries = (cleanName, type, year, season, episode) => {
switch (type) {
case Type.MOVIE:
return getMovieSearchQueries(cleanName, year);
case Type.SERIES:
return getSeriesSearchQueries(cleanName, year, season, episode);
default:
return { };
}
};

View File

@@ -0,0 +1,100 @@
import cacheManager from 'cache-manager';
import mangodbStore from 'cache-manager-mongodb';
import { isStaticUrl } from '../moch/static.js';
import {cacheConfig} from "./settings.js";
const STREAM_KEY_PREFIX = `${cacheConfig.GLOBAL_KEY_PREFIX}|stream`;
const IMDB_KEY_PREFIX = `${cacheConfig.GLOBAL_KEY_PREFIX}|imdb`;
const AVAILABILITY_KEY_PREFIX = `${cacheConfig.GLOBAL_KEY_PREFIX}|availability`;
const RESOLVED_URL_KEY_PREFIX = `${cacheConfig.GLOBAL_KEY_PREFIX}|resolved`;
const memoryCache = initiateMemoryCache();
const remoteCache = initiateRemoteCache();
function initiateRemoteCache() {
if (cacheConfig.NO_CACHE) {
return null;
} else if (cacheConfig.MONGODB_URI) {
return cacheManager.caching({
store: mangodbStore,
uri: cacheConfig.MONGODB_URI,
options: {
collection: 'jackettio_addon_collection',
socketTimeoutMS: 120000,
useNewUrlParser: true,
useUnifiedTopology: false,
ttl: cacheConfig.STREAM_EMPTY_TTL
},
ttl: cacheConfig.STREAM_EMPTY_TTL,
ignoreCacheErrors: true
});
} else {
return cacheManager.caching({
store: 'memory',
ttl: cacheConfig.STREAM_EMPTY_TTL
});
}
}
function initiateMemoryCache() {
return cacheManager.caching({
store: 'memory',
ttl: cacheConfig.MESSAGE_VIDEO_URL_TTL,
max: Infinity // infinite LRU cache size
});
}
function cacheWrap(cache, key, method, options) {
if (cacheConfig.NO_CACHE || !cache) {
return method();
}
return cache.wrap(key, method, options);
}
export function cacheWrapStream(id, method) {
return cacheWrap(remoteCache, `${STREAM_KEY_PREFIX}:${id}`, method, {
ttl: (streams) => streams.length ? cacheConfig.STREAM_TTL : cacheConfig.STREAM_EMPTY_TTL
});
}
export function cacheWrapImdbMetaData(id, method) {
return cacheWrap(remoteCache, `${IMDB_KEY_PREFIX}:${id}`, method, {
ttl: cacheConfig.IMDB_TTL
});
}
export function cacheWrapResolvedUrl(id, method) {
return cacheWrap(memoryCache, `${RESOLVED_URL_KEY_PREFIX}:${id}`, method, {
ttl: (url) => isStaticUrl(url) ? cacheConfig.MESSAGE_VIDEO_URL_TTL : cacheConfig.STREAM_TTL
});
}
export function cacheAvailabilityResults(results) {
Object.keys(results)
.forEach(infohash => {
const key = `${AVAILABILITY_KEY_PREFIX}:${infohash}`;
const value = results[infohash];
const ttl = value?.length ? cacheConfig.AVAILABILITY_TTL : cacheConfig.AVAILABILITY_EMPTY_TTL;
memoryCache.set(key, value, { ttl })
});
return results;
}
export function getCachedAvailabilityResults(infohashes) {
const keys = infohashes.map(infohash => `${AVAILABILITY_KEY_PREFIX}:${infohash}`)
return new Promise(resolve => {
memoryCache.mget(...keys, (error, result) => {
if (error) {
console.log('Failed retrieve availability cache', error)
return resolve({});
}
const availabilityResults = {};
infohashes.forEach((infohash, index) => {
if (result[index]) {
availabilityResults[infohash] = result[index];
}
});
resolve(availabilityResults);
})
});
}

View File

@@ -0,0 +1,31 @@
import axios from "axios";
import {cacheWrapImdbMetaData} from "./cache.js";
import {getRandomUserAgent} from "./requestHelper.js";
import {cinemetaConfig, processConfig} from "./settings.js";
const cinemetaUri = cinemetaConfig.URI;
export const getMetaData = (args) => {
const [imdbId] = args.id.split(':');
const {type} = args;
return cacheWrapImdbMetaData(args.id, () => getInfoForImdbId(imdbId, type));
}
const getInfoForImdbId = async (imdbId, type) => {
const requestUri = `${cinemetaUri}/${type}/${imdbId}.json`;
const options = { timeout: 30000, headers: { 'User-Agent': getRandomUserAgent() } };
if (processConfig.DEBUG) {
console.log(`Getting info for ${imdbId} of type ${type}`);
console.log(`Request URI: ${requestUri}`);
}
try {
const { data: response } = await axios.get(requestUri, options);
return response.meta;
} catch (error) {
console.log(error);
return {};
}
};

View File

@@ -0,0 +1,25 @@
import { DebridOptions } from '../moch/options.js';
const keysToSplit = [DebridOptions.key];
export function parseConfiguration(configuration) {
if (!configuration) {
return undefined;
}
const configValues = configuration.split('|')
.reduce((map, next) => {
const [key, value] = next.split('=');
if (key && value) {
map[key.toLowerCase()] = value;
}
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;
}

View File

@@ -0,0 +1,385 @@
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 { MochOptions } from '../moch/moch.js';
import { DebridOptions } from '../moch/options.js';
export default function landingTemplate(manifest, config = {}) {
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 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 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');
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>
<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" 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="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) {
$('#iDebridOptions').multiselect({
nonSelectedText: 'None',
buttonTextAlignment: 'left',
onChange: () => generateInstallLink()
});
$('#iDebridOptions').multiselect('select', [${debridOptions.map(option => '"' + option + '"')}]);
} else {
$('#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}");
$('#iPutioClientId').val("${putioClientId}");
$('#iPutioToken').val("${putioToken}");
$('#iLimit').val("${limit}");
generateInstallLink();
debridProvidersChange();
});
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}');
$('#dPutio').toggle(provider === '${MochOptions.putio.key}');
}
function generateInstallLink() {
const limitValue = $('#iLimit').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 putioClientIdValue = $('#iPutioClientId').val() || '';
const putioTokenValue = $('#iPutioToken').val() || '';
const limit = /^[1-9][0-9]{0,2}$/.test(limitValue) && limitValue;
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 putio = putioClientIdValue.length && putioTokenValue.length && putioClientIdValue.trim() + '@' + putioTokenValue.trim();
let configurationValue = [
['limit', limit],
['${DebridOptions.key}', debridOptions],
['${MochOptions.realdebrid.key}', realDebrid],
['${MochOptions.premiumize.key}', premiumize],
['${MochOptions.alldebrid.key}', allDebrid],
['${MochOptions.debridlink.key}', debridLink],
['${MochOptions.offcloud.key}', offcloud],
['${MochOptions.putio.key}', putio]
].filter(([_, value]) => value.length).map(([key, value]) => key + '=' + value).join('|');
const configuration = configurationValue && configurationValue.length ? '/' + configurationValue : '';
const location = window.location.host + configuration + '/manifest.json'
installLink.href = 'stremio://' + location;
console.log("Install link: " + installLink.href.replace('stremio://', 'https://'));
}
installLink.addEventListener('click', function() {
navigator.clipboard.writeText(installLink.href.replace('stremio://', 'https://'));
});
</script>
</body>
</html>`
}

View File

@@ -0,0 +1,59 @@
import axios from 'axios';
import magnet from 'magnet-uri';
import { getRandomUserAgent } from './requestHelper.js';
const TRACKERS_URL = 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt';
const DEFAULT_TRACKERS = [
"udp://47.ip-51-68-199.eu:6969/announce",
"udp://9.rarbg.me:2940",
"udp://9.rarbg.to:2820",
"udp://exodus.desync.com:6969/announce",
"udp://explodie.org:6969/announce",
"udp://ipv4.tracker.harry.lu:80/announce",
"udp://open.stealth.si:80/announce",
"udp://opentor.org:2710/announce",
"udp://opentracker.i2p.rocks:6969/announce",
"udp://retracker.lanta-net.ru:2710/announce",
"udp://tracker.cyberia.is:6969/announce",
"udp://tracker.dler.org:6969/announce",
"udp://tracker.ds.is:6969/announce",
"udp://tracker.internetwarriors.net:1337",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://tracker.opentrackr.org:1337/announce",
"udp://tracker.tiny-vps.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://valakas.rollo.dnsabr.com:2710/announce",
"udp://www.torrent.eu.org:451/announce",
]
let BEST_TRACKERS = [];
export async function getMagnetLink(infohash) {
const trackers = [].concat(DEFAULT_TRACKERS).concat(BEST_TRACKERS);
return magnet.encode({ infohash: infohash, announce: trackers });
}
export async function initBestTrackers() {
BEST_TRACKERS = await getBestTrackers();
}
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(magnetInfo) {
if (!magnetInfo.announce) {
return null;
}
const trackers = Array.isArray(magnetInfo.announce) ? magnetInfo.announce : magnetInfo.announce.split(',');
return trackers.map(tracker => `tracker:${tracker}`).concat(`dht:${magnetInfo.infohash}`);
}

View File

@@ -0,0 +1,69 @@
import { MochOptions } from '../moch/moch.js';
import { showDebridCatalog } from '../moch/options.js';
import { Type } from './types.js';
const CatalogMochs = Object.values(MochOptions).filter(moch => moch.catalog);
export function manifest(config = {}) {
return {
id: 'com.stremio.jackettio-addon',
version: '0.0.1',
name: getName(config),
description: getDescription(config),
catalogs: getCatalogs(config),
resources: getResources(config),
types: [Type.MOVIE, Type.SERIES, Type.ANIME, Type.OTHER],
behaviorHints: {
configurable: true,
configurationRequired: false,
}
};
}
export function dummyManifest() {
const manifestDefault = manifest();
manifestDefault.catalogs = [{ id: 'dummy', type: Type.OTHER }];
manifestDefault.resources = ['stream', 'meta'];
return manifestDefault;
}
function getName(config) {
const rootName = 'jackettio';
const mochSuffix = Object.values(MochOptions)
.filter(moch => config[moch.key])
.map(moch => moch.shortName)
.join('/');
return [rootName, mochSuffix].filter(v => v).join(' ');
}
function getDescription(config) {
return 'Freedom is not worth having if it does not include the freedom to make mistakes...'
}
function getCatalogs(config) {
return CatalogMochs
.filter(moch => showDebridCatalog(config) && config[moch.key])
.map(moch => ({
id: `jackettio-${moch.key}`,
name: `${moch.name}`,
type: 'other',
extra: [{ name: 'skip' }],
}));
}
function getResources(config) {
const streamResource = {
name: 'stream',
types: [Type.MOVIE, Type.SERIES],
idPrefixes: ['tt', 'kitsu']
};
const metaResource = {
name: 'meta',
types: [Type.OTHER],
idPrefixes: CatalogMochs.filter(moch => config[moch.key]).map(moch => moch.key)
};
if (showDebridCatalog(config) && CatalogMochs.filter(moch => config[moch.key]).length) {
return [streamResource, metaResource];
}
return [streamResource];
}

View File

@@ -0,0 +1,42 @@
const parseBool = (boolString, defaultValue)=> {
const isString = typeof boolString === 'string' || boolString instanceof String;
if (!isString) {
return defaultValue;
}
return boolString.toLowerCase() === 'true' ? true : defaultValue;
}
export const jackettConfig = {
URI: process.env.JACKETT_URI,
API_KEY: process.env.JACKETT_API_KEY,
TIMEOUT: parseInt(process.env.JACKETT_TIMEOUT || 10000),
MAXIMUM_RESULTS: parseInt(process.env.JACKETT_MAXIMUM_RESULTS || 20),
}
export const processConfig = {
DEBUG: parseBool(process.env.DEBUG_MODE, false),
PORT: parseInt(process.env.PORT || 7001),
}
export const cinemetaConfig = {
URI: process.env.CINEMETA_URI || 'https://v3-cinemeta.strem.io/meta',
}
export const cacheConfig = {
MONGODB_URI: process.env.MONGODB_URI,
NO_CACHE: parseBool(process.env.NO_CACHE, false),
IMDB_TTL: parseInt(process.env.IMDB_TTL || 60 * 60 * 4), // 4 Hours
STREAM_TTL: parseInt(process.env.STREAM_TTL || 60 * 60 * 4), // 1 Hour
STREAM_EMPTY_TTL: parseInt(process.env.STREAM_EMPTY_TTL || 60), // 60 seconds
AVAILABILITY_TTL: parseInt(process.env.AVAILABILITY_TTL || 8 * 60 * 60), // 8 hours
AVAILABILITY_EMPTY_TTL: parseInt(process.env.AVAILABILITY_EMPTY_TTL || 30 * 60), // 30 minutes
MESSAGE_VIDEO_URL_TTL: parseInt(process.env.MESSAGE_VIDEO_URL_TTL || 60), // 1 minutes
CACHE_MAX_AGE: parseInt(process.env.CACHE_MAX_AGE) || 60 * 60, // 1 hour in seconds
CACHE_MAX_AGE_EMPTY: parseInt(process.env.CACHE_MAX_AGE_EMPTY) || 60, // 60 seconds
CATALOG_CACHE_MAX_AGE: parseInt(process.env.CATALOG_CACHE_MAX_AGE) || 20 * 60, // 20 minutes
STALE_REVALIDATE_AGE: parseInt(process.env.STALE_REVALIDATE_AGE) || 4 * 60 * 60, // 4 hours
STALE_ERROR_AGE: parseInt(process.env.STALE_ERROR_AGE) || 7 * 24 * 60 * 60, // 7 days
GLOBAL_KEY_PREFIX: process.env.GLOBAL_KEY_PREFIX || 'jackettio-addon',
}

View File

@@ -0,0 +1,16 @@
import {extractSize} from "../jackett/jacketParser.js";
export default function sortStreams(streams, config) {
const limit = /^[1-9][0-9]*$/.test(config.limit) && parseInt(config.limit) || undefined;
return sortBySize(streams, limit);
}
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);
}

View File

@@ -0,0 +1,73 @@
import {decode} from "magnet-uri";
import titleParser from 'parse-torrent-title';
import {getSources} from './magnetHelper.js';
import { Type } from './types.js';
const ADDON_NAME = 'jackettio';
const UNKNOWN_SIZE = 300000000;
export function toStreamInfo(record, type) {
const torrentInfo = titleParser.parse(record.title);
const fileInfo = titleParser.parse(record.title);
const title = joinDetailParts(
[
joinDetailParts([record.title.replace(/[, ]+/g, ' ')]),
joinDetailParts([
joinDetailParts([formatSize(record.size)], '💾 ')
]),
],
'',
'\n'
);
const name = joinDetailParts(
[
joinDetailParts([ADDON_NAME]),
],
'',
'\n'
);
const bingeGroupParts = getBingeGroupParts(record, torrentInfo, fileInfo, type);
const bingeGroup = joinDetailParts(bingeGroupParts, "jackettio|", "|")
const behaviorHints = bingeGroup ? { bingeGroup } : undefined;
const magnetInfo = decode(record.magneturl)
return cleanOutputObject({
name: name,
title: title,
infohash: record.infohash,
behaviorHints: behaviorHints,
sources: getSources(magnetInfo),
});
}
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];
}
function getBingeGroupParts(record, sameInfo, quality, torrentInfo, fileInfo, type) {
if (type === Type.MOVIE) {
return [quality];
} else if (sameInfo) {
return [quality];
}
return [record.infohash];
}
function cleanOutputObject(object) {
return Object.fromEntries(Object.entries(object).filter(([_, v]) => v != null));
}

View File

@@ -0,0 +1,195 @@
import AllDebridClient from 'all-debrid-api';
import { isVideo, isArchive } from '../lib/extension.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { Type } from '../lib/types.js';
import { BadTokenError, AccessDeniedError, sameFilename } from './mochHelper.js';
import StaticResponse from './static.js';
const KEY = 'alldebrid';
const AGENT = 'torrentio';
export async function getCachedStreams(streams, apiKey) {
const options = await getDefaultOptions();
const AD = new AllDebridClient(apiKey, options);
const hashes = streams.map(stream => stream.infohash);
const available = await AD.magnet.instant(hashes)
.catch(error => {
if (toCommonError(error)) {
return Promise.reject(error);
}
console.warn(`Failed AllDebrid cached [${hashes[0]}] torrent availability request:`, error);
return undefined;
});
return available?.data?.magnets && streams
.reduce((mochStreams, stream) => {
const cachedEntry = available.data.magnets.find(magnet => stream.infohash === magnet.hash.toLowerCase());
const streamTitleParts = stream.title.replace(/\n👤.*/s, '').split('\n');
const fileName = streamTitleParts[streamTitleParts.length - 1];
const fileIndex = streamTitleParts.length === 2 ? stream.fileIdx : null;
const encodedFileName = encodeURIComponent(fileName);
mochStreams[stream.infohash] = {
url: `${apiKey}/${stream.infohash}/${encodedFileName}/${fileIndex}`,
cached: cachedEntry?.instant
}
return mochStreams;
}, {})
}
export async function getCatalog(apiKey, offset = 0) {
if (offset > 0) {
return [];
}
const options = await getDefaultOptions();
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) {
const options = await getDefaultOptions();
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 (errorExpiredSubscriptionError(error)) {
console.log(`Access denied to AllDebrid ${infohash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
} else 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 (torrent && statusReady(torrent.statusCode)) {
return _unrestrictLink(AD, torrent, cachedEntryInfo, fileIndex);
} else if (torrent && statusDownloading(torrent.statusCode)) {
console.log(`Downloading to AllDebrid ${infohash} [${fileIndex}]...`);
return StaticResponse.DOWNLOADING;
} else if (torrent && statusHandledError(torrent.statusCode)) {
console.log(`Retrying downloading to AllDebrid ${infohash} [${fileIndex}]...`);
return _retryCreateTorrent(AD, infohash, cachedEntryInfo, fileIndex);
}
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));
const targetVideo = Number.isInteger(fileIndex)
? videos.find(video => sameFilename(targetFileName, video.filename))
: videos.sort((a, b) => b.size - a.size)[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 { 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;
}
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 errorExpiredSubscriptionError(error) {
return ['AUTH_BAD_APIKEY', 'MUST_BE_PREMIUM', 'MAGNET_MUST_BE_PREMIUM', 'FREE_TRIAL_LIMIT_REACHED', 'AUTH_USER_BANNED']
.includes(error.code);
}

View File

@@ -0,0 +1,155 @@
import DebridLinkClient from 'debrid-link-api';
import { isVideo, isArchive } from '../lib/extension.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { Type } from '../lib/types.js';
import { chunkArray, BadTokenError } from './mochHelper.js';
import StaticResponse from './static.js';
const KEY = 'debridlink';
export async function getCachedStreams(streams, apiKey) {
const options = await getDefaultOptions();
const DL = new DebridLinkClient(apiKey, options);
const hashBatches = chunkArray(streams.map(stream => stream.infohash), 50)
.map(batch => batch.join(','));
const available = await Promise.all(hashBatches.map(hashes => DL.seedbox.cached(hashes)))
.then(results => results.map(result => result.value))
.then(results => results.reduce((all, result) => Object.assign(all, result), {}))
.catch(error => {
if (toCommonError(error)) {
return Promise.reject(error);
}
console.warn('Failed DebridLink cached torrent availability request:', error);
return undefined;
});
return available && streams
.reduce((mochStreams, stream) => {
const cachedEntry = available[stream.infohash];
mochStreams[stream.infohash] = {
url: `${apiKey}/${stream.infohash}/null/${stream.fileIdx}`,
cached: !!cachedEntry
};
return mochStreams;
}, {})
}
export async function getCatalog(apiKey, offset = 0) {
if (offset > 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 (errorExpiredSubscriptionError(error)) {
console.log(`Access denied to DebridLink ${infohash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
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 errorExpiredSubscriptionError(error) {
return ['freeServerOverload', 'maxTorrent', 'maxLink', 'maxLinkHost', 'maxData', 'maxDataHost'].includes(error);
}

View File

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

View File

@@ -0,0 +1,36 @@
const METAHUB_URL = 'https://images.metahub.space'
export const BadTokenError = { code: 'BAD_TOKEN' }
export const AccessDeniedError = { code: 'ACCESS_DENIED' }
export function chunkArray(arr, size) {
return arr.length > size
? [arr.slice(0, size), ...chunkArray(arr.slice(size), size)]
: [arr];
}
export function streamFilename(stream) {
const titleParts = stream.title.replace(/\n👤.*/s, '').split('\n');
const filename = titleParts.pop().split('/').pop();
return encodeURIComponent(filename)
}
export async function enrichMeta(imdbId) {
return {
id: imdbId,
thumbnail: `${METAHUB_URL}/background/small/${imdbId}/img`
};
}
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,186 @@
import magnet from 'magnet-uri';
import OffcloudClient from 'offcloud-api';
import { isVideo } from '../lib/extension.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { Type } from '../lib/types.js';
import { chunkArray, BadTokenError, sameFilename } from './mochHelper.js';
import StaticResponse from './static.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 streamTitleParts = stream.title.replace(/\n👤.*/s, '').split('\n');
const fileName = streamTitleParts[streamTitleParts.length - 1];
const fileIndex = streamTitleParts.length === 2 ? stream.fileIdx : null;
const encodedFileName = encodeURIComponent(fileName);
mochStreams[stream.infohash] = {
url: `${apiKey}/${stream.infohash}/${encodedFileName}/${fileIndex}`,
cached: isCached
};
return mochStreams;
}, {})
}
export async function getCatalog(apiKey, offset = 0) {
if (offset > 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 => 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'].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,195 @@
import magnet from 'magnet-uri';
import PremiumizeClient from 'premiumize-api';
import { isVideo, isArchive } from '../lib/extension.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { Type } from '../lib/types.js';
import { BadTokenError, chunkArray, sameFilename } from './mochHelper.js';
import StaticResponse from './static.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 streamTitleParts = stream.title.replace(/\n👤.*/s, '').split('\n');
const fileName = streamTitleParts[streamTitleParts.length - 1];
const fileIndex = streamTitleParts.length === 2 ? stream.fileIdx : null;
const encodedFileName = encodeURIComponent(fileName);
mochStreams[stream.infohash] = {
url: `${apiKey}/${stream.infohash}/${encodedFileName}/${fileIndex}`,
cached: available?.response[index]
};
return mochStreams;
}, {}));
}
export async function getCatalog(apiKey, offset = 0) {
if (offset > 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 (error?.message?.includes('Account not premium.')) {
console.log(`Access denied to Premiumize ${infohash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
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));
const targetVideo = Number.isInteger(fileIndex)
? videos.find(video => sameFilename(video.path, targetFileName))
: videos.sort((a, b) => b.size - a.size)[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);
}
async function getDefaultOptions(ip) {
return { timeout: 5000 };
}

View File

@@ -0,0 +1,219 @@
import PutioClient from '@putdotio/api-client'
import { decode } from "magnet-uri";
import { isVideo } from '../lib/extension.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { delay } from '../lib/promises.js';
import { Type } from "../lib/types.js";
import { sameFilename } from "./mochHelper.js";
import StaticResponse from './static.js';
const PutioAPI = PutioClient.default;
const KEY = 'putio';
export async function getCachedStreams(streams, apiKey) {
return streams
.reduce((mochStreams, stream) => {
const streamTitleParts = stream.title.replace(/\n👤.*/s, '').split('\n');
const fileName = streamTitleParts[streamTitleParts.length - 1];
const fileIndex = streamTitleParts.length === 2 ? stream.fileIdx : null;
const encodedFileName = encodeURIComponent(fileName);
mochStreams[stream.infohash] = {
url: `${apiKey}/${stream.infohash}/${encodedFileName}/${fileIndex}`,
cached: false
};
return mochStreams;
}, {});
}
export async function getCatalog(apiKey, offset = 0) {
if (offset > 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)));
// 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.sort((a, b) => b.size - a.size)[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,399 @@
import RealDebridClient from 'real-debrid-api';
import { cacheAvailabilityResults, getCachedAvailabilityResults } from '../lib/cache.js';
import { isVideo, isArchive } from '../lib/extension.js';
import { getMagnetLink } from '../lib/magnetHelper.js';
import { delay } from '../lib/promises.js';
import { Type } from '../lib/types.js';
import { chunkArray, BadTokenError, AccessDeniedError } from './mochHelper.js';
import StaticResponse from './static.js';
const MIN_SIZE = 5 * 1024 * 1024; // 5 MB
const CATALOG_MAX_PAGE = 1;
const CATALOG_PAGE_SIZE = 100;
const NON_BLACKLIST_ERRORS = ['ESOCKETTIMEDOUT', 'EAI_AGAIN', '504 Gateway Time-out'];
const KEY = 'realdebrid';
const DEBRID_DOWNLOADS = 'Downloads';
export async function getCachedStreams(streams, apiKey) {
const hashes = streams.map(stream => stream.infohash);
const available = await _getInstantAvailable(hashes, apiKey);
return available && streams
.reduce((mochStreams, stream) => {
const cachedEntry = available[stream.infohash];
const cachedIds = _getCachedFileIds(stream.fileIdx, cachedEntry);
mochStreams[stream.infohash] = {
url: `${apiKey}/${stream.infohash}/null/${stream.fileIdx}`,
cached: !!cachedIds.length
};
return mochStreams;
}, {})
}
async function _getInstantAvailable(hashes, apiKey, retries = 3, maxChunkSize = 150) {
const cachedResults = await getCachedAvailabilityResults(hashes);
const missingHashes = hashes.filter(infohash => !cachedResults[infohash]);
if (!missingHashes.length) {
return cachedResults
}
const options = await getDefaultOptions();
const RD = new RealDebridClient(apiKey, options);
const hashBatches = chunkArray(missingHashes, maxChunkSize)
return Promise.all(hashBatches.map(batch => RD.torrents.instantAvailability(batch)
.then(response => {
if (typeof response !== 'object') {
return Promise.reject(new Error('RD returned non JSON response: ' + response));
}
return processAvailabilityResults(response);
})))
.then(results => results.reduce((all, result) => Object.assign(all, result), {}))
.then(results => cacheAvailabilityResults(results))
.then(results => Object.assign(cachedResults, results))
.catch(error => {
if (toCommonError(error)) {
return Promise.reject(error);
}
if (!error && maxChunkSize !== 1) {
// sometimes due to large response size RD responds with an empty body. Reduce chunk size to reduce body
console.log(`Reducing chunk size for availability request: ${hashes[0]}`);
return _getInstantAvailable(hashes, apiKey, retries - 1, Math.ceil(maxChunkSize / 10));
}
if (retries > 0 && NON_BLACKLIST_ERRORS.some(v => error?.message?.includes(v))) {
return _getInstantAvailable(hashes, apiKey, retries - 1);
}
console.warn(`Failed RealDebrid cached [${hashes[0]}] torrent availability request:`, error.message);
return undefined;
});
}
function processAvailabilityResults(availabilityResults) {
const processedResults = {};
Object.entries(availabilityResults)
.forEach(([infohash, hosterResults]) => processedResults[infohash] = getCachedIds(hosterResults));
return processedResults;
}
function getCachedIds(hosterResults) {
if (!hosterResults || Array.isArray(hosterResults)) {
return [];
}
// if not all cached files are videos, then the torrent will be zipped to a rar
return Object.values(hosterResults)
.reduce((a, b) => a.concat(b), [])
.filter(cached => Object.keys(cached).length && Object.values(cached).every(file => isVideo(file.filename)))
.map(cached => Object.keys(cached))
.sort((a, b) => b.length - a.length)
.filter((cached, index, array) => index === 0 || cached.some(id => !array[0].includes(id)));
}
function _getCachedFileIds(fileIndex, cachedResults) {
if (!cachedResults || !Array.isArray(cachedResults)) {
return [];
}
const cachedIds = Number.isInteger(fileIndex)
? cachedResults.find(ids => ids.includes(`${fileIndex + 1}`))
: cachedResults[0];
return cachedIds || [];
}
export async function getCatalog(apiKey, offset, ip) {
if (offset > 0) {
return [];
}
const options = await getDefaultOptions(ip);
const RD = new RealDebridClient(apiKey, options);
const downloadsMeta = {
id: `${KEY}:${DEBRID_DOWNLOADS}`,
type: Type.OTHER,
name: DEBRID_DOWNLOADS
};
const torrentMetas = await _getAllTorrents(RD)
.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);
const cachedFileIds = await _resolveCachedFileIds(infohash, fileIndex, apiKey);
return _resolve(RD, infohash, cachedFileIds, fileIndex, isBrowser)
.catch(error => {
if (accessDeniedError(error)) {
console.log(`Access denied to RealDebrid ${infohash} [${fileIndex}]`);
return StaticResponse.FAILED_ACCESS;
}
if (infringingFile(error)) {
console.log(`Infringing file removed from RealDebrid ${infohash} [${fileIndex}]`);
return StaticResponse.FAILED_INFRINGEMENT;
}
return Promise.reject(`Failed RealDebrid adding torrent ${JSON.stringify(error)}`);
});
}
async function _resolveCachedFileIds(infohash, fileIndex, apiKey) {
const available = await _getInstantAvailable([infohash], apiKey);
const cachedEntry = available?.[infohash];
const cachedIds = _getCachedFileIds(fileIndex, cachedEntry);
return cachedIds?.join(',');
}
async function _resolve(RD, infohash, cachedFileIds, fileIndex, isBrowser) {
const torrentId = await _createOrFindTorrentId(RD, infohash, cachedFileIds, 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}]...`);
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, cachedFileIds, fileIndex) {
return _findTorrent(RD, infohash, fileIndex)
.catch(() => _createTorrentId(RD, infohash, cachedFileIds));
}
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, cachedFileIds) {
const magnetLink = await getMagnetLink(infohash);
const addedMagnet = await RD.torrents.addMagnet(magnetLink);
if (cachedFileIds && !['null', 'undefined'].includes(cachedFileIds)) {
await RD.torrents.selectFiles(addedMagnet.id, cachedFileIds);
}
return addedMagnet.id;
}
async function _recreateTorrentId(RD, infohash, fileIndex) {
const newTorrentId = await _createTorrentId(RD, infohash);
await _selectTorrentFiles(RD, { id: newTorrentId }, fileIndex);
return newTorrentId;
}
async function _retryCreateTorrent(RD, infohash, fileIndex) {
console.log(`Retry failed download in RealDebrid ${infohash} [${fileIndex}]...`);
const newTorrentId = await _recreateTorrentId(RD, infohash, fileIndex);
const newTorrent = await _getTorrentInfo(RD, newTorrentId);
return newTorrent && statusReady(newTorrent.status)
? _unrestrictLink(RD, newTorrent, fileIndex)
: 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);
}
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) {
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 (!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);
}
async function _unrestrictFileLink(RD, fileLink, torrent, fileIndex, isBrowser) {
return RD.unrestrict.link(fileLink)
.then(response => {
if (isArchive(response.download)) {
if (torrent.files.filter(file => file.selected).length > 1) {
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}`);
return unrestrictedLink;
})
.catch(error => {
if (error.code === 19) {
return _retryCreateTorrent(RD, torrent.hash.toLowerCase(), fileIndex);
}
return Promise.reject(error);
});
}
export function toCommonError(error) {
if (error && error.code === 8) {
return BadTokenError;
}
if (error && accessDeniedError(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 accessDeniedError(error) {
return [9, 20].includes(error?.code);
}
function infringingFile(error) {
return error && error.code === 35;
}
async function getDefaultOptions(ip) {
return { ip, timeout: 10000 };
}

View File

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

Binary file not shown.