mirror of
https://github.com/TheBeastLT/torrentio-scraper.git
synced 2026-05-16 08:51:44 +00:00
Added typescript base
This commit is contained in:
parent
c5e948d30c
commit
386e5b5b09
62 changed files with 2810 additions and 10509 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,4 +2,4 @@
|
|||
**/node_modules
|
||||
**.env
|
||||
.now
|
||||
/scraper/manual/examples.js
|
||||
/scraper/manual/examples.js
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/.env
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
FROM node:21-alpine
|
||||
|
||||
RUN apk update && apk upgrade && \
|
||||
apk add --no-cache git
|
||||
|
||||
WORKDIR /home/node/app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only-production
|
||||
COPY . .
|
||||
|
||||
CMD [ "node", "--insecure-http-parser", "index.js" ]
|
||||
127
addon/addon.js
127
addon/addon.js
|
|
@ -1,127 +0,0 @@
|
|||
import { addonBuilder } from 'stremio-addon-sdk';
|
||||
import { Type } from './lib/types.js';
|
||||
import { dummyManifest } from './lib/manifest.js';
|
||||
import { cacheWrapStream } from './lib/cache.js';
|
||||
import { toStreamInfo, applyStaticInfo } from './lib/streamInfo.js';
|
||||
import * as repository from './lib/repository.js';
|
||||
import applySorting from './lib/sort.js';
|
||||
import applyFilters from './lib/filter.js';
|
||||
import { applyMochs, getMochCatalog, getMochItemMeta } from './moch/moch.js';
|
||||
import StaticLinks from './moch/static.js';
|
||||
import { createNamedQueue } from "./lib/namedQueue.js";
|
||||
import pLimit from "p-limit";
|
||||
|
||||
const CACHE_MAX_AGE = parseInt(process.env.CACHE_MAX_AGE) || 60 * 60; // 1 hour in seconds
|
||||
const CACHE_MAX_AGE_EMPTY = 60; // 60 seconds
|
||||
const CATALOG_CACHE_MAX_AGE = 0; // 0 minutes
|
||||
const STALE_REVALIDATE_AGE = 4 * 60 * 60; // 4 hours
|
||||
const STALE_ERROR_AGE = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
const builder = new addonBuilder(dummyManifest());
|
||||
const requestQueue = createNamedQueue(Infinity);
|
||||
const newLimiter = pLimit(30)
|
||||
|
||||
builder.defineStreamHandler((args) => {
|
||||
if (!args.id.match(/tt\d+/i) && !args.id.match(/kitsu:\d+/i)) {
|
||||
return Promise.resolve({ streams: [] });
|
||||
}
|
||||
|
||||
return requestQueue.wrap(args.id, () => resolveStreams(args))
|
||||
.then(streams => applyFilters(streams, args.extra))
|
||||
.then(streams => applySorting(streams, args.extra, args.type))
|
||||
.then(streams => applyStaticInfo(streams))
|
||||
.then(streams => applyMochs(streams, args.extra))
|
||||
.then(streams => enrichCacheParams(streams))
|
||||
.catch(error => {
|
||||
return Promise.reject(`Failed request ${args.id}: ${error}`);
|
||||
});
|
||||
});
|
||||
|
||||
builder.defineCatalogHandler((args) => {
|
||||
const [_, mochKey, catalogId] = args.id.split('-');
|
||||
console.log(`Incoming catalog ${args.id} request with skip=${args.extra.skip || 0}`)
|
||||
return getMochCatalog(mochKey, catalogId, args.extra)
|
||||
.then(metas => ({
|
||||
metas: metas,
|
||||
cacheMaxAge: CATALOG_CACHE_MAX_AGE
|
||||
}))
|
||||
.catch(error => {
|
||||
return Promise.reject(`Failed retrieving catalog ${args.id}: ${JSON.stringify(error.message || error)}`);
|
||||
});
|
||||
})
|
||||
|
||||
builder.defineMetaHandler((args) => {
|
||||
const [mochKey, metaId] = args.id.split(':');
|
||||
console.log(`Incoming debrid meta ${args.id} request`)
|
||||
return getMochItemMeta(mochKey, metaId, args.extra)
|
||||
.then(meta => ({
|
||||
meta: meta,
|
||||
cacheMaxAge: metaId === 'Downloads' ? 0 : CACHE_MAX_AGE
|
||||
}))
|
||||
.catch(error => {
|
||||
return Promise.reject(`Failed retrieving catalog meta ${args.id}: ${JSON.stringify(error)}`);
|
||||
});
|
||||
})
|
||||
|
||||
async function resolveStreams(args) {
|
||||
return cacheWrapStream(args.id, () => newLimiter(() => streamHandler(args)
|
||||
.then(records => records
|
||||
.sort((a, b) => b.torrent.seeders - a.torrent.seeders || b.torrent.uploadDate - a.torrent.uploadDate)
|
||||
.map(record => toStreamInfo(record)))));
|
||||
}
|
||||
|
||||
async function streamHandler(args) {
|
||||
// console.log(`Pending count: ${newLimiter.pendingCount}, active count: ${newLimiter.activeCount}`, )
|
||||
if (args.type === Type.MOVIE) {
|
||||
return movieRecordsHandler(args);
|
||||
} else if (args.type === Type.SERIES) {
|
||||
return seriesRecordsHandler(args);
|
||||
}
|
||||
return Promise.reject('not supported type');
|
||||
}
|
||||
|
||||
async function seriesRecordsHandler(args) {
|
||||
if (args.id.match(/^tt\d+:\d+:\d+$/)) {
|
||||
const parts = args.id.split(':');
|
||||
const imdbId = parts[0];
|
||||
const season = parts[1] !== undefined ? parseInt(parts[1], 10) : 1;
|
||||
const episode = parts[2] !== undefined ? parseInt(parts[2], 10) : 1;
|
||||
return repository.getImdbIdSeriesEntries(imdbId, season, episode);
|
||||
} else if (args.id.match(/^kitsu:\d+(?::\d+)?$/i)) {
|
||||
const parts = args.id.split(':');
|
||||
const kitsuId = parts[1];
|
||||
const episode = parts[2] !== undefined ? parseInt(parts[2], 10) : undefined;
|
||||
return episode !== undefined
|
||||
? repository.getKitsuIdSeriesEntries(kitsuId, episode)
|
||||
: repository.getKitsuIdMovieEntries(kitsuId);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
async function movieRecordsHandler(args) {
|
||||
if (args.id.match(/^tt\d+$/)) {
|
||||
const parts = args.id.split(':');
|
||||
const imdbId = parts[0];
|
||||
return repository.getImdbIdMovieEntries(imdbId);
|
||||
} else if (args.id.match(/^kitsu:\d+(?::\d+)?$/i)) {
|
||||
return seriesRecordsHandler(args);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
function enrichCacheParams(streams) {
|
||||
let cacheAge = CACHE_MAX_AGE;
|
||||
if (!streams.length) {
|
||||
cacheAge = CACHE_MAX_AGE_EMPTY;
|
||||
} else if (streams.every(stream => stream?.url?.endsWith(StaticLinks.FAILED_ACCESS))) {
|
||||
cacheAge = 0;
|
||||
}
|
||||
return {
|
||||
streams: streams,
|
||||
cacheMaxAge: cacheAge,
|
||||
staleRevalidate: STALE_REVALIDATE_AGE,
|
||||
staleError: STALE_ERROR_AGE
|
||||
}
|
||||
}
|
||||
|
||||
export default builder.getInterface();
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import express from 'express';
|
||||
import swStats from 'swagger-stats';
|
||||
import serverless from './serverless.js';
|
||||
import { manifest } from './lib/manifest.js';
|
||||
import { initBestTrackers } from './lib/magnetHelper.js';
|
||||
|
||||
const app = express();
|
||||
app.enable('trust proxy');
|
||||
app.use(swStats.getMiddleware({
|
||||
name: manifest().name,
|
||||
version: manifest().version,
|
||||
timelineBucketDuration: 60 * 60 * 1000,
|
||||
apdexThreshold: 100,
|
||||
authentication: true,
|
||||
onAuthenticate: (req, username, password) => {
|
||||
return username === process.env.METRICS_USER
|
||||
&& password === process.env.METRICS_PASSWORD
|
||||
},
|
||||
}))
|
||||
app.use(express.static('static', { maxAge: '1y' }));
|
||||
app.use((req, res, next) => serverless(req, res, next));
|
||||
app.listen(process.env.PORT || 7000, () => {
|
||||
initBestTrackers()
|
||||
.then(() => console.log(`Started addon at: http://localhost:${process.env.PORT || 7000}`));
|
||||
});
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import KeyvMongo from "@keyv/mongo";
|
||||
import { KeyvCacheableMemory } from "cacheable";
|
||||
import { isStaticUrl } from '../moch/static.js';
|
||||
|
||||
const GLOBAL_KEY_PREFIX = 'torrentio-addon';
|
||||
const STREAM_KEY_PREFIX = `${GLOBAL_KEY_PREFIX}|stream`;
|
||||
const AVAILABILITY_KEY_PREFIX = `${GLOBAL_KEY_PREFIX}|availability`;
|
||||
const RESOLVED_URL_KEY_PREFIX = `${GLOBAL_KEY_PREFIX}|resolved`;
|
||||
|
||||
const STREAM_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const STREAM_EMPTY_TTL = 60 * 1000; // 1 minute
|
||||
const RESOLVED_URL_TTL = 3 * 60 * 60 * 1000; // 3 hours
|
||||
const AVAILABILITY_TTL = 5 * 24 * 60 * 60 * 1000; // 5 days
|
||||
const MESSAGE_VIDEO_URL_TTL = 60 * 1000; // 1 minutes
|
||||
// When the streams are empty we want to cache it for less time in case of timeouts or failures
|
||||
|
||||
const MONGO_URI = process.env.MONGODB_URI;
|
||||
|
||||
const memoryCache = new KeyvCacheableMemory({ ttl: MESSAGE_VIDEO_URL_TTL, lruSize: Infinity });
|
||||
const remoteCache = MONGO_URI && new KeyvMongo(MONGO_URI, {
|
||||
collection: 'torrentio_addon_collection',
|
||||
minPoolSize: 50,
|
||||
maxPoolSize: 200,
|
||||
maxConnecting: 5,
|
||||
});
|
||||
|
||||
async function cacheWrap(cache, key, method, ttl) {
|
||||
if (!cache) {
|
||||
return method();
|
||||
}
|
||||
const value = await cache.get(key);
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
const result = await method();
|
||||
const ttlValue = ttl instanceof Function ? ttl(result) : ttl;
|
||||
await cache.set(key, result, ttlValue);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function cacheWrapStream(id, method) {
|
||||
const ttl = (streams) => streams.length ? STREAM_TTL : STREAM_EMPTY_TTL;
|
||||
return cacheWrap(remoteCache, `${STREAM_KEY_PREFIX}:${id}`, method, ttl);
|
||||
}
|
||||
|
||||
export function cacheWrapResolvedUrl(id, method) {
|
||||
const ttl = (url) => isStaticUrl(url) ? MESSAGE_VIDEO_URL_TTL : RESOLVED_URL_TTL;
|
||||
return cacheWrap(remoteCache, `${RESOLVED_URL_KEY_PREFIX}:${id}`, method, ttl);
|
||||
}
|
||||
|
||||
export function cacheAvailabilityResults(infoHash, fileIds) {
|
||||
const key = `${AVAILABILITY_KEY_PREFIX}:${infoHash}`;
|
||||
const fileIdsString = fileIds.toString();
|
||||
const containsFileIds = (array) => array.some(ids => ids.toString() === fileIdsString)
|
||||
return remoteCache.get(key)
|
||||
.then(result => {
|
||||
const newResult = result || [];
|
||||
if (!containsFileIds(newResult)) {
|
||||
newResult.push(fileIds);
|
||||
newResult.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
return remoteCache.set(key, newResult, AVAILABILITY_TTL);
|
||||
});
|
||||
}
|
||||
|
||||
export function removeAvailabilityResults(infoHash, fileIds) {
|
||||
const key = `${AVAILABILITY_KEY_PREFIX}:${infoHash}`;
|
||||
const fileIdsString = fileIds.toString();
|
||||
return remoteCache.get(key)
|
||||
.then(result => {
|
||||
const storedIndex = result?.findIndex(ids => ids.toString() === fileIdsString);
|
||||
if (storedIndex >= 0) {
|
||||
result.splice(storedIndex, 1);
|
||||
return remoteCache.set(key, result, AVAILABILITY_TTL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getCachedAvailabilityResults(infoHashes) {
|
||||
const keys = infoHashes.map(infoHash => `${AVAILABILITY_KEY_PREFIX}:${infoHash}`)
|
||||
return remoteCache.getMany(keys)
|
||||
.then(result => {
|
||||
const availabilityResults = {};
|
||||
infoHashes.forEach((infoHash, index) => {
|
||||
if (result[index]) {
|
||||
availabilityResults[infoHash] = result[index];
|
||||
}
|
||||
});
|
||||
return availabilityResults;
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Failed retrieve availability cache', error)
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import { DebridOptions } from '../moch/options.js';
|
||||
import { QualityFilter, Providers, SizeFilter } from './filter.js';
|
||||
import { LanguageOptions } from './languages.js';
|
||||
|
||||
export const PreConfigurations = {
|
||||
lite: {
|
||||
config: liteConfig(),
|
||||
serialized: configValue(liteConfig()),
|
||||
manifest: {
|
||||
id: 'com.stremio.torrentio.lite.addon',
|
||||
name: 'Torrentio Lite',
|
||||
description: 'Preconfigured Lite version of Torrentio addon.'
|
||||
+ ' To configure advanced options visit https://torrentio.strem.fun/lite'
|
||||
}
|
||||
},
|
||||
brazuca: {
|
||||
config: brazucaConfig(),
|
||||
serialized: configValue(brazucaConfig()),
|
||||
manifest: {
|
||||
id: 'com.stremio.torrentio.brazuca.addon',
|
||||
name: 'Torrentio Brazuca',
|
||||
description: 'Preconfigured version of Torrentio addon for Brazilian content.'
|
||||
+ ' To configure advanced options visit https://torrentio.strem.fun/brazuca',
|
||||
logo: 'https://i.ibb.co/8mgRZPp/GwxAcDV.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const keysToSplit = [Providers.key, LanguageOptions.key, QualityFilter.key, SizeFilter.key, DebridOptions.key];
|
||||
const keysToUppercase = [SizeFilter.key];
|
||||
|
||||
export function parseConfiguration(configuration) {
|
||||
if (!configuration) {
|
||||
return undefined;
|
||||
}
|
||||
if (PreConfigurations[configuration]) {
|
||||
return PreConfigurations[configuration].config;
|
||||
}
|
||||
const configValues = configuration.split('|')
|
||||
.reduce((map, next) => {
|
||||
const parameterParts = next.split('=');
|
||||
if (parameterParts.length === 2) {
|
||||
map[parameterParts[0].toLowerCase()] = parameterParts[1];
|
||||
}
|
||||
return map;
|
||||
}, {});
|
||||
keysToSplit
|
||||
.filter(key => configValues[key])
|
||||
.forEach(key => configValues[key] = configValues[key].split(',')
|
||||
.map(value => keysToUppercase.includes(key) ? value.toUpperCase() : value.toLowerCase()))
|
||||
return configValues;
|
||||
}
|
||||
|
||||
function liteConfig() {
|
||||
const config = {};
|
||||
config[Providers.key] = Providers.options
|
||||
.filter(provider => !provider.foreign)
|
||||
.map(provider => provider.key);
|
||||
config[QualityFilter.key] = ['scr', 'cam']
|
||||
config['limit'] = 1;
|
||||
return config;
|
||||
}
|
||||
|
||||
function brazucaConfig() {
|
||||
const config = {};
|
||||
config[Providers.key] = Providers.options
|
||||
.filter(provider => !provider.foreign || provider.foreign === '🇵🇹')
|
||||
.map(provider => provider.key);
|
||||
config[LanguageOptions.key] = ['portuguese'];
|
||||
return config;
|
||||
}
|
||||
|
||||
function configValue(config) {
|
||||
return Object.entries(config)
|
||||
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(',') : value}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export function getManifestOverride(config) {
|
||||
const preConfig = Object.values(PreConfigurations).find(pre => pre.config === config);
|
||||
return preConfig ? preConfig.manifest : {};
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
const VIDEO_EXTENSIONS = [
|
||||
"3g2",
|
||||
"3gp",
|
||||
"avi",
|
||||
"flv",
|
||||
"mkv",
|
||||
"mk3d",
|
||||
"mov",
|
||||
"mp2",
|
||||
"mp4",
|
||||
"m4v",
|
||||
"mpe",
|
||||
"mpeg",
|
||||
"mpg",
|
||||
"mpv",
|
||||
"webm",
|
||||
"wmv",
|
||||
"ogm",
|
||||
"ts",
|
||||
"m2ts"
|
||||
];
|
||||
const SUBTITLE_EXTENSIONS = [
|
||||
"aqt",
|
||||
"gsub",
|
||||
"jss",
|
||||
"sub",
|
||||
"ttxt",
|
||||
"pjs",
|
||||
"psb",
|
||||
"rt",
|
||||
"smi",
|
||||
"slt",
|
||||
"ssf",
|
||||
"srt",
|
||||
"ssa",
|
||||
"ass",
|
||||
"usf",
|
||||
"idx",
|
||||
"vtt"
|
||||
];
|
||||
const DISK_EXTENSIONS = [
|
||||
"iso",
|
||||
"m2ts",
|
||||
"ts",
|
||||
"vob"
|
||||
]
|
||||
|
||||
const ARCHIVE_EXTENSIONS = [
|
||||
"rar",
|
||||
"zip"
|
||||
]
|
||||
|
||||
export function isVideo(filename) {
|
||||
return isExtension(filename, VIDEO_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isSubtitle(filename) {
|
||||
return isExtension(filename, SUBTITLE_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isDisk(filename) {
|
||||
return isExtension(filename, DISK_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isArchive(filename) {
|
||||
return isExtension(filename, ARCHIVE_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isExtension(filename, extensions) {
|
||||
const extensionMatch = filename?.match(/\.(\w{2,4})$/);
|
||||
return extensionMatch && extensions.includes(extensionMatch[1].toLowerCase());
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
import { extractProvider, parseSize, extractSize } from './titleHelper.js';
|
||||
import { Type } from './types.js';
|
||||
export const Providers = {
|
||||
key: 'providers',
|
||||
options: [
|
||||
{
|
||||
key: 'yts',
|
||||
label: 'YTS'
|
||||
},
|
||||
{
|
||||
key: 'eztv',
|
||||
label: 'EZTV'
|
||||
},
|
||||
{
|
||||
key: 'rarbg',
|
||||
label: 'RARBG'
|
||||
},
|
||||
{
|
||||
key: '1337x',
|
||||
label: '1337x'
|
||||
},
|
||||
{
|
||||
key: 'thepiratebay',
|
||||
label: 'ThePirateBay'
|
||||
},
|
||||
{
|
||||
key: 'kickasstorrents',
|
||||
label: 'KickassTorrents'
|
||||
},
|
||||
{
|
||||
key: 'torrentgalaxy',
|
||||
label: 'TorrentGalaxy'
|
||||
},
|
||||
{
|
||||
key: 'magnetdl',
|
||||
label: 'MagnetDL'
|
||||
},
|
||||
{
|
||||
key: 'horriblesubs',
|
||||
label: 'HorribleSubs',
|
||||
anime: true
|
||||
},
|
||||
{
|
||||
key: 'nyaasi',
|
||||
label: 'NyaaSi',
|
||||
anime: true
|
||||
},
|
||||
{
|
||||
key: 'tokyotosho',
|
||||
label: 'TokyoTosho',
|
||||
anime: true
|
||||
},
|
||||
{
|
||||
key: 'anidex',
|
||||
label: 'AniDex',
|
||||
anime: true
|
||||
},
|
||||
{
|
||||
key: 'rutor',
|
||||
label: 'Rutor',
|
||||
foreign: '🇷🇺'
|
||||
},
|
||||
{
|
||||
key: 'rutracker',
|
||||
label: 'Rutracker',
|
||||
foreign: '🇷🇺'
|
||||
},
|
||||
{
|
||||
key: 'comando',
|
||||
label: 'Comando',
|
||||
foreign: '🇵🇹'
|
||||
},
|
||||
{
|
||||
key: 'bludv',
|
||||
label: 'BluDV',
|
||||
foreign: '🇵🇹'
|
||||
},
|
||||
{
|
||||
key: 'torrent9',
|
||||
label: 'Torrent9',
|
||||
foreign: '🇫🇷'
|
||||
},
|
||||
{
|
||||
key: 'ilcorsaronero',
|
||||
label: 'ilCorSaRoNeRo',
|
||||
foreign: '🇮🇹'
|
||||
},
|
||||
{
|
||||
key: 'mejortorrent',
|
||||
label: 'MejorTorrent',
|
||||
foreign: '🇪🇸'
|
||||
},
|
||||
{
|
||||
key: 'wolfmax4k',
|
||||
label: 'Wolfmax4k',
|
||||
foreign: '🇪🇸'
|
||||
},
|
||||
{
|
||||
key: 'cinecalidad',
|
||||
label: 'Cinecalidad',
|
||||
foreign: '🇲🇽'
|
||||
},
|
||||
]
|
||||
};
|
||||
export const QualityFilter = {
|
||||
key: 'qualityfilter',
|
||||
options: [
|
||||
{
|
||||
key: 'brremux',
|
||||
label: 'BluRay REMUX',
|
||||
test(quality, bingeGroup) {
|
||||
return bingeGroup?.includes(this.label);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'hdrall',
|
||||
label: 'HDR/HDR10+/Dolby Vision',
|
||||
items: ['HDR', 'HDR10+', 'DV'],
|
||||
test(quality) {
|
||||
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
|
||||
return this.items.some(hdrType => hdrProfiles.includes(hdrType));
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'dolbyvision',
|
||||
label: 'Dolby Vision',
|
||||
test(quality) {
|
||||
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
|
||||
return hdrProfiles === 'DV';
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'dolbyvisionwithhdr',
|
||||
label: 'Dolby Vision + HDR',
|
||||
test(quality) {
|
||||
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
|
||||
return hdrProfiles.includes('DV') && hdrProfiles.includes('HDR');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'threed',
|
||||
label: '3D',
|
||||
test(quality) {
|
||||
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
|
||||
return hdrProfiles.includes('3D');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'nonthreed',
|
||||
label: 'Non 3D (DO NOT SELECT IF NOT SURE)',
|
||||
test(quality) {
|
||||
const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || '';
|
||||
return !hdrProfiles.includes('3D');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: '4k',
|
||||
label: '4k',
|
||||
items: ['4k'],
|
||||
test(quality) {
|
||||
return quality && this.items.includes(quality.split(' ')[0]);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: '1080p',
|
||||
label: '1080p',
|
||||
items: ['1080p'],
|
||||
test(quality) {
|
||||
return this.items.includes(quality)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: '720p',
|
||||
label: '720p',
|
||||
items: ['720p'],
|
||||
test(quality) {
|
||||
return this.items.includes(quality)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: '480p',
|
||||
label: '480p',
|
||||
items: ['480p'],
|
||||
test(quality) {
|
||||
return this.items.includes(quality)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: 'Other (DVDRip/HDRip/BDRip...)',
|
||||
// could be ['DVDRip', 'HDRip', 'BDRip', 'BRRip', 'BluRay', 'WEB-DL', 'WEBRip', 'HDTV', 'DivX', 'XviD']
|
||||
items: ['4k', '1080p', '720p', '480p', 'SCR', 'CAM', 'TeleSync', 'TeleCine'],
|
||||
test(quality) {
|
||||
return quality && !this.items.includes(quality.split(' ')[0]);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'scr',
|
||||
label: 'Screener',
|
||||
items: ['SCR'],
|
||||
test(quality) {
|
||||
return this.items.includes(quality)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'cam',
|
||||
label: 'Cam',
|
||||
items: ['CAM', 'TeleSync', 'TeleCine'],
|
||||
test(quality) {
|
||||
return this.items.includes(quality)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'unknown',
|
||||
label: 'Unknown',
|
||||
test(quality) {
|
||||
return !quality
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
export const SizeFilter = {
|
||||
key: 'sizefilter'
|
||||
}
|
||||
const defaultProviderKeys = Providers.options.map(provider => provider.key);
|
||||
|
||||
export default function applyFilters(streams, config) {
|
||||
return [
|
||||
filterByProvider,
|
||||
filterByQuality,
|
||||
filterBySize
|
||||
].reduce((filteredStreams, filter) => filter(filteredStreams, config), streams);
|
||||
}
|
||||
|
||||
function filterByProvider(streams, config) {
|
||||
const providers = config.providers || defaultProviderKeys;
|
||||
if (!providers?.length) {
|
||||
return streams;
|
||||
}
|
||||
return streams.filter(stream => {
|
||||
const provider = extractProvider(stream.title).toLowerCase();
|
||||
return providers.includes(provider);
|
||||
})
|
||||
}
|
||||
|
||||
function filterByQuality(streams, config) {
|
||||
const filters = config[QualityFilter.key];
|
||||
if (!filters) {
|
||||
return streams;
|
||||
}
|
||||
const filterOptions = QualityFilter.options.filter(option => filters.includes(option.key));
|
||||
return streams.filter(stream => {
|
||||
const streamQuality = stream.name.split('\n')[1];
|
||||
const bingeGroup = stream.behaviorHints?.bingeGroup;
|
||||
return !filterOptions.some(option => option.test(streamQuality, bingeGroup));
|
||||
});
|
||||
}
|
||||
|
||||
function filterBySize(streams, config) {
|
||||
const sizeFilters = config[SizeFilter.key];
|
||||
if (!sizeFilters?.length) {
|
||||
return streams;
|
||||
}
|
||||
const sizeLimit = parseSize(config.type === Type.MOVIE ? sizeFilters.shift() : sizeFilters.pop());
|
||||
return streams.filter(stream => {
|
||||
const size = extractSize(stream.title)
|
||||
return size <= sizeLimit;
|
||||
})
|
||||
}
|
||||
|
|
@ -1,506 +0,0 @@
|
|||
const STYLESHEET = `
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
html {
|
||||
background-size: auto 100%;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-repeat: repeat-y;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
background-color: transparent;
|
||||
font-family: 'Open Sans', Arial, sans-serif;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 4.5vh;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.2vh;
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2.2vh;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p,
|
||||
label {
|
||||
margin: 0;
|
||||
text-shadow: 0 0 1vh rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.75vh;
|
||||
}
|
||||
|
||||
ul {
|
||||
font-size: 1.75vh;
|
||||
margin: 0;
|
||||
margin-top: 1vh;
|
||||
padding-left: 3vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: green
|
||||
}
|
||||
|
||||
a.install-link {
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
.install-button {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: white;
|
||||
background: #8A5AAB;
|
||||
padding: 1.2vh 3.5vh;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
font-family: 'Open Sans', Arial, sans-serif;
|
||||
font-size: 2.2vh;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2);
|
||||
transition: box-shadow 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.install-button:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.install-button:active {
|
||||
box-shadow: 0 0 0 0.5vh white inset;
|
||||
}
|
||||
|
||||
#addon {
|
||||
width: 90vh;
|
||||
margin: auto;
|
||||
padding-left: 10%;
|
||||
padding-right: 10%;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 14vh;
|
||||
width: 14vh;
|
||||
margin: auto;
|
||||
margin-bottom: 3vh;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.name, .version {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.name {
|
||||
line-height: 5vh;
|
||||
}
|
||||
|
||||
.version {
|
||||
position: absolute;
|
||||
line-height: 5vh;
|
||||
margin-left: 1vh;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.contact {
|
||||
left: 0;
|
||||
bottom: 4vh;
|
||||
width: 100%;
|
||||
margin-top: 1vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contact a {
|
||||
font-size: 1.4vh;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin-bottom: 4vh;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 2.2vh;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.btn-group, .multiselect-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.multiselect-container {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.input, .btn {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
color: #333;
|
||||
background-color: rgb(255, 255, 255);
|
||||
box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.input:focus, .btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2pt rgb(30, 144, 255, 0.7);
|
||||
}
|
||||
`;
|
||||
import { Providers, QualityFilter, SizeFilter } from './filter.js';
|
||||
import { SortOptions } from './sort.js';
|
||||
import { LanguageOptions } from './languages.js';
|
||||
import { DebridOptions } from '../moch/options.js';
|
||||
import { MochOptions } from '../moch/moch.js';
|
||||
import { PreConfigurations } from './configuration.js';
|
||||
|
||||
export default function landingTemplate(manifest, config = {}) {
|
||||
const providers = config[Providers.key] || Providers.options.map(provider => provider.key);
|
||||
const sort = config[SortOptions.key] || SortOptions.options.qualitySeeders.key;
|
||||
const languages = config[LanguageOptions.key] || [];
|
||||
const qualityFilters = config[QualityFilter.key] || [];
|
||||
const sizeFilter = (config[SizeFilter.key] || []).join(',');
|
||||
const limit = config.limit || '';
|
||||
|
||||
const debridProvider = Object.keys(MochOptions).find(mochKey => config[mochKey]);
|
||||
const debridOptions = config[DebridOptions.key] || [];
|
||||
const realDebridApiKey = config[MochOptions.realdebrid.key] || '';
|
||||
const premiumizeApiKey = config[MochOptions.premiumize.key] || '';
|
||||
const allDebridApiKey = config[MochOptions.alldebrid.key] || '';
|
||||
const debridLinkApiKey = config[MochOptions.debridlink.key] || '';
|
||||
const offcloudApiKey = config[MochOptions.offcloud.key] || '';
|
||||
const torboxApiKey = config[MochOptions.torbox.key] || '';
|
||||
const putioKey = config[MochOptions.putio.key] || '';
|
||||
const putioClientId = putioKey.replace(/@.*/, '');
|
||||
const putioToken = putioKey.replace(/.*@/, '');
|
||||
|
||||
const background = manifest.background || 'https://dl.strem.io/addon-background.jpg';
|
||||
const logo = manifest.logo || 'https://dl.strem.io/addon-logo.png';
|
||||
const providersHTML = Providers.options
|
||||
.map(provider => `<option value="${provider.key}">${provider.foreign ? provider.foreign + ' ' : ''}${provider.label}</option>`)
|
||||
.join('\n');
|
||||
const sortOptionsHTML = Object.values(SortOptions.options)
|
||||
.map((option, i) => `<option value="${option.key}" ${i === 0 ? 'selected' : ''}>${option.description}</option>`)
|
||||
.join('\n');
|
||||
const languagesOptionsHTML = LanguageOptions.options
|
||||
.map((option, i) => `<option value="${option.key}">${option.label}</option>`)
|
||||
.join('\n');
|
||||
const qualityFiltersHTML = Object.values(QualityFilter.options)
|
||||
.map(option => `<option value="${option.key}">${option.label}</option>`)
|
||||
.join('\n');
|
||||
const debridProvidersHTML = Object.values(MochOptions)
|
||||
.map(moch => `<option value="${moch.key}">${moch.name}</option>`)
|
||||
.join('\n');
|
||||
const debridOptionsHTML = Object.values(DebridOptions.options)
|
||||
.map(option => `<option value="${option.key}">${option.description}</option>`)
|
||||
.join('\n');
|
||||
const stylizedTypes = manifest.types
|
||||
.map(t => t[0].toUpperCase() + t.slice(1) + (t !== 'series' ? 's' : ''));
|
||||
const preConfigurationObject = Object.entries(PreConfigurations)
|
||||
.map(([key, config]) => `${key}: '${config.serialized}'`)
|
||||
.join(',');
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html style="background-image: url(${background});">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${manifest.name} - Stremio Addon</title>
|
||||
<link rel="shortcut icon" href="${logo}" type="image/x-icon">
|
||||
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,600,700&display=swap" rel="stylesheet">
|
||||
<script src="https://code.jquery.com/jquery-3.6.4.slim.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
|
||||
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" >
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/1.1.2/js/bootstrap-multiselect.min.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/1.1.2/css/bootstrap-multiselect.css" rel="stylesheet"/>
|
||||
<style>${STYLESHEET}</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="addon">
|
||||
<div class="logo">
|
||||
<img src="${logo}">
|
||||
</div>
|
||||
<h1 class="name">${manifest.name}</h1>
|
||||
<h2 class="version">${manifest.version || '0.0.0'}</h2>
|
||||
<h2 class="description">${manifest.description || ''}</h2>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<h3 class="gives">This addon has more :</h3>
|
||||
<ul>
|
||||
${stylizedTypes.map(t => `<li>${t}</li>`).join('')}
|
||||
</ul>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<label class="label" for="iProviders">Providers:</label>
|
||||
<select id="iProviders" class="input" onchange="generateInstallLink()" name="providers[]" multiple="multiple">
|
||||
${providersHTML}
|
||||
</select>
|
||||
|
||||
<label class="label" for="iSort">Sorting:</label>
|
||||
<select id="iSort" class="input" onchange="sortModeChange()">
|
||||
${sortOptionsHTML}
|
||||
</select>
|
||||
|
||||
<label class="label" for="iLanguages">Priority foreign language:</label>
|
||||
<select id="iLanguages" class="input" onchange="generateInstallLink()" name="languages[]" multiple="multiple" title="Streams with the selected dubs/subs language will be shown on the top">
|
||||
${languagesOptionsHTML}
|
||||
</select>
|
||||
|
||||
<label class="label" for="iQualityFilter">Exclude qualities/resolutions:</label>
|
||||
<select id="iQualityFilter" class="input" onchange="generateInstallLink()" name="qualityFilters[]" multiple="multiple">
|
||||
${qualityFiltersHTML}
|
||||
</select>
|
||||
|
||||
<label class="label" id="iLimitLabel" for="iLimit">Max results per quality:</label>
|
||||
<input type="text" inputmode="numeric" pattern="[0-9]*" id="iLimit" onchange="generateInstallLink()" class="input" placeholder="All results">
|
||||
|
||||
<label class="label" id="iSizeFilterLabel" for="iSizeFilter">Video size limit:</label>
|
||||
<input type="text" pattern="([0-9.]*(?:MB|GB),?)+" id="iSizeFilter" onchange="generateInstallLink()" class="input" placeholder="No limit" title="Returned videos cannot exceed this size, use comma to have different size for movies and series. Examples: 5GB ; 800MB ; 10GB,2GB">
|
||||
|
||||
|
||||
<label class="label" for="iDebridProviders">Debrid provider:</label>
|
||||
<select id="iDebridProviders" class="input" onchange="debridProvidersChange()">
|
||||
<option value="none" selected>None</option>
|
||||
${debridProvidersHTML}
|
||||
</select>
|
||||
|
||||
<div id="dRealDebrid">
|
||||
<label class="label" for="iRealDebrid">RealDebrid API Key (Find it <a href='https://real-debrid.com/apitoken' target="_blank">here</a>):</label>
|
||||
<input type="text" id="iRealDebrid" onchange="generateInstallLink()" class="input">
|
||||
</div>
|
||||
|
||||
<div id="dAllDebrid">
|
||||
<label class="label" for="iAllDebrid">AllDebrid API Key (Create it <a href='https://alldebrid.com/apikeys' target="_blank">here</a>):</label>
|
||||
<input type="text" id="iAllDebrid" onchange="generateInstallLink()" class="input">
|
||||
</div>
|
||||
|
||||
<div id="dPremiumize">
|
||||
<label class="label" for="iPremiumize">Premiumize API Key (Find it <a href='https://www.premiumize.me/account' target="_blank">here</a>):</label>
|
||||
<input type="text" id="iPremiumize" onchange="generateInstallLink()" class="input">
|
||||
</div>
|
||||
|
||||
<div id="dDebridLink">
|
||||
<label class="label" for="iDebridLink">DebridLink API Key (Find it <a href='https://debrid-link.fr/webapp/apikey' target="_blank">here</a>):</label>
|
||||
<input type="text" id="iDebridLink" onchange="generateInstallLink()" class="input">
|
||||
</div>
|
||||
|
||||
<div id="dOffcloud">
|
||||
<label class="label" for="iOffcloud">Offcloud API Key (Find it <a href='https://offcloud.com/#/account' target="_blank">here</a>):</label>
|
||||
<input type="text" id="iOffcloud" onchange="generateInstallLink()" class="input">
|
||||
</div>
|
||||
|
||||
<div id="dTorbox">
|
||||
<label class="label" for="iTorbox">TorBox API Key (Find it <a href='https://torbox.app/settings' target="_blank">here</a>):</label>
|
||||
<input type="text" id="iTorbox" onchange="generateInstallLink()" class="input">
|
||||
</div>
|
||||
|
||||
<div id="dPutio">
|
||||
<label class="label" for="iPutio">Put.io ClientId and Token (Create new OAuth App <a href='https://app.put.io/oauth' target="_blank">here</a>):</label>
|
||||
<input type="text" id="iPutioClientId" placeholder="ClientId" onchange="generateInstallLink()" class="input">
|
||||
<input type="text" id="iPutioToken" placeholder="Token" onchange="generateInstallLink()" class="input">
|
||||
</div>
|
||||
|
||||
<div id="dDebridOptions">
|
||||
<label class="label" for="iDebridOptions">Debrid options:</label>
|
||||
<select id="iDebridOptions" class="input" onchange="generateInstallLink()" name="debridOptions[]" multiple="multiple">
|
||||
${debridOptionsHTML}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<a id="installLink" class="install-link" href="#">
|
||||
<button name="Install" class="install-button">INSTALL</button>
|
||||
</a>
|
||||
<div class="contact">
|
||||
<p>Or paste into Stremio search bar after clicking install</p>
|
||||
</div>
|
||||
|
||||
<div class="separator"></div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
const isTvMedia = window.matchMedia("tv").matches;
|
||||
const isTvAgent = /\\b(?:tv|wv)\\b/i.test(navigator.userAgent)
|
||||
const isDesktopMedia = window.matchMedia("(pointer:fine)").matches;
|
||||
if (isDesktopMedia && !isTvMedia && !isTvAgent) {
|
||||
$('#iProviders').multiselect({
|
||||
nonSelectedText: 'All providers',
|
||||
buttonTextAlignment: 'left',
|
||||
onChange: () => generateInstallLink()
|
||||
});
|
||||
$('#iProviders').multiselect('select', [${providers.map(provider => '"' + provider + '"')}]);
|
||||
$('#iLanguages').multiselect({
|
||||
nonSelectedText: 'None',
|
||||
buttonTextAlignment: 'left',
|
||||
onChange: () => generateInstallLink()
|
||||
});
|
||||
$('#iLanguages').multiselect('select', [${languages.map(language => '"' + language + '"')}]);
|
||||
$('#iQualityFilter').multiselect({
|
||||
nonSelectedText: 'None',
|
||||
buttonTextAlignment: 'left',
|
||||
onChange: () => generateInstallLink()
|
||||
});
|
||||
$('#iQualityFilter').multiselect('select', [${qualityFilters.map(filter => '"' + filter + '"')}]);
|
||||
$('#iDebridOptions').multiselect({
|
||||
nonSelectedText: 'None',
|
||||
buttonTextAlignment: 'left',
|
||||
onChange: () => generateInstallLink()
|
||||
});
|
||||
$('#iDebridOptions').multiselect('select', [${debridOptions.map(option => '"' + option + '"')}]);
|
||||
} else {
|
||||
$('#iProviders').val([${providers.map(provider => '"' + provider + '"')}]);
|
||||
$('#iLanguages').val([${languages.map(language => '"' + language + '"')}]);
|
||||
$('#iQualityFilter').val([${qualityFilters.map(filter => '"' + filter + '"')}]);
|
||||
$('#iDebridOptions').val([${debridOptions.map(option => '"' + option + '"')}]);
|
||||
}
|
||||
$('#iDebridProviders').val("${debridProvider || 'none'}");
|
||||
$('#iRealDebrid').val("${realDebridApiKey}");
|
||||
$('#iPremiumize').val("${premiumizeApiKey}");
|
||||
$('#iAllDebrid').val("${allDebridApiKey}");
|
||||
$('#iDebridLink').val("${debridLinkApiKey}");
|
||||
$('#iOffcloud').val("${offcloudApiKey}");
|
||||
$('#iTorbox').val("${torboxApiKey}");
|
||||
$('#iPutioClientId').val("${putioClientId}");
|
||||
$('#iPutioToken').val("${putioToken}");
|
||||
$('#iSort').val("${sort}");
|
||||
$('#iLimit').val("${limit}");
|
||||
$('#iSizeFilter').val("${sizeFilter}");
|
||||
generateInstallLink();
|
||||
debridProvidersChange();
|
||||
});
|
||||
|
||||
function sortModeChange() {
|
||||
if (['${SortOptions.options.seeders.key}', '${SortOptions.options.size.key}'].includes($('#iSort').val())) {
|
||||
$("#iLimitLabel").text("Max results:");
|
||||
} else {
|
||||
$("#iLimitLabel").text("Max results per quality:");
|
||||
}
|
||||
generateInstallLink();
|
||||
}
|
||||
|
||||
function debridProvidersChange() {
|
||||
const provider = $('#iDebridProviders').val()
|
||||
$('#dDebridOptions').toggle(provider !== 'none');
|
||||
$('#dRealDebrid').toggle(provider === '${MochOptions.realdebrid.key}');
|
||||
$('#dPremiumize').toggle(provider === '${MochOptions.premiumize.key}');
|
||||
$('#dAllDebrid').toggle(provider === '${MochOptions.alldebrid.key}');
|
||||
$('#dDebridLink').toggle(provider === '${MochOptions.debridlink.key}');
|
||||
$('#dOffcloud').toggle(provider === '${MochOptions.offcloud.key}');
|
||||
$('#dTorbox').toggle(provider === '${MochOptions.torbox.key}');
|
||||
$('#dPutio').toggle(provider === '${MochOptions.putio.key}');
|
||||
}
|
||||
|
||||
function generateInstallLink() {
|
||||
const providersList = $('#iProviders').val() || [];
|
||||
const providersValue = providersList.join(',');
|
||||
const qualityFilterValue = $('#iQualityFilter').val().join(',') || '';
|
||||
const sortValue = $('#iSort').val() || '';
|
||||
const languagesValue = $('#iLanguages').val().join(',') || [];
|
||||
const limitValue = $('#iLimit').val() || '';
|
||||
const sizeFilterValue = $('#iSizeFilter').val() || '';
|
||||
|
||||
const debridOptionsValue = $('#iDebridOptions').val().join(',') || '';
|
||||
const realDebridValue = $('#iRealDebrid').val() || '';
|
||||
const allDebridValue = $('#iAllDebrid').val() || '';
|
||||
const debridLinkValue = $('#iDebridLink').val() || ''
|
||||
const premiumizeValue = $('#iPremiumize').val() || '';
|
||||
const offcloudValue = $('#iOffcloud').val() || '';
|
||||
const torboxValue = $('#iTorbox').val() || '';
|
||||
const putioClientIdValue = $('#iPutioClientId').val() || '';
|
||||
const putioTokenValue = $('#iPutioToken').val() || '';
|
||||
|
||||
|
||||
const providers = providersList.length && providersList.length < ${Providers.options.length} && providersValue;
|
||||
const qualityFilters = qualityFilterValue.length && qualityFilterValue;
|
||||
const sort = sortValue !== '${SortOptions.options.qualitySeeders.key}' && sortValue;
|
||||
const languages = languagesValue.length && languagesValue;
|
||||
const limit = /^[1-9][0-9]{0,2}$/.test(limitValue) && limitValue;
|
||||
const sizeFilter = sizeFilterValue.length && sizeFilterValue;
|
||||
|
||||
const debridOptions = debridOptionsValue.length && debridOptionsValue.trim();
|
||||
const realDebrid = realDebridValue.length && realDebridValue.trim();
|
||||
const premiumize = premiumizeValue.length && premiumizeValue.trim();
|
||||
const allDebrid = allDebridValue.length && allDebridValue.trim();
|
||||
const debridLink = debridLinkValue.length && debridLinkValue.trim();
|
||||
const offcloud = offcloudValue.length && offcloudValue.trim();
|
||||
const torbox = torboxValue.length && torboxValue.trim();
|
||||
const putio = putioClientIdValue.length && putioTokenValue.length && putioClientIdValue.trim() + '@' + putioTokenValue.trim();
|
||||
|
||||
const preConfigurations = {
|
||||
${preConfigurationObject}
|
||||
};
|
||||
let configurationValue = [
|
||||
['${Providers.key}', providers],
|
||||
['${SortOptions.key}', sort],
|
||||
['${LanguageOptions.key}', languages],
|
||||
['${QualityFilter.key}', qualityFilters],
|
||||
['limit', limit],
|
||||
['${SizeFilter.key}', sizeFilter],
|
||||
['${DebridOptions.key}', debridOptions],
|
||||
['${MochOptions.realdebrid.key}', realDebrid],
|
||||
['${MochOptions.premiumize.key}', premiumize],
|
||||
['${MochOptions.alldebrid.key}', allDebrid],
|
||||
['${MochOptions.debridlink.key}', debridLink],
|
||||
['${MochOptions.offcloud.key}', offcloud],
|
||||
['${MochOptions.torbox.key}', torbox],
|
||||
['${MochOptions.putio.key}', putio]
|
||||
].filter(([_, value]) => value.length).map(([key, value]) => key + '=' + value).join('|');
|
||||
configurationValue = Object.entries(preConfigurations)
|
||||
.filter(([key, value]) => value === configurationValue)
|
||||
.map(([key, value]) => key)[0] || configurationValue;
|
||||
const configuration = configurationValue && configurationValue.length ? '/' + configurationValue : '';
|
||||
const location = window.location.host + configuration + '/manifest.json'
|
||||
installLink.href = 'stremio://' + location;
|
||||
}
|
||||
|
||||
installLink.addEventListener('click', function() {
|
||||
navigator.clipboard.writeText(installLink.href.replace('stremio://', 'https://'));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>`
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
const languageMapping = {
|
||||
'dubbed': 'Dubbed',
|
||||
'multi audio': 'Multi Audio',
|
||||
'multi subs': 'Multi Subs',
|
||||
'dual audio': 'Dual Audio',
|
||||
'english': '🇬🇧',
|
||||
'japanese': '🇯🇵',
|
||||
'russian': '🇷🇺',
|
||||
'italian': '🇮🇹',
|
||||
'portuguese': '🇵🇹',
|
||||
'spanish': '🇪🇸',
|
||||
'latino': '🇲🇽',
|
||||
'korean': '🇰🇷',
|
||||
'chinese': '🇨🇳',
|
||||
'taiwanese': '🇹🇼',
|
||||
'french': '🇫🇷',
|
||||
'german': '🇩🇪',
|
||||
'dutch': '🇳🇱',
|
||||
'hindi': '🇮🇳',
|
||||
'telugu': '🇮🇳',
|
||||
'tamil': '🇮🇳',
|
||||
'polish': '🇵🇱',
|
||||
'lithuanian': '🇱🇹',
|
||||
'latvian': '🇱🇻',
|
||||
'estonian': '🇪🇪',
|
||||
'czech': '🇨🇿',
|
||||
'slovakian': '🇸🇰',
|
||||
'slovenian': '🇸🇮',
|
||||
'hungarian': '🇭🇺',
|
||||
'romanian': '🇷🇴',
|
||||
'bulgarian': '🇧🇬',
|
||||
'serbian': '🇷🇸 ',
|
||||
'croatian': '🇭🇷',
|
||||
'ukrainian': '🇺🇦',
|
||||
'greek': '🇬🇷',
|
||||
'danish': '🇩🇰',
|
||||
'finnish': '🇫🇮',
|
||||
'swedish': '🇸🇪',
|
||||
'norwegian': '🇳🇴',
|
||||
'turkish': '🇹🇷',
|
||||
'arabic': '🇸🇦',
|
||||
'persian': '🇮🇷',
|
||||
'hebrew': '🇮🇱',
|
||||
'vietnamese': '🇻🇳',
|
||||
'indonesian': '🇮🇩',
|
||||
'malay': '🇲🇾',
|
||||
'thai': '🇹🇭'
|
||||
}
|
||||
|
||||
export const LanguageOptions = {
|
||||
key: 'language',
|
||||
options: Object.keys(languageMapping).slice(5).map(lang => ({
|
||||
key: lang,
|
||||
label: `${languageMapping[lang]} ${lang.charAt(0).toUpperCase()}${lang.slice(1)}`
|
||||
}))
|
||||
}
|
||||
|
||||
export function mapLanguages(languages) {
|
||||
const mapped = languages
|
||||
.map(language => languageMapping[language])
|
||||
.filter(language => language)
|
||||
.sort((a, b) => Object.values(languageMapping).indexOf(a) - Object.values(languageMapping).indexOf(b));
|
||||
const unmapped = languages
|
||||
.filter(language => !languageMapping[language])
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
return [...new Set([].concat(mapped).concat(unmapped))];
|
||||
}
|
||||
|
||||
export function containsLanguage(stream, languages) {
|
||||
return languages.map(lang => languageMapping[lang]).some(lang => stream.title.includes(lang));
|
||||
}
|
||||
|
||||
export function languageFromCode(code) {
|
||||
const entry = Object.entries(languageMapping).find(entry => entry[1] === code);
|
||||
return entry?.[0];
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import axios from 'axios';
|
||||
import magnet from 'magnet-uri';
|
||||
import { getRandomUserAgent } from './requestHelper.js';
|
||||
import { getTorrent } from './repository.js';
|
||||
import { Type } from './types.js';
|
||||
import { extractProvider } from "./titleHelper.js";
|
||||
import { Providers } from "./filter.js";
|
||||
|
||||
const TRACKERS_URL = 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt';
|
||||
const ANIME_TRACKERS = [
|
||||
"http://nyaa.tracker.wf:7777/announce",
|
||||
"http://anidex.moe:6969/announce",
|
||||
"http://tracker.anirena.com:80/announce",
|
||||
"udp://tracker.uw0.xyz:6969/announce",
|
||||
"http://share.camoe.cn:8080/announce",
|
||||
"http://t.nyaatracker.com:80/announce",
|
||||
];
|
||||
const RUSSIAN_TRACKERS = [
|
||||
"udp://opentor.net:6969",
|
||||
"http://bt.t-ru.org/ann?magnet",
|
||||
"http://bt2.t-ru.org/ann?magnet",
|
||||
"http://bt3.t-ru.org/ann?magnet",
|
||||
"http://bt4.t-ru.org/ann?magnet",
|
||||
];
|
||||
// Some trackers have limits on original torrent trackers,
|
||||
// where downloading ip has to seed the torrents for some amount of time,
|
||||
// thus it doesn't work on mochs.
|
||||
// So it's better to exclude them and try to download through DHT,
|
||||
// as the torrent won't start anyway.
|
||||
const RUSSIAN_PROVIDERS = Providers.options
|
||||
.filter(provider => provider.foreign === '🇷🇺')
|
||||
.map(provider => provider.label);
|
||||
const ANIME_PROVIDERS = Providers.options
|
||||
.filter(provider => provider.anime)
|
||||
.map(provider => provider.label);
|
||||
let BEST_TRACKERS = [];
|
||||
let ALL_ANIME_TRACKERS = [];
|
||||
let ALL_RUSSIAN_TRACKERS = [];
|
||||
|
||||
export async function getMagnetLink(infoHash) {
|
||||
const torrent = await getTorrent(infoHash).catch(() => ({ infoHash }));
|
||||
const torrentTrackers = torrent?.trackers?.split(',') || [];
|
||||
const animeTrackers = torrent?.type === Type.ANIME ? ALL_ANIME_TRACKERS : [];
|
||||
const providerTrackers = RUSSIAN_PROVIDERS.includes(torrent?.provider) && ALL_RUSSIAN_TRACKERS || [];
|
||||
const trackers = unique([].concat(torrentTrackers).concat(animeTrackers).concat(providerTrackers));
|
||||
|
||||
return magnet.encode({ infoHash: infoHash, name: torrent?.title, announce: trackers });
|
||||
}
|
||||
|
||||
export async function initBestTrackers() {
|
||||
BEST_TRACKERS = await getBestTrackers();
|
||||
ALL_ANIME_TRACKERS = unique(BEST_TRACKERS.concat(ANIME_TRACKERS));
|
||||
ALL_RUSSIAN_TRACKERS = unique(BEST_TRACKERS.concat(RUSSIAN_TRACKERS));
|
||||
console.log('Retrieved best trackers: ', BEST_TRACKERS);
|
||||
}
|
||||
|
||||
async function getBestTrackers(retry = 2) {
|
||||
const options = { timeout: 30000, headers: { 'User-Agent': getRandomUserAgent() } };
|
||||
return axios.get(TRACKERS_URL, options)
|
||||
.then(response => response?.data?.trim()?.split('\n\n') || [])
|
||||
.catch(error => {
|
||||
if (retry === 0) {
|
||||
console.log(`Failed retrieving best trackers: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
return getBestTrackers(retry - 1);
|
||||
});
|
||||
}
|
||||
|
||||
export function getSources(trackersInput, infoHash) {
|
||||
if (!trackersInput) {
|
||||
return null;
|
||||
}
|
||||
const trackers = Array.isArray(trackersInput) ? trackersInput : trackersInput.split(',');
|
||||
return trackers.map(tracker => `tracker:${tracker}`).concat(`dht:${infoHash}`);
|
||||
}
|
||||
|
||||
export function enrichStreamSources(stream) {
|
||||
const provider = extractProvider(stream.title);
|
||||
if (ANIME_PROVIDERS.includes(provider)) {
|
||||
const sources = getSources(ALL_ANIME_TRACKERS, stream.infoHash);
|
||||
return { ...stream, sources };
|
||||
}
|
||||
if (RUSSIAN_PROVIDERS.includes(provider)) {
|
||||
const sources = unique([].concat(stream.sources || []).concat(getSources(ALL_RUSSIAN_TRACKERS, stream.infoHash)));
|
||||
return { ...stream, sources };
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
function unique(array) {
|
||||
return Array.from(new Set(array));
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { MochOptions } from '../moch/moch.js';
|
||||
import { Providers } from './filter.js';
|
||||
import { showDebridCatalog } from '../moch/options.js';
|
||||
import { getManifestOverride } from './configuration.js';
|
||||
import { Type } from './types.js';
|
||||
|
||||
const DefaultProviders = Providers.options.map(provider => provider.key);
|
||||
const MochProviders = Object.values(MochOptions);
|
||||
|
||||
export function manifest(config = {}) {
|
||||
const overrideManifest = getManifestOverride(config);
|
||||
const baseManifest = {
|
||||
id: 'com.stremio.torrentio.addon',
|
||||
version: '0.0.14',
|
||||
name: getName(overrideManifest, config),
|
||||
description: getDescription(config),
|
||||
catalogs: getCatalogs(config),
|
||||
resources: getResources(config),
|
||||
types: [Type.MOVIE, Type.SERIES, Type.ANIME, Type.OTHER],
|
||||
background: `https://i.ibb.co/VtSfFP9/t8wVwcg.jpg`,
|
||||
logo: `https://i.ibb.co/w4BnkC9/GwxAcDV.png`,
|
||||
behaviorHints: {
|
||||
configurable: true,
|
||||
configurationRequired: false
|
||||
}
|
||||
};
|
||||
return Object.assign(baseManifest, overrideManifest);
|
||||
}
|
||||
|
||||
export function dummyManifest() {
|
||||
const manifestDefault = manifest();
|
||||
manifestDefault.catalogs = [{ id: 'dummy', type: Type.OTHER }];
|
||||
manifestDefault.resources = ['stream', 'meta'];
|
||||
return manifestDefault;
|
||||
}
|
||||
|
||||
function getName(manifest, config) {
|
||||
const rootName = manifest?.name || 'Torrentio';
|
||||
const mochSuffix = MochProviders
|
||||
.filter(moch => config[moch.key])
|
||||
.map(moch => moch.shortName)
|
||||
.join('/');
|
||||
return [rootName, mochSuffix].filter(v => v).join(' ');
|
||||
}
|
||||
|
||||
function getDescription(config) {
|
||||
const providersList = config[Providers.key] || DefaultProviders;
|
||||
const enabledProvidersDesc = Providers.options
|
||||
.map(provider => `${provider.label}${providersList.includes(provider.key) ? '(+)' : '(-)'}`)
|
||||
.join(', ')
|
||||
const enabledMochs = MochProviders
|
||||
.filter(moch => config[moch.key])
|
||||
.map(moch => moch.name)
|
||||
.join(' & ');
|
||||
const possibleMochs = MochProviders.map(moch => moch.name).join('/')
|
||||
const mochsDesc = enabledMochs ? ` and ${enabledMochs} enabled` : '';
|
||||
return 'Provides torrent streams from scraped torrent providers.'
|
||||
+ ` Currently supports ${enabledProvidersDesc}${mochsDesc}.`
|
||||
+ ` To configure providers, ${possibleMochs} support and other settings visit https://torrentio.strem.fun`
|
||||
}
|
||||
|
||||
function getCatalogs(config) {
|
||||
return MochProviders
|
||||
.filter(moch => showDebridCatalog(config) && config[moch.key])
|
||||
.map(moch => moch.catalogs.map(catalogName => ({
|
||||
id: catalogName ? `torrentio-${moch.key}-${catalogName.toLowerCase()}` : `torrentio-${moch.key}`,
|
||||
name: catalogName ? `${moch.name} ${catalogName}` : `${moch.name}`,
|
||||
type: 'other',
|
||||
extra: [{ name: 'skip' }],
|
||||
})))
|
||||
.reduce((a, b) => a.concat(b), []);
|
||||
}
|
||||
|
||||
function getResources(config) {
|
||||
const streamResource = {
|
||||
name: 'stream',
|
||||
types: [Type.MOVIE, Type.SERIES],
|
||||
idPrefixes: ['tt', 'kitsu']
|
||||
};
|
||||
const metaResource = {
|
||||
name: 'meta',
|
||||
types: [Type.OTHER],
|
||||
idPrefixes: MochProviders.filter(moch => config[moch.key]).map(moch => moch.key)
|
||||
};
|
||||
if (showDebridCatalog(config) && MochProviders.filter(moch => config[moch.key]).length) {
|
||||
return [streamResource, metaResource];
|
||||
}
|
||||
return [streamResource];
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import namedQueue from "named-queue";
|
||||
|
||||
export function createNamedQueue(concurrency) {
|
||||
const queue = new namedQueue((task, callback) => task.method()
|
||||
.then(result => callback(false, result))
|
||||
.catch((error => callback(error))), 200);
|
||||
queue.wrap = (id, method) => new Promise(((resolve, reject) => {
|
||||
queue.push({ id, method }, (error, result) => result ? resolve(result) : reject(error));
|
||||
}));
|
||||
return queue;
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
/**
|
||||
* Delay promise
|
||||
*/
|
||||
export async function delay(duration) {
|
||||
return new Promise((resolve) => setTimeout(resolve, duration));
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout promise after a set time in ms
|
||||
*/
|
||||
export async function timeout(timeoutMs, promise, message = 'Timed out') {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise(function (resolve, reject) {
|
||||
setTimeout(function () {
|
||||
reject(message);
|
||||
}, timeoutMs);
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { Sequelize } from 'sequelize';
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
const DATABASE_URI = process.env.DATABASE_URI;
|
||||
|
||||
const database = new Sequelize(DATABASE_URI, { logging: false, pool: { max: 30, min: 5, idle: 20 * 60 * 1000 } });
|
||||
|
||||
const Torrent = database.define('torrent',
|
||||
{
|
||||
infoHash: { type: Sequelize.STRING(64), primaryKey: true },
|
||||
provider: { type: Sequelize.STRING(32), allowNull: false },
|
||||
torrentId: { type: Sequelize.STRING(128) },
|
||||
title: { type: Sequelize.STRING(256), allowNull: false },
|
||||
size: { type: Sequelize.BIGINT },
|
||||
type: { type: Sequelize.STRING(16), allowNull: false },
|
||||
uploadDate: { type: Sequelize.DATE, allowNull: false },
|
||||
seeders: { type: Sequelize.SMALLINT },
|
||||
trackers: { type: Sequelize.STRING(4096) },
|
||||
languages: { type: Sequelize.STRING(4096) },
|
||||
resolution: { type: Sequelize.STRING(16) }
|
||||
}
|
||||
);
|
||||
|
||||
const File = database.define('file',
|
||||
{
|
||||
id: { type: Sequelize.BIGINT, autoIncrement: true, primaryKey: true },
|
||||
infoHash: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: false,
|
||||
references: { model: Torrent, key: 'infoHash' },
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
fileIndex: { type: Sequelize.INTEGER },
|
||||
title: { type: Sequelize.STRING(256), allowNull: false },
|
||||
size: { type: Sequelize.BIGINT },
|
||||
imdbId: { type: Sequelize.STRING(32) },
|
||||
imdbSeason: { type: Sequelize.INTEGER },
|
||||
imdbEpisode: { type: Sequelize.INTEGER },
|
||||
kitsuId: { type: Sequelize.INTEGER },
|
||||
kitsuEpisode: { type: Sequelize.INTEGER }
|
||||
},
|
||||
);
|
||||
|
||||
const Subtitle = database.define('subtitle',
|
||||
{
|
||||
infoHash: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: false,
|
||||
references: { model: Torrent, key: 'infoHash' },
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
fileIndex: { type: Sequelize.INTEGER, allowNull: false },
|
||||
fileId: {
|
||||
type: Sequelize.BIGINT,
|
||||
allowNull: true,
|
||||
references: { model: File, key: 'id' },
|
||||
onDelete: 'SET NULL'
|
||||
},
|
||||
title: { type: Sequelize.STRING(512), allowNull: false },
|
||||
size: { type: Sequelize.BIGINT, allowNull: false },
|
||||
},
|
||||
{ timestamps: false }
|
||||
);
|
||||
|
||||
Torrent.hasMany(File, { foreignKey: 'infoHash', constraints: false });
|
||||
File.belongsTo(Torrent, { foreignKey: 'infoHash', constraints: false });
|
||||
File.hasMany(Subtitle, { foreignKey: 'fileId', constraints: false });
|
||||
Subtitle.belongsTo(File, { foreignKey: 'fileId', constraints: false });
|
||||
|
||||
export function getTorrent(infoHash) {
|
||||
return Torrent.findOne({ where: { infoHash: infoHash } });
|
||||
}
|
||||
|
||||
export function getFiles(infoHashes) {
|
||||
return File.findAll({ where: { infoHash: { [Op.in]: infoHashes} } });
|
||||
}
|
||||
|
||||
export function getImdbIdMovieEntries(imdbId) {
|
||||
return File.findAll({
|
||||
where: {
|
||||
imdbId: { [Op.eq]: imdbId }
|
||||
},
|
||||
include: [Torrent],
|
||||
limit: 500,
|
||||
order: [
|
||||
[Torrent, 'seeders', 'DESC']
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
export function getImdbIdSeriesEntries(imdbId, season, episode) {
|
||||
return File.findAll({
|
||||
where: {
|
||||
imdbId: { [Op.eq]: imdbId },
|
||||
imdbSeason: { [Op.eq]: season },
|
||||
imdbEpisode: { [Op.eq]: episode }
|
||||
},
|
||||
include: [Torrent],
|
||||
limit: 500,
|
||||
order: [
|
||||
[Torrent, 'seeders', 'DESC']
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
export function getKitsuIdMovieEntries(kitsuId) {
|
||||
return File.findAll({
|
||||
where: {
|
||||
kitsuId: { [Op.eq]: kitsuId }
|
||||
},
|
||||
include: [Torrent],
|
||||
limit: 500,
|
||||
order: [
|
||||
[Torrent, 'seeders', 'DESC']
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
export function getKitsuIdSeriesEntries(kitsuId, episode) {
|
||||
return File.findAll({
|
||||
where: {
|
||||
kitsuId: { [Op.eq]: kitsuId },
|
||||
kitsuEpisode: { [Op.eq]: episode }
|
||||
},
|
||||
include: [Torrent],
|
||||
limit: 500,
|
||||
order: [
|
||||
[Torrent, 'seeders', 'DESC']
|
||||
]
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import UserAgent from 'user-agents';
|
||||
const userAgent = new UserAgent();
|
||||
|
||||
export function getRandomUserAgent() {
|
||||
return userAgent.random().toString();
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { QualityFilter } from './filter.js';
|
||||
import { containsLanguage, LanguageOptions } from './languages.js';
|
||||
import { Type } from './types.js';
|
||||
import { hasMochConfigured } from '../moch/moch.js';
|
||||
import { extractSeeders, extractSize } from './titleHelper.js';
|
||||
|
||||
const OTHER_QUALITIES = QualityFilter.options.find(option => option.key === 'other');
|
||||
const CAM_QUALITIES = QualityFilter.options.find(option => option.key === 'cam');
|
||||
const HEALTHY_SEEDERS = 5;
|
||||
const SEEDED_SEEDERS = 1;
|
||||
const MIN_HEALTHY_COUNT = 50;
|
||||
const MAX_UNHEALTHY_COUNT = 5;
|
||||
|
||||
export const SortOptions = {
|
||||
key: 'sort',
|
||||
options: {
|
||||
qualitySeeders: {
|
||||
key: 'quality',
|
||||
description: 'By quality then seeders'
|
||||
},
|
||||
qualitySize: {
|
||||
key: 'qualitysize',
|
||||
description: 'By quality then size'
|
||||
},
|
||||
seeders: {
|
||||
key: 'seeders',
|
||||
description: 'By seeders'
|
||||
},
|
||||
size: {
|
||||
key: 'size',
|
||||
description: 'By size'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function sortStreams(streams, config, type) {
|
||||
const languages = config[LanguageOptions.key];
|
||||
if (languages?.length && languages[0] !== 'english') {
|
||||
// No need to filter english since it's hard to predict which entries are english
|
||||
const streamsWithLanguage = streams.filter(stream => containsLanguage(stream, languages));
|
||||
const streamsNoLanguage = streams.filter(stream => !streamsWithLanguage.includes(stream));
|
||||
return _sortStreams(streamsWithLanguage, config, type).concat(_sortStreams(streamsNoLanguage, config, type));
|
||||
}
|
||||
return _sortStreams(streams, config, type);
|
||||
}
|
||||
|
||||
function _sortStreams(streams, config, type) {
|
||||
const sort = config?.sort?.toLowerCase() || undefined;
|
||||
const limit = /^[1-9][0-9]*$/.test(config.limit) && parseInt(config.limit) || undefined;
|
||||
const sortedStreams = sortBySeeders(streams, config, type);
|
||||
if (sort === SortOptions.options.seeders.key) {
|
||||
return sortedStreams.slice(0, limit);
|
||||
} else if (sort === SortOptions.options.size.key) {
|
||||
return sortBySize(sortedStreams, limit);
|
||||
}
|
||||
const nestedSort = sort === SortOptions.options.qualitySize.key ? sortBySize : noopSort;
|
||||
return sortByVideoQuality(sortedStreams, nestedSort, limit)
|
||||
}
|
||||
|
||||
function noopSort(streams) {
|
||||
return streams;
|
||||
}
|
||||
|
||||
function sortBySeeders(streams, config, type) {
|
||||
// streams are already presorted by seeders and upload date
|
||||
const healthy = streams.filter(stream => extractSeeders(stream.title) >= HEALTHY_SEEDERS);
|
||||
const seeded = streams.filter(stream => extractSeeders(stream.title) >= SEEDED_SEEDERS);
|
||||
|
||||
if (type === Type.SERIES && hasMochConfigured(config)) {
|
||||
return streams;
|
||||
} else if (healthy.length >= MIN_HEALTHY_COUNT) {
|
||||
return healthy;
|
||||
} else if (seeded.length >= MAX_UNHEALTHY_COUNT) {
|
||||
return seeded.slice(0, MIN_HEALTHY_COUNT);
|
||||
}
|
||||
return streams.slice(0, MAX_UNHEALTHY_COUNT);
|
||||
}
|
||||
|
||||
function sortBySize(streams, limit) {
|
||||
return streams
|
||||
.sort((a, b) => {
|
||||
const aSize = extractSize(a.title);
|
||||
const bSize = extractSize(b.title);
|
||||
return bSize - aSize;
|
||||
}).slice(0, limit);
|
||||
}
|
||||
|
||||
function sortByVideoQuality(streams, nestedSort, limit) {
|
||||
const qualityMap = streams
|
||||
.reduce((map, stream) => {
|
||||
const quality = extractQuality(stream.name);
|
||||
map[quality] = (map[quality] || []).concat(stream);
|
||||
return map;
|
||||
}, {});
|
||||
const sortedQualities = Object.keys(qualityMap)
|
||||
.sort((a, b) => {
|
||||
const aResolution = a?.match(/\d+p/) && parseInt(a, 10);
|
||||
const bResolution = b?.match(/\d+p/) && parseInt(b, 10);
|
||||
if (aResolution && bResolution) {
|
||||
return bResolution - aResolution; // higher resolution first;
|
||||
} else if (aResolution) {
|
||||
return -1; // remain higher if resolution is there
|
||||
} else if (bResolution) {
|
||||
return 1; // move downward if other stream has resolution
|
||||
}
|
||||
return a < b ? -1 : b < a ? 1 : 0; // otherwise sort by alphabetic order
|
||||
});
|
||||
return sortedQualities
|
||||
.map(quality => nestedSort(qualityMap[quality]).slice(0, limit))
|
||||
.reduce((a, b) => a.concat(b), []);
|
||||
}
|
||||
|
||||
function extractQuality(title) {
|
||||
const qualityDesc = title.split('\n')[1];
|
||||
const resolutionMatch = qualityDesc?.match(/\d+p/);
|
||||
const isHDR = qualityDesc?.match(/HDR|DV/);
|
||||
const withHDRScore = resolution => isHDR ? resolution.replace('0p', '1p') : resolution;
|
||||
if (resolutionMatch) {
|
||||
return withHDRScore(resolutionMatch[0]);
|
||||
} else if (/8k/i.test(qualityDesc)) {
|
||||
return withHDRScore('4320p');
|
||||
} else if (/4k|uhd/i.test(qualityDesc)) {
|
||||
return withHDRScore('2060p');
|
||||
} else if (CAM_QUALITIES.test(qualityDesc)) {
|
||||
return CAM_QUALITIES.label;
|
||||
} else if (OTHER_QUALITIES.test(qualityDesc)) {
|
||||
return OTHER_QUALITIES.label;
|
||||
}
|
||||
return qualityDesc;
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import titleParser from 'parse-torrent-title';
|
||||
import { Type } from './types.js';
|
||||
import { mapLanguages } from './languages.js';
|
||||
import { enrichStreamSources, getSources } from './magnetHelper.js';
|
||||
import { getSubtitles } from './subtitles.js';
|
||||
|
||||
const ADDON_NAME = 'Torrentio';
|
||||
const SIZE_DELTA = 0.05;
|
||||
const UNKNOWN_SIZE = 300000000;
|
||||
const CAM_SOURCES = ['CAM', 'TeleSync', 'TeleCine', 'SCR'];
|
||||
|
||||
export function toStreamInfo(record) {
|
||||
const torrentInfo = titleParser.parse(record.torrent.title);
|
||||
const fileInfo = titleParser.parse(record.title);
|
||||
const sameInfo = !Number.isInteger(record.fileIndex)
|
||||
|| Math.abs(record.size / record.torrent.size - 1) < SIZE_DELTA
|
||||
|| record.title.includes(record.torrent.title);
|
||||
const quality = getQuality(record, torrentInfo, fileInfo);
|
||||
const three3Quality = fileInfo.threeD || torrentInfo.threeD;
|
||||
const hdrProfiles = torrentInfo.hdr || fileInfo.hdr || [];
|
||||
const title = joinDetailParts(
|
||||
[
|
||||
joinDetailParts([record.torrent.title.replace(/[, ]+/g, ' ')]),
|
||||
joinDetailParts([!sameInfo && record.title || undefined]),
|
||||
joinDetailParts([
|
||||
joinDetailParts([record.torrent.seeders], '👤 '),
|
||||
joinDetailParts([formatSize(record.size)], '💾 '),
|
||||
joinDetailParts([record.torrent.provider], '⚙️ ')
|
||||
]),
|
||||
joinDetailParts(getLanguages(record, torrentInfo, fileInfo), '', ' / '),
|
||||
],
|
||||
'',
|
||||
'\n'
|
||||
);
|
||||
const name = joinDetailParts(
|
||||
[
|
||||
joinDetailParts([ADDON_NAME]),
|
||||
joinDetailParts([quality, three3Quality, joinDetailParts(hdrProfiles, '', ' | ')])
|
||||
],
|
||||
'',
|
||||
'\n'
|
||||
);
|
||||
const bingeGroupParts = getBingeGroupParts(record, sameInfo, quality, torrentInfo, fileInfo);
|
||||
const bingeGroup = joinDetailParts(bingeGroupParts, "torrentio|", "|")
|
||||
const filename = Number.isInteger(record.fileIndex) ? record.title.split('/').pop() : undefined;
|
||||
const behaviorHints = bingeGroup || filename ? cleanOutputObject({ bingeGroup, filename }) : undefined;
|
||||
|
||||
return cleanOutputObject({
|
||||
name: name,
|
||||
title: title,
|
||||
infoHash: record.infoHash,
|
||||
fileIdx: record.fileIndex,
|
||||
behaviorHints: behaviorHints,
|
||||
sources: getSources(record.torrent.trackers, record.infoHash),
|
||||
subtitles: getSubtitles(record)
|
||||
});
|
||||
}
|
||||
|
||||
function getQuality(record, torrentInfo, fileInfo) {
|
||||
if (CAM_SOURCES.includes(fileInfo.source)) {
|
||||
return fileInfo.source;
|
||||
}
|
||||
if (CAM_SOURCES.includes(torrentInfo.source)) {
|
||||
return torrentInfo.source;
|
||||
}
|
||||
const resolution = fileInfo.resolution || torrentInfo.resolution || record.torrent.resolution;
|
||||
const source = fileInfo.source || torrentInfo.source;
|
||||
return resolution || source;
|
||||
}
|
||||
|
||||
function getLanguages(record, torrentInfo, fileInfo) {
|
||||
const providerLanguages = record.torrent.languages && titleParser.parse(record.torrent.languages + '.srt').languages || [];
|
||||
const torrentLanguages = torrentInfo.languages || [];
|
||||
const fileLanguages = fileInfo.languages || [];
|
||||
const dubbed = torrentInfo.dubbed || fileInfo.dubbed;
|
||||
let languages = Array.from(new Set([].concat(torrentLanguages).concat(fileLanguages).concat(providerLanguages)));
|
||||
if (record.kitsuId || record.torrent.type === Type.ANIME) {
|
||||
// no need to display japanese for anime
|
||||
languages = languages.concat(dubbed ? ['dubbed'] : [])
|
||||
.filter(lang => lang !== 'japanese');
|
||||
}
|
||||
if (languages.length === 1 && languages.includes('english')) {
|
||||
// no need to display languages if only english is present
|
||||
languages = [];
|
||||
}
|
||||
if (languages.length === 0 && dubbed) {
|
||||
// display dubbed only if there are no other languages defined for non anime
|
||||
languages = ['dubbed'];
|
||||
}
|
||||
return mapLanguages(languages);
|
||||
}
|
||||
|
||||
function joinDetailParts(parts, prefix = '', delimiter = ' ') {
|
||||
const filtered = parts.filter((part) => part !== undefined && part !== null).join(delimiter);
|
||||
|
||||
return filtered.length > 0 ? `${prefix}${filtered}` : undefined;
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
if (!size) {
|
||||
return undefined;
|
||||
}
|
||||
if (size === UNKNOWN_SIZE) {
|
||||
return undefined;
|
||||
}
|
||||
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
|
||||
return Number((size / Math.pow(1024, i)).toFixed(2)) + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
|
||||
}
|
||||
|
||||
export function applyStaticInfo(streams) {
|
||||
return streams.map(stream => enrichStaticInfo(stream));
|
||||
}
|
||||
|
||||
function enrichStaticInfo(stream) {
|
||||
return enrichSubtitles(enrichStreamSources({ ...stream }));
|
||||
}
|
||||
|
||||
function enrichSubtitles(stream) {
|
||||
if (stream.subtitles?.length) {
|
||||
stream.subtitles = stream.subtitles.map(subtitle =>{
|
||||
if (subtitle.url) {
|
||||
return subtitle;
|
||||
}
|
||||
return {
|
||||
id: `${subtitle.fileIndex}`,
|
||||
lang: subtitle.lang,
|
||||
url: `http://localhost:11470/${subtitle.infoHash}/${subtitle.fileIndex}/${subtitle.title.split('/').pop()}`
|
||||
};
|
||||
});
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
function getBingeGroupParts(record, sameInfo, quality, torrentInfo, fileInfo) {
|
||||
if (record.torrent.type === Type.MOVIE) {
|
||||
const source = torrentInfo.source || fileInfo.source
|
||||
return [quality]
|
||||
.concat(source !== quality ? source : [])
|
||||
.concat(torrentInfo.codec || fileInfo.codec)
|
||||
.concat(torrentInfo.bitDepth || fileInfo.bitDepth)
|
||||
.concat(torrentInfo.hdr || fileInfo.hdr);
|
||||
} else if (sameInfo) {
|
||||
return [quality]
|
||||
.concat(fileInfo.hdr)
|
||||
.concat(fileInfo.group);
|
||||
}
|
||||
return [record.infoHash];
|
||||
}
|
||||
|
||||
function cleanOutputObject(object) {
|
||||
return Object.fromEntries(Object.entries(object).filter(([_, v]) => v != null));
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { parse } from 'parse-torrent-title';
|
||||
import { isExtension } from './extension.js';
|
||||
import { Providers } from './filter.js';
|
||||
import { languageFromCode } from './languages.js';
|
||||
|
||||
const languageMapping = {
|
||||
'english': 'eng',
|
||||
'japanese': 'jpn',
|
||||
'russian': 'rus',
|
||||
'italian': 'ita',
|
||||
'portuguese': 'por',
|
||||
'spanish': 'spa',
|
||||
'latino': 'lat',
|
||||
'korean': 'kor',
|
||||
'chinese': 'zho',
|
||||
'taiwanese': 'zht',
|
||||
'french': 'fre',
|
||||
'german': 'ger',
|
||||
'dutch': 'dut',
|
||||
'hindi': 'hin ',
|
||||
'telugu': 'tel',
|
||||
'tamil': 'tam',
|
||||
'polish': 'pol',
|
||||
'lithuanian': 'lit',
|
||||
'latvian': 'lav',
|
||||
'estonian': 'est',
|
||||
'czech': 'cze',
|
||||
'slovakian': 'slo',
|
||||
'slovenian': 'slv',
|
||||
'hungarian': 'hun',
|
||||
'romanian': 'rum',
|
||||
'bulgarian': 'bul',
|
||||
'serbian': 'scc',
|
||||
'croatian': 'hrv',
|
||||
'ukrainian': 'ukr',
|
||||
'greek': 'ell',
|
||||
'danish': 'dan',
|
||||
'finnish': 'fin',
|
||||
'swedish': 'swe',
|
||||
'norwegian': 'nor',
|
||||
'turkish': 'tur',
|
||||
'arabic': 'ara',
|
||||
'persian': 'per',
|
||||
'hebrew': 'heb',
|
||||
'vietnamese': 'vie',
|
||||
'indonesian': 'ind',
|
||||
'thai': 'tha'
|
||||
}
|
||||
|
||||
const ignoreSet = new Set(['dubbed', 'multi audio', 'multi subs', 'dual audio']);
|
||||
const allowedExtensions = ['srt', 'vtt', 'ass', 'ssa'];
|
||||
|
||||
export function getSubtitles(record) {
|
||||
if (!record?.subtitles?.length) {
|
||||
return null;
|
||||
}
|
||||
return record.subtitles
|
||||
.filter(subtitle => isExtension(subtitle.title, allowedExtensions))
|
||||
.sort((a, b) => b.size - a.size)
|
||||
.map(subtitle => ({
|
||||
infoHash: subtitle.infoHash,
|
||||
fileIndex: subtitle.fileIndex,
|
||||
title: subtitle.title,
|
||||
lang: parseLanguage(subtitle.title, record),
|
||||
}));
|
||||
}
|
||||
|
||||
function parseLanguage(title, record) {
|
||||
const subtitlePathParts = title.split('/');
|
||||
const subtitleFileName = subtitlePathParts.pop();
|
||||
const subtitleTitleNoExt = title.replace(/\.\w{2,5}$/, '');
|
||||
const videoFileName = record.title.split('/').pop().replace(/\.\w{2,5}$/, '');
|
||||
const fileNameLanguage = getSingleLanguage(subtitleFileName.replace(videoFileName, ''));
|
||||
if (fileNameLanguage) {
|
||||
return fileNameLanguage;
|
||||
}
|
||||
const videoTitleNoExt = record.title.replace(/\.\w{2,5}$/, '');
|
||||
if (subtitleTitleNoExt === record.title || subtitleTitleNoExt === videoTitleNoExt) {
|
||||
const provider = Providers.options.find(provider => provider.label === record.torrent.provider);
|
||||
return provider?.foreign && languageFromCode(provider.foreign) || 'eng';
|
||||
}
|
||||
const folderName = subtitlePathParts.join('/');
|
||||
const folderNameLanguage = getSingleLanguage(folderName.replace(videoFileName, ''));
|
||||
if (folderNameLanguage) {
|
||||
return folderNameLanguage
|
||||
}
|
||||
return getFileNameLanguageCode(subtitleFileName) || 'Unknown';
|
||||
}
|
||||
|
||||
function getSingleLanguage(title) {
|
||||
const parsedInfo = parse(title);
|
||||
const languages = (parsedInfo.languages || []).filter(language => !ignoreSet.has(language));
|
||||
return languages.length === 1 ? languageMapping[languages[0]] : undefined;
|
||||
}
|
||||
|
||||
function getFileNameLanguageCode(fileName) {
|
||||
const match = fileName.match(/(?:(?:^|[._ ])([A-Za-z][a-z]{1,2})|\[([a-z]{2,3})])\.\w{3,4}$/);
|
||||
return match?.[1]?.toLowerCase();
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
export function extractSeeders(title) {
|
||||
const seedersMatch = title.match(/👤 (\d+)/);
|
||||
return seedersMatch && parseInt(seedersMatch[1]) || 0;
|
||||
}
|
||||
|
||||
export function extractSize(title) {
|
||||
const seedersMatch = title.match(/💾 ([\d.]+ \w+)/);
|
||||
return seedersMatch && parseSize(seedersMatch[1]) || 0;
|
||||
}
|
||||
|
||||
export function extractProvider(title) {
|
||||
const match = title.match(/⚙.* ([^ \n]+)/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export function parseSize(sizeText) {
|
||||
if (!sizeText) {
|
||||
return 0;
|
||||
}
|
||||
let scale = 1;
|
||||
if (sizeText.includes('TB')) {
|
||||
scale = 1024 * 1024 * 1024 * 1024
|
||||
} else if (sizeText.includes('GB')) {
|
||||
scale = 1024 * 1024 * 1024
|
||||
} else if (sizeText.includes('MB')) {
|
||||
scale = 1024 * 1024;
|
||||
} else if (sizeText.includes('kB')) {
|
||||
scale = 1024;
|
||||
}
|
||||
return Math.floor(parseFloat(sizeText.replace(/,/g, '')) * scale);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export const Type = {
|
||||
MOVIE: 'movie',
|
||||
SERIES: 'series',
|
||||
ANIME: 'anime',
|
||||
OTHER: 'other'
|
||||
};
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import AllDebridClient from 'all-debrid-api';
|
||||
import { Type } from '../lib/types.js';
|
||||
import { isVideo, isArchive } from '../lib/extension.js';
|
||||
import StaticResponse from './static.js';
|
||||
import { getMagnetLink } from '../lib/magnetHelper.js';
|
||||
import { BadTokenError, AccessDeniedError, sameFilename, streamFilename, AccessBlockedError } from './mochHelper.js';
|
||||
|
||||
const KEY = 'alldebrid';
|
||||
const AGENT = 'torrentio';
|
||||
|
||||
export async function getCachedStreams(streams, apiKey, ip) {
|
||||
return streams
|
||||
.reduce((mochStreams, stream) => {
|
||||
const filename = streamFilename(stream);
|
||||
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
|
||||
url: `${apiKey}/${stream.infoHash}/${filename}/${stream.fileIdx}`,
|
||||
cached: false
|
||||
}
|
||||
return mochStreams;
|
||||
}, {})
|
||||
}
|
||||
|
||||
export async function getCatalog(apiKey, catalogId, config) {
|
||||
if (config.skip > 0) {
|
||||
return [];
|
||||
}
|
||||
const options = await getDefaultOptions(config.ip);
|
||||
const AD = new AllDebridClient(apiKey, options);
|
||||
return AD.magnet.status()
|
||||
.then(response => response.data.magnets)
|
||||
.then(torrents => (torrents || [])
|
||||
.filter(torrent => torrent && statusReady(torrent.statusCode))
|
||||
.map(torrent => ({
|
||||
id: `${KEY}:${torrent.id}`,
|
||||
type: Type.OTHER,
|
||||
name: torrent.filename
|
||||
})));
|
||||
}
|
||||
|
||||
export async function getItemMeta(itemId, apiKey, ip) {
|
||||
const options = await getDefaultOptions(ip);
|
||||
const AD = new AllDebridClient(apiKey, options);
|
||||
return AD.magnet.status(itemId)
|
||||
.then(response => response.data.magnets)
|
||||
.then(torrent => ({
|
||||
id: `${KEY}:${torrent.id}`,
|
||||
type: Type.OTHER,
|
||||
name: torrent.filename,
|
||||
infoHash: torrent.hash.toLowerCase(),
|
||||
videos: torrent.links
|
||||
.filter(file => isVideo(file.filename))
|
||||
.map((file, index) => ({
|
||||
id: `${KEY}:${torrent.id}:${index}`,
|
||||
title: file.filename,
|
||||
released: new Date(torrent.uploadDate * 1000 - index).toISOString(),
|
||||
streams: [{ url: `${apiKey}/${torrent.hash.toLowerCase()}/${encodeURIComponent(file.filename)}/${index}` }]
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
|
||||
console.log(`Unrestricting AllDebrid ${infoHash} [${fileIndex}]`);
|
||||
const options = await getDefaultOptions(ip);
|
||||
const AD = new AllDebridClient(apiKey, options);
|
||||
|
||||
return _resolve(AD, infoHash, cachedEntryInfo, fileIndex)
|
||||
.catch(error => {
|
||||
if (isExpiredSubscriptionError(error)) {
|
||||
console.log(`Access denied to AllDebrid ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_ACCESS;
|
||||
}
|
||||
if (isBlockedAccessError(error)) {
|
||||
console.log(`Access blocked to AllDebrid ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.BLOCKED_ACCESS;
|
||||
}
|
||||
if (error.code === 'MAGNET_TOO_MANY') {
|
||||
console.log(`Deleting and retrying adding to AllDebrid ${infoHash} [${fileIndex}]...`);
|
||||
return _deleteAndRetry(AD, infoHash, cachedEntryInfo, fileIndex);
|
||||
}
|
||||
return Promise.reject(`Failed AllDebrid adding torrent ${JSON.stringify(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function _resolve(AD, infoHash, cachedEntryInfo, fileIndex) {
|
||||
const torrent = await _createOrFindTorrent(AD, infoHash);
|
||||
if (statusReady(torrent?.statusCode)) {
|
||||
return _unrestrictLink(AD, torrent, cachedEntryInfo, fileIndex);
|
||||
} else if (statusDownloading(torrent?.statusCode)) {
|
||||
console.log(`Downloading to AllDebrid ${infoHash} [${fileIndex}]...`);
|
||||
return StaticResponse.DOWNLOADING;
|
||||
} else if (statusHandledError(torrent?.statusCode)) {
|
||||
console.log(`Retrying downloading to AllDebrid ${infoHash} [${fileIndex}]...`);
|
||||
return _retryCreateTorrent(AD, infoHash, cachedEntryInfo, fileIndex);
|
||||
} else if (statusTooBigEntry(torrent?.statusCode)) {
|
||||
console.log(`Torrent too big for AllDebrid ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_TOO_BIG;
|
||||
}
|
||||
|
||||
return Promise.reject(`Failed AllDebrid adding torrent ${JSON.stringify(torrent)}`);
|
||||
}
|
||||
|
||||
async function _createOrFindTorrent(AD, infoHash) {
|
||||
return _findTorrent(AD, infoHash)
|
||||
.catch(() => _createTorrent(AD, infoHash));
|
||||
}
|
||||
|
||||
async function _retryCreateTorrent(AD, infoHash, encodedFileName, fileIndex) {
|
||||
const newTorrent = await _createTorrent(AD, infoHash);
|
||||
return newTorrent && statusReady(newTorrent.statusCode)
|
||||
? _unrestrictLink(AD, newTorrent, encodedFileName, fileIndex)
|
||||
: StaticResponse.FAILED_DOWNLOAD;
|
||||
}
|
||||
|
||||
async function _deleteAndRetry(AD, infoHash, encodedFileName, fileIndex) {
|
||||
const torrents = await AD.magnet.status().then(response => response.data.magnets);
|
||||
const lastTorrent = torrents[torrents.length - 1];
|
||||
return AD.magnet.delete(lastTorrent.id)
|
||||
.then(() => _retryCreateTorrent(AD, infoHash, encodedFileName, fileIndex));
|
||||
}
|
||||
|
||||
async function _findTorrent(AD, infoHash) {
|
||||
const torrents = await AD.magnet.status().then(response => response.data.magnets);
|
||||
const foundTorrents = torrents.filter(torrent => torrent.hash.toLowerCase() === infoHash);
|
||||
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent.statusCode));
|
||||
const foundTorrent = nonFailedTorrent || foundTorrents[0];
|
||||
return foundTorrent || Promise.reject('No recent torrent found');
|
||||
}
|
||||
|
||||
async function _createTorrent(AD, infoHash) {
|
||||
const magnetLink = await getMagnetLink(infoHash);
|
||||
const uploadResponse = await AD.magnet.upload(magnetLink);
|
||||
const torrentId = uploadResponse.data.magnets[0].id;
|
||||
return AD.magnet.status(torrentId).then(statusResponse => statusResponse.data.magnets);
|
||||
}
|
||||
|
||||
async function _unrestrictLink(AD, torrent, encodedFileName, fileIndex) {
|
||||
const targetFileName = decodeURIComponent(encodedFileName);
|
||||
const videos = torrent.links.filter(link => isVideo(link.filename)).sort((a, b) => b.size - a.size);
|
||||
const targetVideo = Number.isInteger(fileIndex)
|
||||
&& videos.find(video => sameFilename(targetFileName, video.filename))
|
||||
|| videos[0];
|
||||
|
||||
if (!targetVideo && torrent.links.every(link => isArchive(link.filename))) {
|
||||
console.log(`Only AllDebrid archive is available for [${torrent.hash}] ${encodedFileName}`)
|
||||
return StaticResponse.FAILED_RAR;
|
||||
}
|
||||
if (!targetVideo || !targetVideo.link || !targetVideo.link.length) {
|
||||
return Promise.reject(`No AllDebrid links found for [${torrent.hash}] ${encodedFileName}`);
|
||||
}
|
||||
const unrestrictedLink = await AD.link.unlock(targetVideo.link).then(response => response.data.link);
|
||||
console.log(`Unrestricted AllDebrid ${torrent.hash} [${fileIndex}] to ${unrestrictedLink}`);
|
||||
return unrestrictedLink;
|
||||
}
|
||||
|
||||
async function getDefaultOptions(ip) {
|
||||
return { ip, base_agent: AGENT, timeout: 10000 };
|
||||
}
|
||||
|
||||
export function toCommonError(error) {
|
||||
if (error && error.code === 'AUTH_BAD_APIKEY') {
|
||||
return BadTokenError;
|
||||
}
|
||||
if (error && error.code === 'AUTH_USER_BANNED') {
|
||||
return AccessDeniedError;
|
||||
}
|
||||
if (error && error.code === 'AUTH_BLOCKED') {
|
||||
return AccessBlockedError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function statusError(statusCode) {
|
||||
return [5, 6, 7, 8, 9, 10, 11].includes(statusCode);
|
||||
}
|
||||
|
||||
function statusHandledError(statusCode) {
|
||||
return [5, 7, 9, 10, 11].includes(statusCode);
|
||||
}
|
||||
|
||||
function statusDownloading(statusCode) {
|
||||
return [0, 1, 2, 3].includes(statusCode);
|
||||
}
|
||||
|
||||
function statusReady(statusCode) {
|
||||
return statusCode === 4;
|
||||
}
|
||||
|
||||
function statusTooBigEntry(statusCode) {
|
||||
return statusCode === 8;
|
||||
}
|
||||
|
||||
function isExpiredSubscriptionError(error) {
|
||||
return ['AUTH_BAD_APIKEY', 'MUST_BE_PREMIUM', 'MAGNET_MUST_BE_PREMIUM', 'FREE_TRIAL_LIMIT_REACHED', 'AUTH_USER_BANNED']
|
||||
.includes(error.code);
|
||||
}
|
||||
|
||||
function isBlockedAccessError(error) {
|
||||
return ['AUTH_BLOCKED'].includes(error.code);
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import DebridLinkClient from 'debrid-link-api';
|
||||
import { Type } from '../lib/types.js';
|
||||
import { isVideo, isArchive } from '../lib/extension.js';
|
||||
import StaticResponse from './static.js';
|
||||
import { getMagnetLink } from '../lib/magnetHelper.js';
|
||||
import { BadTokenError } from './mochHelper.js';
|
||||
|
||||
const KEY = 'debridlink';
|
||||
|
||||
export async function getCachedStreams(streams, apiKey) {
|
||||
return streams
|
||||
.reduce((mochStreams, stream) => {
|
||||
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
|
||||
url: `${apiKey}/${stream.infoHash}/null/${stream.fileIdx}`,
|
||||
cached: false
|
||||
};
|
||||
return mochStreams;
|
||||
}, {})
|
||||
}
|
||||
|
||||
export async function getCatalog(apiKey, catalogId, config) {
|
||||
if (config.skip > 0) {
|
||||
return [];
|
||||
}
|
||||
const options = await getDefaultOptions();
|
||||
const DL = new DebridLinkClient(apiKey, options);
|
||||
return DL.seedbox.list()
|
||||
.then(response => response.value)
|
||||
.then(torrents => (torrents || [])
|
||||
.filter(torrent => torrent && statusReady(torrent))
|
||||
.map(torrent => ({
|
||||
id: `${KEY}:${torrent.id}`,
|
||||
type: Type.OTHER,
|
||||
name: torrent.name
|
||||
})));
|
||||
}
|
||||
|
||||
export async function getItemMeta(itemId, apiKey, ip) {
|
||||
const options = await getDefaultOptions(ip);
|
||||
const DL = new DebridLinkClient(apiKey, options);
|
||||
return DL.seedbox.list(itemId)
|
||||
.then(response => response.value[0])
|
||||
.then(torrent => ({
|
||||
id: `${KEY}:${torrent.id}`,
|
||||
type: Type.OTHER,
|
||||
name: torrent.name,
|
||||
infoHash: torrent.hashString.toLowerCase(),
|
||||
videos: torrent.files
|
||||
.filter(file => isVideo(file.name))
|
||||
.map((file, index) => ({
|
||||
id: `${KEY}:${torrent.id}:${index}`,
|
||||
title: file.name,
|
||||
released: new Date(torrent.created * 1000 - index).toISOString(),
|
||||
streams: [{ url: file.downloadUrl }]
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
export async function resolve({ ip, apiKey, infoHash, fileIndex }) {
|
||||
console.log(`Unrestricting DebridLink ${infoHash} [${fileIndex}]`);
|
||||
const options = await getDefaultOptions(ip);
|
||||
const DL = new DebridLinkClient(apiKey, options);
|
||||
|
||||
return _resolve(DL, infoHash, fileIndex)
|
||||
.catch(error => {
|
||||
if (isAccessDeniedError(error)) {
|
||||
console.log(`Access denied to DebridLink ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_ACCESS;
|
||||
}
|
||||
if (isLimitsExceededError(error)) {
|
||||
console.log(`Limits exceeded in DebridLink ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.LIMITS_EXCEEDED;
|
||||
}
|
||||
if (isTorrentTooBigError(error)) {
|
||||
console.log(`Torrent too big for DebridLink ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_TOO_BIG;
|
||||
}
|
||||
return Promise.reject(`Failed DebridLink adding torrent ${JSON.stringify(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function _resolve(DL, infoHash, fileIndex) {
|
||||
const torrent = await _createOrFindTorrent(DL, infoHash);
|
||||
if (torrent && statusReady(torrent)) {
|
||||
return _unrestrictLink(DL, torrent, fileIndex);
|
||||
} else if (torrent && statusDownloading(torrent)) {
|
||||
console.log(`Downloading to DebridLink ${infoHash} [${fileIndex}]...`);
|
||||
return StaticResponse.DOWNLOADING;
|
||||
}
|
||||
|
||||
return Promise.reject(`Failed DebridLink adding torrent ${JSON.stringify(torrent)}`);
|
||||
}
|
||||
|
||||
async function _createOrFindTorrent(DL, infoHash) {
|
||||
return _findTorrent(DL, infoHash)
|
||||
.catch(() => _createTorrent(DL, infoHash));
|
||||
}
|
||||
|
||||
async function _findTorrent(DL, infoHash) {
|
||||
const torrents = await DL.seedbox.list().then(response => response.value);
|
||||
const foundTorrents = torrents.filter(torrent => torrent.hashString.toLowerCase() === infoHash);
|
||||
return foundTorrents[0] || Promise.reject('No recent torrent found');
|
||||
}
|
||||
|
||||
async function _createTorrent(DL, infoHash) {
|
||||
const magnetLink = await getMagnetLink(infoHash);
|
||||
const uploadResponse = await DL.seedbox.add(magnetLink, null, true);
|
||||
return uploadResponse.value;
|
||||
}
|
||||
|
||||
async function _unrestrictLink(DL, torrent, fileIndex) {
|
||||
const targetFile = Number.isInteger(fileIndex)
|
||||
? torrent.files[fileIndex]
|
||||
: torrent.files.filter(file => file.downloadPercent === 100).sort((a, b) => b.size - a.size)[0];
|
||||
|
||||
if (!targetFile && torrent.files.every(file => isArchive(file.downloadUrl))) {
|
||||
console.log(`Only DebridLink archive is available for [${torrent.hashString}] ${fileIndex}`)
|
||||
return StaticResponse.FAILED_RAR;
|
||||
}
|
||||
if (!targetFile || !targetFile.downloadUrl) {
|
||||
return Promise.reject(`No DebridLink links found for index ${fileIndex} in: ${JSON.stringify(torrent)}`);
|
||||
}
|
||||
console.log(`Unrestricted DebridLink ${torrent.hashString} [${fileIndex}] to ${targetFile.downloadUrl}`);
|
||||
return targetFile.downloadUrl;
|
||||
}
|
||||
|
||||
async function getDefaultOptions(ip) {
|
||||
return { ip, timeout: 10000 };
|
||||
}
|
||||
|
||||
export function toCommonError(error) {
|
||||
if (error === 'badToken') {
|
||||
return BadTokenError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function statusDownloading(torrent) {
|
||||
return torrent.downloadPercent < 100
|
||||
}
|
||||
|
||||
function statusReady(torrent) {
|
||||
return torrent.downloadPercent === 100;
|
||||
}
|
||||
|
||||
function isAccessDeniedError(error) {
|
||||
return ['badToken', 'accountLocked'].includes(error);
|
||||
}
|
||||
|
||||
function isLimitsExceededError(error) {
|
||||
return ['freeServerOverload', 'maxTorrent', 'maxLink', 'maxLinkHost', 'maxData', 'maxDataHost', 'floodDetected'].includes(error);
|
||||
}
|
||||
|
||||
function isTorrentTooBigError(error) {
|
||||
return ['torrentTooBig'].includes(error);
|
||||
}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
import * as options from './options.js';
|
||||
import * as realdebrid from './realdebrid.js';
|
||||
import * as premiumize from './premiumize.js';
|
||||
import * as alldebrid from './alldebrid.js';
|
||||
import * as debridlink from './debridlink.js';
|
||||
import * as offcloud from './offcloud.js';
|
||||
import * as torbox from './torbox.js';
|
||||
import * as putio from './putio.js';
|
||||
import StaticResponse, { isStaticUrl } from './static.js';
|
||||
import { cacheWrapResolvedUrl } from '../lib/cache.js';
|
||||
import { timeout } from '../lib/promises.js';
|
||||
import { BadTokenError, streamFilename, AccessDeniedError, enrichMeta, AccessBlockedError } from './mochHelper.js';
|
||||
import { createNamedQueue } from "../lib/namedQueue.js";
|
||||
|
||||
const RESOLVE_TIMEOUT = 2 * 60 * 1000; // 2 minutes
|
||||
const MIN_API_KEY_SYMBOLS = 15;
|
||||
const TOKEN_BLACKLIST = [];
|
||||
export const MochOptions = {
|
||||
realdebrid: {
|
||||
key: 'realdebrid',
|
||||
instance: realdebrid,
|
||||
name: "RealDebrid",
|
||||
shortName: 'RD',
|
||||
catalogs: ['']
|
||||
},
|
||||
premiumize: {
|
||||
key: 'premiumize',
|
||||
instance: premiumize,
|
||||
name: 'Premiumize',
|
||||
shortName: 'PM',
|
||||
catalogs: ['']
|
||||
},
|
||||
alldebrid: {
|
||||
key: 'alldebrid',
|
||||
instance: alldebrid,
|
||||
name: 'AllDebrid',
|
||||
shortName: 'AD',
|
||||
catalogs: ['']
|
||||
},
|
||||
debridlink: {
|
||||
key: 'debridlink',
|
||||
instance: debridlink,
|
||||
name: 'DebridLink',
|
||||
shortName: 'DL',
|
||||
catalogs: ['']
|
||||
},
|
||||
offcloud: {
|
||||
key: 'offcloud',
|
||||
instance: offcloud,
|
||||
name: 'Offcloud',
|
||||
shortName: 'OC',
|
||||
catalogs: ['']
|
||||
},
|
||||
torbox: {
|
||||
key: 'torbox',
|
||||
instance: torbox,
|
||||
name: 'TorBox',
|
||||
shortName: 'TB',
|
||||
catalogs: [`Torrents`, `Usenet`, `WebDL`]
|
||||
},
|
||||
putio: {
|
||||
key: 'putio',
|
||||
instance: putio,
|
||||
name: 'Put.io',
|
||||
shortName: 'Putio',
|
||||
catalogs: ['']
|
||||
}
|
||||
};
|
||||
|
||||
const unrestrictQueues = {}
|
||||
Object.values(MochOptions)
|
||||
.map(moch => moch.key)
|
||||
.forEach(mochKey => unrestrictQueues[mochKey] = createNamedQueue(50));
|
||||
|
||||
export function hasMochConfigured(config) {
|
||||
return Object.keys(MochOptions).find(moch => config?.[moch])
|
||||
}
|
||||
|
||||
export async function applyMochs(streams, config) {
|
||||
if (!streams?.length || !hasMochConfigured(config)) {
|
||||
return streams;
|
||||
}
|
||||
return Promise.all(Object.keys(config)
|
||||
.filter(configKey => MochOptions[configKey])
|
||||
.map(configKey => MochOptions[configKey])
|
||||
.map(moch => {
|
||||
if (isInvalidToken(config[moch.key], moch.key)) {
|
||||
return { moch, error: BadTokenError };
|
||||
}
|
||||
return moch.instance.getCachedStreams(streams, config[moch.key], config.ip)
|
||||
.then(mochStreams => ({ moch, mochStreams }))
|
||||
.catch(rawError => {
|
||||
const error = moch.instance.toCommonError(rawError) || rawError;
|
||||
if (error === BadTokenError) {
|
||||
blackListToken(config[moch.key], moch.key);
|
||||
}
|
||||
return { moch, error };
|
||||
})
|
||||
}))
|
||||
.then(results => processMochResults(streams, config, results));
|
||||
}
|
||||
|
||||
export async function resolve(parameters) {
|
||||
const moch = MochOptions[parameters.mochKey];
|
||||
if (!moch) {
|
||||
return Promise.reject(new Error(`Not a valid moch provider: ${parameters.mochKey}`));
|
||||
}
|
||||
|
||||
if (!parameters.apiKey || !parameters.infoHash || !parameters.cachedEntryInfo) {
|
||||
return Promise.reject(new Error("No valid parameters passed"));
|
||||
}
|
||||
const id = `${parameters.ip}_${parameters.mochKey}_${parameters.apiKey}_${parameters.infoHash}_${parameters.fileIndex}`;
|
||||
const method = () => timeout(RESOLVE_TIMEOUT, cacheWrapResolvedUrl(id, () => moch.instance.resolve(parameters)))
|
||||
.catch(error => {
|
||||
console.warn(error);
|
||||
return StaticResponse.FAILED_UNEXPECTED;
|
||||
})
|
||||
.then(url => isStaticUrl(url) ? `${parameters.host}/${url}` : url);
|
||||
return unrestrictQueues[moch.key].wrap(id, method);
|
||||
}
|
||||
|
||||
export async function getMochCatalog(mochKey, catalogId, config, ) {
|
||||
const moch = MochOptions[mochKey];
|
||||
if (!moch) {
|
||||
return Promise.reject(new Error(`Not a valid moch provider: ${mochKey}`));
|
||||
}
|
||||
if (isInvalidToken(config[mochKey], mochKey)) {
|
||||
return Promise.reject(new Error(`Invalid API key for moch provider: ${mochKey}`));
|
||||
}
|
||||
return moch.instance.getCatalog(config[moch.key], catalogId, config)
|
||||
.catch(rawError => {
|
||||
const commonError = moch.instance.toCommonError(rawError);
|
||||
if (commonError === BadTokenError) {
|
||||
blackListToken(config[moch.key], moch.key);
|
||||
}
|
||||
return commonError ? [] : Promise.reject(rawError);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMochItemMeta(mochKey, itemId, config) {
|
||||
const moch = MochOptions[mochKey];
|
||||
if (!moch) {
|
||||
return Promise.reject(new Error(`Not a valid moch provider: ${mochKey}`));
|
||||
}
|
||||
|
||||
return moch.instance.getItemMeta(itemId, config[moch.key], config.ip)
|
||||
.then(meta => enrichMeta(meta))
|
||||
.then(meta => {
|
||||
meta.videos.forEach(video => video.streams.forEach(stream => {
|
||||
if (!stream.url.startsWith('http')) {
|
||||
stream.url = `${config.host}/${moch.key}/${stream.url}/${streamFilename(video)}`
|
||||
}
|
||||
stream.behaviorHints = { bingeGroup: itemId }
|
||||
}))
|
||||
return meta;
|
||||
});
|
||||
}
|
||||
|
||||
function processMochResults(streams, config, results) {
|
||||
const errorResults = results
|
||||
.map(result => errorStreamResponse(result.moch.key, result.error, config))
|
||||
.filter(errorResponse => errorResponse);
|
||||
if (errorResults.length) {
|
||||
return errorResults;
|
||||
}
|
||||
|
||||
const excludeDownloadLinks = options.excludeDownloadLinks(config);
|
||||
const mochResults = results.filter(result => result?.mochStreams);
|
||||
|
||||
const cachedStreams = mochResults
|
||||
.reduce((resultStreams, mochResult) => populateCachedLinks(resultStreams, mochResult, config), streams);
|
||||
const resultStreams = excludeDownloadLinks ? cachedStreams : populateDownloadLinks(cachedStreams, mochResults, config);
|
||||
return resultStreams.filter(stream => stream.url);
|
||||
}
|
||||
|
||||
function populateCachedLinks(streams, mochResult, config) {
|
||||
return streams.map(stream => {
|
||||
const cachedEntry = stream.infoHash && mochResult.mochStreams[`${stream.infoHash}@${stream.fileIdx}`];
|
||||
if (cachedEntry?.cached) {
|
||||
return {
|
||||
name: `[${mochResult.moch.shortName}+] ${stream.name}`,
|
||||
title: stream.title,
|
||||
url: `${config.host}/${mochResult.moch.key}/${cachedEntry.url}/${streamFilename(stream)}`,
|
||||
behaviorHints: stream.behaviorHints
|
||||
};
|
||||
}
|
||||
return stream;
|
||||
});
|
||||
}
|
||||
|
||||
function populateDownloadLinks(streams, mochResults, config) {
|
||||
const torrentStreams = streams.filter(stream => stream.infoHash);
|
||||
const seededStreams = streams.filter(stream => !stream.title.includes('👤 0'));
|
||||
torrentStreams.forEach(stream => mochResults.forEach(mochResult => {
|
||||
const cachedEntry = mochResult.mochStreams[`${stream.infoHash}@${stream.fileIdx}`];
|
||||
const isCached = cachedEntry?.cached;
|
||||
if (!isCached && isHealthyStreamForDebrid(seededStreams, stream)) {
|
||||
streams.push({
|
||||
name: `[${mochResult.moch.shortName} download] ${stream.name}`,
|
||||
title: stream.title,
|
||||
url: `${config.host}/${mochResult.moch.key}/${cachedEntry.url}/${streamFilename(stream)}`,
|
||||
behaviorHints: stream.behaviorHints
|
||||
})
|
||||
}
|
||||
}));
|
||||
return streams;
|
||||
}
|
||||
|
||||
function isHealthyStreamForDebrid(streams, stream) {
|
||||
const isZeroSeeders = stream.title.includes('👤 0');
|
||||
const is4kStream = stream.name.includes('4k');
|
||||
const isNotEnoughOptions = streams.length <= 5;
|
||||
return !isZeroSeeders || is4kStream || isNotEnoughOptions;
|
||||
}
|
||||
|
||||
function isInvalidToken(token, mochKey) {
|
||||
return token.length < MIN_API_KEY_SYMBOLS || TOKEN_BLACKLIST.includes(`${mochKey}|${token}`);
|
||||
}
|
||||
|
||||
function blackListToken(token, mochKey) {
|
||||
const tokenKey = `${mochKey}|${token}`;
|
||||
console.log(`Blacklisting invalid token: ${tokenKey}`)
|
||||
TOKEN_BLACKLIST.push(tokenKey);
|
||||
}
|
||||
|
||||
function errorStreamResponse(mochKey, error, config) {
|
||||
if (error === BadTokenError) {
|
||||
return {
|
||||
name: `Torrentio\n${MochOptions[mochKey].shortName} error`,
|
||||
title: `Invalid ${MochOptions[mochKey].name} ApiKey/Token!`,
|
||||
url: `${config.host}/${StaticResponse.FAILED_ACCESS}`
|
||||
};
|
||||
}
|
||||
if (error === AccessDeniedError) {
|
||||
return {
|
||||
name: `Torrentio\n${MochOptions[mochKey].shortName} error`,
|
||||
title: `Expired/invalid ${MochOptions[mochKey].name} subscription!`,
|
||||
url: `${config.host}/${StaticResponse.FAILED_ACCESS}`
|
||||
};
|
||||
}
|
||||
if (error === AccessBlockedError) {
|
||||
return {
|
||||
name: `Torrentio\n${MochOptions[mochKey].shortName} error`,
|
||||
title: `Access to ${MochOptions[mochKey].name} is blocked!\nCheck your account or email.`,
|
||||
url: `${config.host}/${StaticResponse.FAILED_ACCESS}`
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import * as repository from '../lib/repository.js';
|
||||
|
||||
const METAHUB_URL = 'https://images.metahub.space'
|
||||
export const BadTokenError = { code: 'BAD_TOKEN' }
|
||||
export const AccessDeniedError = { code: 'ACCESS_DENIED' }
|
||||
export const AccessBlockedError = { code: 'ACCESS_BLOCKED' }
|
||||
|
||||
export function chunkArray(arr, size) {
|
||||
return arr.length > size
|
||||
? [arr.slice(0, size), ...chunkArray(arr.slice(size), size)]
|
||||
: [arr];
|
||||
}
|
||||
|
||||
export function streamFilename(stream) {
|
||||
const filename = stream?.behaviorHints?.filename
|
||||
|| stream.title.replace(/\n👤.*/s, '').split('\n').pop().split('/').pop();
|
||||
return encodeURIComponent(filename)
|
||||
}
|
||||
|
||||
export async function enrichMeta(itemMeta) {
|
||||
const infoHashes = [...new Set([itemMeta.infoHash]
|
||||
.concat(itemMeta.videos.map(video => video.infoHash))
|
||||
.filter(infoHash => infoHash))];
|
||||
const files = infoHashes.length ? await repository.getFiles(infoHashes).catch(() => []) : [];
|
||||
const commonImdbId = itemMeta.infoHash && mostCommonValue(files.map(file => file.imdbId));
|
||||
if (files.length) {
|
||||
return {
|
||||
...itemMeta,
|
||||
logo: commonImdbId && `${METAHUB_URL}/logo/medium/${commonImdbId}/img`,
|
||||
poster: commonImdbId && `${METAHUB_URL}/poster/medium/${commonImdbId}/img`,
|
||||
background: commonImdbId && `${METAHUB_URL}/background/medium/${commonImdbId}/img`,
|
||||
videos: itemMeta.videos.map(video => {
|
||||
const file = files.find(file => sameFilename(video.title, file.title));
|
||||
if (file?.imdbId) {
|
||||
if (file.imdbSeason && file.imdbEpisode) {
|
||||
video.id = `${file.imdbId}:${file.imdbSeason}:${file.imdbEpisode}`;
|
||||
video.season = file.imdbSeason;
|
||||
video.episode = file.imdbEpisode;
|
||||
video.thumbnail = `https://episodes.metahub.space/${file.imdbId}/${video.season}/${video.episode}/w780.jpg`
|
||||
} else {
|
||||
video.id = file.imdbId;
|
||||
video.thumbnail = `${METAHUB_URL}/background/small/${file.imdbId}/img`;
|
||||
}
|
||||
}
|
||||
return video;
|
||||
})
|
||||
}
|
||||
}
|
||||
return itemMeta
|
||||
}
|
||||
|
||||
export function sameFilename(filename, expectedFilename) {
|
||||
const offset = filename.length - expectedFilename.length;
|
||||
for (let i = 0; i < expectedFilename.length; i++) {
|
||||
if (filename[offset + i] !== expectedFilename[i] && expectedFilename[i] !== '<27>') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mostCommonValue(array) {
|
||||
return array.sort((a, b) => array.filter(v => v === a).length - array.filter(v => v === b).length).pop();
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import OffcloudClient from 'offcloud-api';
|
||||
import magnet from 'magnet-uri';
|
||||
import { Type } from '../lib/types.js';
|
||||
import { isVideo } from '../lib/extension.js';
|
||||
import StaticResponse from './static.js';
|
||||
import { getMagnetLink } from '../lib/magnetHelper.js';
|
||||
import { chunkArray, BadTokenError, sameFilename, streamFilename } from './mochHelper.js';
|
||||
|
||||
const KEY = 'offcloud';
|
||||
|
||||
export async function getCachedStreams(streams, apiKey) {
|
||||
const options = await getDefaultOptions();
|
||||
const OC = new OffcloudClient(apiKey, options);
|
||||
const hashBatches = chunkArray(streams.map(stream => stream.infoHash), 100);
|
||||
const available = await Promise.all(hashBatches.map(hashes => OC.instant.cache(hashes)))
|
||||
.then(results => results.map(result => result.cachedItems))
|
||||
.then(results => results.reduce((all, result) => all.concat(result), []))
|
||||
.catch(error => {
|
||||
if (toCommonError(error)) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
console.warn('Failed Offcloud cached torrent availability request:', error);
|
||||
return undefined;
|
||||
});
|
||||
return available && streams
|
||||
.reduce((mochStreams, stream) => {
|
||||
const isCached = available.includes(stream.infoHash);
|
||||
const fileName = streamFilename(stream);
|
||||
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
|
||||
url: `${apiKey}/${stream.infoHash}/${fileName}/${stream.fileIdx}`,
|
||||
cached: isCached
|
||||
};
|
||||
return mochStreams;
|
||||
}, {})
|
||||
}
|
||||
|
||||
export async function getCatalog(apiKey, catalogId, config) {
|
||||
if (config.skip > 0) {
|
||||
return [];
|
||||
}
|
||||
const options = await getDefaultOptions();
|
||||
const OC = new OffcloudClient(apiKey, options);
|
||||
return OC.cloud.history()
|
||||
.then(torrents => torrents)
|
||||
.then(torrents => (torrents || [])
|
||||
.map(torrent => ({
|
||||
id: `${KEY}:${torrent.requestId}`,
|
||||
type: Type.OTHER,
|
||||
name: torrent.fileName
|
||||
})));
|
||||
}
|
||||
|
||||
export async function getItemMeta(itemId, apiKey, ip) {
|
||||
const options = await getDefaultOptions(ip);
|
||||
const OC = new OffcloudClient(apiKey, options);
|
||||
const torrents = await OC.cloud.history();
|
||||
const torrent = torrents.find(torrent => torrent.requestId === itemId)
|
||||
const infoHash = torrent && magnet.decode(torrent.originalLink).infoHash
|
||||
const createDate = torrent ? new Date(torrent.createdOn) : new Date();
|
||||
return _getFileUrls(OC, torrent)
|
||||
.then(files => ({
|
||||
id: `${KEY}:${itemId}`,
|
||||
type: Type.OTHER,
|
||||
name: torrent.name,
|
||||
infoHash: infoHash,
|
||||
videos: files
|
||||
.filter(file => isVideo(file))
|
||||
.map((file, index) => ({
|
||||
id: `${KEY}:${itemId}:${index}`,
|
||||
title: file.split('/').pop(),
|
||||
released: new Date(createDate.getTime() - index).toISOString(),
|
||||
streams: [{ url: file }]
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
|
||||
console.log(`Unrestricting Offcloud ${infoHash} [${fileIndex}]`);
|
||||
const options = await getDefaultOptions(ip);
|
||||
const OC = new OffcloudClient(apiKey, options);
|
||||
|
||||
return _resolve(OC, infoHash, cachedEntryInfo, fileIndex)
|
||||
.catch(error => {
|
||||
if (errorExpiredSubscriptionError(error)) {
|
||||
console.log(`Access denied to Offcloud ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_ACCESS;
|
||||
}
|
||||
return Promise.reject(`Failed Offcloud adding torrent ${JSON.stringify(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function _resolve(OC, infoHash, cachedEntryInfo, fileIndex) {
|
||||
const torrent = await _createOrFindTorrent(OC, infoHash)
|
||||
.then(info => info.requestId ? OC.cloud.status(info.requestId) : Promise.resolve(info))
|
||||
.then(info => info.status || info);
|
||||
if (torrent && statusReady(torrent)) {
|
||||
return _unrestrictLink(OC, infoHash, torrent, cachedEntryInfo, fileIndex);
|
||||
} else if (torrent && statusDownloading(torrent)) {
|
||||
console.log(`Downloading to Offcloud ${infoHash} [${fileIndex}]...`);
|
||||
return StaticResponse.DOWNLOADING;
|
||||
} else if (torrent && statusError(torrent)) {
|
||||
console.log(`Retry failed download in Offcloud ${infoHash} [${fileIndex}]...`);
|
||||
return _retryCreateTorrent(OC, infoHash, cachedEntryInfo, fileIndex);
|
||||
}
|
||||
|
||||
return Promise.reject(`Failed Offcloud adding torrent ${JSON.stringify(torrent)}`);
|
||||
}
|
||||
|
||||
async function _createOrFindTorrent(OC, infoHash) {
|
||||
return _findTorrent(OC, infoHash)
|
||||
.catch(() => _createTorrent(OC, infoHash));
|
||||
}
|
||||
|
||||
async function _findTorrent(OC, infoHash) {
|
||||
const torrents = await OC.cloud.history();
|
||||
const foundTorrents = torrents.filter(torrent => torrent.originalLink.toLowerCase().includes(infoHash));
|
||||
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent));
|
||||
const foundTorrent = nonFailedTorrent || foundTorrents[0];
|
||||
return foundTorrent || Promise.reject('No recent torrent found');
|
||||
}
|
||||
|
||||
async function _createTorrent(OC, infoHash) {
|
||||
const magnetLink = await getMagnetLink(infoHash);
|
||||
return OC.cloud.download(magnetLink)
|
||||
}
|
||||
|
||||
async function _retryCreateTorrent(OC, infoHash, cachedEntryInfo, fileIndex) {
|
||||
const newTorrent = await _createTorrent(OC, infoHash);
|
||||
return newTorrent && statusReady(newTorrent.status)
|
||||
? _unrestrictLink(OC, infoHash, newTorrent, cachedEntryInfo, fileIndex)
|
||||
: StaticResponse.FAILED_DOWNLOAD;
|
||||
}
|
||||
|
||||
async function _unrestrictLink(OC, infoHash, torrent, cachedEntryInfo, fileIndex) {
|
||||
const targetFileName = decodeURIComponent(cachedEntryInfo);
|
||||
const files = await _getFileUrls(OC, torrent)
|
||||
const targetFile = files.find(file => sameFilename(targetFileName, file.split('/').pop()))
|
||||
|| files.find(file => file.includes(`/${torrent.requestId}/${fileIndex + 1}/`) && isVideo(file))
|
||||
|| files.find(file => isVideo(file))
|
||||
|| files.pop();
|
||||
|
||||
if (!targetFile) {
|
||||
return Promise.reject(`No Offcloud links found for index ${fileIndex} in: ${JSON.stringify(torrent)}`);
|
||||
}
|
||||
console.log(`Unrestricted Offcloud ${infoHash} [${fileIndex}] to ${targetFile}`);
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
async function _getFileUrls(OC, torrent) {
|
||||
return OC.cloud.explore(torrent.requestId)
|
||||
.catch(error => {
|
||||
if (error === 'Bad archive') {
|
||||
return [`https://${torrent.server}.offcloud.com/cloud/download/${torrent.requestId}/${torrent.fileName}`];
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
}
|
||||
|
||||
async function getDefaultOptions(ip) {
|
||||
return { ip, timeout: 10000 };
|
||||
}
|
||||
|
||||
export function toCommonError(error) {
|
||||
if (error?.error === 'NOAUTH' || error?.message?.startsWith('Cannot read property')) {
|
||||
return BadTokenError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function statusDownloading(torrent) {
|
||||
return ['downloading', 'created', 'queued'].includes(torrent.status);
|
||||
}
|
||||
|
||||
function statusError(torrent) {
|
||||
return ['error', 'canceled'].includes(torrent.status);
|
||||
}
|
||||
|
||||
function statusReady(torrent) {
|
||||
return torrent.status === 'downloaded';
|
||||
}
|
||||
|
||||
function errorExpiredSubscriptionError(error) {
|
||||
return error?.includes && (error.includes('not_available') || error.includes('NOAUTH') || error.includes('premium membership'));
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
export const DebridOptions = {
|
||||
key: 'debridoptions',
|
||||
options: {
|
||||
noDownloadLinks: {
|
||||
key: 'nodownloadlinks',
|
||||
description: 'Don\'t show download to debrid links'
|
||||
},
|
||||
noCatalog: {
|
||||
key: 'nocatalog',
|
||||
description: 'Don\'t show debrid catalog'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function excludeDownloadLinks(config) {
|
||||
return config[DebridOptions.key]?.includes(DebridOptions.options.noDownloadLinks.key);
|
||||
}
|
||||
|
||||
export function showDebridCatalog(config) {
|
||||
return !config[DebridOptions.key]?.includes(DebridOptions.options.noCatalog.key);
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
import PremiumizeClient from 'premiumize-api';
|
||||
import magnet from 'magnet-uri';
|
||||
import { Type } from '../lib/types.js';
|
||||
import { isVideo, isArchive } from '../lib/extension.js';
|
||||
import StaticResponse from './static.js';
|
||||
import { getMagnetLink } from '../lib/magnetHelper.js';
|
||||
import { BadTokenError, chunkArray, sameFilename, streamFilename } from './mochHelper.js';
|
||||
|
||||
const KEY = 'premiumize';
|
||||
|
||||
export async function getCachedStreams(streams, apiKey) {
|
||||
const options = await getDefaultOptions();
|
||||
const PM = new PremiumizeClient(apiKey, options);
|
||||
return Promise.all(chunkArray(streams, 100)
|
||||
.map(chunkedStreams => _getCachedStreams(PM, apiKey, chunkedStreams)))
|
||||
.then(results => results.reduce((all, result) => Object.assign(all, result), {}));
|
||||
}
|
||||
|
||||
async function _getCachedStreams(PM, apiKey, streams) {
|
||||
const hashes = streams.map(stream => stream.infoHash);
|
||||
return PM.cache.check(hashes)
|
||||
.catch(error => {
|
||||
if (toCommonError(error)) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
console.warn('Failed Premiumize cached torrent availability request:', error);
|
||||
return undefined;
|
||||
})
|
||||
.then(available => streams
|
||||
.reduce((mochStreams, stream, index) => {
|
||||
const filename = streamFilename(stream);
|
||||
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
|
||||
url: `${apiKey}/${stream.infoHash}/${filename}/${stream.fileIdx}`,
|
||||
cached: available?.response[index]
|
||||
};
|
||||
return mochStreams;
|
||||
}, {}));
|
||||
}
|
||||
|
||||
export async function getCatalog(apiKey, catalogId, config) {
|
||||
if (config.skip > 0) {
|
||||
return [];
|
||||
}
|
||||
const options = await getDefaultOptions();
|
||||
const PM = new PremiumizeClient(apiKey, options);
|
||||
return PM.folder.list()
|
||||
.then(response => response.content)
|
||||
.then(torrents => (torrents || [])
|
||||
.filter(torrent => torrent && torrent.type === 'folder')
|
||||
.map(torrent => ({
|
||||
id: `${KEY}:${torrent.id}`,
|
||||
type: Type.OTHER,
|
||||
name: torrent.name
|
||||
})));
|
||||
}
|
||||
|
||||
export async function getItemMeta(itemId, apiKey, ip) {
|
||||
const options = await getDefaultOptions();
|
||||
const PM = new PremiumizeClient(apiKey, options);
|
||||
const rootFolder = await PM.folder.list(itemId, null);
|
||||
const infoHash = await _findInfoHash(PM, itemId);
|
||||
return getFolderContents(PM, itemId, ip)
|
||||
.then(contents => ({
|
||||
id: `${KEY}:${itemId}`,
|
||||
type: Type.OTHER,
|
||||
name: rootFolder.name,
|
||||
infoHash: infoHash,
|
||||
videos: contents
|
||||
.map((file, index) => ({
|
||||
id: `${KEY}:${file.id}:${index}`,
|
||||
title: file.name,
|
||||
released: new Date(file.created_at * 1000 - index).toISOString(),
|
||||
streams: [{ url: file.link || file.stream_link }]
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
async function getFolderContents(PM, itemId, ip, folderPrefix = '') {
|
||||
return PM.folder.list(itemId, null, ip)
|
||||
.then(response => response.content)
|
||||
.then(contents => Promise.all(contents
|
||||
.filter(content => content.type === 'folder')
|
||||
.map(content => getFolderContents(PM, content.id, ip, [folderPrefix, content.name].join('/'))))
|
||||
.then(otherContents => otherContents.reduce((a, b) => a.concat(b), []))
|
||||
.then(otherContents => contents
|
||||
.filter(content => content.type === 'file' && isVideo(content.name))
|
||||
.map(content => ({ ...content, name: [folderPrefix, content.name].join('/') }))
|
||||
.concat(otherContents)));
|
||||
}
|
||||
|
||||
export async function resolve({ ip, isBrowser, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
|
||||
console.log(`Unrestricting Premiumize ${infoHash} [${fileIndex}] for IP ${ip} from browser=${isBrowser}`);
|
||||
const options = await getDefaultOptions();
|
||||
const PM = new PremiumizeClient(apiKey, options);
|
||||
return _getCachedLink(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser)
|
||||
.catch(() => _resolve(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser))
|
||||
.catch(error => {
|
||||
if (isAccessDeniedError(error)) {
|
||||
console.log(`Access denied to Premiumize ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_ACCESS;
|
||||
}
|
||||
if (isLimitExceededError(error)) {
|
||||
console.log(`Limits exceeded in Premiumize ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.LIMITS_EXCEEDED;
|
||||
}
|
||||
return Promise.reject(`Failed Premiumize adding torrent ${JSON.stringify(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function _resolve(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser) {
|
||||
const torrent = await _createOrFindTorrent(PM, infoHash);
|
||||
if (torrent && statusReady(torrent.status)) {
|
||||
return _getCachedLink(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser);
|
||||
} else if (torrent && statusDownloading(torrent.status)) {
|
||||
console.log(`Downloading to Premiumize ${infoHash} [${fileIndex}]...`);
|
||||
return StaticResponse.DOWNLOADING;
|
||||
} else if (torrent && statusError(torrent.status)) {
|
||||
console.log(`Retrying downloading to Premiumize ${infoHash} [${fileIndex}]...`);
|
||||
return _retryCreateTorrent(PM, infoHash, cachedEntryInfo, fileIndex);
|
||||
}
|
||||
return Promise.reject(`Failed Premiumize adding torrent ${JSON.stringify(torrent)}`);
|
||||
}
|
||||
|
||||
async function _getCachedLink(PM, infoHash, encodedFileName, fileIndex, ip, isBrowser) {
|
||||
const cachedTorrent = await PM.transfer.directDownload(magnet.encode({ infoHash }), ip);
|
||||
if (cachedTorrent?.content?.length) {
|
||||
const targetFileName = decodeURIComponent(encodedFileName);
|
||||
const videos = cachedTorrent.content.filter(file => isVideo(file.path)).sort((a, b) => b.size - a.size);
|
||||
const targetVideo = Number.isInteger(fileIndex)
|
||||
&& videos.find(video => sameFilename(video.path, targetFileName))
|
||||
|| videos[0];
|
||||
if (!targetVideo && videos.every(video => isArchive(video.path))) {
|
||||
console.log(`Only Premiumize archive is available for [${infoHash}] ${fileIndex}`)
|
||||
return StaticResponse.FAILED_RAR;
|
||||
}
|
||||
const streamLink = isBrowser && targetVideo.transcode_status === 'finished' && targetVideo.stream_link;
|
||||
const unrestrictedLink = streamLink || targetVideo.link;
|
||||
console.log(`Unrestricted Premiumize ${infoHash} [${fileIndex}] to ${unrestrictedLink}`);
|
||||
return unrestrictedLink;
|
||||
}
|
||||
return Promise.reject('No cached entry found');
|
||||
}
|
||||
|
||||
async function _createOrFindTorrent(PM, infoHash) {
|
||||
return _findTorrent(PM, infoHash)
|
||||
.catch(() => _createTorrent(PM, infoHash));
|
||||
}
|
||||
|
||||
async function _findTorrent(PM, infoHash) {
|
||||
const torrents = await PM.transfer.list().then(response => response.transfers);
|
||||
const foundTorrents = torrents.filter(torrent => torrent.src.toLowerCase().includes(infoHash));
|
||||
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent.statusCode));
|
||||
const foundTorrent = nonFailedTorrent || foundTorrents[0];
|
||||
return foundTorrent || Promise.reject('No recent torrent found');
|
||||
}
|
||||
|
||||
async function _findInfoHash(PM, itemId) {
|
||||
const torrents = await PM.transfer.list().then(response => response.transfers);
|
||||
const foundTorrent = torrents.find(torrent => `${torrent.file_id}` === itemId || `${torrent.folder_id}` === itemId);
|
||||
return foundTorrent?.src ? magnet.decode(foundTorrent.src).infoHash : undefined;
|
||||
}
|
||||
|
||||
async function _createTorrent(PM, infoHash) {
|
||||
const magnetLink = await getMagnetLink(infoHash);
|
||||
return PM.transfer.create(magnetLink).then(() => _findTorrent(PM, infoHash));
|
||||
}
|
||||
|
||||
async function _retryCreateTorrent(PM, infoHash, encodedFileName, fileIndex) {
|
||||
const newTorrent = await _createTorrent(PM, infoHash).then(() => _findTorrent(PM, infoHash));
|
||||
return newTorrent && statusReady(newTorrent.status)
|
||||
? _getCachedLink(PM, infoHash, encodedFileName, fileIndex)
|
||||
: StaticResponse.FAILED_DOWNLOAD;
|
||||
}
|
||||
|
||||
export function toCommonError(error) {
|
||||
if (error && error.message === 'Not logged in.') {
|
||||
return BadTokenError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function statusError(status) {
|
||||
return ['deleted', 'error', 'timeout'].includes(status);
|
||||
}
|
||||
|
||||
function statusDownloading(status) {
|
||||
return ['waiting', 'queued', 'running'].includes(status);
|
||||
}
|
||||
|
||||
function statusReady(status) {
|
||||
return ['finished', 'seeding'].includes(status);
|
||||
}
|
||||
|
||||
function isAccessDeniedError(error) {
|
||||
return ['Account not premium.'].some(value => error?.message?.includes(value));
|
||||
}
|
||||
|
||||
function isLimitExceededError(error) {
|
||||
return [
|
||||
'Fair use limit reached!',
|
||||
'You already have a maximum of 25 active downloads in progress!',
|
||||
'Your space is full! Please delete old files first!'
|
||||
].some(value => error?.message?.includes(value));
|
||||
}
|
||||
|
||||
async function getDefaultOptions(ip) {
|
||||
return { timeout: 5000 };
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
import PutioClient from '@putdotio/api-client'
|
||||
import { isVideo } from '../lib/extension.js';
|
||||
import { delay } from '../lib/promises.js';
|
||||
import StaticResponse from './static.js';
|
||||
import { getMagnetLink } from '../lib/magnetHelper.js';
|
||||
import { Type } from "../lib/types.js";
|
||||
import { decode } from "magnet-uri";
|
||||
import { sameFilename, streamFilename } from "./mochHelper.js";
|
||||
const PutioAPI = PutioClient.default;
|
||||
|
||||
const KEY = 'putio';
|
||||
|
||||
export async function getCachedStreams(streams, apiKey) {
|
||||
return streams
|
||||
.reduce((mochStreams, stream) => {
|
||||
const filename = streamFilename(stream);
|
||||
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
|
||||
url: `${apiKey}/${stream.infoHash}/${filename}/${stream.fileIdx}`,
|
||||
cached: false
|
||||
};
|
||||
return mochStreams;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export async function getCatalog(apiKey, catalogId, config) {
|
||||
if (config.skip > 0) {
|
||||
return [];
|
||||
}
|
||||
const Putio = createPutioAPI(apiKey)
|
||||
return Putio.Files.Query(0)
|
||||
.then(response => response?.body?.files)
|
||||
.then(files => (files || [])
|
||||
.map(file => ({
|
||||
id: `${KEY}:${file.id}`,
|
||||
type: Type.OTHER,
|
||||
name: file.name
|
||||
})));
|
||||
}
|
||||
|
||||
export async function getItemMeta(itemId, apiKey) {
|
||||
const Putio = createPutioAPI(apiKey)
|
||||
const infoHash = await _findInfoHash(Putio, itemId)
|
||||
return getFolderContents(Putio, itemId)
|
||||
.then(contents => ({
|
||||
id: `${KEY}:${itemId}`,
|
||||
type: Type.OTHER,
|
||||
name: contents.name,
|
||||
infoHash: infoHash,
|
||||
videos: contents
|
||||
.map((file, index) => ({
|
||||
id: `${KEY}:${file.id}:${index}`,
|
||||
title: file.name,
|
||||
released: new Date(file.created_at).toISOString(),
|
||||
streams: [{ url: `${apiKey}/null/null/${file.id}` }]
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
async function getFolderContents(Putio, itemId, folderPrefix = '') {
|
||||
return await Putio.Files.Query(itemId)
|
||||
.then(response => response?.body)
|
||||
.then(body => body?.files?.length ? body.files : [body?.parent].filter(x => x))
|
||||
.then(contents => Promise.all(contents
|
||||
.filter(content => content.file_type === 'FOLDER')
|
||||
.map(content => getFolderContents(Putio, content.id, [folderPrefix, content.name].join('/'))))
|
||||
.then(otherContents => otherContents.reduce((a, b) => a.concat(b), []))
|
||||
.then(otherContents => contents
|
||||
.filter(content => content.file_type === 'VIDEO')
|
||||
.map(content => ({ ...content, name: [folderPrefix, content.name].join('/') }))
|
||||
.concat(otherContents)));
|
||||
}
|
||||
|
||||
export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
|
||||
console.log(`Unrestricting Putio ${infoHash} [${fileIndex}]`);
|
||||
const Putio = createPutioAPI(apiKey)
|
||||
|
||||
return _resolve(Putio, infoHash, cachedEntryInfo, fileIndex)
|
||||
.catch(error => {
|
||||
if (error?.data?.status_code === 401) {
|
||||
console.log(`Access denied to Putio ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_ACCESS;
|
||||
}
|
||||
return Promise.reject(`Failed Putio adding torrent ${JSON.stringify(error.data || error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function _resolve(Putio, infoHash, cachedEntryInfo, fileIndex) {
|
||||
if (infoHash === 'null') {
|
||||
return _unrestrictVideo(Putio, fileIndex);
|
||||
}
|
||||
const torrent = await _createOrFindTorrent(Putio, infoHash);
|
||||
if (torrent && statusReady(torrent.status)) {
|
||||
return _unrestrictLink(Putio, torrent, cachedEntryInfo, fileIndex);
|
||||
} else if (torrent && statusDownloading(torrent.status)) {
|
||||
console.log(`Downloading to Putio ${infoHash} [${fileIndex}]...`);
|
||||
return StaticResponse.DOWNLOADING;
|
||||
} else if (torrent && statusError(torrent.status)) {
|
||||
console.log(`Retrying downloading to Putio ${infoHash} [${fileIndex}]...`);
|
||||
return _retryCreateTorrent(Putio, infoHash, cachedEntryInfo, fileIndex);
|
||||
}
|
||||
return Promise.reject("Failed Putio adding torrent");
|
||||
}
|
||||
|
||||
async function _createOrFindTorrent(Putio, infoHash) {
|
||||
return _findTorrent(Putio, infoHash)
|
||||
.catch(() => _createTorrent(Putio, infoHash));
|
||||
}
|
||||
|
||||
async function _retryCreateTorrent(Putio, infoHash, encodedFileName, fileIndex) {
|
||||
const newTorrent = await _createTorrent(Putio, infoHash);
|
||||
return newTorrent && statusReady(newTorrent.status)
|
||||
? _unrestrictLink(Putio, newTorrent, encodedFileName, fileIndex)
|
||||
: StaticResponse.FAILED_DOWNLOAD;
|
||||
}
|
||||
|
||||
async function _findTorrent(Putio, infoHash) {
|
||||
const torrents = await Putio.Transfers.Query().then(response => response.data.transfers);
|
||||
const foundTorrents = torrents.filter(torrent => torrent.source.toLowerCase().includes(infoHash));
|
||||
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent.status));
|
||||
const foundTorrent = nonFailedTorrent || foundTorrents[0];
|
||||
if (foundTorrents && !foundTorrents.userfile_exists) {
|
||||
return await Putio.Transfers.Cancel(foundTorrents.id).then(() => Promise.reject())
|
||||
}
|
||||
return foundTorrent || Promise.reject('No recent torrent found in Putio');
|
||||
}
|
||||
|
||||
async function _findInfoHash(Putio, fileId) {
|
||||
const torrents = await Putio.Transfers.Query().then(response => response?.data?.transfers);
|
||||
const foundTorrent = torrents.find(torrent => `${torrent.file_id}` === fileId);
|
||||
return foundTorrent?.source ? decode(foundTorrent.source).infoHash : undefined;
|
||||
}
|
||||
|
||||
async function _createTorrent(Putio, infoHash) {
|
||||
const magnetLink = await getMagnetLink(infoHash);
|
||||
// Add the torrent and then delay for 3 secs for putio to process it and then check it's status.
|
||||
return Putio.Transfers.Add({ url: magnetLink })
|
||||
.then(response => _getNewTorrent(Putio, response.data.transfer.id));
|
||||
}
|
||||
|
||||
async function _getNewTorrent(Putio, torrentId, pollCounter = 0, pollRate = 2000, maxPollNumber = 15) {
|
||||
return Putio.Transfers.Get(torrentId)
|
||||
.then(response => response.data.transfer)
|
||||
.then(torrent => statusProcessing(torrent.status) && pollCounter < maxPollNumber
|
||||
? delay(pollRate).then(() => _getNewTorrent(Putio, torrentId, pollCounter + 1))
|
||||
: torrent);
|
||||
}
|
||||
|
||||
async function _unrestrictLink(Putio, torrent, encodedFileName, fileIndex) {
|
||||
const targetVideo = await _getTargetFile(Putio, torrent, encodedFileName, fileIndex);
|
||||
return _unrestrictVideo(Putio, targetVideo.id);
|
||||
}
|
||||
|
||||
async function _unrestrictVideo(Putio, videoId) {
|
||||
const response = await Putio.File.GetStorageURL(videoId);
|
||||
const downloadUrl = response.data.url
|
||||
console.log(`Unrestricted Putio [${videoId}] to ${downloadUrl}`);
|
||||
return downloadUrl;
|
||||
}
|
||||
|
||||
async function _getTargetFile(Putio, torrent, encodedFileName, fileIndex) {
|
||||
const targetFileName = decodeURIComponent(encodedFileName);
|
||||
let targetFile;
|
||||
let files = await _getFiles(Putio, torrent.file_id);
|
||||
let videos = [];
|
||||
|
||||
while (!targetFile && files.length) {
|
||||
const folders = files.filter(file => file.file_type === 'FOLDER');
|
||||
videos = videos.concat(files.filter(file => isVideo(file.name))).sort((a, b) => b.size - a.size);
|
||||
// when specific file index is defined search by filename
|
||||
// when it's not defined find all videos and take the largest one
|
||||
targetFile = Number.isInteger(fileIndex)
|
||||
&& videos.find(video => sameFilename(targetFileName, video.name))
|
||||
|| !folders.length && videos[0];
|
||||
files = !targetFile
|
||||
? await Promise.all(folders.map(folder => _getFiles(Putio, folder.id)))
|
||||
.then(results => results.reduce((a, b) => a.concat(b), []))
|
||||
: [];
|
||||
}
|
||||
return targetFile || Promise.reject(`No target file found for Putio [${torrent.hash}] ${targetFileName}`);
|
||||
}
|
||||
|
||||
async function _getFiles(Putio, fileId) {
|
||||
const response = await Putio.Files.Query(fileId)
|
||||
.catch(error => Promise.reject({ ...error.data, path: error.request.path }));
|
||||
return response.data.files.length
|
||||
? response.data.files
|
||||
: [response.data.parent];
|
||||
}
|
||||
|
||||
function createPutioAPI(apiKey) {
|
||||
const clientId = apiKey.replace(/@.*/, '');
|
||||
const token = apiKey.replace(/.*@/, '');
|
||||
const Putio = new PutioAPI({ clientID: clientId });
|
||||
Putio.setToken(token);
|
||||
return Putio;
|
||||
}
|
||||
|
||||
export function toCommonError(error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function statusError(status) {
|
||||
return ['ERROR'].includes(status);
|
||||
}
|
||||
|
||||
function statusDownloading(status) {
|
||||
return ['WAITING', 'IN_QUEUE', 'DOWNLOADING'].includes(status);
|
||||
}
|
||||
|
||||
function statusProcessing(status) {
|
||||
return ['WAITING', 'IN_QUEUE', 'COMPLETING'].includes(status);
|
||||
}
|
||||
|
||||
function statusReady(status) {
|
||||
return ['COMPLETED', 'SEEDING'].includes(status);
|
||||
}
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
import RealDebridClient from 'real-debrid-api';
|
||||
import { Type } from '../lib/types.js';
|
||||
import { isVideo, isArchive } from '../lib/extension.js';
|
||||
import { delay } from '../lib/promises.js';
|
||||
import { cacheAvailabilityResults, getCachedAvailabilityResults, removeAvailabilityResults } from '../lib/cache.js';
|
||||
import StaticResponse from './static.js';
|
||||
import { getMagnetLink } from '../lib/magnetHelper.js';
|
||||
import { BadTokenError, AccessDeniedError } from './mochHelper.js';
|
||||
|
||||
const MIN_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const CATALOG_MAX_PAGE = 1;
|
||||
const CATALOG_PAGE_SIZE = 100;
|
||||
const KEY = 'realdebrid';
|
||||
const DEBRID_DOWNLOADS = 'Downloads';
|
||||
|
||||
export async function getCachedStreams(streams, apiKey) {
|
||||
const hashes = streams.map(stream => stream.infoHash);
|
||||
const available = await getCachedAvailabilityResults(hashes);
|
||||
return available && streams
|
||||
.reduce((mochStreams, stream) => {
|
||||
const cachedEntry = available[stream.infoHash];
|
||||
const cachedIds = _getCachedFileIds(stream.fileIdx, cachedEntry);
|
||||
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
|
||||
url: `${apiKey}/${stream.infoHash}/null/${stream.fileIdx}`,
|
||||
cached: !!cachedIds.length
|
||||
};
|
||||
return mochStreams;
|
||||
}, {})
|
||||
}
|
||||
|
||||
function _getCachedFileIds(fileIndex, cachedResults) {
|
||||
if (!cachedResults || !Array.isArray(cachedResults)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cachedIds = Number.isInteger(fileIndex)
|
||||
? cachedResults.find(ids => Array.isArray(ids) && ids.includes(fileIndex + 1))
|
||||
: cachedResults[0];
|
||||
return cachedIds || [];
|
||||
}
|
||||
|
||||
export async function getCatalog(apiKey, catalogId, config) {
|
||||
const options = await getDefaultOptions(config.ip);
|
||||
const RD = new RealDebridClient(apiKey, options);
|
||||
const page = Math.floor((config.skip || 0) / 100) + 1;
|
||||
const downloadsMeta = page === 1 ? [{
|
||||
id: `${KEY}:${DEBRID_DOWNLOADS}`,
|
||||
type: Type.OTHER,
|
||||
name: DEBRID_DOWNLOADS
|
||||
}] : [];
|
||||
const torrentMetas = await _getAllTorrents(RD, page)
|
||||
.then(torrents => Array.isArray(torrents) ? torrents : [])
|
||||
.then(torrents => torrents
|
||||
.filter(torrent => torrent && statusReady(torrent.status))
|
||||
.map(torrent => ({
|
||||
id: `${KEY}:${torrent.id}`,
|
||||
type: Type.OTHER,
|
||||
name: torrent.filename
|
||||
})));
|
||||
return downloadsMeta.concat(torrentMetas)
|
||||
}
|
||||
|
||||
export async function getItemMeta(itemId, apiKey, ip) {
|
||||
const options = await getDefaultOptions(ip);
|
||||
const RD = new RealDebridClient(apiKey, options);
|
||||
if (itemId === DEBRID_DOWNLOADS) {
|
||||
const videos = await _getAllDownloads(RD)
|
||||
.then(downloads => downloads
|
||||
.map(download => ({
|
||||
id: `${KEY}:${DEBRID_DOWNLOADS}:${download.id}`,
|
||||
// infoHash: allTorrents
|
||||
// .filter(torrent => (torrent.links || []).find(link => link === download.link))
|
||||
// .map(torrent => torrent.hash.toLowerCase())[0],
|
||||
title: download.filename,
|
||||
released: new Date(download.generated).toISOString(),
|
||||
streams: [{ url: download.download }]
|
||||
})));
|
||||
return {
|
||||
id: `${KEY}:${DEBRID_DOWNLOADS}`,
|
||||
type: Type.OTHER,
|
||||
name: DEBRID_DOWNLOADS,
|
||||
videos: videos
|
||||
};
|
||||
}
|
||||
return _getTorrentInfo(RD, itemId)
|
||||
.then(torrent => ({
|
||||
id: `${KEY}:${torrent.id}`,
|
||||
type: Type.OTHER,
|
||||
name: torrent.filename,
|
||||
infoHash: torrent.hash.toLowerCase(),
|
||||
videos: torrent.files
|
||||
.filter(file => file.selected)
|
||||
.filter(file => isVideo(file.path))
|
||||
.map((file, index) => ({
|
||||
id: `${KEY}:${torrent.id}:${file.id}`,
|
||||
title: file.path,
|
||||
released: new Date(new Date(torrent.added).getTime() - index).toISOString(),
|
||||
streams: [{ url: `${apiKey}/${torrent.hash.toLowerCase()}/null/${file.id - 1}` }]
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
async function _getAllTorrents(RD, page = 1) {
|
||||
return RD.torrents.get(page - 1, page, CATALOG_PAGE_SIZE)
|
||||
.then(torrents => torrents && torrents.length === CATALOG_PAGE_SIZE && page < CATALOG_MAX_PAGE
|
||||
? _getAllTorrents(RD, page + 1)
|
||||
.then(nextTorrents => torrents.concat(nextTorrents))
|
||||
.catch(() => torrents)
|
||||
: torrents)
|
||||
}
|
||||
|
||||
async function _getAllDownloads(RD, page = 1) {
|
||||
return RD.downloads.get(page - 1, page, CATALOG_PAGE_SIZE);
|
||||
}
|
||||
|
||||
export async function resolve({ ip, isBrowser, apiKey, infoHash, fileIndex }) {
|
||||
console.log(`Unrestricting RealDebrid ${infoHash} [${fileIndex}]`);
|
||||
const options = await getDefaultOptions(ip);
|
||||
const RD = new RealDebridClient(apiKey, options);
|
||||
|
||||
return _resolve(RD, infoHash, fileIndex, isBrowser)
|
||||
.catch(error => {
|
||||
if (isAccessDeniedError(error)) {
|
||||
console.log(`Access denied to RealDebrid ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_ACCESS;
|
||||
}
|
||||
if (isInfringingFileError(error)) {
|
||||
console.log(`Infringing file removed from RealDebrid ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_INFRINGEMENT;
|
||||
}
|
||||
if (isLimitExceededError(error)) {
|
||||
console.log(`Limits exceeded in RealDebrid ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.LIMITS_EXCEEDED;
|
||||
}
|
||||
if (isTorrentTooBigError(error)) {
|
||||
console.log(`Torrent too big for RealDebrid ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_TOO_BIG;
|
||||
}
|
||||
return Promise.reject(`Failed RealDebrid adding torrent ${JSON.stringify(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function _resolveCachedFileIds(infoHash, fileIndex) {
|
||||
const available = await getCachedAvailabilityResults([infoHash]);
|
||||
const cachedEntry = available?.[infoHash];
|
||||
const cachedIds = _getCachedFileIds(fileIndex, cachedEntry);
|
||||
return cachedIds?.join(',');
|
||||
}
|
||||
|
||||
async function _resolve(RD, infoHash, fileIndex, isBrowser) {
|
||||
const torrentId = await _createOrFindTorrentId(RD, infoHash, fileIndex);
|
||||
const torrent = await _getTorrentInfo(RD, torrentId);
|
||||
if (torrent && statusReady(torrent.status)) {
|
||||
return _unrestrictLink(RD, torrent, fileIndex, isBrowser);
|
||||
} else if (torrent && statusDownloading(torrent.status)) {
|
||||
console.log(`Downloading to RealDebrid ${infoHash} [${fileIndex}]...`);
|
||||
const cachedFileIds = torrent.files.filter(file => file.selected).map(file => file.id);
|
||||
removeAvailabilityResults(infoHash, cachedFileIds);
|
||||
return StaticResponse.DOWNLOADING;
|
||||
} else if (torrent && statusMagnetError(torrent.status)) {
|
||||
console.log(`Failed RealDebrid opening torrent ${infoHash} [${fileIndex}] due to magnet error`);
|
||||
return StaticResponse.FAILED_OPENING;
|
||||
} else if (torrent && statusError(torrent.status)) {
|
||||
return _retryCreateTorrent(RD, infoHash, fileIndex);
|
||||
} else if (torrent && (statusWaitingSelection(torrent.status) || statusOpening(torrent.status))) {
|
||||
console.log(`Trying to select files on RealDebrid ${infoHash} [${fileIndex}]...`);
|
||||
return _selectTorrentFiles(RD, torrent)
|
||||
.then(() => {
|
||||
console.log(`Downloading to RealDebrid ${infoHash} [${fileIndex}]...`);
|
||||
return StaticResponse.DOWNLOADING
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(`Failed RealDebrid opening torrent ${infoHash} [${fileIndex}]:`, error);
|
||||
return StaticResponse.FAILED_OPENING;
|
||||
});
|
||||
}
|
||||
return Promise.reject(`Failed RealDebrid adding torrent ${JSON.stringify(torrent)}`);
|
||||
}
|
||||
|
||||
async function _createOrFindTorrentId(RD, infoHash, fileIndex) {
|
||||
return _findTorrent(RD, infoHash, fileIndex)
|
||||
.catch(() => _createTorrentId(RD, infoHash, fileIndex));
|
||||
}
|
||||
|
||||
async function _findTorrent(RD, infoHash, fileIndex) {
|
||||
const torrents = await RD.torrents.get(0, 1) || [];
|
||||
const foundTorrents = torrents
|
||||
.filter(torrent => torrent.hash.toLowerCase() === infoHash)
|
||||
.filter(torrent => !statusError(torrent.status));
|
||||
const foundTorrent = await _findBestFitTorrent(RD, foundTorrents, fileIndex);
|
||||
return foundTorrent?.id || Promise.reject('No recent torrent found');
|
||||
}
|
||||
|
||||
async function _findBestFitTorrent(RD, torrents, fileIndex) {
|
||||
if (torrents.length === 1) {
|
||||
return torrents[0];
|
||||
}
|
||||
const torrentInfos = await Promise.all(torrents.map(torrent => _getTorrentInfo(RD, torrent.id)));
|
||||
const bestFitTorrents = torrentInfos
|
||||
.filter(torrent => torrent.files.find(f => f.id === fileIndex + 1 && f.selected))
|
||||
.sort((a, b) => b.links.length - a.links.length);
|
||||
return bestFitTorrents[0] || torrents[0];
|
||||
}
|
||||
|
||||
async function _getTorrentInfo(RD, torrentId) {
|
||||
if (!torrentId || typeof torrentId === 'object') {
|
||||
return torrentId || Promise.reject('No RealDebrid torrentId provided')
|
||||
}
|
||||
return RD.torrents.info(torrentId);
|
||||
}
|
||||
|
||||
async function _createTorrentId(RD, infoHash, fileIndex, force = false) {
|
||||
const magnetLink = await getMagnetLink(infoHash);
|
||||
const addedMagnet = await RD.torrents.addMagnet(magnetLink);
|
||||
const cachedFileIds = !force && await _resolveCachedFileIds(infoHash, fileIndex);
|
||||
if (cachedFileIds && !['null', 'undefined'].includes(cachedFileIds)) {
|
||||
await RD.torrents.selectFiles(addedMagnet.id, cachedFileIds);
|
||||
} else if (!force) {
|
||||
await _selectTorrentFiles(RD, { id: addedMagnet.id });
|
||||
}
|
||||
return addedMagnet.id;
|
||||
}
|
||||
|
||||
async function _recreateTorrentId(RD, infoHash, fileIndex, force = false) {
|
||||
const newTorrentId = await _createTorrentId(RD, infoHash, fileIndex, force);
|
||||
await _selectTorrentFiles(RD, { id: newTorrentId }, fileIndex);
|
||||
return newTorrentId;
|
||||
}
|
||||
|
||||
async function _retryCreateTorrent(RD, infoHash, fileIndex, shouldRetry = false) {
|
||||
console.log(`Retry failed download in RealDebrid ${infoHash} [${fileIndex}]...`);
|
||||
const newTorrentId = await _recreateTorrentId(RD, infoHash, fileIndex, true);
|
||||
const newTorrent = await _getTorrentInfo(RD, newTorrentId);
|
||||
return newTorrent && statusReady(newTorrent.status)
|
||||
? _unrestrictLink(RD, newTorrent, fileIndex, false, shouldRetry)
|
||||
: StaticResponse.FAILED_DOWNLOAD;
|
||||
}
|
||||
|
||||
async function _selectTorrentFiles(RD, torrent, fileIndex) {
|
||||
torrent = statusWaitingSelection(torrent.status) ? torrent : await _openTorrent(RD, torrent.id);
|
||||
if (torrent?.files && statusWaitingSelection(torrent.status)) {
|
||||
const videoFileIds = Number.isInteger(fileIndex) ? `${fileIndex + 1}` : torrent.files
|
||||
.filter(file => isVideo(file.path))
|
||||
.filter(file => file.bytes > MIN_SIZE)
|
||||
.map(file => file.id)
|
||||
.join(',');
|
||||
return RD.torrents.selectFiles(torrent.id, videoFileIds);
|
||||
} else if (statusReady(torrent.status) || statusDownloading(torrent.status)) {
|
||||
return torrent;
|
||||
}
|
||||
return Promise.reject('Failed RealDebrid torrent file selection')
|
||||
}
|
||||
|
||||
async function _openTorrent(RD, torrentId, pollCounter = 0, pollRate = 2000, maxPollNumber = 15) {
|
||||
return _getTorrentInfo(RD, torrentId)
|
||||
.then(torrent => torrent && statusOpening(torrent.status) && pollCounter < maxPollNumber
|
||||
? delay(pollRate).then(() => _openTorrent(RD, torrentId, pollCounter + 1))
|
||||
: torrent);
|
||||
}
|
||||
|
||||
async function _unrestrictLink(RD, torrent, fileIndex, isBrowser, shouldRetry = true) {
|
||||
const targetFile = torrent.files.find(file => file.id === fileIndex + 1)
|
||||
|| torrent.files.filter(file => file.selected).sort((a, b) => b.bytes - a.bytes)[0];
|
||||
if (!targetFile.selected) {
|
||||
console.log(`Target RealDebrid file is not downloaded: ${JSON.stringify(targetFile)}`);
|
||||
await _recreateTorrentId(RD, torrent.hash.toLowerCase(), fileIndex);
|
||||
return StaticResponse.DOWNLOADING;
|
||||
}
|
||||
|
||||
const selectedFiles = torrent.files.filter(file => file.selected);
|
||||
const fileLink = torrent.links.length === 1
|
||||
? torrent.links[0]
|
||||
: torrent.links[selectedFiles.indexOf(targetFile)];
|
||||
|
||||
if (shouldRetry && !fileLink?.length) {
|
||||
console.log(`No RealDebrid links found for ${torrent.hash} [${fileIndex}]`);
|
||||
return _retryCreateTorrent(RD, torrent.hash, fileIndex)
|
||||
}
|
||||
|
||||
return _unrestrictFileLink(RD, fileLink, torrent, fileIndex, isBrowser, shouldRetry);
|
||||
}
|
||||
|
||||
async function _unrestrictFileLink(RD, fileLink, torrent, fileIndex, isBrowser, shouldRetry) {
|
||||
return RD.unrestrict.link(fileLink)
|
||||
.then(response => {
|
||||
if (isArchive(response.download)) {
|
||||
if (shouldRetry && Number.isInteger(fileIndex) && torrent.files.filter(file => file.selected).length > 1) {
|
||||
console.log(`Only archive is available, try to download single file for ${torrent.hash} [${fileIndex}]`);
|
||||
return _retryCreateTorrent(RD, torrent.hash, fileIndex)
|
||||
}
|
||||
return StaticResponse.FAILED_RAR;
|
||||
}
|
||||
// if (isBrowser && response.streamable) {
|
||||
// return RD.streaming.transcode(response.id)
|
||||
// .then(streamResponse => streamResponse.apple.full)
|
||||
// }
|
||||
return response.download;
|
||||
})
|
||||
.then(unrestrictedLink => {
|
||||
console.log(`Unrestricted RealDebrid ${torrent.hash} [${fileIndex}] to ${unrestrictedLink}`);
|
||||
const cachedFileIds = torrent.files.filter(file => file.selected).map(file => file.id);
|
||||
cacheAvailabilityResults(torrent.hash.toLowerCase(), cachedFileIds); // no need to await can happen async
|
||||
return unrestrictedLink;
|
||||
})
|
||||
.catch(error => {
|
||||
if (shouldRetry && error.code === 19) {
|
||||
console.log(`Retry download as hoster is unavailable for ${torrent.hash} [${fileIndex}]`);
|
||||
return _retryCreateTorrent(RD, torrent.hash.toLowerCase(), fileIndex);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
export function toCommonError(error) {
|
||||
if (error && error.code === 8) {
|
||||
return BadTokenError;
|
||||
}
|
||||
if (error && isAccessDeniedError(error)) {
|
||||
return AccessDeniedError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function statusError(status) {
|
||||
return ['error', 'magnet_error'].includes(status);
|
||||
}
|
||||
|
||||
function statusMagnetError(status) {
|
||||
return status === 'magnet_error';
|
||||
}
|
||||
|
||||
function statusOpening(status) {
|
||||
return status === 'magnet_conversion';
|
||||
}
|
||||
|
||||
function statusWaitingSelection(status) {
|
||||
return status === 'waiting_files_selection';
|
||||
}
|
||||
|
||||
function statusDownloading(status) {
|
||||
return ['downloading', 'uploading', 'queued'].includes(status);
|
||||
}
|
||||
|
||||
function statusReady(status) {
|
||||
return ['downloaded', 'dead'].includes(status);
|
||||
}
|
||||
|
||||
function isAccessDeniedError(error) {
|
||||
return [8, 9, 20].includes(error?.code);
|
||||
}
|
||||
|
||||
function isInfringingFileError(error) {
|
||||
return [35].includes(error?.code);
|
||||
}
|
||||
|
||||
function isLimitExceededError(error) {
|
||||
return [21, 23, 26, 36].includes(error?.code);
|
||||
}
|
||||
|
||||
function isTorrentTooBigError(error) {
|
||||
return [29].includes(error?.code);
|
||||
}
|
||||
|
||||
async function getDefaultOptions(ip) {
|
||||
return { ip, timeout: 15000 };
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
const staticVideoUrls = {
|
||||
DOWNLOADING: `videos/downloading_v2.mp4`,
|
||||
FAILED_DOWNLOAD: `videos/download_failed_v2.mp4`,
|
||||
FAILED_ACCESS: `videos/failed_access_v2.mp4`,
|
||||
FAILED_RAR: `videos/failed_rar_v2.mp4`,
|
||||
FAILED_TOO_BIG: 'failed_too_big_v1.mp4',
|
||||
FAILED_OPENING: `videos/failed_opening_v2.mp4`,
|
||||
FAILED_UNEXPECTED: `videos/failed_unexpected_v2.mp4`,
|
||||
FAILED_INFRINGEMENT: `videos/failed_infringement_v2.mp4`,
|
||||
LIMITS_EXCEEDED: `videos/limits_exceeded_v1.mp4`,
|
||||
BLOCKED_ACCESS: `videos/blocked_access_v1.mp4`,
|
||||
}
|
||||
|
||||
|
||||
export function isStaticUrl(url) {
|
||||
return Object.values(staticVideoUrls).some(videoUrl => url?.endsWith(videoUrl));
|
||||
}
|
||||
|
||||
export default staticVideoUrls
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
import axios from 'axios';
|
||||
import { Type } from '../lib/types.js';
|
||||
import { isVideo } from '../lib/extension.js';
|
||||
import StaticResponse from './static.js';
|
||||
import { getMagnetLink } from '../lib/magnetHelper.js';
|
||||
import { chunkArray, BadTokenError, sameFilename, streamFilename } from './mochHelper.js';
|
||||
|
||||
const KEY = 'torbox';
|
||||
const timeout = 30000;
|
||||
const baseUrl = 'https://api.torbox.app/v1'
|
||||
|
||||
export async function getCachedStreams(streams, apiKey, ip) {
|
||||
const hashBatches = chunkArray(streams.map(stream => stream.infoHash), 150)
|
||||
.map(hashes => getAvailabilityResponse(apiKey, hashes));
|
||||
const available = await Promise.all(hashBatches)
|
||||
.then(results => results
|
||||
.map(data => data.map(entry => entry.hash))
|
||||
.reduce((all, result) => all.concat(result), []))
|
||||
.catch(error => {
|
||||
if (toCommonError(error)) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const message = error.message || error;
|
||||
console.warn('Failed TorBox cached torrent availability request:', message);
|
||||
return undefined;
|
||||
});
|
||||
return available && streams
|
||||
.reduce((mochStreams, stream) => {
|
||||
const isCached = available.includes(stream.infoHash);
|
||||
const fileName = streamFilename(stream);
|
||||
mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = {
|
||||
url: `${apiKey}/${stream.infoHash}/${fileName}/${stream.fileIdx}`,
|
||||
cached: isCached
|
||||
};
|
||||
return mochStreams;
|
||||
}, {})
|
||||
}
|
||||
|
||||
export async function getCatalog(apiKey, type, config) {
|
||||
return getItemList(apiKey, type, null, config.skip)
|
||||
.then(items => (items || [])
|
||||
.filter(item => statusReady(item))
|
||||
.map(item => ({
|
||||
id: `${KEY}:${type}-${item.id}`,
|
||||
type: Type.OTHER,
|
||||
name: item.name
|
||||
})));
|
||||
}
|
||||
|
||||
export async function getItemMeta(itemId, apiKey) {
|
||||
const [type, id] = itemId.split('-');
|
||||
const item = await getItemList(apiKey, type, id);
|
||||
const createDate = item ? new Date(item.created_at) : new Date();
|
||||
return {
|
||||
id: `${KEY}:${itemId}`,
|
||||
type: Type.OTHER,
|
||||
name: item.name,
|
||||
infoHash: item.hash,
|
||||
videos: item.files
|
||||
.filter(file => isVideo(file.short_name))
|
||||
.map((file, index) => ({
|
||||
id: `${KEY}:${itemId}:${file.id}`,
|
||||
title: file.name,
|
||||
released: new Date(createDate.getTime() - index).toISOString(),
|
||||
streams: [{ url: `${apiKey}/${itemId}-${file.id}/null/null` }]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) {
|
||||
console.log(`Unrestricting TorBox ${infoHash} [${fileIndex}]`);
|
||||
return _resolve(apiKey, infoHash, cachedEntryInfo, fileIndex, ip)
|
||||
.catch(error => {
|
||||
if (isAccessDeniedError(error)) {
|
||||
console.log(`Access denied to TorBox ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_ACCESS;
|
||||
}
|
||||
if (isLimitExceededError(error)) {
|
||||
console.log(`Limits exceeded to TorBox ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.LIMITS_EXCEEDED;
|
||||
}
|
||||
if (isTorrentTooBigError(error)) {
|
||||
console.log(`Torrent too big for TorBox ${infoHash} [${fileIndex}]`);
|
||||
return StaticResponse.FAILED_TOO_BIG;
|
||||
}
|
||||
return Promise.reject(`Failed TorBox adding torrent: ${JSON.stringify(error.message || error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function _resolve(apiKey, infoHash, cachedEntryInfo, fileIndex, ip) {
|
||||
if (infoHash?.includes('-')) {
|
||||
const [type, rootId, fileId] = infoHash.split('-');
|
||||
return getDownloadLink(apiKey, type, rootId, fileId, ip);
|
||||
}
|
||||
const torrent = await _createOrFindTorrent(apiKey, infoHash);
|
||||
if (torrent && statusReady(torrent)) {
|
||||
return _unrestrictLink(apiKey, infoHash, torrent, cachedEntryInfo, fileIndex, ip);
|
||||
} else if (torrent && statusDownloading(torrent)) {
|
||||
console.log(`Downloading to TorBox ${infoHash} [${fileIndex}]...`);
|
||||
return StaticResponse.DOWNLOADING;
|
||||
} else if (torrent && statusError(torrent)) {
|
||||
console.log(`Retry failed download in TorBox ${infoHash} [${fileIndex}]...`);
|
||||
return controlTorrent(apiKey, torrent.id, 'delete')
|
||||
.then(() => _retryCreateTorrent(apiKey, infoHash, cachedEntryInfo, fileIndex));
|
||||
}
|
||||
|
||||
return Promise.reject(`Failed TorBox adding torrent ${JSON.stringify(torrent)}`);
|
||||
}
|
||||
|
||||
async function _createOrFindTorrent(apiKey, infoHash) {
|
||||
return _findTorrent(apiKey, infoHash)
|
||||
.catch(() => _createTorrent(apiKey, infoHash));
|
||||
}
|
||||
|
||||
async function _findTorrent(apiKey, infoHash) {
|
||||
const torrents = await getTorrentList(apiKey);
|
||||
const foundTorrents = torrents.filter(torrent => torrent.hash === infoHash);
|
||||
const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent));
|
||||
const foundTorrent = nonFailedTorrent || foundTorrents[0];
|
||||
return foundTorrent || Promise.reject('No recent torrent found');
|
||||
}
|
||||
|
||||
async function _createTorrent(apiKey, infoHash, attempts = 1) {
|
||||
const magnetLink = await getMagnetLink(infoHash);
|
||||
return createTorrent(apiKey, magnetLink)
|
||||
.then(data => {
|
||||
if (data.torrent_id) {
|
||||
return getTorrentList(apiKey, data.torrent_id);
|
||||
}
|
||||
if (data.queued_id) {
|
||||
return Promise.resolve({ ...data, download_state: 'metaDL' })
|
||||
}
|
||||
if (data?.error === 'ACTIVE_LIMIT' && attempts > 0) {
|
||||
return freeLastActiveTorrent(apiKey)
|
||||
.then(() => _createTorrent(apiKey, infoHash, attempts - 1));
|
||||
}
|
||||
return Promise.reject(`Unexpected create data: ${JSON.stringify(data)}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function _retryCreateTorrent(apiKey, infoHash, cachedEntryInfo, fileIndex) {
|
||||
const newTorrent = await _createTorrent(apiKey, infoHash);
|
||||
return newTorrent && statusReady(newTorrent)
|
||||
? _unrestrictLink(apiKey, infoHash, newTorrent, cachedEntryInfo, fileIndex)
|
||||
: StaticResponse.FAILED_DOWNLOAD;
|
||||
}
|
||||
|
||||
async function freeLastActiveTorrent(apiKey) {
|
||||
const torrents = await getTorrentList(apiKey);
|
||||
const seedingTorrent = torrents.filter(statusSeeding).pop();
|
||||
if (seedingTorrent) {
|
||||
console.log(`Stopping seeded item in TorBox to make space...`);
|
||||
return controlTorrent(apiKey, seedingTorrent.id, 'stop_seeding');
|
||||
}
|
||||
const downloadingTorrent = torrents.filter(statusDownloading).pop();
|
||||
if (downloadingTorrent) {
|
||||
console.log(`Deleting downloading item in TorBox to make space...`);
|
||||
return controlTorrent(apiKey, downloadingTorrent.id, 'delete');
|
||||
}
|
||||
return Promise.reject({ detail: 'No torrent to pause found' });
|
||||
}
|
||||
|
||||
async function _unrestrictLink(apiKey, infoHash, torrent, cachedEntryInfo, fileIndex, ip) {
|
||||
const targetFileName = decodeURIComponent(cachedEntryInfo);
|
||||
const videos = torrent.files
|
||||
.filter(file => isVideo(file.short_name))
|
||||
.sort((a, b) => b.size - a.size);
|
||||
const targetVideo = Number.isInteger(fileIndex)
|
||||
&& videos.find(video => sameFilename(video.name, targetFileName))
|
||||
|| videos[0];
|
||||
|
||||
if (!targetVideo) {
|
||||
if (torrent.files.every(file => file.zipped)) {
|
||||
return StaticResponse.FAILED_RAR;
|
||||
}
|
||||
return Promise.reject(`No TorBox file found for index ${fileIndex} in: ${JSON.stringify(torrent)}`);
|
||||
}
|
||||
return getDownloadLink(apiKey, 'torrents', torrent.id, targetVideo.id, ip);
|
||||
}
|
||||
|
||||
async function getAvailabilityResponse(apiKey, hashes) {
|
||||
const url = `${baseUrl}/api/torrents/checkcached`;
|
||||
const headers = getHeaders(apiKey);
|
||||
const params = { hash: hashes.join(','), format: 'list' };
|
||||
return axios.get(url, { params, headers, timeout })
|
||||
.then(response => {
|
||||
if (response.data?.success) {
|
||||
return Promise.resolve(response.data.data || []);
|
||||
}
|
||||
return Promise.reject(response.data);
|
||||
})
|
||||
.catch(error => Promise.reject(error.response?.data || error));
|
||||
}
|
||||
|
||||
async function createTorrent(apiKey, magnetLink){
|
||||
const url = `${baseUrl}/api/torrents/createtorrent`
|
||||
const headers = getHeaders(apiKey);
|
||||
const data = new URLSearchParams();
|
||||
data.append('magnet', magnetLink);
|
||||
data.append('allow_zip', 'false');
|
||||
return axios.post(url, data, { headers, timeout })
|
||||
.then(response => {
|
||||
if (response.data?.success) {
|
||||
return Promise.resolve(response.data.data);
|
||||
}
|
||||
return Promise.reject(response.data);
|
||||
})
|
||||
.catch(error => Promise.reject(error.response?.data || error));
|
||||
}
|
||||
|
||||
async function controlTorrent(apiKey, torrent_id, operation){
|
||||
const url = `${baseUrl}/api/torrents/controltorrent`
|
||||
const headers = getHeaders(apiKey);
|
||||
const data = { torrent_id, operation}
|
||||
return axios.post(url, data, { headers, timeout })
|
||||
.then(response => {
|
||||
if (response.data?.success) {
|
||||
return Promise.resolve(response.data.data);
|
||||
}
|
||||
return Promise.reject(response.data);
|
||||
})
|
||||
.catch(error => Promise.reject(error.response?.data || error));
|
||||
}
|
||||
|
||||
async function getTorrentList(apiKey, id = undefined, offset = 0) {
|
||||
return getItemList(apiKey, 'torrents', id, offset);
|
||||
}
|
||||
|
||||
async function getItemList(apiKey, type, id = undefined, offset = 0) {
|
||||
const url = `${baseUrl}/api/${type}/mylist`;
|
||||
const headers = getHeaders(apiKey);
|
||||
const params = { id, offset };
|
||||
return axios.get(url, { params, headers, timeout })
|
||||
.then(response => {
|
||||
if (response.data?.success) {
|
||||
if (Array.isArray(response.data.data)) {
|
||||
response.data.data.sort((a, b) => b.id - a.id);
|
||||
}
|
||||
return Promise.resolve(response.data.data);
|
||||
}
|
||||
return Promise.reject(response.data);
|
||||
})
|
||||
.catch(error => Promise.reject(error.response?.data || error));
|
||||
}
|
||||
|
||||
async function getDownloadLink(token, type, rootId, file_id, user_ip) {
|
||||
const url = `${baseUrl}/api/${type}/requestdl`;
|
||||
const params = { token, torrent_id: rootId, usenet_id: rootId, web_id: rootId, file_id, user_ip };
|
||||
return axios.get(url, { params, timeout })
|
||||
.then(response => {
|
||||
if (response.data?.success) {
|
||||
console.log(`Unrestricted TorBox ${type} [${rootId}] to ${response.data.data}`);
|
||||
return Promise.resolve(response.data.data);
|
||||
}
|
||||
return Promise.reject(response.data);
|
||||
})
|
||||
.catch(error => Promise.reject(error.response?.data || error));
|
||||
}
|
||||
|
||||
function getHeaders(apiKey) {
|
||||
return { Authorization: `Bearer ${apiKey}` };
|
||||
}
|
||||
|
||||
export function toCommonError(data) {
|
||||
const error = data?.response?.data || data;
|
||||
if (['AUTH_ERROR', 'BAD_TOKEN'].includes(error?.error)) {
|
||||
return BadTokenError;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function statusDownloading(torrent) {
|
||||
return !statusReady(torrent) && !statusError(torrent);
|
||||
}
|
||||
|
||||
function statusError(torrent) {
|
||||
return (!torrent?.active && !torrent?.download_finished) || torrent?.download_state === 'error';
|
||||
}
|
||||
|
||||
function statusReady(torrent) {
|
||||
return torrent?.download_present;
|
||||
}
|
||||
|
||||
function statusSeeding(torrent) {
|
||||
return ['seeding', 'uploading', 'uploading (no peers)'].includes(torrent?.download_state);
|
||||
}
|
||||
|
||||
function isAccessDeniedError(error) {
|
||||
return ['AUTH_ERROR', 'BAD_TOKEN', 'PLAN_RESTRICTED_FEATURE'].includes(error?.error);
|
||||
}
|
||||
|
||||
function isLimitExceededError(error) {
|
||||
return ['MONTHLY_LIMIT', 'COOLDOWN_LIMIT', 'ACTIVE_LIMIT'].includes(error?.error);
|
||||
}
|
||||
|
||||
function isTorrentTooBigError(error) {
|
||||
return ['DOWNLOAD_TOO_LARGE'].includes(error?.error);
|
||||
}
|
||||
2574
addon/package-lock.json
generated
2574
addon/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,38 +0,0 @@
|
|||
{
|
||||
"name": "stremio-torrentio",
|
||||
"version": "1.0.14",
|
||||
"exports": "./index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"author": "TheBeastLT <pauliox@beyond.lt>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/mongo": "^3.0.1",
|
||||
"@putdotio/api-client": "^8.42.0",
|
||||
"all-debrid-api": "^1.2.0",
|
||||
"axios": "^1.7.7",
|
||||
"cacheable": "^1.8.4",
|
||||
"cors": "^2.8.5",
|
||||
"debrid-link-api": "^1.0.1",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"magnet-uri": "^6.2.0",
|
||||
"name-to-imdb": "^3.0.4",
|
||||
"named-queue": "^2.2.1",
|
||||
"offcloud-api": "^1.0.2",
|
||||
"p-limit": "^5.0.0",
|
||||
"parse-torrent-title": "git://github.com/TheBeastLT/parse-torrent-title.git#1bc3539e0d7c6686655ede649e6be8da53e4c217",
|
||||
"pg": "^8.8.0",
|
||||
"premiumize-api": "^1.0.3",
|
||||
"prom-client": "^14.2.0",
|
||||
"real-debrid-api": "git://github.com/TheBeastLT/node-real-debrid.git#d1f7eaa8593b947edbfbc8a92a176448b48ef445",
|
||||
"request-ip": "^3.3.0",
|
||||
"router": "^1.3.8",
|
||||
"sequelize": "^6.29.0",
|
||||
"stremio-addon-sdk": "^1.6.10",
|
||||
"swagger-stats": "^0.99.7",
|
||||
"ua-parser-js": "^1.0.36",
|
||||
"user-agents": "^1.0.1444"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import Router from 'router';
|
||||
import cors from 'cors';
|
||||
import rateLimit from "express-rate-limit";
|
||||
import requestIp from 'request-ip';
|
||||
import userAgentParser from 'ua-parser-js';
|
||||
import addonInterface from './addon.js';
|
||||
import qs from 'querystring';
|
||||
import { manifest } from './lib/manifest.js';
|
||||
import { parseConfiguration, PreConfigurations } from './lib/configuration.js';
|
||||
import landingTemplate from './lib/landingTemplate.js';
|
||||
import * as moch from './moch/moch.js';
|
||||
|
||||
const router = new Router();
|
||||
const limiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 300, // limit each IP to 300 requests per windowMs
|
||||
headers: false,
|
||||
keyGenerator: (req) => requestIp.getClientIp(req)
|
||||
})
|
||||
|
||||
router.use(cors())
|
||||
router.get('/', (_, res) => {
|
||||
res.redirect('/configure')
|
||||
res.end();
|
||||
});
|
||||
|
||||
router.get(`/:preconfiguration(${Object.keys(PreConfigurations).join('|')})`, (req, res) => {
|
||||
res.redirect(`/${req.params.preconfiguration}/configure`)
|
||||
res.end();
|
||||
});
|
||||
|
||||
router.get('/:configuration?/configure', (req, res) => {
|
||||
const configValues = parseConfiguration(req.params.configuration || '');
|
||||
const landingHTML = landingTemplate(manifest(configValues), configValues);
|
||||
res.setHeader('content-type', 'text/html');
|
||||
res.end(landingHTML);
|
||||
});
|
||||
|
||||
router.get('/:configuration?/manifest.json', (req, res) => {
|
||||
const configValues = parseConfiguration(req.params.configuration || '');
|
||||
const manifestBuf = JSON.stringify(manifest(configValues));
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(manifestBuf)
|
||||
});
|
||||
|
||||
router.get('/:configuration?/:resource/:type/:id/:extra?.json', limiter, (req, res, next) => {
|
||||
const { configuration, resource, type, id } = req.params;
|
||||
const extra = req.params.extra ? qs.parse(req.url.split('/').pop().slice(0, -5)) : {}
|
||||
const ip = requestIp.getClientIp(req);
|
||||
const host = `${req.protocol}://${req.headers.host}`;
|
||||
const configValues = { ...extra, ...parseConfiguration(configuration), id, type, ip, host };
|
||||
addonInterface.get(resource, type, id, configValues)
|
||||
.then(resp => {
|
||||
const cacheHeaders = {
|
||||
cacheMaxAge: 'max-age',
|
||||
staleRevalidate: 'stale-while-revalidate',
|
||||
staleError: 'stale-if-error'
|
||||
};
|
||||
const cacheControl = Object.keys(cacheHeaders)
|
||||
.map(prop => Number.isInteger(resp[prop]) && cacheHeaders[prop] + '=' + resp[prop])
|
||||
.filter(val => !!val).join(', ');
|
||||
|
||||
res.setHeader('Cache-Control', `${cacheControl}, public`);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(resp));
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.noHandler) {
|
||||
if (next) {
|
||||
next()
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end(JSON.stringify({ err: 'not found' }));
|
||||
}
|
||||
} else {
|
||||
console.error(err);
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ err: 'handler error' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:moch/:apiKey/:infoHash/:cachedEntryInfo/:fileIndex/:filename?', (req, res) => {
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const parameters = {
|
||||
mochKey: req.params.moch,
|
||||
apiKey: req.params.apiKey,
|
||||
infoHash: req.params.infoHash.toLowerCase(),
|
||||
fileIndex: isNaN(req.params.fileIndex) ? undefined : parseInt(req.params.fileIndex),
|
||||
cachedEntryInfo: req.params.cachedEntryInfo,
|
||||
ip: requestIp.getClientIp(req),
|
||||
host: `${req.protocol}://${req.headers.host}`,
|
||||
isBrowser: !userAgent.includes('Stremio') && !!userAgentParser(userAgent).browser.name
|
||||
}
|
||||
moch.resolve(parameters)
|
||||
.then(url => {
|
||||
res.writeHead(302, { Location: url });
|
||||
res.end();
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error);
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
export default function (req, res) {
|
||||
router(req, res, function () {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,3 +0,0 @@
|
|||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/.env
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
FROM node:16-alpine
|
||||
|
||||
RUN apk update && apk upgrade && \
|
||||
apk add --no-cache git
|
||||
|
||||
WORKDIR /home/node/app
|
||||
|
||||
COPY ./catalogs .
|
||||
COPY ./addon ../addon
|
||||
RUN npm ci --only-production
|
||||
|
||||
CMD [ "node", "index.js" ]
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import Bottleneck from 'bottleneck';
|
||||
import moment from 'moment';
|
||||
import { addonBuilder } from 'stremio-addon-sdk';
|
||||
import { Providers } from '../addon/lib/filter.js';
|
||||
import { createManifest, genres } from './lib/manifest.js';
|
||||
import { getMetas } from './lib/metadata.js';
|
||||
import { cacheWrapCatalog, cacheWrapIds } from './lib/cache.js';
|
||||
import * as repository from './lib/repository.js';
|
||||
|
||||
const CACHE_MAX_AGE = parseInt(process.env.CACHE_MAX_AGE) || 4 * 60 * 60; // 4 hours in seconds
|
||||
const STALE_REVALIDATE_AGE = 4 * 60 * 60; // 4 hours
|
||||
const STALE_ERROR_AGE = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
const manifest = createManifest();
|
||||
const builder = new addonBuilder(manifest);
|
||||
const limiter = new Bottleneck({
|
||||
maxConcurrent: process.env.LIMIT_MAX_CONCURRENT || 20,
|
||||
highWater: process.env.LIMIT_QUEUE_SIZE || 50,
|
||||
strategy: Bottleneck.strategy.OVERFLOW
|
||||
});
|
||||
const defaultProviders = Providers.options
|
||||
.filter(provider => !provider.foreign)
|
||||
.map(provider => provider.label)
|
||||
.sort();
|
||||
|
||||
builder.defineCatalogHandler((args) => {
|
||||
const offset = parseInt(args.extra.skip || '0', 10);
|
||||
const genre = args.extra.genre || 'default';
|
||||
const catalog = manifest.catalogs.find(c => c.id === args.id);
|
||||
const providers = defaultProviders;
|
||||
console.log(`Incoming catalog ${args.id} request with genre=${genre} and skip=${offset}`);
|
||||
if (!catalog) {
|
||||
return Promise.reject(`No catalog found for with id: ${args.id}`);
|
||||
}
|
||||
|
||||
const cacheKey = createCacheKey(catalog.id, providers, genre, offset);
|
||||
return limiter.schedule(() => cacheWrapCatalog(cacheKey, () => getCatalog(catalog, providers, genre, offset)))
|
||||
.then(metas => ({
|
||||
metas: metas,
|
||||
cacheMaxAge: CACHE_MAX_AGE,
|
||||
staleRevalidate: STALE_REVALIDATE_AGE,
|
||||
staleError: STALE_ERROR_AGE
|
||||
}))
|
||||
.catch(error => Promise.reject(`Failed retrieving catalog ${args.id}: ${error.message || error}`));
|
||||
})
|
||||
|
||||
async function getCursor(catalog, providers, genre, offset) {
|
||||
if (offset === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const previousOffset = offset - catalog.pageSize;
|
||||
const previousCacheKey = createCacheKey(catalog.id, providers, genre, previousOffset);
|
||||
return cacheWrapCatalog(previousCacheKey, () => Promise.reject("cursor not found"))
|
||||
.then(metas => metas[metas.length - 1])
|
||||
.then(meta => meta.id.replace('kitsu:', ''))
|
||||
}
|
||||
|
||||
async function getCatalog(catalog, providers, genre, offset) {
|
||||
const cursor = await getCursor(catalog, providers, genre, offset)
|
||||
const startDate = getStartDate(genre)?.toISOString();
|
||||
const endDate = getEndDate(genre)?.toISOString();
|
||||
const cacheKey = createCacheKey(catalog.id, providers, genre);
|
||||
|
||||
return cacheWrapIds(cacheKey, () => repository.getIds(providers, catalog.type, startDate, endDate))
|
||||
.then(ids => ids.slice(ids.indexOf(cursor) + 1))
|
||||
.then(ids => getMetas(ids, catalog.type))
|
||||
.then(metas => metas.slice(0, catalog.pageSize));
|
||||
}
|
||||
|
||||
function getStartDate(genre) {
|
||||
switch (genre) {
|
||||
case genres[0]: return moment().utc().subtract(1, 'day').startOf('day');
|
||||
case genres[1]: return moment().utc().startOf('isoWeek');
|
||||
case genres[2]: return moment().utc().subtract(7, 'day').startOf('isoWeek');
|
||||
case genres[3]: return moment().utc().startOf('month');
|
||||
case genres[4]: return moment().utc().subtract(30, 'day').startOf('month');
|
||||
case genres[5]: return undefined;
|
||||
default: return moment().utc().subtract(30, 'day').startOf('day');
|
||||
}
|
||||
}
|
||||
|
||||
function getEndDate(genre) {
|
||||
switch (genre) {
|
||||
case genres[0]: return moment().utc().subtract(1, 'day').endOf('day');
|
||||
case genres[1]: return moment().utc().endOf('isoWeek');
|
||||
case genres[2]: return moment().utc().subtract(7, 'day').endOf('isoWeek');
|
||||
case genres[3]: return moment().utc().endOf('month');
|
||||
case genres[4]: return moment().utc().subtract(30, 'day').endOf('month');
|
||||
case genres[5]: return undefined;
|
||||
default: return moment().utc().subtract(1, 'day').endOf('day');
|
||||
}
|
||||
}
|
||||
|
||||
function createCacheKey(catalogId, providers, genre, offset) {
|
||||
const dateKey = moment().format('YYYY-MM-DD');
|
||||
return [catalogId, providers.join(','), genre, dateKey, offset].filter(x => x !== undefined).join('|');
|
||||
}
|
||||
|
||||
export default builder.getInterface();
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import express from 'express';
|
||||
import serverless from './serverless.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use((req, res, next) => serverless(req, res, next));
|
||||
app.listen(process.env.PORT || 7000, () => {
|
||||
console.log(`Started addon at: http://localhost:${process.env.PORT || 7000}`);
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import KeyvMongo from "@keyv/mongo";
|
||||
|
||||
const CATALOG_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
const MONGO_URI = process.env.MONGODB_URI;
|
||||
|
||||
const remoteCache = MONGO_URI && new KeyvMongo(MONGO_URI, { collection: 'torrentio_catalog_collection' });
|
||||
|
||||
async function cacheWrap(cache, key, method, ttl) {
|
||||
if (!cache) {
|
||||
return method();
|
||||
}
|
||||
const value = await cache.get(key);
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
const result = await method();
|
||||
await cache.set(key, result, ttl);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function cacheWrapCatalog(key, method) {
|
||||
return cacheWrap(remoteCache, key, method, CATALOG_TTL);
|
||||
}
|
||||
|
||||
export function cacheWrapIds(key, method) {
|
||||
return cacheWrap(remoteCache, `ids|${key}`, method, CATALOG_TTL);
|
||||
}
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
const STYLESHEET = `
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
html {
|
||||
background-size: auto 100%;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-repeat: repeat-y;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
background-color: transparent;
|
||||
font-family: 'Open Sans', Arial, sans-serif;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 4.5vh;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.2vh;
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2.2vh;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p,
|
||||
label {
|
||||
margin: 0;
|
||||
text-shadow: 0 0 1vh rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.75vh;
|
||||
}
|
||||
|
||||
ul {
|
||||
font-size: 1.75vh;
|
||||
margin: 0;
|
||||
margin-top: 1vh;
|
||||
padding-left: 3vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: green
|
||||
}
|
||||
|
||||
a.install-link {
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: white;
|
||||
background: #8A5AAB;
|
||||
padding: 1.2vh 3.5vh;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
font-family: 'Open Sans', Arial, sans-serif;
|
||||
font-size: 2.2vh;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2);
|
||||
transition: box-shadow 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button:active {
|
||||
box-shadow: 0 0 0 0.5vh white inset;
|
||||
}
|
||||
|
||||
#addon {
|
||||
width: 90vh;
|
||||
margin: auto;
|
||||
padding-left: 10%;
|
||||
padding-right: 10%;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 14vh;
|
||||
width: 14vh;
|
||||
margin: auto;
|
||||
margin-bottom: 3vh;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.name, .version {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.name {
|
||||
line-height: 5vh;
|
||||
}
|
||||
|
||||
.version {
|
||||
position: absolute;
|
||||
line-height: 5vh;
|
||||
margin-left: 1vh;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.contact {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 4vh;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contact a {
|
||||
font-size: 1.4vh;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin-bottom: 4vh;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 2.2vh;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.btn-group, .multiselect-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.multiselect-container {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.input, .btn {
|
||||
height: 3.8vh;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
color: #333;
|
||||
background-color: rgb(255, 255, 255);
|
||||
box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
`;
|
||||
import { Providers } from '../../addon/lib/filter.js';
|
||||
|
||||
export default function landingTemplate(manifest, config = {}) {
|
||||
const providers = config.providers || [];
|
||||
|
||||
const background = manifest.background || 'https://dl.strem.io/addon-background.jpg';
|
||||
const logo = manifest.logo || 'https://dl.strem.io/addon-logo.png';
|
||||
const contactHTML = manifest.contactEmail ?
|
||||
`<div class="contact">
|
||||
<p>Contact ${manifest.name} creator:</p>
|
||||
<a href="mailto:${manifest.contactEmail}">${manifest.contactEmail}</a>
|
||||
</div>` : '<div class="separator"></div>';
|
||||
const providersHTML = Providers.options
|
||||
.map(provider => `<option value="${provider.key}">${provider.foreign || ''}${provider.label}</option>`)
|
||||
.join('\n');
|
||||
const stylizedTypes = manifest.types
|
||||
.map(t => t[0].toUpperCase() + t.slice(1) + (t !== 'series' ? 's' : ''));
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html style="background-image: url(${background});">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${manifest.name} - Stremio Addon</title>
|
||||
<link rel="shortcut icon" href="${logo}" type="image/x-icon">
|
||||
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,600,700&display=swap" rel="stylesheet">
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
|
||||
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" >
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/js/bootstrap-multiselect.min.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-multiselect/0.9.15/css/bootstrap-multiselect.css" rel="stylesheet"/>
|
||||
<style>${STYLESHEET}</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="addon">
|
||||
<div class="logo">
|
||||
<img src="${logo}">
|
||||
</div>
|
||||
<h1 class="name">${manifest.name}</h1>
|
||||
<h2 class="version">${manifest.version || '0.0.0'}</h2>
|
||||
<h2 class="description">${manifest.description || ''}</h2>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<h3 class="gives">This addon has more :</h3>
|
||||
<ul>
|
||||
${stylizedTypes.map(t => `<li>${t}</li>`).join('')}
|
||||
</ul>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<label class="label" for="iProviders">Providers:</label>
|
||||
<select id="iProviders" class="input" name="providers[]" multiple="multiple">
|
||||
${providersHTML}
|
||||
</select>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<a id="installLink" class="install-link" href="#">
|
||||
<button name="Install">INSTALL</button>
|
||||
</a>
|
||||
${contactHTML}
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$('#iProviders').multiselect({
|
||||
nonSelectedText: 'All providers',
|
||||
onChange: () => generateInstallLink()
|
||||
});
|
||||
$('#iProviders').multiselect('select', [${providers.map(provider => '"' + provider + '"')}]);
|
||||
generateInstallLink();
|
||||
});
|
||||
|
||||
function generateInstallLink() {
|
||||
const providersValue = $('#iProviders').val().join(',') || '';
|
||||
const providers = providersValue.length && providersValue;
|
||||
const configurationValue = [
|
||||
['${Providers.key}', providers],
|
||||
]
|
||||
.filter(([_, value]) => value.length)
|
||||
.map(([key, value]) => key + '=' + value).join('|');
|
||||
const configuration = configurationValue && configurationValue.length ? '/' + configurationValue : '';
|
||||
installLink.href = 'stremio://' + window.location.host + configuration + '/manifest.json';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>`
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { Type } from '../../addon/lib/types.js';
|
||||
|
||||
export const genres = [
|
||||
'Yesterday',
|
||||
'This Week',
|
||||
'Last Week',
|
||||
'This Month',
|
||||
'Last Month',
|
||||
'All Time'
|
||||
]
|
||||
|
||||
export function createManifest() {
|
||||
return {
|
||||
id: 'com.stremio.torrentio.catalog.addon',
|
||||
version: '1.0.2',
|
||||
name: 'Torrent Catalogs',
|
||||
description: 'Provides catalogs for movies/series/anime based on top seeded torrents. Requires Kitsu addon for anime.',
|
||||
logo: `https://i.ibb.co/w4BnkC9/GwxAcDV.png`,
|
||||
background: `https://i.ibb.co/VtSfFP9/t8wVwcg.jpg`,
|
||||
types: [Type.MOVIE, Type.SERIES, Type.ANIME],
|
||||
resources: ['catalog'],
|
||||
catalogs: [
|
||||
{
|
||||
id: 'top-movies',
|
||||
type: Type.MOVIE,
|
||||
name: "Top seeded",
|
||||
pageSize: 20,
|
||||
extra: [{ name: 'genre', options: genres }, { name: 'skip' }],
|
||||
genres: genres
|
||||
},
|
||||
{
|
||||
id: 'top-series',
|
||||
type: Type.SERIES,
|
||||
name: "Top seeded",
|
||||
pageSize: 20,
|
||||
extra: [{ name: 'genre', options: genres }, { name: 'skip' }],
|
||||
genres: genres
|
||||
},
|
||||
{
|
||||
id: 'top-anime',
|
||||
type: Type.ANIME,
|
||||
name: "Top seeded",
|
||||
pageSize: 20,
|
||||
extra: [{ name: 'genre', options: genres }, { name: 'skip' }],
|
||||
genres: genres
|
||||
}
|
||||
],
|
||||
behaviorHints: {
|
||||
// @TODO might enable configuration to configure providers
|
||||
configurable: false,
|
||||
configurationRequired: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import axios from 'axios';
|
||||
import { Type } from '../../addon/lib/types.js';
|
||||
|
||||
const CINEMETA_URL = 'https://v3-cinemeta.strem.io';
|
||||
const KITSU_URL = 'https://anime-kitsu.strem.fun';
|
||||
const TIMEOUT = 30000;
|
||||
const MAX_SIZE = 40;
|
||||
|
||||
export async function getMetas(ids, type) {
|
||||
if (!ids.length || !type) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _requestMetadata(ids, type)
|
||||
.catch((error) => {
|
||||
throw new Error(`failed metadata ${type} query due: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function _requestMetadata(ids, type) {
|
||||
const url = _getUrl(ids, type);
|
||||
return axios.get(url, { timeout: TIMEOUT })
|
||||
.then(response => response?.data?.metas || response?.data?.metasDetailed || [])
|
||||
.then(metas => metas.filter(meta => meta))
|
||||
.then(metas => metas.map(meta => _sanitizeMeta(meta)));
|
||||
}
|
||||
|
||||
function _getUrl(ids, type) {
|
||||
const joinedIds = ids.slice(0, MAX_SIZE).join(',');
|
||||
if (type === Type.ANIME) {
|
||||
return `${KITSU_URL}/catalog/${type}/kitsu-anime-list/lastVideosIds=${joinedIds}.json`;
|
||||
}
|
||||
return `${CINEMETA_URL}/catalog/${type}/last-videos/lastVideosIds=${joinedIds}.json`;
|
||||
}
|
||||
|
||||
function _sanitizeMeta(meta) {
|
||||
delete meta.videos;
|
||||
delete meta.credits_cast;
|
||||
delete meta.credits_crew;
|
||||
return meta;
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { Sequelize, QueryTypes } from 'sequelize';
|
||||
import { Type } from '../../addon/lib/types.js';
|
||||
|
||||
const DATABASE_URI = process.env.DATABASE_URI;
|
||||
|
||||
const database = new Sequelize(DATABASE_URI, { logging: false });
|
||||
|
||||
export async function getIds(providers, type, startDate, endDate) {
|
||||
const idName = type === Type.ANIME ? 'kitsuId' : 'imdbId';
|
||||
const episodeCondition = type === Type.SERIES
|
||||
? 'AND files."imdbSeason" IS NOT NULL AND files."imdbEpisode" IS NOT NULL'
|
||||
: '';
|
||||
const dateCondition = startDate && endDate
|
||||
? `AND "uploadDate" BETWEEN '${startDate}' AND '${endDate}'`
|
||||
: '';
|
||||
const providersCondition = providers && providers.length
|
||||
? `AND provider in (${providers.map(it => `'${it}'`).join(',')})`
|
||||
: '';
|
||||
const titleCondition = type === Type.MOVIE
|
||||
? 'AND torrents.title NOT LIKE \'%[Erotic]%\''
|
||||
: '';
|
||||
const sortCondition = type === Type.MOVIE ? 'sum(torrents.seeders)' : 'max(torrents.seeders)';
|
||||
const query = `SELECT files."${idName}"
|
||||
FROM (SELECT torrents."infoHash", torrents.seeders FROM torrents
|
||||
WHERE seeders > 0 AND type = '${type}' ${providersCondition} ${dateCondition} ${titleCondition}
|
||||
) as torrents
|
||||
JOIN files ON torrents."infoHash" = files."infoHash"
|
||||
WHERE files."${idName}" IS NOT NULL ${episodeCondition}
|
||||
GROUP BY files."${idName}"
|
||||
ORDER BY ${sortCondition} DESC
|
||||
LIMIT 5000`
|
||||
const results = await database.query(query, { type: QueryTypes.SELECT });
|
||||
return results.map(result => `${result.imdbId || result.kitsuId}`);
|
||||
}
|
||||
3145
catalogs/package-lock.json
generated
3145
catalogs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,68 +0,0 @@
|
|||
import getRouter from 'stremio-addon-sdk/src/getRouter.js';
|
||||
import addonInterface from './addon.js';
|
||||
import qs from 'querystring';
|
||||
import { parseConfiguration } from '../addon/lib/configuration.js';
|
||||
import { createManifest } from './lib/manifest.js';
|
||||
|
||||
const router = getRouter(addonInterface);
|
||||
|
||||
// router.get('/', (_, res) => {
|
||||
// res.redirect('/configure')
|
||||
// res.end();
|
||||
// });
|
||||
//
|
||||
// router.get('/:configuration?/configure', (req, res) => {
|
||||
// const configValues = parseConfiguration(req.params.configuration || '');
|
||||
// const landingHTML = landingTemplate(createManifest(configValues), configValues);
|
||||
// res.setHeader('content-type', 'text/html');
|
||||
// res.end(landingHTML);
|
||||
// });
|
||||
|
||||
router.get('/:configuration?/manifest.json', (req, res) => {
|
||||
const configValues = parseConfiguration(req.params.configuration || '');
|
||||
const manifestBuf = JSON.stringify(createManifest(configValues));
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(manifestBuf)
|
||||
});
|
||||
|
||||
router.get('/:configuration/:resource/:type/:id/:extra?.json', (req, res, next) => {
|
||||
const { configuration, resource, type, id } = req.params;
|
||||
const extra = req.params.extra ? qs.parse(req.url.split('/').pop().slice(0, -5)) : {}
|
||||
const configValues = { ...extra, ...parseConfiguration(configuration) };
|
||||
addonInterface.get(resource, type, id, configValues)
|
||||
.then(resp => {
|
||||
const cacheHeaders = {
|
||||
cacheMaxAge: 'max-age',
|
||||
staleRevalidate: 'stale-while-revalidate',
|
||||
staleError: 'stale-if-error'
|
||||
};
|
||||
const cacheControl = Object.keys(cacheHeaders)
|
||||
.map(prop => Number.isInteger(resp[prop]) && cacheHeaders[prop] + '=' + resp[prop])
|
||||
.filter(val => !!val).join(', ');
|
||||
|
||||
res.setHeader('Cache-Control', `${cacheControl}, public`);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(resp));
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.noHandler) {
|
||||
if (next) {
|
||||
next()
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end(JSON.stringify({ err: 'not found' }));
|
||||
}
|
||||
} else {
|
||||
console.error(err);
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ err: 'handler error' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default function (req, res) {
|
||||
router(req, res, function () {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
};
|
||||
|
|
@ -4,15 +4,21 @@
|
|||
"exports": "./index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
"start": "tsx index.ts",
|
||||
"dev": "tsx --watch index.ts",
|
||||
"lint": "eslint **/*.ts",
|
||||
"lint:fix": "eslint --fix **/*.ts",
|
||||
"check": "tsc --noEmit",
|
||||
"build": "tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
"node": ">=22.x"
|
||||
},
|
||||
"author": "TheBeastLT <pauliox@beyond.lt>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@keyv/mongo": "^3.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"bottleneck": "^2.19.5",
|
||||
"moment": "^2.30.1",
|
||||
|
|
@ -21,5 +27,14 @@
|
|||
"request-ip": "^3.3.0",
|
||||
"sequelize": "^6.29.0",
|
||||
"stremio-addon-sdk": "^1.6.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/stremio-addon-sdk": "^1.6.11",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"globals": "^15.14.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
}
|
||||
}
|
||||
2767
pnpm-lock.yaml
Normal file
2767
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
3
src/addon.ts
Normal file
3
src/addon.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import addon from '@/lib/addon/index'
|
||||
|
||||
export default addon;
|
||||
3
src/catalog.ts
Normal file
3
src/catalog.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import catalogs from '@/lib/catalogs'
|
||||
|
||||
export default catalogs;
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"module": "commonjs" /* Specify what module code is generated. */,
|
||||
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||
"baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
} /* Specify a set of entries that re-map imports to additional lookup locations. */,
|
||||
"sourceMap": true /* Create source map files for emitted JavaScript files. */,
|
||||
"noEmit": true /* Disable emitting files from a compilation. */,
|
||||
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
|
||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
"noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue