diff --git a/src/lib/addon/.dockerignore b/src/lib/addon/.dockerignore new file mode 100644 index 0000000..5538a38 --- /dev/null +++ b/src/lib/addon/.dockerignore @@ -0,0 +1,3 @@ +**/node_modules +**/npm-debug.log +**/.env \ No newline at end of file diff --git a/src/lib/addon/Dockerfile b/src/lib/addon/Dockerfile new file mode 100644 index 0000000..14dd96e --- /dev/null +++ b/src/lib/addon/Dockerfile @@ -0,0 +1,12 @@ +FROM node:21-alpine + +RUN apk update && apk upgrade && \ + apk add --no-cache git + +WORKDIR /home/node/app + +COPY package*.json ./ +RUN npm ci --only-production +COPY . . + +CMD [ "node", "--insecure-http-parser", "index.js" ] \ No newline at end of file diff --git a/src/lib/addon/addon.ts b/src/lib/addon/addon.ts new file mode 100644 index 0000000..2c2bb1d --- /dev/null +++ b/src/lib/addon/addon.ts @@ -0,0 +1,136 @@ +import { addonBuilder, Stream } from 'stremio-addon-sdk'; +import { Type } from './lib/types.js'; +import { dummyManifest } from './lib/manifest.js'; +import { cacheWrapStream } from './lib/cache.js'; +import { toStreamInfo, applyStaticInfo } from './lib/streamInfo.js'; +import * as repository from './lib/repository.js'; +import applySorting from './lib/sort.js'; +import applyFilters from './lib/filter.js'; +import { applyMochs, getMochCatalog, getMochItemMeta } from './moch/moch.js'; +import StaticLinks from './moch/static.js'; +import { createNamedQueue } from "./lib/namedQueue.js"; +import pLimit from "p-limit"; + +function getMaxAge() { + const maxAgeEnv = process.env.CACHE_MAX_AGE; + if (maxAgeEnv) { + return parseInt(maxAgeEnv); + } + + return 60 * 60; +} + +const CACHE_MAX_AGE = getMaxAge(); // 1 hour in seconds +const CACHE_MAX_AGE_EMPTY = 60; // 60 seconds +const CATALOG_CACHE_MAX_AGE = 0; // 0 minutes +const STALE_REVALIDATE_AGE = 4 * 60 * 60; // 4 hours +const STALE_ERROR_AGE = 7 * 24 * 60 * 60; // 7 days + +const builder = new addonBuilder(dummyManifest()); +const requestQueue = createNamedQueue(Infinity); +const newLimiter = pLimit(30) + +builder.defineStreamHandler((args) => { + if (!args.id.match(/tt\d+/i) && !args.id.match(/kitsu:\d+/i)) { + return Promise.resolve({ streams: [] }); + } + + return requestQueue.wrap(args.id, () => resolveStreams(args)) + .then((streams: Stream[]) => applyFilters(streams, args.extra)) + .then((streams: Stream[]) => applySorting(streams, args.extra, args.type)) + .then((streams: Stream[]) => applyStaticInfo(streams)) + .then((streams: Stream[]) => applyMochs(streams, args.extra)) + .then((streams: Stream[]) => enrichCacheParams(streams)) + .catch((error: unknown) => { + return Promise.reject(`Failed request ${args.id}: ${error}`); + }); +}); + +builder.defineCatalogHandler((args) => { + const [_, mochKey, catalogId] = args.id.split('-'); + console.log(`Incoming catalog ${args.id} request with skip=${args.extra.skip || 0}`) + return getMochCatalog(mochKey, catalogId, args.extra) + .then(metas => ({ + metas: metas, + cacheMaxAge: CATALOG_CACHE_MAX_AGE + })) + .catch(error => { + return Promise.reject(`Failed retrieving catalog ${args.id}: ${JSON.stringify(error.message || error)}`); + }); +}) + +builder.defineMetaHandler((args) => { + const [mochKey, metaId] = args.id.split(':'); + console.log(`Incoming debrid meta ${args.id} request`) + return getMochItemMeta(mochKey, metaId, args.extra) + .then(meta => ({ + meta: meta, + cacheMaxAge: metaId === 'Downloads' ? 0 : CACHE_MAX_AGE + })) + .catch(error => { + return Promise.reject(`Failed retrieving catalog meta ${args.id}: ${JSON.stringify(error)}`); + }); +}) + +async function resolveStreams(args) { + return cacheWrapStream(args.id, () => newLimiter(() => streamHandler(args) + .then(records => records + .sort((a, b) => b.torrent.seeders - a.torrent.seeders || b.torrent.uploadDate - a.torrent.uploadDate) + .map(record => toStreamInfo(record))))); +} + +async function streamHandler(args) { + // console.log(`Pending count: ${newLimiter.pendingCount}, active count: ${newLimiter.activeCount}`, ) + if (args.type === Type.MOVIE) { + return movieRecordsHandler(args); + } else if (args.type === Type.SERIES) { + return seriesRecordsHandler(args); + } + return Promise.reject('not supported type'); +} + +async function seriesRecordsHandler(args) { + if (args.id.match(/^tt\d+:\d+:\d+$/)) { + const parts = args.id.split(':'); + const imdbId = parts[0]; + const season = parts[1] !== undefined ? parseInt(parts[1], 10) : 1; + const episode = parts[2] !== undefined ? parseInt(parts[2], 10) : 1; + return repository.getImdbIdSeriesEntries(imdbId, season, episode); + } else if (args.id.match(/^kitsu:\d+(?::\d+)?$/i)) { + const parts = args.id.split(':'); + const kitsuId = parts[1]; + const episode = parts[2] !== undefined ? parseInt(parts[2], 10) : undefined; + return episode !== undefined + ? repository.getKitsuIdSeriesEntries(kitsuId, episode) + : repository.getKitsuIdMovieEntries(kitsuId); + } + return Promise.resolve([]); +} + +async function movieRecordsHandler(args) { + if (args.id.match(/^tt\d+$/)) { + const parts = args.id.split(':'); + const imdbId = parts[0]; + return repository.getImdbIdMovieEntries(imdbId); + } else if (args.id.match(/^kitsu:\d+(?::\d+)?$/i)) { + return seriesRecordsHandler(args); + } + return Promise.resolve([]); +} + +function enrichCacheParams(streams) { + let cacheAge = CACHE_MAX_AGE; + if (!streams.length) { + cacheAge = CACHE_MAX_AGE_EMPTY; + } else if (streams.every(stream => stream?.url?.endsWith(StaticLinks.FAILED_ACCESS))) { + cacheAge = 0; + } + return { + streams: streams, + cacheMaxAge: cacheAge, + staleRevalidate: STALE_REVALIDATE_AGE, + staleError: STALE_ERROR_AGE + } +} + +export default builder.getInterface(); diff --git a/src/lib/addon/index.ts b/src/lib/addon/index.ts new file mode 100644 index 0000000..6bf497a --- /dev/null +++ b/src/lib/addon/index.ts @@ -0,0 +1,27 @@ +import express from 'express'; +import swStats from 'swagger-stats'; +import serverless from './serverless.js'; +import { manifest } from './lib/manifest.js'; +import { initBestTrackers } from './lib/magnetHelper.js'; + +const app = express(); + +app.enable('trust proxy'); +app.use(swStats.getMiddleware({ + name: manifest().name, + version: manifest().version, + timelineBucketDuration: 60 * 60 * 1000, + apdexThreshold: 100, + authentication: true, + onAuthenticate: (req, username, password) => { + return username === process.env.METRICS_USER + && password === process.env.METRICS_PASSWORD + }, +})) + +app.use(express.static('static', { maxAge: '1y' })); +app.use((req, res) => serverless(req, res)); +app.listen(process.env.PORT || 7000, () => { + initBestTrackers() + .then(() => console.log(`Started addon at: http://localhost:${process.env.PORT || 7000}`)); +}); diff --git a/src/lib/addon/lib/cache.ts b/src/lib/addon/lib/cache.ts new file mode 100644 index 0000000..3b9bc77 --- /dev/null +++ b/src/lib/addon/lib/cache.ts @@ -0,0 +1,109 @@ +import KeyvMongo from "@keyv/mongo"; +import { KeyvCacheableMemory } from "cacheable"; +import { isStaticUrl } from '../moch/static.js'; +import { Stream } from "stremio-addon-sdk"; + +const GLOBAL_KEY_PREFIX = 'torrentio-addon'; +const STREAM_KEY_PREFIX = `${GLOBAL_KEY_PREFIX}|stream`; +const AVAILABILITY_KEY_PREFIX = `${GLOBAL_KEY_PREFIX}|availability`; +const RESOLVED_URL_KEY_PREFIX = `${GLOBAL_KEY_PREFIX}|resolved`; + +const STREAM_TTL = 24 * 60 * 60 * 1000; // 24 hours +const STREAM_EMPTY_TTL = 60 * 1000; // 1 minute +const RESOLVED_URL_TTL = 3 * 60 * 60 * 1000; // 3 hours +const AVAILABILITY_TTL = 5 * 24 * 60 * 60 * 1000; // 5 days +const MESSAGE_VIDEO_URL_TTL = 60 * 1000; // 1 minutes +// When the streams are empty we want to cache it for less time in case of timeouts or failures + +const MONGO_URI = process.env.MONGODB_URI; + +const memoryCache = new KeyvCacheableMemory({ ttl: MESSAGE_VIDEO_URL_TTL, lruSize: Infinity }); +const remoteCache = MONGO_URI && new KeyvMongo(MONGO_URI, { + collection: 'torrentio_addon_collection', + minPoolSize: 50, + maxPoolSize: 200, + maxConnecting: 5, +}); + +async function cacheWrap(cache, key, method, ttl) { + if (!cache) { + return method(); + } + const value = await cache.get(key); + if (value !== undefined) { + return value; + } + const result = await method(); + const ttlValue = ttl instanceof Function ? ttl(result) : ttl; + await cache.set(key, result, ttlValue); + return result; +} + +export function cacheWrapStream(id: string, method: string) { + const ttl = (streams: Stream[]) => streams.length ? STREAM_TTL : STREAM_EMPTY_TTL; + return cacheWrap(remoteCache, `${STREAM_KEY_PREFIX}:${id}`, method, ttl); +} + +export function cacheWrapResolvedUrl(id, method) { + const ttl = (url) => isStaticUrl(url) ? MESSAGE_VIDEO_URL_TTL : RESOLVED_URL_TTL; + return cacheWrap(remoteCache, `${RESOLVED_URL_KEY_PREFIX}:${id}`, method, ttl); +} + +export function cacheAvailabilityResults(infoHash, fileIds) { + const key = `${AVAILABILITY_KEY_PREFIX}:${infoHash}`; + const fileIdsString = fileIds.toString(); + const containsFileIds = (array) => array.some(ids => ids.toString() === fileIdsString) + return remoteCache.get(key) + .then(result => { + const newResult = result || []; + if (!containsFileIds(newResult)) { + newResult.push(fileIds); + newResult.sort((a, b) => b.length - a.length); + } + return remoteCache.set(key, newResult, AVAILABILITY_TTL); + }); +} + +export function removeAvailabilityResults(infoHash, fileIds) { + const key = `${AVAILABILITY_KEY_PREFIX}:${infoHash}`; + const fileIdsString = fileIds.toString(); + return remoteCache.get(key) + .then(result => { + const storedIndex = result?.findIndex(ids => ids.toString() === fileIdsString); + if (storedIndex >= 0) { + result.splice(storedIndex, 1); + return remoteCache.set(key, result, AVAILABILITY_TTL); + } + }); +} + +export function getCachedAvailabilityResults(infoHashes) { + const keys = infoHashes.map(infoHash => `${AVAILABILITY_KEY_PREFIX}:${infoHash}`) + return remoteCache.getMany(keys) + .then(result => { + const availabilityResults = {}; + infoHashes.forEach((infoHash, index) => { + if (result[index]) { + availabilityResults[infoHash] = result[index]; + } + }); + return availabilityResults; + }) + .catch(error => { + console.log('Failed retrieve availability cache', error) + return {}; + }); +} + +/** + * Returns the max age for the cache in seconds. + * It first tries to parse the `CACHE_MAX_AGE` environment variable, and if it's not set, it defaults to 1 hour. + */ +export function getCacheMaxAge() { + const maxAgeEnv = process.env.CACHE_MAX_AGE; + if (maxAgeEnv) { + return parseInt(maxAgeEnv); + } + + return 60 * 60; +} diff --git a/src/lib/addon/lib/configuration.ts b/src/lib/addon/lib/configuration.ts new file mode 100644 index 0000000..d646297 --- /dev/null +++ b/src/lib/addon/lib/configuration.ts @@ -0,0 +1,82 @@ +import { DebridOptions } from '../moch/options.js'; +import { QualityFilter, Providers, SizeFilter } from './filter.js'; +import { LanguageOptions } from './languages.js'; + +export const PreConfigurations = { + lite: { + config: liteConfig(), + serialized: configValue(liteConfig()), + manifest: { + id: 'com.stremio.torrentio.lite.addon', + name: 'Torrentio Lite', + description: 'Preconfigured Lite version of Torrentio addon.' + + ' To configure advanced options visit https://torrentio.strem.fun/lite' + } + }, + brazuca: { + config: brazucaConfig(), + serialized: configValue(brazucaConfig()), + manifest: { + id: 'com.stremio.torrentio.brazuca.addon', + name: 'Torrentio Brazuca', + description: 'Preconfigured version of Torrentio addon for Brazilian content.' + + ' To configure advanced options visit https://torrentio.strem.fun/brazuca', + logo: 'https://i.ibb.co/8mgRZPp/GwxAcDV.png' + } + } +} + +const keysToSplit = [Providers.key, LanguageOptions.key, QualityFilter.key, SizeFilter.key, DebridOptions.key]; +const keysToUppercase = [SizeFilter.key]; + +export function parseConfiguration(configuration) { + if (!configuration) { + return undefined; + } + if (PreConfigurations[configuration]) { + return PreConfigurations[configuration].config; + } + const configValues = configuration.split('|') + .reduce((map, next) => { + const parameterParts = next.split('='); + if (parameterParts.length === 2) { + map[parameterParts[0].toLowerCase()] = parameterParts[1]; + } + return map; + }, {}); + keysToSplit + .filter(key => configValues[key]) + .forEach(key => configValues[key] = configValues[key].split(',') + .map(value => keysToUppercase.includes(key) ? value.toUpperCase() : value.toLowerCase())) + return configValues; +} + +function liteConfig() { + const config = {}; + config[Providers.key] = Providers.options + .filter(provider => !provider.foreign) + .map(provider => provider.key); + config[QualityFilter.key] = ['scr', 'cam'] + config['limit'] = 1; + return config; +} + +function brazucaConfig() { + const config = {}; + config[Providers.key] = Providers.options + .filter(provider => !provider.foreign || provider.foreign === '🇵🇹') + .map(provider => provider.key); + config[LanguageOptions.key] = ['portuguese']; + return config; +} + +function configValue(config) { + return Object.entries(config) + .map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(',') : value}`) + .join('|'); +} + +export function getManifestOverride(config) { + const preConfig = Object.values(PreConfigurations).find(pre => pre.config === config); + return preConfig ? preConfig.manifest : {}; +} \ No newline at end of file diff --git a/src/lib/addon/lib/extension.ts b/src/lib/addon/lib/extension.ts new file mode 100644 index 0000000..abd1b72 --- /dev/null +++ b/src/lib/addon/lib/extension.ts @@ -0,0 +1,72 @@ +const VIDEO_EXTENSIONS = [ + "3g2", + "3gp", + "avi", + "flv", + "mkv", + "mk3d", + "mov", + "mp2", + "mp4", + "m4v", + "mpe", + "mpeg", + "mpg", + "mpv", + "webm", + "wmv", + "ogm", + "ts", + "m2ts" +]; +const SUBTITLE_EXTENSIONS = [ + "aqt", + "gsub", + "jss", + "sub", + "ttxt", + "pjs", + "psb", + "rt", + "smi", + "slt", + "ssf", + "srt", + "ssa", + "ass", + "usf", + "idx", + "vtt" +]; +const DISK_EXTENSIONS = [ + "iso", + "m2ts", + "ts", + "vob" +] + +const ARCHIVE_EXTENSIONS = [ + "rar", + "zip" +] + +export function isVideo(filename) { + return isExtension(filename, VIDEO_EXTENSIONS); +} + +export function isSubtitle(filename) { + return isExtension(filename, SUBTITLE_EXTENSIONS); +} + +export function isDisk(filename) { + return isExtension(filename, DISK_EXTENSIONS); +} + +export function isArchive(filename) { + return isExtension(filename, ARCHIVE_EXTENSIONS); +} + +export function isExtension(filename, extensions) { + const extensionMatch = filename?.match(/\.(\w{2,4})$/); + return extensionMatch && extensions.includes(extensionMatch[1].toLowerCase()); +} diff --git a/src/lib/addon/lib/filter.ts b/src/lib/addon/lib/filter.ts new file mode 100644 index 0000000..c8eea6f --- /dev/null +++ b/src/lib/addon/lib/filter.ts @@ -0,0 +1,269 @@ +import { extractProvider, parseSize, extractSize } from './titleHelper.js'; +import { Type } from './types.js'; +export const Providers = { + key: 'providers', + options: [ + { + key: 'yts', + label: 'YTS' + }, + { + key: 'eztv', + label: 'EZTV' + }, + { + key: 'rarbg', + label: 'RARBG' + }, + { + key: '1337x', + label: '1337x' + }, + { + key: 'thepiratebay', + label: 'ThePirateBay' + }, + { + key: 'kickasstorrents', + label: 'KickassTorrents' + }, + { + key: 'torrentgalaxy', + label: 'TorrentGalaxy' + }, + { + key: 'magnetdl', + label: 'MagnetDL' + }, + { + key: 'horriblesubs', + label: 'HorribleSubs', + anime: true + }, + { + key: 'nyaasi', + label: 'NyaaSi', + anime: true + }, + { + key: 'tokyotosho', + label: 'TokyoTosho', + anime: true + }, + { + key: 'anidex', + label: 'AniDex', + anime: true + }, + { + key: 'rutor', + label: 'Rutor', + foreign: '🇷🇺' + }, + { + key: 'rutracker', + label: 'Rutracker', + foreign: '🇷🇺' + }, + { + key: 'comando', + label: 'Comando', + foreign: '🇵🇹' + }, + { + key: 'bludv', + label: 'BluDV', + foreign: '🇵🇹' + }, + { + key: 'torrent9', + label: 'Torrent9', + foreign: '🇫🇷' + }, + { + key: 'ilcorsaronero', + label: 'ilCorSaRoNeRo', + foreign: '🇮🇹' + }, + { + key: 'mejortorrent', + label: 'MejorTorrent', + foreign: '🇪🇸' + }, + { + key: 'wolfmax4k', + label: 'Wolfmax4k', + foreign: '🇪🇸' + }, + { + key: 'cinecalidad', + label: 'Cinecalidad', + foreign: '🇲🇽' + }, + ] +}; +export const QualityFilter = { + key: 'qualityfilter', + options: [ + { + key: 'brremux', + label: 'BluRay REMUX', + test(quality, bingeGroup) { + return bingeGroup?.includes(this.label); + } + }, + { + key: 'hdrall', + label: 'HDR/HDR10+/Dolby Vision', + items: ['HDR', 'HDR10+', 'DV'], + test(quality) { + const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || ''; + return this.items.some(hdrType => hdrProfiles.includes(hdrType)); + } + }, + { + key: 'dolbyvision', + label: 'Dolby Vision', + test(quality) { + const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || ''; + return hdrProfiles === 'DV'; + } + }, + { + key: 'dolbyvisionwithhdr', + label: 'Dolby Vision + HDR', + test(quality) { + const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || ''; + return hdrProfiles.includes('DV') && hdrProfiles.includes('HDR'); + } + }, + { + key: 'threed', + label: '3D', + test(quality) { + const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || ''; + return hdrProfiles.includes('3D'); + } + }, + { + key: 'nonthreed', + label: 'Non 3D (DO NOT SELECT IF NOT SURE)', + test(quality) { + const hdrProfiles = quality?.split(' ')?.slice(1)?.join() || ''; + return !hdrProfiles.includes('3D'); + } + }, + { + key: '4k', + label: '4k', + items: ['4k'], + test(quality) { + return quality && this.items.includes(quality.split(' ')[0]); + } + }, + { + key: '1080p', + label: '1080p', + items: ['1080p'], + test(quality) { + return this.items.includes(quality) + } + }, + { + key: '720p', + label: '720p', + items: ['720p'], + test(quality) { + return this.items.includes(quality) + } + }, + { + key: '480p', + label: '480p', + items: ['480p'], + test(quality) { + return this.items.includes(quality) + } + }, + { + key: 'other', + label: 'Other (DVDRip/HDRip/BDRip...)', + // could be ['DVDRip', 'HDRip', 'BDRip', 'BRRip', 'BluRay', 'WEB-DL', 'WEBRip', 'HDTV', 'DivX', 'XviD'] + items: ['4k', '1080p', '720p', '480p', 'SCR', 'CAM', 'TeleSync', 'TeleCine'], + test(quality) { + return quality && !this.items.includes(quality.split(' ')[0]); + } + }, + { + key: 'scr', + label: 'Screener', + items: ['SCR'], + test(quality) { + return this.items.includes(quality) + } + }, + { + key: 'cam', + label: 'Cam', + items: ['CAM', 'TeleSync', 'TeleCine'], + test(quality) { + return this.items.includes(quality) + } + }, + { + key: 'unknown', + label: 'Unknown', + test(quality) { + return !quality + } + } + ] +}; +export const SizeFilter = { + key: 'sizefilter' +} +const defaultProviderKeys = Providers.options.map(provider => provider.key); + +export default function applyFilters(streams, config) { + return [ + filterByProvider, + filterByQuality, + filterBySize + ].reduce((filteredStreams, filter) => filter(filteredStreams, config), streams); +} + +function filterByProvider(streams, config) { + const providers = config.providers || defaultProviderKeys; + if (!providers?.length) { + return streams; + } + return streams.filter(stream => { + const provider = extractProvider(stream.title).toLowerCase(); + return providers.includes(provider); + }) +} + +function filterByQuality(streams, config) { + const filters = config[QualityFilter.key]; + if (!filters) { + return streams; + } + const filterOptions = QualityFilter.options.filter(option => filters.includes(option.key)); + return streams.filter(stream => { + const streamQuality = stream.name.split('\n')[1]; + const bingeGroup = stream.behaviorHints?.bingeGroup; + return !filterOptions.some(option => option.test(streamQuality, bingeGroup)); + }); +} + +function filterBySize(streams, config) { + const sizeFilters = config[SizeFilter.key]; + if (!sizeFilters?.length) { + return streams; + } + const sizeLimit = parseSize(config.type === Type.MOVIE ? sizeFilters.shift() : sizeFilters.pop()); + return streams.filter(stream => { + const size = extractSize(stream.title) + return size <= sizeLimit; + }) +} diff --git a/src/lib/addon/lib/landingTemplate.ts b/src/lib/addon/lib/landingTemplate.ts new file mode 100644 index 0000000..59206d7 --- /dev/null +++ b/src/lib/addon/lib/landingTemplate.ts @@ -0,0 +1,506 @@ +const STYLESHEET = ` +* { + box-sizing: border-box; +} + +body, +html { + margin: 0; + padding: 0; + width: 100%; + height: 100% +} + +html { + background-size: auto 100%; + background-size: cover; + background-position: center center; + background-repeat: repeat-y; +} + +body { + display: flex; + background-color: transparent; + font-family: 'Open Sans', Arial, sans-serif; + color: white; +} + +h1 { + font-size: 4.5vh; + font-weight: 700; +} + +h2 { + font-size: 2.2vh; + font-weight: normal; + font-style: italic; + opacity: 0.8; +} + +h3 { + font-size: 2.2vh; +} + +h1, +h2, +h3, +p, +label { + margin: 0; + text-shadow: 0 0 1vh rgba(0, 0, 0, 0.15); +} + +p { + font-size: 1.75vh; +} + +ul { + font-size: 1.75vh; + margin: 0; + margin-top: 1vh; + padding-left: 3vh; +} + +a { + color: green +} + +a.install-link { + text-decoration: none +} + +.install-button { + border: 0; + outline: 0; + color: white; + background: #8A5AAB; + padding: 1.2vh 3.5vh; + margin: auto; + text-align: center; + font-family: 'Open Sans', Arial, sans-serif; + font-size: 2.2vh; + font-weight: 600; + cursor: pointer; + display: block; + box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2); + transition: box-shadow 0.1s ease-in-out; +} + +.install-button:hover { + box-shadow: none; +} + +.install-button:active { + box-shadow: 0 0 0 0.5vh white inset; +} + +#addon { + width: 90vh; + margin: auto; + padding-left: 10%; + padding-right: 10%; + background: rgba(0, 0, 0, 0.60); +} + +.logo { + height: 14vh; + width: 14vh; + margin: auto; + margin-bottom: 3vh; +} + +.logo img { + width: 100%; +} + +.name, .version { + display: inline-block; + vertical-align: top; +} + +.name { + line-height: 5vh; +} + +.version { + position: absolute; + line-height: 5vh; + margin-left: 1vh; + opacity: 0.8; +} + +.contact { + left: 0; + bottom: 4vh; + width: 100%; + margin-top: 1vh; + text-align: center; +} + +.contact a { + font-size: 1.4vh; + font-style: italic; +} + +.separator { + margin-bottom: 4vh; +} + +.label { + font-size: 2.2vh; + font-weight: 600; + padding: 0; + line-height: inherit; +} + +.btn-group, .multiselect-container { + width: 100%; +} + +.btn { + text-align: left; +} + +.multiselect-container { + border: 0; + border-radius: 0; +} + +.input, .btn { + width: 100%; + margin: auto; + margin-bottom: 10px; + padding: 6px 12px; + border: 0; + border-radius: 0; + outline: 0; + color: #333; + background-color: rgb(255, 255, 255); + box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2); +} + +.input:focus, .btn:focus { + outline: none; + box-shadow: 0 0 0 2pt rgb(30, 144, 255, 0.7); +} +`; +import { Providers, QualityFilter, SizeFilter } from './filter.js'; +import { SortOptions } from './sort.js'; +import { LanguageOptions } from './languages.js'; +import { DebridOptions } from '../moch/options.js'; +import { MochOptions } from '../moch/moch.js'; +import { PreConfigurations } from './configuration.js'; + +export default function landingTemplate(manifest, config = {}) { + const providers = config[Providers.key] || Providers.options.map(provider => provider.key); + const sort = config[SortOptions.key] || SortOptions.options.qualitySeeders.key; + const languages = config[LanguageOptions.key] || []; + const qualityFilters = config[QualityFilter.key] || []; + const sizeFilter = (config[SizeFilter.key] || []).join(','); + const limit = config.limit || ''; + + const debridProvider = Object.keys(MochOptions).find(mochKey => config[mochKey]); + const debridOptions = config[DebridOptions.key] || []; + const realDebridApiKey = config[MochOptions.realdebrid.key] || ''; + const premiumizeApiKey = config[MochOptions.premiumize.key] || ''; + const allDebridApiKey = config[MochOptions.alldebrid.key] || ''; + const debridLinkApiKey = config[MochOptions.debridlink.key] || ''; + const offcloudApiKey = config[MochOptions.offcloud.key] || ''; + const torboxApiKey = config[MochOptions.torbox.key] || ''; + const putioKey = config[MochOptions.putio.key] || ''; + const putioClientId = putioKey.replace(/@.*/, ''); + const putioToken = putioKey.replace(/.*@/, ''); + + const background = manifest.background || 'https://dl.strem.io/addon-background.jpg'; + const logo = manifest.logo || 'https://dl.strem.io/addon-logo.png'; + const providersHTML = Providers.options + .map(provider => ``) + .join('\n'); + const sortOptionsHTML = Object.values(SortOptions.options) + .map((option, i) => ``) + .join('\n'); + const languagesOptionsHTML = LanguageOptions.options + .map((option, i) => ``) + .join('\n'); + const qualityFiltersHTML = Object.values(QualityFilter.options) + .map(option => ``) + .join('\n'); + const debridProvidersHTML = Object.values(MochOptions) + .map(moch => ``) + .join('\n'); + const debridOptionsHTML = Object.values(DebridOptions.options) + .map(option => ``) + .join('\n'); + const stylizedTypes = manifest.types + .map(t => t[0].toUpperCase() + t.slice(1) + (t !== 'series' ? 's' : '')); + const preConfigurationObject = Object.entries(PreConfigurations) + .map(([key, config]) => `${key}: '${config.serialized}'`) + .join(','); + + return ` + + + + + + ${manifest.name} - Stremio Addon + + + + + + + + + + + + +
+ +

${manifest.name}

+

${manifest.version || '0.0.0'}

+

${manifest.description || ''}

+ +
+ +

This addon has more :

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + + + +
+

Or paste into Stremio search bar after clicking install

+
+ +
+
+ + + + ` +} diff --git a/src/lib/addon/lib/languages.ts b/src/lib/addon/lib/languages.ts new file mode 100644 index 0000000..1b80ac4 --- /dev/null +++ b/src/lib/addon/lib/languages.ts @@ -0,0 +1,76 @@ +const languageMapping = { + 'dubbed': 'Dubbed', + 'multi audio': 'Multi Audio', + 'multi subs': 'Multi Subs', + 'dual audio': 'Dual Audio', + 'english': '🇬🇧', + 'japanese': '🇯🇵', + 'russian': '🇷🇺', + 'italian': '🇮🇹', + 'portuguese': '🇵🇹', + 'spanish': '🇪🇸', + 'latino': '🇲🇽', + 'korean': '🇰🇷', + 'chinese': '🇨🇳', + 'taiwanese': '🇹🇼', + 'french': '🇫🇷', + 'german': '🇩🇪', + 'dutch': '🇳🇱', + 'hindi': '🇮🇳', + 'telugu': '🇮🇳', + 'tamil': '🇮🇳', + 'polish': '🇵🇱', + 'lithuanian': '🇱🇹', + 'latvian': '🇱🇻', + 'estonian': '🇪🇪', + 'czech': '🇨🇿', + 'slovakian': '🇸🇰', + 'slovenian': '🇸🇮', + 'hungarian': '🇭🇺', + 'romanian': '🇷🇴', + 'bulgarian': '🇧🇬', + 'serbian': '🇷🇸 ', + 'croatian': '🇭🇷', + 'ukrainian': '🇺🇦', + 'greek': '🇬🇷', + 'danish': '🇩🇰', + 'finnish': '🇫🇮', + 'swedish': '🇸🇪', + 'norwegian': '🇳🇴', + 'turkish': '🇹🇷', + 'arabic': '🇸🇦', + 'persian': '🇮🇷', + 'hebrew': '🇮🇱', + 'vietnamese': '🇻🇳', + 'indonesian': '🇮🇩', + 'malay': '🇲🇾', + 'thai': '🇹🇭' +} + +export const LanguageOptions = { + key: 'language', + options: Object.keys(languageMapping).slice(5).map(lang => ({ + key: lang, + label: `${languageMapping[lang]} ${lang.charAt(0).toUpperCase()}${lang.slice(1)}` + })) +} + +export function mapLanguages(languages) { + const mapped = languages + .map(language => languageMapping[language]) + .filter(language => language) + .sort((a, b) => Object.values(languageMapping).indexOf(a) - Object.values(languageMapping).indexOf(b)); + const unmapped = languages + .filter(language => !languageMapping[language]) + .sort((a, b) => a.localeCompare(b)) + return [...new Set([].concat(mapped).concat(unmapped))]; +} + +export function containsLanguage(stream, languages) { + return languages.map(lang => languageMapping[lang]).some(lang => stream.title.includes(lang)); +} + +export function languageFromCode(code) { + const entry = Object.entries(languageMapping).find(entry => entry[1] === code); + return entry?.[0]; +} diff --git a/src/lib/addon/lib/magnetHelper.ts b/src/lib/addon/lib/magnetHelper.ts new file mode 100644 index 0000000..4893692 --- /dev/null +++ b/src/lib/addon/lib/magnetHelper.ts @@ -0,0 +1,93 @@ +import axios from 'axios'; +import magnet from 'magnet-uri'; +import { getRandomUserAgent } from './requestHelper.js'; +import { getTorrent } from './repository.js'; +import { Type } from './types.js'; +import { extractProvider } from "./titleHelper.js"; +import { Providers } from "./filter.js"; + +const TRACKERS_URL = 'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt'; +const ANIME_TRACKERS = [ + "http://nyaa.tracker.wf:7777/announce", + "http://anidex.moe:6969/announce", + "http://tracker.anirena.com:80/announce", + "udp://tracker.uw0.xyz:6969/announce", + "http://share.camoe.cn:8080/announce", + "http://t.nyaatracker.com:80/announce", +]; +const RUSSIAN_TRACKERS = [ + "udp://opentor.net:6969", + "http://bt.t-ru.org/ann?magnet", + "http://bt2.t-ru.org/ann?magnet", + "http://bt3.t-ru.org/ann?magnet", + "http://bt4.t-ru.org/ann?magnet", +]; +// Some trackers have limits on original torrent trackers, +// where downloading ip has to seed the torrents for some amount of time, +// thus it doesn't work on mochs. +// So it's better to exclude them and try to download through DHT, +// as the torrent won't start anyway. +const RUSSIAN_PROVIDERS = Providers.options + .filter(provider => provider.foreign === '🇷🇺') + .map(provider => provider.label); +const ANIME_PROVIDERS = Providers.options + .filter(provider => provider.anime) + .map(provider => provider.label); +let BEST_TRACKERS = []; +let ALL_ANIME_TRACKERS = []; +let ALL_RUSSIAN_TRACKERS = []; + +export async function getMagnetLink(infoHash) { + const torrent = await getTorrent(infoHash).catch(() => ({ infoHash })); + const torrentTrackers = torrent?.trackers?.split(',') || []; + const animeTrackers = torrent?.type === Type.ANIME ? ALL_ANIME_TRACKERS : []; + const providerTrackers = RUSSIAN_PROVIDERS.includes(torrent?.provider) && ALL_RUSSIAN_TRACKERS || []; + const trackers = unique([].concat(torrentTrackers).concat(animeTrackers).concat(providerTrackers)); + + return magnet.encode({ infoHash: infoHash, name: torrent?.title, announce: trackers }); +} + +export async function initBestTrackers() { + BEST_TRACKERS = await getBestTrackers(); + ALL_ANIME_TRACKERS = unique(BEST_TRACKERS.concat(ANIME_TRACKERS)); + ALL_RUSSIAN_TRACKERS = unique(BEST_TRACKERS.concat(RUSSIAN_TRACKERS)); + console.log('Retrieved best trackers: ', BEST_TRACKERS); +} + +async function getBestTrackers(retry = 2) { + const options = { timeout: 30000, headers: { 'User-Agent': getRandomUserAgent() } }; + return axios.get(TRACKERS_URL, options) + .then(response => response?.data?.trim()?.split('\n\n') || []) + .catch(error => { + if (retry === 0) { + console.log(`Failed retrieving best trackers: ${error.message}`); + throw error; + } + return getBestTrackers(retry - 1); + }); +} + +export function getSources(trackersInput, infoHash) { + if (!trackersInput) { + return null; + } + const trackers = Array.isArray(trackersInput) ? trackersInput : trackersInput.split(','); + return trackers.map(tracker => `tracker:${tracker}`).concat(`dht:${infoHash}`); +} + +export function enrichStreamSources(stream) { + const provider = extractProvider(stream.title); + if (ANIME_PROVIDERS.includes(provider)) { + const sources = getSources(ALL_ANIME_TRACKERS, stream.infoHash); + return { ...stream, sources }; + } + if (RUSSIAN_PROVIDERS.includes(provider)) { + const sources = unique([].concat(stream.sources || []).concat(getSources(ALL_RUSSIAN_TRACKERS, stream.infoHash))); + return { ...stream, sources }; + } + return stream; +} + +function unique(array) { + return Array.from(new Set(array)); +} diff --git a/src/lib/addon/lib/manifest.ts b/src/lib/addon/lib/manifest.ts new file mode 100644 index 0000000..4141f4d --- /dev/null +++ b/src/lib/addon/lib/manifest.ts @@ -0,0 +1,89 @@ +import { MochOptions } from '../moch/moch.js'; +import { Providers } from './filter.js'; +import { showDebridCatalog } from '../moch/options.js'; +import { getManifestOverride } from './configuration.js'; +import { Type } from './types.js'; + +const DefaultProviders = Providers.options.map(provider => provider.key); +const MochProviders = Object.values(MochOptions); + +export function manifest(config = {}) { + const overrideManifest = getManifestOverride(config); + const baseManifest = { + id: 'com.stremio.torrentio.addon', + version: '0.0.14', + name: getName(overrideManifest, config), + description: getDescription(config), + catalogs: getCatalogs(config), + resources: getResources(config), + types: [Type.MOVIE, Type.SERIES, Type.ANIME, Type.OTHER], + background: `https://i.ibb.co/VtSfFP9/t8wVwcg.jpg`, + logo: `https://i.ibb.co/w4BnkC9/GwxAcDV.png`, + behaviorHints: { + configurable: true, + configurationRequired: false + } + }; + return Object.assign(baseManifest, overrideManifest); +} + +export function dummyManifest() { + const manifestDefault = manifest(); + manifestDefault.catalogs = [{ id: 'dummy', type: Type.OTHER }]; + manifestDefault.resources = ['stream', 'meta']; + return manifestDefault; +} + +function getName(manifest, config) { + const rootName = manifest?.name || 'Torrentio'; + const mochSuffix = MochProviders + .filter(moch => config[moch.key]) + .map(moch => moch.shortName) + .join('/'); + return [rootName, mochSuffix].filter(v => v).join(' '); +} + +function getDescription(config) { + const providersList = config[Providers.key] || DefaultProviders; + const enabledProvidersDesc = Providers.options + .map(provider => `${provider.label}${providersList.includes(provider.key) ? '(+)' : '(-)'}`) + .join(', ') + const enabledMochs = MochProviders + .filter(moch => config[moch.key]) + .map(moch => moch.name) + .join(' & '); + const possibleMochs = MochProviders.map(moch => moch.name).join('/') + const mochsDesc = enabledMochs ? ` and ${enabledMochs} enabled` : ''; + return 'Provides torrent streams from scraped torrent providers.' + + ` Currently supports ${enabledProvidersDesc}${mochsDesc}.` + + ` To configure providers, ${possibleMochs} support and other settings visit https://torrentio.strem.fun` +} + +function getCatalogs(config) { + return MochProviders + .filter(moch => showDebridCatalog(config) && config[moch.key]) + .map(moch => moch.catalogs.map(catalogName => ({ + id: catalogName ? `torrentio-${moch.key}-${catalogName.toLowerCase()}` : `torrentio-${moch.key}`, + name: catalogName ? `${moch.name} ${catalogName}` : `${moch.name}`, + type: 'other', + extra: [{ name: 'skip' }], + }))) + .reduce((a, b) => a.concat(b), []); +} + +function getResources(config) { + const streamResource = { + name: 'stream', + types: [Type.MOVIE, Type.SERIES], + idPrefixes: ['tt', 'kitsu'] + }; + const metaResource = { + name: 'meta', + types: [Type.OTHER], + idPrefixes: MochProviders.filter(moch => config[moch.key]).map(moch => moch.key) + }; + if (showDebridCatalog(config) && MochProviders.filter(moch => config[moch.key]).length) { + return [streamResource, metaResource]; + } + return [streamResource]; +} diff --git a/src/lib/addon/lib/namedQueue.ts b/src/lib/addon/lib/namedQueue.ts new file mode 100644 index 0000000..695ce05 --- /dev/null +++ b/src/lib/addon/lib/namedQueue.ts @@ -0,0 +1,11 @@ +import namedQueue from "named-queue"; + +export function createNamedQueue(concurrency) { + const queue = new namedQueue((task, callback) => task.method() + .then(result => callback(false, result)) + .catch((error => callback(error))), 200); + queue.wrap = (id, method) => new Promise(((resolve, reject) => { + queue.push({ id, method }, (error, result) => result ? resolve(result) : reject(error)); + })); + return queue; +} \ No newline at end of file diff --git a/src/lib/addon/lib/promises.ts b/src/lib/addon/lib/promises.ts new file mode 100644 index 0000000..55094bb --- /dev/null +++ b/src/lib/addon/lib/promises.ts @@ -0,0 +1,20 @@ +/** + * Delay promise + */ +export async function delay(duration) { + return new Promise((resolve) => setTimeout(resolve, duration)); +} + +/** + * Timeout promise after a set time in ms + */ +export async function timeout(timeoutMs, promise, message = 'Timed out') { + return Promise.race([ + promise, + new Promise(function (resolve, reject) { + setTimeout(function () { + reject(message); + }, timeoutMs); + }) + ]); +} diff --git a/src/lib/addon/lib/repository.ts b/src/lib/addon/lib/repository.ts new file mode 100644 index 0000000..ceb8b61 --- /dev/null +++ b/src/lib/addon/lib/repository.ts @@ -0,0 +1,132 @@ +import { Sequelize } from 'sequelize'; + +const Op = Sequelize.Op; + +const DATABASE_URI = process.env.DATABASE_URI; + +const database = new Sequelize(DATABASE_URI, { logging: false, pool: { max: 30, min: 5, idle: 20 * 60 * 1000 } }); + +const Torrent = database.define('torrent', + { + infoHash: { type: Sequelize.STRING(64), primaryKey: true }, + provider: { type: Sequelize.STRING(32), allowNull: false }, + torrentId: { type: Sequelize.STRING(128) }, + title: { type: Sequelize.STRING(256), allowNull: false }, + size: { type: Sequelize.BIGINT }, + type: { type: Sequelize.STRING(16), allowNull: false }, + uploadDate: { type: Sequelize.DATE, allowNull: false }, + seeders: { type: Sequelize.SMALLINT }, + trackers: { type: Sequelize.STRING(4096) }, + languages: { type: Sequelize.STRING(4096) }, + resolution: { type: Sequelize.STRING(16) } + } +); + +const File = database.define('file', + { + id: { type: Sequelize.BIGINT, autoIncrement: true, primaryKey: true }, + infoHash: { + type: Sequelize.STRING(64), + allowNull: false, + references: { model: Torrent, key: 'infoHash' }, + onDelete: 'CASCADE' + }, + fileIndex: { type: Sequelize.INTEGER }, + title: { type: Sequelize.STRING(256), allowNull: false }, + size: { type: Sequelize.BIGINT }, + imdbId: { type: Sequelize.STRING(32) }, + imdbSeason: { type: Sequelize.INTEGER }, + imdbEpisode: { type: Sequelize.INTEGER }, + kitsuId: { type: Sequelize.INTEGER }, + kitsuEpisode: { type: Sequelize.INTEGER } + }, +); + +const Subtitle = database.define('subtitle', + { + infoHash: { + type: Sequelize.STRING(64), + allowNull: false, + references: { model: Torrent, key: 'infoHash' }, + onDelete: 'CASCADE' + }, + fileIndex: { type: Sequelize.INTEGER, allowNull: false }, + fileId: { + type: Sequelize.BIGINT, + allowNull: true, + references: { model: File, key: 'id' }, + onDelete: 'SET NULL' + }, + title: { type: Sequelize.STRING(512), allowNull: false }, + size: { type: Sequelize.BIGINT, allowNull: false }, + }, + { timestamps: false } +); + +Torrent.hasMany(File, { foreignKey: 'infoHash', constraints: false }); +File.belongsTo(Torrent, { foreignKey: 'infoHash', constraints: false }); +File.hasMany(Subtitle, { foreignKey: 'fileId', constraints: false }); +Subtitle.belongsTo(File, { foreignKey: 'fileId', constraints: false }); + +export function getTorrent(infoHash) { + return Torrent.findOne({ where: { infoHash: infoHash } }); +} + +export function getFiles(infoHashes) { + return File.findAll({ where: { infoHash: { [Op.in]: infoHashes } } }); +} + +export function getImdbIdMovieEntries(imdbId) { + return File.findAll({ + where: { + imdbId: { [Op.eq]: imdbId } + }, + include: [Torrent], + limit: 500, + order: [ + [Torrent, 'seeders', 'DESC'] + ] + }); +} + +export function getImdbIdSeriesEntries(imdbId, season, episode) { + return File.findAll({ + where: { + imdbId: { [Op.eq]: imdbId }, + imdbSeason: { [Op.eq]: season }, + imdbEpisode: { [Op.eq]: episode } + }, + include: [Torrent], + limit: 500, + order: [ + [Torrent, 'seeders', 'DESC'] + ] + }); +} + +export function getKitsuIdMovieEntries(kitsuId) { + return File.findAll({ + where: { + kitsuId: { [Op.eq]: kitsuId } + }, + include: [Torrent], + limit: 500, + order: [ + [Torrent, 'seeders', 'DESC'] + ] + }); +} + +export function getKitsuIdSeriesEntries(kitsuId, episode) { + return File.findAll({ + where: { + kitsuId: { [Op.eq]: kitsuId }, + kitsuEpisode: { [Op.eq]: episode } + }, + include: [Torrent], + limit: 500, + order: [ + [Torrent, 'seeders', 'DESC'] + ] + }); +} diff --git a/src/lib/addon/lib/requestHelper.ts b/src/lib/addon/lib/requestHelper.ts new file mode 100644 index 0000000..7053ac4 --- /dev/null +++ b/src/lib/addon/lib/requestHelper.ts @@ -0,0 +1,6 @@ +import UserAgent from 'user-agents'; +const userAgent = new UserAgent(); + +export function getRandomUserAgent() { + return userAgent.random().toString(); +} diff --git a/src/lib/addon/lib/sort.ts b/src/lib/addon/lib/sort.ts new file mode 100644 index 0000000..2430284 --- /dev/null +++ b/src/lib/addon/lib/sort.ts @@ -0,0 +1,130 @@ +import { QualityFilter } from './filter.js'; +import { containsLanguage, LanguageOptions } from './languages.js'; +import { Type } from './types.js'; +import { hasMochConfigured } from '../moch/moch.js'; +import { extractSeeders, extractSize } from './titleHelper.js'; + +const OTHER_QUALITIES = QualityFilter.options.find(option => option.key === 'other'); +const CAM_QUALITIES = QualityFilter.options.find(option => option.key === 'cam'); +const HEALTHY_SEEDERS = 5; +const SEEDED_SEEDERS = 1; +const MIN_HEALTHY_COUNT = 50; +const MAX_UNHEALTHY_COUNT = 5; + +export const SortOptions = { + key: 'sort', + options: { + qualitySeeders: { + key: 'quality', + description: 'By quality then seeders' + }, + qualitySize: { + key: 'qualitysize', + description: 'By quality then size' + }, + seeders: { + key: 'seeders', + description: 'By seeders' + }, + size: { + key: 'size', + description: 'By size' + }, + } +} + +export default function sortStreams(streams, config, type) { + const languages = config[LanguageOptions.key]; + if (languages?.length && languages[0] !== 'english') { + // No need to filter english since it's hard to predict which entries are english + const streamsWithLanguage = streams.filter(stream => containsLanguage(stream, languages)); + const streamsNoLanguage = streams.filter(stream => !streamsWithLanguage.includes(stream)); + return _sortStreams(streamsWithLanguage, config, type).concat(_sortStreams(streamsNoLanguage, config, type)); + } + return _sortStreams(streams, config, type); +} + +function _sortStreams(streams, config, type) { + const sort = config?.sort?.toLowerCase() || undefined; + const limit = /^[1-9][0-9]*$/.test(config.limit) && parseInt(config.limit) || undefined; + const sortedStreams = sortBySeeders(streams, config, type); + if (sort === SortOptions.options.seeders.key) { + return sortedStreams.slice(0, limit); + } else if (sort === SortOptions.options.size.key) { + return sortBySize(sortedStreams, limit); + } + const nestedSort = sort === SortOptions.options.qualitySize.key ? sortBySize : noopSort; + return sortByVideoQuality(sortedStreams, nestedSort, limit) +} + +function noopSort(streams) { + return streams; +} + +function sortBySeeders(streams, config, type) { + // streams are already presorted by seeders and upload date + const healthy = streams.filter(stream => extractSeeders(stream.title) >= HEALTHY_SEEDERS); + const seeded = streams.filter(stream => extractSeeders(stream.title) >= SEEDED_SEEDERS); + + if (type === Type.SERIES && hasMochConfigured(config)) { + return streams; + } else if (healthy.length >= MIN_HEALTHY_COUNT) { + return healthy; + } else if (seeded.length >= MAX_UNHEALTHY_COUNT) { + return seeded.slice(0, MIN_HEALTHY_COUNT); + } + return streams.slice(0, MAX_UNHEALTHY_COUNT); +} + +function sortBySize(streams, limit) { + return streams + .sort((a, b) => { + const aSize = extractSize(a.title); + const bSize = extractSize(b.title); + return bSize - aSize; + }).slice(0, limit); +} + +function sortByVideoQuality(streams, nestedSort, limit) { + const qualityMap = streams + .reduce((map, stream) => { + const quality = extractQuality(stream.name); + map[quality] = (map[quality] || []).concat(stream); + return map; + }, {}); + const sortedQualities = Object.keys(qualityMap) + .sort((a, b) => { + const aResolution = a?.match(/\d+p/) && parseInt(a, 10); + const bResolution = b?.match(/\d+p/) && parseInt(b, 10); + if (aResolution && bResolution) { + return bResolution - aResolution; // higher resolution first; + } else if (aResolution) { + return -1; // remain higher if resolution is there + } else if (bResolution) { + return 1; // move downward if other stream has resolution + } + return a < b ? -1 : b < a ? 1 : 0; // otherwise sort by alphabetic order + }); + return sortedQualities + .map(quality => nestedSort(qualityMap[quality]).slice(0, limit)) + .reduce((a, b) => a.concat(b), []); +} + +function extractQuality(title) { + const qualityDesc = title.split('\n')[1]; + const resolutionMatch = qualityDesc?.match(/\d+p/); + const isHDR = qualityDesc?.match(/HDR|DV/); + const withHDRScore = resolution => isHDR ? resolution.replace('0p', '1p') : resolution; + if (resolutionMatch) { + return withHDRScore(resolutionMatch[0]); + } else if (/8k/i.test(qualityDesc)) { + return withHDRScore('4320p'); + } else if (/4k|uhd/i.test(qualityDesc)) { + return withHDRScore('2060p'); + } else if (CAM_QUALITIES.test(qualityDesc)) { + return CAM_QUALITIES.label; + } else if (OTHER_QUALITIES.test(qualityDesc)) { + return OTHER_QUALITIES.label; + } + return qualityDesc; +} diff --git a/src/lib/addon/lib/streamInfo.ts b/src/lib/addon/lib/streamInfo.ts new file mode 100644 index 0000000..4a4b743 --- /dev/null +++ b/src/lib/addon/lib/streamInfo.ts @@ -0,0 +1,152 @@ +import titleParser from 'parse-torrent-title'; +import { Type } from './types.js'; +import { mapLanguages } from './languages.js'; +import { enrichStreamSources, getSources } from './magnetHelper.js'; +import { getSubtitles } from './subtitles.js'; + +const ADDON_NAME = 'Torrentio'; +const SIZE_DELTA = 0.05; +const UNKNOWN_SIZE = 300000000; +const CAM_SOURCES = ['CAM', 'TeleSync', 'TeleCine', 'SCR']; + +export function toStreamInfo(record) { + const torrentInfo = titleParser.parse(record.torrent.title); + const fileInfo = titleParser.parse(record.title); + const sameInfo = !Number.isInteger(record.fileIndex) + || Math.abs(record.size / record.torrent.size - 1) < SIZE_DELTA + || record.title.includes(record.torrent.title); + const quality = getQuality(record, torrentInfo, fileInfo); + const three3Quality = fileInfo.threeD || torrentInfo.threeD; + const hdrProfiles = torrentInfo.hdr || fileInfo.hdr || []; + const title = joinDetailParts( + [ + joinDetailParts([record.torrent.title.replace(/[, ]+/g, ' ')]), + joinDetailParts([!sameInfo && record.title || undefined]), + joinDetailParts([ + joinDetailParts([record.torrent.seeders], '👤 '), + joinDetailParts([formatSize(record.size)], '💾 '), + joinDetailParts([record.torrent.provider], '⚙️ ') + ]), + joinDetailParts(getLanguages(record, torrentInfo, fileInfo), '', ' / '), + ], + '', + '\n' + ); + const name = joinDetailParts( + [ + joinDetailParts([ADDON_NAME]), + joinDetailParts([quality, three3Quality, joinDetailParts(hdrProfiles, '', ' | ')]) + ], + '', + '\n' + ); + const bingeGroupParts = getBingeGroupParts(record, sameInfo, quality, torrentInfo, fileInfo); + const bingeGroup = joinDetailParts(bingeGroupParts, "torrentio|", "|") + const filename = Number.isInteger(record.fileIndex) ? record.title.split('/').pop() : undefined; + const behaviorHints = bingeGroup || filename ? cleanOutputObject({ bingeGroup, filename }) : undefined; + + return cleanOutputObject({ + name: name, + title: title, + infoHash: record.infoHash, + fileIdx: record.fileIndex, + behaviorHints: behaviorHints, + sources: getSources(record.torrent.trackers, record.infoHash), + subtitles: getSubtitles(record) + }); +} + +function getQuality(record, torrentInfo, fileInfo) { + if (CAM_SOURCES.includes(fileInfo.source)) { + return fileInfo.source; + } + if (CAM_SOURCES.includes(torrentInfo.source)) { + return torrentInfo.source; + } + const resolution = fileInfo.resolution || torrentInfo.resolution || record.torrent.resolution; + const source = fileInfo.source || torrentInfo.source; + return resolution || source; +} + +function getLanguages(record, torrentInfo, fileInfo) { + const providerLanguages = record.torrent.languages && titleParser.parse(record.torrent.languages + '.srt').languages || []; + const torrentLanguages = torrentInfo.languages || []; + const fileLanguages = fileInfo.languages || []; + const dubbed = torrentInfo.dubbed || fileInfo.dubbed; + let languages = Array.from(new Set([].concat(torrentLanguages).concat(fileLanguages).concat(providerLanguages))); + if (record.kitsuId || record.torrent.type === Type.ANIME) { + // no need to display japanese for anime + languages = languages.concat(dubbed ? ['dubbed'] : []) + .filter(lang => lang !== 'japanese'); + } + if (languages.length === 1 && languages.includes('english')) { + // no need to display languages if only english is present + languages = []; + } + if (languages.length === 0 && dubbed) { + // display dubbed only if there are no other languages defined for non anime + languages = ['dubbed']; + } + return mapLanguages(languages); +} + +function joinDetailParts(parts, prefix = '', delimiter = ' ') { + const filtered = parts.filter((part) => part !== undefined && part !== null).join(delimiter); + + return filtered.length > 0 ? `${prefix}${filtered}` : undefined; +} + +function formatSize(size) { + if (!size) { + return undefined; + } + if (size === UNKNOWN_SIZE) { + return undefined; + } + const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); + return Number((size / Math.pow(1024, i)).toFixed(2)) + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; +} + +export function applyStaticInfo(streams) { + return streams.map(stream => enrichStaticInfo(stream)); +} + +function enrichStaticInfo(stream) { + return enrichSubtitles(enrichStreamSources({ ...stream })); +} + +function enrichSubtitles(stream) { + if (stream.subtitles?.length) { + stream.subtitles = stream.subtitles.map(subtitle =>{ + if (subtitle.url) { + return subtitle; + } + return { + id: `${subtitle.fileIndex}`, + lang: subtitle.lang, + url: `http://localhost:11470/${subtitle.infoHash}/${subtitle.fileIndex}/${subtitle.title.split('/').pop()}` + }; + }); + } + return stream; +} + +function getBingeGroupParts(record, sameInfo, quality, torrentInfo, fileInfo) { + if (record.torrent.type === Type.MOVIE) { + const source = torrentInfo.source || fileInfo.source + return [quality] + .concat(source !== quality ? source : []) + .concat(torrentInfo.codec || fileInfo.codec) + .concat(torrentInfo.bitDepth || fileInfo.bitDepth) + .concat(torrentInfo.hdr || fileInfo.hdr); + } else if (sameInfo) { + return [quality] + .concat(fileInfo.hdr) + .concat(fileInfo.group); + } + return [record.infoHash]; +} + +function cleanOutputObject(object) { + return Object.fromEntries(Object.entries(object).filter(([_, v]) => v != null)); +} diff --git a/src/lib/addon/lib/subtitles.ts b/src/lib/addon/lib/subtitles.ts new file mode 100644 index 0000000..10be676 --- /dev/null +++ b/src/lib/addon/lib/subtitles.ts @@ -0,0 +1,99 @@ +import { parse } from 'parse-torrent-title'; +import { isExtension } from './extension.js'; +import { Providers } from './filter.js'; +import { languageFromCode } from './languages.js'; + +const languageMapping = { + 'english': 'eng', + 'japanese': 'jpn', + 'russian': 'rus', + 'italian': 'ita', + 'portuguese': 'por', + 'spanish': 'spa', + 'latino': 'lat', + 'korean': 'kor', + 'chinese': 'zho', + 'taiwanese': 'zht', + 'french': 'fre', + 'german': 'ger', + 'dutch': 'dut', + 'hindi': 'hin ', + 'telugu': 'tel', + 'tamil': 'tam', + 'polish': 'pol', + 'lithuanian': 'lit', + 'latvian': 'lav', + 'estonian': 'est', + 'czech': 'cze', + 'slovakian': 'slo', + 'slovenian': 'slv', + 'hungarian': 'hun', + 'romanian': 'rum', + 'bulgarian': 'bul', + 'serbian': 'scc', + 'croatian': 'hrv', + 'ukrainian': 'ukr', + 'greek': 'ell', + 'danish': 'dan', + 'finnish': 'fin', + 'swedish': 'swe', + 'norwegian': 'nor', + 'turkish': 'tur', + 'arabic': 'ara', + 'persian': 'per', + 'hebrew': 'heb', + 'vietnamese': 'vie', + 'indonesian': 'ind', + 'thai': 'tha' +} + +const ignoreSet = new Set(['dubbed', 'multi audio', 'multi subs', 'dual audio']); +const allowedExtensions = ['srt', 'vtt', 'ass', 'ssa']; + +export function getSubtitles(record) { + if (!record?.subtitles?.length) { + return null; + } + return record.subtitles + .filter(subtitle => isExtension(subtitle.title, allowedExtensions)) + .sort((a, b) => b.size - a.size) + .map(subtitle => ({ + infoHash: subtitle.infoHash, + fileIndex: subtitle.fileIndex, + title: subtitle.title, + lang: parseLanguage(subtitle.title, record), + })); +} + +function parseLanguage(title, record) { + const subtitlePathParts = title.split('/'); + const subtitleFileName = subtitlePathParts.pop(); + const subtitleTitleNoExt = title.replace(/\.\w{2,5}$/, ''); + const videoFileName = record.title.split('/').pop().replace(/\.\w{2,5}$/, ''); + const fileNameLanguage = getSingleLanguage(subtitleFileName.replace(videoFileName, '')); + if (fileNameLanguage) { + return fileNameLanguage; + } + const videoTitleNoExt = record.title.replace(/\.\w{2,5}$/, ''); + if (subtitleTitleNoExt === record.title || subtitleTitleNoExt === videoTitleNoExt) { + const provider = Providers.options.find(provider => provider.label === record.torrent.provider); + return provider?.foreign && languageFromCode(provider.foreign) || 'eng'; + } + const folderName = subtitlePathParts.join('/'); + const folderNameLanguage = getSingleLanguage(folderName.replace(videoFileName, '')); + if (folderNameLanguage) { + return folderNameLanguage + } + return getFileNameLanguageCode(subtitleFileName) || 'Unknown'; +} + +function getSingleLanguage(title) { + const parsedInfo = parse(title); + const languages = (parsedInfo.languages || []).filter(language => !ignoreSet.has(language)); + return languages.length === 1 ? languageMapping[languages[0]] : undefined; +} + +function getFileNameLanguageCode(fileName) { + const match = fileName.match(/(?:(?:^|[._ ])([A-Za-z][a-z]{1,2})|\[([a-z]{2,3})])\.\w{3,4}$/); + return match?.[1]?.toLowerCase(); +} diff --git a/src/lib/addon/lib/titleHelper.ts b/src/lib/addon/lib/titleHelper.ts new file mode 100644 index 0000000..7cb62b9 --- /dev/null +++ b/src/lib/addon/lib/titleHelper.ts @@ -0,0 +1,31 @@ +export function extractSeeders(title) { + const seedersMatch = title.match(/👤 (\d+)/); + return seedersMatch && parseInt(seedersMatch[1]) || 0; +} + +export function extractSize(title) { + const seedersMatch = title.match(/💾 ([\d.]+ \w+)/); + return seedersMatch && parseSize(seedersMatch[1]) || 0; +} + +export function extractProvider(title) { + const match = title.match(/⚙.* ([^ \n]+)/); + return match?.[1]; +} + +export function parseSize(sizeText) { + if (!sizeText) { + return 0; + } + let scale = 1; + if (sizeText.includes('TB')) { + scale = 1024 * 1024 * 1024 * 1024 + } else if (sizeText.includes('GB')) { + scale = 1024 * 1024 * 1024 + } else if (sizeText.includes('MB')) { + scale = 1024 * 1024; + } else if (sizeText.includes('kB')) { + scale = 1024; + } + return Math.floor(parseFloat(sizeText.replace(/,/g, '')) * scale); +} diff --git a/src/lib/addon/lib/types.ts b/src/lib/addon/lib/types.ts new file mode 100644 index 0000000..cf05502 --- /dev/null +++ b/src/lib/addon/lib/types.ts @@ -0,0 +1,6 @@ +export enum Type { + MOVIE = 'movie', + SERIES = 'series', + ANIME = 'anime', + OTHER = 'other' +}; diff --git a/src/lib/addon/moch/alldebrid.ts b/src/lib/addon/moch/alldebrid.ts new file mode 100644 index 0000000..f58c146 --- /dev/null +++ b/src/lib/addon/moch/alldebrid.ts @@ -0,0 +1,199 @@ +import AllDebridClient from 'all-debrid-api'; +import { Type } from '../lib/types.js'; +import { isVideo, isArchive } from '../lib/extension.js'; +import StaticResponse from './static.js'; +import { getMagnetLink } from '../lib/magnetHelper.js'; +import { BadTokenError, AccessDeniedError, sameFilename, streamFilename, AccessBlockedError } from './mochHelper.js'; + +const KEY = 'alldebrid'; +const AGENT = 'torrentio'; + +export async function getCachedStreams(streams, apiKey, ip) { + return streams + .reduce((mochStreams, stream) => { + const filename = streamFilename(stream); + mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = { + url: `${apiKey}/${stream.infoHash}/${filename}/${stream.fileIdx}`, + cached: false + } + return mochStreams; + }, {}) +} + +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { + return []; + } + const options = await getDefaultOptions(config.ip); + const AD = new AllDebridClient(apiKey, options); + return AD.magnet.status() + .then(response => response.data.magnets) + .then(torrents => (torrents || []) + .filter(torrent => torrent && statusReady(torrent.statusCode)) + .map(torrent => ({ + id: `${KEY}:${torrent.id}`, + type: Type.OTHER, + name: torrent.filename + }))); +} + +export async function getItemMeta(itemId, apiKey, ip) { + const options = await getDefaultOptions(ip); + const AD = new AllDebridClient(apiKey, options); + return AD.magnet.status(itemId) + .then(response => response.data.magnets) + .then(torrent => ({ + id: `${KEY}:${torrent.id}`, + type: Type.OTHER, + name: torrent.filename, + infoHash: torrent.hash.toLowerCase(), + videos: torrent.links + .filter(file => isVideo(file.filename)) + .map((file, index) => ({ + id: `${KEY}:${torrent.id}:${index}`, + title: file.filename, + released: new Date(torrent.uploadDate * 1000 - index).toISOString(), + streams: [{ url: `${apiKey}/${torrent.hash.toLowerCase()}/${encodeURIComponent(file.filename)}/${index}` }] + })) + })) +} + +export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) { + console.log(`Unrestricting AllDebrid ${infoHash} [${fileIndex}]`); + const options = await getDefaultOptions(ip); + const AD = new AllDebridClient(apiKey, options); + + return _resolve(AD, infoHash, cachedEntryInfo, fileIndex) + .catch(error => { + if (isExpiredSubscriptionError(error)) { + console.log(`Access denied to AllDebrid ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_ACCESS; + } + if (isBlockedAccessError(error)) { + console.log(`Access blocked to AllDebrid ${infoHash} [${fileIndex}]`); + return StaticResponse.BLOCKED_ACCESS; + } + if (error.code === 'MAGNET_TOO_MANY') { + console.log(`Deleting and retrying adding to AllDebrid ${infoHash} [${fileIndex}]...`); + return _deleteAndRetry(AD, infoHash, cachedEntryInfo, fileIndex); + } + return Promise.reject(`Failed AllDebrid adding torrent ${JSON.stringify(error)}`); + }); +} + +async function _resolve(AD, infoHash, cachedEntryInfo, fileIndex) { + const torrent = await _createOrFindTorrent(AD, infoHash); + if (statusReady(torrent?.statusCode)) { + return _unrestrictLink(AD, torrent, cachedEntryInfo, fileIndex); + } else if (statusDownloading(torrent?.statusCode)) { + console.log(`Downloading to AllDebrid ${infoHash} [${fileIndex}]...`); + return StaticResponse.DOWNLOADING; + } else if (statusHandledError(torrent?.statusCode)) { + console.log(`Retrying downloading to AllDebrid ${infoHash} [${fileIndex}]...`); + return _retryCreateTorrent(AD, infoHash, cachedEntryInfo, fileIndex); + } else if (statusTooBigEntry(torrent?.statusCode)) { + console.log(`Torrent too big for AllDebrid ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_TOO_BIG; + } + + return Promise.reject(`Failed AllDebrid adding torrent ${JSON.stringify(torrent)}`); +} + +async function _createOrFindTorrent(AD, infoHash) { + return _findTorrent(AD, infoHash) + .catch(() => _createTorrent(AD, infoHash)); +} + +async function _retryCreateTorrent(AD, infoHash, encodedFileName, fileIndex) { + const newTorrent = await _createTorrent(AD, infoHash); + return newTorrent && statusReady(newTorrent.statusCode) + ? _unrestrictLink(AD, newTorrent, encodedFileName, fileIndex) + : StaticResponse.FAILED_DOWNLOAD; +} + +async function _deleteAndRetry(AD, infoHash, encodedFileName, fileIndex) { + const torrents = await AD.magnet.status().then(response => response.data.magnets); + const lastTorrent = torrents[torrents.length - 1]; + return AD.magnet.delete(lastTorrent.id) + .then(() => _retryCreateTorrent(AD, infoHash, encodedFileName, fileIndex)); +} + +async function _findTorrent(AD, infoHash) { + const torrents = await AD.magnet.status().then(response => response.data.magnets); + const foundTorrents = torrents.filter(torrent => torrent.hash.toLowerCase() === infoHash); + const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent.statusCode)); + const foundTorrent = nonFailedTorrent || foundTorrents[0]; + return foundTorrent || Promise.reject('No recent torrent found'); +} + +async function _createTorrent(AD, infoHash) { + const magnetLink = await getMagnetLink(infoHash); + const uploadResponse = await AD.magnet.upload(magnetLink); + const torrentId = uploadResponse.data.magnets[0].id; + return AD.magnet.status(torrentId).then(statusResponse => statusResponse.data.magnets); +} + +async function _unrestrictLink(AD, torrent, encodedFileName, fileIndex) { + const targetFileName = decodeURIComponent(encodedFileName); + const videos = torrent.links.filter(link => isVideo(link.filename)).sort((a, b) => b.size - a.size); + const targetVideo = Number.isInteger(fileIndex) + && videos.find(video => sameFilename(targetFileName, video.filename)) + || videos[0]; + + if (!targetVideo && torrent.links.every(link => isArchive(link.filename))) { + console.log(`Only AllDebrid archive is available for [${torrent.hash}] ${encodedFileName}`) + return StaticResponse.FAILED_RAR; + } + if (!targetVideo || !targetVideo.link || !targetVideo.link.length) { + return Promise.reject(`No AllDebrid links found for [${torrent.hash}] ${encodedFileName}`); + } + const unrestrictedLink = await AD.link.unlock(targetVideo.link).then(response => response.data.link); + console.log(`Unrestricted AllDebrid ${torrent.hash} [${fileIndex}] to ${unrestrictedLink}`); + return unrestrictedLink; +} + +async function getDefaultOptions(ip) { + return { ip, base_agent: AGENT, timeout: 10000 }; +} + +export function toCommonError(error) { + if (error && error.code === 'AUTH_BAD_APIKEY') { + return BadTokenError; + } + if (error && error.code === 'AUTH_USER_BANNED') { + return AccessDeniedError; + } + if (error && error.code === 'AUTH_BLOCKED') { + return AccessBlockedError; + } + return undefined; +} + +function statusError(statusCode) { + return [5, 6, 7, 8, 9, 10, 11].includes(statusCode); +} + +function statusHandledError(statusCode) { + return [5, 7, 9, 10, 11].includes(statusCode); +} + +function statusDownloading(statusCode) { + return [0, 1, 2, 3].includes(statusCode); +} + +function statusReady(statusCode) { + return statusCode === 4; +} + +function statusTooBigEntry(statusCode) { + return statusCode === 8; +} + +function isExpiredSubscriptionError(error) { + return ['AUTH_BAD_APIKEY', 'MUST_BE_PREMIUM', 'MAGNET_MUST_BE_PREMIUM', 'FREE_TRIAL_LIMIT_REACHED', 'AUTH_USER_BANNED'] + .includes(error.code); +} + +function isBlockedAccessError(error) { + return ['AUTH_BLOCKED'].includes(error.code); +} diff --git a/src/lib/addon/moch/debridlink.ts b/src/lib/addon/moch/debridlink.ts new file mode 100644 index 0000000..e0d214e --- /dev/null +++ b/src/lib/addon/moch/debridlink.ts @@ -0,0 +1,156 @@ +import DebridLinkClient from 'debrid-link-api'; +import { Type } from '../lib/types.js'; +import { isVideo, isArchive } from '../lib/extension.js'; +import StaticResponse from './static.js'; +import { getMagnetLink } from '../lib/magnetHelper.js'; +import { BadTokenError } from './mochHelper.js'; + +const KEY = 'debridlink'; + +export async function getCachedStreams(streams, apiKey) { + return streams + .reduce((mochStreams, stream) => { + mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = { + url: `${apiKey}/${stream.infoHash}/null/${stream.fileIdx}`, + cached: false + }; + return mochStreams; + }, {}) +} + +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { + return []; + } + const options = await getDefaultOptions(); + const DL = new DebridLinkClient(apiKey, options); + return DL.seedbox.list() + .then(response => response.value) + .then(torrents => (torrents || []) + .filter(torrent => torrent && statusReady(torrent)) + .map(torrent => ({ + id: `${KEY}:${torrent.id}`, + type: Type.OTHER, + name: torrent.name + }))); +} + +export async function getItemMeta(itemId, apiKey, ip) { + const options = await getDefaultOptions(ip); + const DL = new DebridLinkClient(apiKey, options); + return DL.seedbox.list(itemId) + .then(response => response.value[0]) + .then(torrent => ({ + id: `${KEY}:${torrent.id}`, + type: Type.OTHER, + name: torrent.name, + infoHash: torrent.hashString.toLowerCase(), + videos: torrent.files + .filter(file => isVideo(file.name)) + .map((file, index) => ({ + id: `${KEY}:${torrent.id}:${index}`, + title: file.name, + released: new Date(torrent.created * 1000 - index).toISOString(), + streams: [{ url: file.downloadUrl }] + })) + })) +} + +export async function resolve({ ip, apiKey, infoHash, fileIndex }) { + console.log(`Unrestricting DebridLink ${infoHash} [${fileIndex}]`); + const options = await getDefaultOptions(ip); + const DL = new DebridLinkClient(apiKey, options); + + return _resolve(DL, infoHash, fileIndex) + .catch(error => { + if (isAccessDeniedError(error)) { + console.log(`Access denied to DebridLink ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_ACCESS; + } + if (isLimitsExceededError(error)) { + console.log(`Limits exceeded in DebridLink ${infoHash} [${fileIndex}]`); + return StaticResponse.LIMITS_EXCEEDED; + } + if (isTorrentTooBigError(error)) { + console.log(`Torrent too big for DebridLink ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_TOO_BIG; + } + return Promise.reject(`Failed DebridLink adding torrent ${JSON.stringify(error)}`); + }); +} + +async function _resolve(DL, infoHash, fileIndex) { + const torrent = await _createOrFindTorrent(DL, infoHash); + if (torrent && statusReady(torrent)) { + return _unrestrictLink(DL, torrent, fileIndex); + } else if (torrent && statusDownloading(torrent)) { + console.log(`Downloading to DebridLink ${infoHash} [${fileIndex}]...`); + return StaticResponse.DOWNLOADING; + } + + return Promise.reject(`Failed DebridLink adding torrent ${JSON.stringify(torrent)}`); +} + +async function _createOrFindTorrent(DL, infoHash) { + return _findTorrent(DL, infoHash) + .catch(() => _createTorrent(DL, infoHash)); +} + +async function _findTorrent(DL, infoHash) { + const torrents = await DL.seedbox.list().then(response => response.value); + const foundTorrents = torrents.filter(torrent => torrent.hashString.toLowerCase() === infoHash); + return foundTorrents[0] || Promise.reject('No recent torrent found'); +} + +async function _createTorrent(DL, infoHash) { + const magnetLink = await getMagnetLink(infoHash); + const uploadResponse = await DL.seedbox.add(magnetLink, null, true); + return uploadResponse.value; +} + +async function _unrestrictLink(DL, torrent, fileIndex) { + const targetFile = Number.isInteger(fileIndex) + ? torrent.files[fileIndex] + : torrent.files.filter(file => file.downloadPercent === 100).sort((a, b) => b.size - a.size)[0]; + + if (!targetFile && torrent.files.every(file => isArchive(file.downloadUrl))) { + console.log(`Only DebridLink archive is available for [${torrent.hashString}] ${fileIndex}`) + return StaticResponse.FAILED_RAR; + } + if (!targetFile || !targetFile.downloadUrl) { + return Promise.reject(`No DebridLink links found for index ${fileIndex} in: ${JSON.stringify(torrent)}`); + } + console.log(`Unrestricted DebridLink ${torrent.hashString} [${fileIndex}] to ${targetFile.downloadUrl}`); + return targetFile.downloadUrl; +} + +async function getDefaultOptions(ip) { + return { ip, timeout: 10000 }; +} + +export function toCommonError(error) { + if (error === 'badToken') { + return BadTokenError; + } + return undefined; +} + +function statusDownloading(torrent) { + return torrent.downloadPercent < 100 +} + +function statusReady(torrent) { + return torrent.downloadPercent === 100; +} + +function isAccessDeniedError(error) { + return ['badToken', 'accountLocked'].includes(error); +} + +function isLimitsExceededError(error) { + return ['freeServerOverload', 'maxTorrent', 'maxLink', 'maxLinkHost', 'maxData', 'maxDataHost', 'floodDetected'].includes(error); +} + +function isTorrentTooBigError(error) { + return ['torrentTooBig'].includes(error); +} diff --git a/src/lib/addon/moch/moch.ts b/src/lib/addon/moch/moch.ts new file mode 100644 index 0000000..f826843 --- /dev/null +++ b/src/lib/addon/moch/moch.ts @@ -0,0 +1,249 @@ +import * as options from './options.js'; +import * as realdebrid from './realdebrid.js'; +import * as premiumize from './premiumize.js'; +import * as alldebrid from './alldebrid.js'; +import * as debridlink from './debridlink.js'; +import * as offcloud from './offcloud.js'; +import * as torbox from './torbox.js'; +import * as putio from './putio.js'; +import StaticResponse, { isStaticUrl } from './static.js'; +import { cacheWrapResolvedUrl } from '../lib/cache.js'; +import { timeout } from '../lib/promises.js'; +import { BadTokenError, streamFilename, AccessDeniedError, enrichMeta, AccessBlockedError } from './mochHelper.js'; +import { createNamedQueue } from "../lib/namedQueue.js"; + +const RESOLVE_TIMEOUT = 2 * 60 * 1000; // 2 minutes +const MIN_API_KEY_SYMBOLS = 15; +const TOKEN_BLACKLIST = []; +export const MochOptions = { + realdebrid: { + key: 'realdebrid', + instance: realdebrid, + name: "RealDebrid", + shortName: 'RD', + catalogs: [''] + }, + premiumize: { + key: 'premiumize', + instance: premiumize, + name: 'Premiumize', + shortName: 'PM', + catalogs: [''] + }, + alldebrid: { + key: 'alldebrid', + instance: alldebrid, + name: 'AllDebrid', + shortName: 'AD', + catalogs: [''] + }, + debridlink: { + key: 'debridlink', + instance: debridlink, + name: 'DebridLink', + shortName: 'DL', + catalogs: [''] + }, + offcloud: { + key: 'offcloud', + instance: offcloud, + name: 'Offcloud', + shortName: 'OC', + catalogs: [''] + }, + torbox: { + key: 'torbox', + instance: torbox, + name: 'TorBox', + shortName: 'TB', + catalogs: [`Torrents`, `Usenet`, `WebDL`] + }, + putio: { + key: 'putio', + instance: putio, + name: 'Put.io', + shortName: 'Putio', + catalogs: [''] + } +}; + +const unrestrictQueues = {} +Object.values(MochOptions) + .map(moch => moch.key) + .forEach(mochKey => unrestrictQueues[mochKey] = createNamedQueue(50)); + +export function hasMochConfigured(config) { + return Object.keys(MochOptions).find(moch => config?.[moch]) +} + +export async function applyMochs(streams, config) { + if (!streams?.length || !hasMochConfigured(config)) { + return streams; + } + return Promise.all(Object.keys(config) + .filter(configKey => MochOptions[configKey]) + .map(configKey => MochOptions[configKey]) + .map(moch => { + if (isInvalidToken(config[moch.key], moch.key)) { + return { moch, error: BadTokenError }; + } + return moch.instance.getCachedStreams(streams, config[moch.key], config.ip) + .then(mochStreams => ({ moch, mochStreams })) + .catch(rawError => { + const error = moch.instance.toCommonError(rawError) || rawError; + if (error === BadTokenError) { + blackListToken(config[moch.key], moch.key); + } + return { moch, error }; + }) + })) + .then(results => processMochResults(streams, config, results)); +} + +export async function resolve(parameters) { + const moch = MochOptions[parameters.mochKey]; + if (!moch) { + return Promise.reject(new Error(`Not a valid moch provider: ${parameters.mochKey}`)); + } + + if (!parameters.apiKey || !parameters.infoHash || !parameters.cachedEntryInfo) { + return Promise.reject(new Error("No valid parameters passed")); + } + const id = `${parameters.ip}_${parameters.mochKey}_${parameters.apiKey}_${parameters.infoHash}_${parameters.fileIndex}`; + const method = () => timeout(RESOLVE_TIMEOUT, cacheWrapResolvedUrl(id, () => moch.instance.resolve(parameters))) + .catch(error => { + console.warn(error); + return StaticResponse.FAILED_UNEXPECTED; + }) + .then(url => isStaticUrl(url) ? `${parameters.host}/${url}` : url); + return unrestrictQueues[moch.key].wrap(id, method); +} + +export async function getMochCatalog(mochKey, catalogId, config, ) { + const moch = MochOptions[mochKey]; + if (!moch) { + return Promise.reject(new Error(`Not a valid moch provider: ${mochKey}`)); + } + if (isInvalidToken(config[mochKey], mochKey)) { + return Promise.reject(new Error(`Invalid API key for moch provider: ${mochKey}`)); + } + return moch.instance.getCatalog(config[moch.key], catalogId, config) + .catch(rawError => { + const commonError = moch.instance.toCommonError(rawError); + if (commonError === BadTokenError) { + blackListToken(config[moch.key], moch.key); + } + return commonError ? [] : Promise.reject(rawError); + }); +} + +export async function getMochItemMeta(mochKey, itemId, config) { + const moch = MochOptions[mochKey]; + if (!moch) { + return Promise.reject(new Error(`Not a valid moch provider: ${mochKey}`)); + } + + return moch.instance.getItemMeta(itemId, config[moch.key], config.ip) + .then(meta => enrichMeta(meta)) + .then(meta => { + meta.videos.forEach(video => video.streams.forEach(stream => { + if (!stream.url.startsWith('http')) { + stream.url = `${config.host}/${moch.key}/${stream.url}/${streamFilename(video)}` + } + stream.behaviorHints = { bingeGroup: itemId } + })) + return meta; + }); +} + +function processMochResults(streams, config, results) { + const errorResults = results + .map(result => errorStreamResponse(result.moch.key, result.error, config)) + .filter(errorResponse => errorResponse); + if (errorResults.length) { + return errorResults; + } + + const excludeDownloadLinks = options.excludeDownloadLinks(config); + const mochResults = results.filter(result => result?.mochStreams); + + const cachedStreams = mochResults + .reduce((resultStreams, mochResult) => populateCachedLinks(resultStreams, mochResult, config), streams); + const resultStreams = excludeDownloadLinks ? cachedStreams : populateDownloadLinks(cachedStreams, mochResults, config); + return resultStreams.filter(stream => stream.url); +} + +function populateCachedLinks(streams, mochResult, config) { + return streams.map(stream => { + const cachedEntry = stream.infoHash && mochResult.mochStreams[`${stream.infoHash}@${stream.fileIdx}`]; + if (cachedEntry?.cached) { + return { + name: `[${mochResult.moch.shortName}+] ${stream.name}`, + title: stream.title, + url: `${config.host}/${mochResult.moch.key}/${cachedEntry.url}/${streamFilename(stream)}`, + behaviorHints: stream.behaviorHints + }; + } + return stream; + }); +} + +function populateDownloadLinks(streams, mochResults, config) { + const torrentStreams = streams.filter(stream => stream.infoHash); + const seededStreams = streams.filter(stream => !stream.title.includes('👤 0')); + torrentStreams.forEach(stream => mochResults.forEach(mochResult => { + const cachedEntry = mochResult.mochStreams[`${stream.infoHash}@${stream.fileIdx}`]; + const isCached = cachedEntry?.cached; + if (!isCached && isHealthyStreamForDebrid(seededStreams, stream)) { + streams.push({ + name: `[${mochResult.moch.shortName} download] ${stream.name}`, + title: stream.title, + url: `${config.host}/${mochResult.moch.key}/${cachedEntry.url}/${streamFilename(stream)}`, + behaviorHints: stream.behaviorHints + }) + } + })); + return streams; +} + +function isHealthyStreamForDebrid(streams, stream) { + const isZeroSeeders = stream.title.includes('👤 0'); + const is4kStream = stream.name.includes('4k'); + const isNotEnoughOptions = streams.length <= 5; + return !isZeroSeeders || is4kStream || isNotEnoughOptions; +} + +function isInvalidToken(token, mochKey) { + return token.length < MIN_API_KEY_SYMBOLS || TOKEN_BLACKLIST.includes(`${mochKey}|${token}`); +} + +function blackListToken(token, mochKey) { + const tokenKey = `${mochKey}|${token}`; + console.log(`Blacklisting invalid token: ${tokenKey}`) + TOKEN_BLACKLIST.push(tokenKey); +} + +function errorStreamResponse(mochKey, error, config) { + if (error === BadTokenError) { + return { + name: `Torrentio\n${MochOptions[mochKey].shortName} error`, + title: `Invalid ${MochOptions[mochKey].name} ApiKey/Token!`, + url: `${config.host}/${StaticResponse.FAILED_ACCESS}` + }; + } + if (error === AccessDeniedError) { + return { + name: `Torrentio\n${MochOptions[mochKey].shortName} error`, + title: `Expired/invalid ${MochOptions[mochKey].name} subscription!`, + url: `${config.host}/${StaticResponse.FAILED_ACCESS}` + }; + } + if (error === AccessBlockedError) { + return { + name: `Torrentio\n${MochOptions[mochKey].shortName} error`, + title: `Access to ${MochOptions[mochKey].name} is blocked!\nCheck your account or email.`, + url: `${config.host}/${StaticResponse.FAILED_ACCESS}` + }; + } + return undefined; +} diff --git a/src/lib/addon/moch/mochHelper.ts b/src/lib/addon/moch/mochHelper.ts new file mode 100644 index 0000000..3d86766 --- /dev/null +++ b/src/lib/addon/moch/mochHelper.ts @@ -0,0 +1,64 @@ +import * as repository from '../lib/repository.js'; + +const METAHUB_URL = 'https://images.metahub.space' +export const BadTokenError = { code: 'BAD_TOKEN' } +export const AccessDeniedError = { code: 'ACCESS_DENIED' } +export const AccessBlockedError = { code: 'ACCESS_BLOCKED' } + +export function chunkArray(arr, size) { + return arr.length > size + ? [arr.slice(0, size), ...chunkArray(arr.slice(size), size)] + : [arr]; +} + +export function streamFilename(stream) { + const filename = stream?.behaviorHints?.filename + || stream.title.replace(/\n👤.*/s, '').split('\n').pop().split('/').pop(); + return encodeURIComponent(filename) +} + +export async function enrichMeta(itemMeta) { + const infoHashes = [...new Set([itemMeta.infoHash] + .concat(itemMeta.videos.map(video => video.infoHash)) + .filter(infoHash => infoHash))]; + const files = infoHashes.length ? await repository.getFiles(infoHashes).catch(() => []) : []; + const commonImdbId = itemMeta.infoHash && mostCommonValue(files.map(file => file.imdbId)); + if (files.length) { + return { + ...itemMeta, + logo: commonImdbId && `${METAHUB_URL}/logo/medium/${commonImdbId}/img`, + poster: commonImdbId && `${METAHUB_URL}/poster/medium/${commonImdbId}/img`, + background: commonImdbId && `${METAHUB_URL}/background/medium/${commonImdbId}/img`, + videos: itemMeta.videos.map(video => { + const file = files.find(file => sameFilename(video.title, file.title)); + if (file?.imdbId) { + if (file.imdbSeason && file.imdbEpisode) { + video.id = `${file.imdbId}:${file.imdbSeason}:${file.imdbEpisode}`; + video.season = file.imdbSeason; + video.episode = file.imdbEpisode; + video.thumbnail = `https://episodes.metahub.space/${file.imdbId}/${video.season}/${video.episode}/w780.jpg` + } else { + video.id = file.imdbId; + video.thumbnail = `${METAHUB_URL}/background/small/${file.imdbId}/img`; + } + } + return video; + }) + } + } + return itemMeta +} + +export function sameFilename(filename, expectedFilename) { + const offset = filename.length - expectedFilename.length; + for (let i = 0; i < expectedFilename.length; i++) { + if (filename[offset + i] !== expectedFilename[i] && expectedFilename[i] !== '�') { + return false; + } + } + return true; +} + +function mostCommonValue(array) { + return array.sort((a, b) => array.filter(v => v === a).length - array.filter(v => v === b).length).pop(); +} diff --git a/src/lib/addon/moch/offcloud.ts b/src/lib/addon/moch/offcloud.ts new file mode 100644 index 0000000..f928d71 --- /dev/null +++ b/src/lib/addon/moch/offcloud.ts @@ -0,0 +1,184 @@ +import OffcloudClient from 'offcloud-api'; +import magnet from 'magnet-uri'; +import { Type } from '../lib/types.js'; +import { isVideo } from '../lib/extension.js'; +import StaticResponse from './static.js'; +import { getMagnetLink } from '../lib/magnetHelper.js'; +import { chunkArray, BadTokenError, sameFilename, streamFilename } from './mochHelper.js'; + +const KEY = 'offcloud'; + +export async function getCachedStreams(streams, apiKey) { + const options = await getDefaultOptions(); + const OC = new OffcloudClient(apiKey, options); + const hashBatches = chunkArray(streams.map(stream => stream.infoHash), 100); + const available = await Promise.all(hashBatches.map(hashes => OC.instant.cache(hashes))) + .then(results => results.map(result => result.cachedItems)) + .then(results => results.reduce((all, result) => all.concat(result), [])) + .catch(error => { + if (toCommonError(error)) { + return Promise.reject(error); + } + console.warn('Failed Offcloud cached torrent availability request:', error); + return undefined; + }); + return available && streams + .reduce((mochStreams, stream) => { + const isCached = available.includes(stream.infoHash); + const fileName = streamFilename(stream); + mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = { + url: `${apiKey}/${stream.infoHash}/${fileName}/${stream.fileIdx}`, + cached: isCached + }; + return mochStreams; + }, {}) +} + +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { + return []; + } + const options = await getDefaultOptions(); + const OC = new OffcloudClient(apiKey, options); + return OC.cloud.history() + .then(torrents => torrents) + .then(torrents => (torrents || []) + .map(torrent => ({ + id: `${KEY}:${torrent.requestId}`, + type: Type.OTHER, + name: torrent.fileName + }))); +} + +export async function getItemMeta(itemId, apiKey, ip) { + const options = await getDefaultOptions(ip); + const OC = new OffcloudClient(apiKey, options); + const torrents = await OC.cloud.history(); + const torrent = torrents.find(torrent => torrent.requestId === itemId) + const infoHash = torrent && magnet.decode(torrent.originalLink).infoHash + const createDate = torrent ? new Date(torrent.createdOn) : new Date(); + return _getFileUrls(OC, torrent) + .then(files => ({ + id: `${KEY}:${itemId}`, + type: Type.OTHER, + name: torrent.name, + infoHash: infoHash, + videos: files + .filter(file => isVideo(file)) + .map((file, index) => ({ + id: `${KEY}:${itemId}:${index}`, + title: file.split('/').pop(), + released: new Date(createDate.getTime() - index).toISOString(), + streams: [{ url: file }] + })) + })) +} + +export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) { + console.log(`Unrestricting Offcloud ${infoHash} [${fileIndex}]`); + const options = await getDefaultOptions(ip); + const OC = new OffcloudClient(apiKey, options); + + return _resolve(OC, infoHash, cachedEntryInfo, fileIndex) + .catch(error => { + if (errorExpiredSubscriptionError(error)) { + console.log(`Access denied to Offcloud ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_ACCESS; + } + return Promise.reject(`Failed Offcloud adding torrent ${JSON.stringify(error)}`); + }); +} + +async function _resolve(OC, infoHash, cachedEntryInfo, fileIndex) { + const torrent = await _createOrFindTorrent(OC, infoHash) + .then(info => info.requestId ? OC.cloud.status(info.requestId) : Promise.resolve(info)) + .then(info => info.status || info); + if (torrent && statusReady(torrent)) { + return _unrestrictLink(OC, infoHash, torrent, cachedEntryInfo, fileIndex); + } else if (torrent && statusDownloading(torrent)) { + console.log(`Downloading to Offcloud ${infoHash} [${fileIndex}]...`); + return StaticResponse.DOWNLOADING; + } else if (torrent && statusError(torrent)) { + console.log(`Retry failed download in Offcloud ${infoHash} [${fileIndex}]...`); + return _retryCreateTorrent(OC, infoHash, cachedEntryInfo, fileIndex); + } + + return Promise.reject(`Failed Offcloud adding torrent ${JSON.stringify(torrent)}`); +} + +async function _createOrFindTorrent(OC, infoHash) { + return _findTorrent(OC, infoHash) + .catch(() => _createTorrent(OC, infoHash)); +} + +async function _findTorrent(OC, infoHash) { + const torrents = await OC.cloud.history(); + const foundTorrents = torrents.filter(torrent => torrent.originalLink.toLowerCase().includes(infoHash)); + const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent)); + const foundTorrent = nonFailedTorrent || foundTorrents[0]; + return foundTorrent || Promise.reject('No recent torrent found'); +} + +async function _createTorrent(OC, infoHash) { + const magnetLink = await getMagnetLink(infoHash); + return OC.cloud.download(magnetLink) +} + +async function _retryCreateTorrent(OC, infoHash, cachedEntryInfo, fileIndex) { + const newTorrent = await _createTorrent(OC, infoHash); + return newTorrent && statusReady(newTorrent.status) + ? _unrestrictLink(OC, infoHash, newTorrent, cachedEntryInfo, fileIndex) + : StaticResponse.FAILED_DOWNLOAD; +} + +async function _unrestrictLink(OC, infoHash, torrent, cachedEntryInfo, fileIndex) { + const targetFileName = decodeURIComponent(cachedEntryInfo); + const files = await _getFileUrls(OC, torrent) + const targetFile = files.find(file => sameFilename(targetFileName, file.split('/').pop())) + || files.find(file => file.includes(`/${torrent.requestId}/${fileIndex + 1}/`) && isVideo(file)) + || files.find(file => isVideo(file)) + || files.pop(); + + if (!targetFile) { + return Promise.reject(`No Offcloud links found for index ${fileIndex} in: ${JSON.stringify(torrent)}`); + } + console.log(`Unrestricted Offcloud ${infoHash} [${fileIndex}] to ${targetFile}`); + return targetFile; +} + +async function _getFileUrls(OC, torrent) { + return OC.cloud.explore(torrent.requestId) + .catch(error => { + if (error === 'Bad archive') { + return [`https://${torrent.server}.offcloud.com/cloud/download/${torrent.requestId}/${torrent.fileName}`]; + } + throw error; + }) +} + +async function getDefaultOptions(ip) { + return { ip, timeout: 10000 }; +} + +export function toCommonError(error) { + if (error?.error === 'NOAUTH' || error?.message?.startsWith('Cannot read property')) { + return BadTokenError; + } + return undefined; +} + +function statusDownloading(torrent) { + return ['downloading', 'created', 'queued'].includes(torrent.status); +} + +function statusError(torrent) { + return ['error', 'canceled'].includes(torrent.status); +} + +function statusReady(torrent) { + return torrent.status === 'downloaded'; +} + +function errorExpiredSubscriptionError(error) { + return error?.includes && (error.includes('not_available') || error.includes('NOAUTH') || error.includes('premium membership')); +} diff --git a/src/lib/addon/moch/options.ts b/src/lib/addon/moch/options.ts new file mode 100644 index 0000000..31567bf --- /dev/null +++ b/src/lib/addon/moch/options.ts @@ -0,0 +1,21 @@ +export const DebridOptions = { + key: 'debridoptions', + options: { + noDownloadLinks: { + key: 'nodownloadlinks', + description: 'Don\'t show download to debrid links' + }, + noCatalog: { + key: 'nocatalog', + description: 'Don\'t show debrid catalog' + }, + } +} + +export function excludeDownloadLinks(config) { + return config[DebridOptions.key]?.includes(DebridOptions.options.noDownloadLinks.key); +} + +export function showDebridCatalog(config) { + return !config[DebridOptions.key]?.includes(DebridOptions.options.noCatalog.key); +} diff --git a/src/lib/addon/moch/premiumize.ts b/src/lib/addon/moch/premiumize.ts new file mode 100644 index 0000000..943c784 --- /dev/null +++ b/src/lib/addon/moch/premiumize.ts @@ -0,0 +1,208 @@ +import PremiumizeClient from 'premiumize-api'; +import magnet from 'magnet-uri'; +import { Type } from '../lib/types.js'; +import { isVideo, isArchive } from '../lib/extension.js'; +import StaticResponse from './static.js'; +import { getMagnetLink } from '../lib/magnetHelper.js'; +import { BadTokenError, chunkArray, sameFilename, streamFilename } from './mochHelper.js'; + +const KEY = 'premiumize'; + +export async function getCachedStreams(streams, apiKey) { + const options = await getDefaultOptions(); + const PM = new PremiumizeClient(apiKey, options); + return Promise.all(chunkArray(streams, 100) + .map(chunkedStreams => _getCachedStreams(PM, apiKey, chunkedStreams))) + .then(results => results.reduce((all, result) => Object.assign(all, result), {})); +} + +async function _getCachedStreams(PM, apiKey, streams) { + const hashes = streams.map(stream => stream.infoHash); + return PM.cache.check(hashes) + .catch(error => { + if (toCommonError(error)) { + return Promise.reject(error); + } + console.warn('Failed Premiumize cached torrent availability request:', error); + return undefined; + }) + .then(available => streams + .reduce((mochStreams, stream, index) => { + const filename = streamFilename(stream); + mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = { + url: `${apiKey}/${stream.infoHash}/${filename}/${stream.fileIdx}`, + cached: available?.response[index] + }; + return mochStreams; + }, {})); +} + +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { + return []; + } + const options = await getDefaultOptions(); + const PM = new PremiumizeClient(apiKey, options); + return PM.folder.list() + .then(response => response.content) + .then(torrents => (torrents || []) + .filter(torrent => torrent && torrent.type === 'folder') + .map(torrent => ({ + id: `${KEY}:${torrent.id}`, + type: Type.OTHER, + name: torrent.name + }))); +} + +export async function getItemMeta(itemId, apiKey, ip) { + const options = await getDefaultOptions(); + const PM = new PremiumizeClient(apiKey, options); + const rootFolder = await PM.folder.list(itemId, null); + const infoHash = await _findInfoHash(PM, itemId); + return getFolderContents(PM, itemId, ip) + .then(contents => ({ + id: `${KEY}:${itemId}`, + type: Type.OTHER, + name: rootFolder.name, + infoHash: infoHash, + videos: contents + .map((file, index) => ({ + id: `${KEY}:${file.id}:${index}`, + title: file.name, + released: new Date(file.created_at * 1000 - index).toISOString(), + streams: [{ url: file.link || file.stream_link }] + })) + })) +} + +async function getFolderContents(PM, itemId, ip, folderPrefix = '') { + return PM.folder.list(itemId, null, ip) + .then(response => response.content) + .then(contents => Promise.all(contents + .filter(content => content.type === 'folder') + .map(content => getFolderContents(PM, content.id, ip, [folderPrefix, content.name].join('/')))) + .then(otherContents => otherContents.reduce((a, b) => a.concat(b), [])) + .then(otherContents => contents + .filter(content => content.type === 'file' && isVideo(content.name)) + .map(content => ({ ...content, name: [folderPrefix, content.name].join('/') })) + .concat(otherContents))); +} + +export async function resolve({ ip, isBrowser, apiKey, infoHash, cachedEntryInfo, fileIndex }) { + console.log(`Unrestricting Premiumize ${infoHash} [${fileIndex}] for IP ${ip} from browser=${isBrowser}`); + const options = await getDefaultOptions(); + const PM = new PremiumizeClient(apiKey, options); + return _getCachedLink(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser) + .catch(() => _resolve(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser)) + .catch(error => { + if (isAccessDeniedError(error)) { + console.log(`Access denied to Premiumize ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_ACCESS; + } + if (isLimitExceededError(error)) { + console.log(`Limits exceeded in Premiumize ${infoHash} [${fileIndex}]`); + return StaticResponse.LIMITS_EXCEEDED; + } + return Promise.reject(`Failed Premiumize adding torrent ${JSON.stringify(error)}`); + }); +} + +async function _resolve(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser) { + const torrent = await _createOrFindTorrent(PM, infoHash); + if (torrent && statusReady(torrent.status)) { + return _getCachedLink(PM, infoHash, cachedEntryInfo, fileIndex, ip, isBrowser); + } else if (torrent && statusDownloading(torrent.status)) { + console.log(`Downloading to Premiumize ${infoHash} [${fileIndex}]...`); + return StaticResponse.DOWNLOADING; + } else if (torrent && statusError(torrent.status)) { + console.log(`Retrying downloading to Premiumize ${infoHash} [${fileIndex}]...`); + return _retryCreateTorrent(PM, infoHash, cachedEntryInfo, fileIndex); + } + return Promise.reject(`Failed Premiumize adding torrent ${JSON.stringify(torrent)}`); +} + +async function _getCachedLink(PM, infoHash, encodedFileName, fileIndex, ip, isBrowser) { + const cachedTorrent = await PM.transfer.directDownload(magnet.encode({ infoHash }), ip); + if (cachedTorrent?.content?.length) { + const targetFileName = decodeURIComponent(encodedFileName); + const videos = cachedTorrent.content.filter(file => isVideo(file.path)).sort((a, b) => b.size - a.size); + const targetVideo = Number.isInteger(fileIndex) + && videos.find(video => sameFilename(video.path, targetFileName)) + || videos[0]; + if (!targetVideo && videos.every(video => isArchive(video.path))) { + console.log(`Only Premiumize archive is available for [${infoHash}] ${fileIndex}`) + return StaticResponse.FAILED_RAR; + } + const streamLink = isBrowser && targetVideo.transcode_status === 'finished' && targetVideo.stream_link; + const unrestrictedLink = streamLink || targetVideo.link; + console.log(`Unrestricted Premiumize ${infoHash} [${fileIndex}] to ${unrestrictedLink}`); + return unrestrictedLink; + } + return Promise.reject('No cached entry found'); +} + +async function _createOrFindTorrent(PM, infoHash) { + return _findTorrent(PM, infoHash) + .catch(() => _createTorrent(PM, infoHash)); +} + +async function _findTorrent(PM, infoHash) { + const torrents = await PM.transfer.list().then(response => response.transfers); + const foundTorrents = torrents.filter(torrent => torrent.src.toLowerCase().includes(infoHash)); + const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent.statusCode)); + const foundTorrent = nonFailedTorrent || foundTorrents[0]; + return foundTorrent || Promise.reject('No recent torrent found'); +} + +async function _findInfoHash(PM, itemId) { + const torrents = await PM.transfer.list().then(response => response.transfers); + const foundTorrent = torrents.find(torrent => `${torrent.file_id}` === itemId || `${torrent.folder_id}` === itemId); + return foundTorrent?.src ? magnet.decode(foundTorrent.src).infoHash : undefined; +} + +async function _createTorrent(PM, infoHash) { + const magnetLink = await getMagnetLink(infoHash); + return PM.transfer.create(magnetLink).then(() => _findTorrent(PM, infoHash)); +} + +async function _retryCreateTorrent(PM, infoHash, encodedFileName, fileIndex) { + const newTorrent = await _createTorrent(PM, infoHash).then(() => _findTorrent(PM, infoHash)); + return newTorrent && statusReady(newTorrent.status) + ? _getCachedLink(PM, infoHash, encodedFileName, fileIndex) + : StaticResponse.FAILED_DOWNLOAD; +} + +export function toCommonError(error) { + if (error && error.message === 'Not logged in.') { + return BadTokenError; + } + return undefined; +} + +function statusError(status) { + return ['deleted', 'error', 'timeout'].includes(status); +} + +function statusDownloading(status) { + return ['waiting', 'queued', 'running'].includes(status); +} + +function statusReady(status) { + return ['finished', 'seeding'].includes(status); +} + +function isAccessDeniedError(error) { + return ['Account not premium.'].some(value => error?.message?.includes(value)); +} + +function isLimitExceededError(error) { + return [ + 'Fair use limit reached!', + 'You already have a maximum of 25 active downloads in progress!', + 'Your space is full! Please delete old files first!' + ].some(value => error?.message?.includes(value)); +} + +async function getDefaultOptions(ip) { + return { timeout: 5000 }; +} diff --git a/src/lib/addon/moch/putio.ts b/src/lib/addon/moch/putio.ts new file mode 100644 index 0000000..4dc42dd --- /dev/null +++ b/src/lib/addon/moch/putio.ts @@ -0,0 +1,216 @@ +import PutioClient from '@putdotio/api-client' +import { isVideo } from '../lib/extension.js'; +import { delay } from '../lib/promises.js'; +import StaticResponse from './static.js'; +import { getMagnetLink } from '../lib/magnetHelper.js'; +import { Type } from "../lib/types.js"; +import { decode } from "magnet-uri"; +import { sameFilename, streamFilename } from "./mochHelper.js"; +const PutioAPI = PutioClient.default; + +const KEY = 'putio'; + +export async function getCachedStreams(streams, apiKey) { + return streams + .reduce((mochStreams, stream) => { + const filename = streamFilename(stream); + mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = { + url: `${apiKey}/${stream.infoHash}/${filename}/${stream.fileIdx}`, + cached: false + }; + return mochStreams; + }, {}); +} + +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { + return []; + } + const Putio = createPutioAPI(apiKey) + return Putio.Files.Query(0) + .then(response => response?.body?.files) + .then(files => (files || []) + .map(file => ({ + id: `${KEY}:${file.id}`, + type: Type.OTHER, + name: file.name + }))); +} + +export async function getItemMeta(itemId, apiKey) { + const Putio = createPutioAPI(apiKey) + const infoHash = await _findInfoHash(Putio, itemId) + return getFolderContents(Putio, itemId) + .then(contents => ({ + id: `${KEY}:${itemId}`, + type: Type.OTHER, + name: contents.name, + infoHash: infoHash, + videos: contents + .map((file, index) => ({ + id: `${KEY}:${file.id}:${index}`, + title: file.name, + released: new Date(file.created_at).toISOString(), + streams: [{ url: `${apiKey}/null/null/${file.id}` }] + })) + })) +} + +async function getFolderContents(Putio, itemId, folderPrefix = '') { + return await Putio.Files.Query(itemId) + .then(response => response?.body) + .then(body => body?.files?.length ? body.files : [body?.parent].filter(x => x)) + .then(contents => Promise.all(contents + .filter(content => content.file_type === 'FOLDER') + .map(content => getFolderContents(Putio, content.id, [folderPrefix, content.name].join('/')))) + .then(otherContents => otherContents.reduce((a, b) => a.concat(b), [])) + .then(otherContents => contents + .filter(content => content.file_type === 'VIDEO') + .map(content => ({ ...content, name: [folderPrefix, content.name].join('/') })) + .concat(otherContents))); +} + +export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) { + console.log(`Unrestricting Putio ${infoHash} [${fileIndex}]`); + const Putio = createPutioAPI(apiKey) + + return _resolve(Putio, infoHash, cachedEntryInfo, fileIndex) + .catch(error => { + if (error?.data?.status_code === 401) { + console.log(`Access denied to Putio ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_ACCESS; + } + return Promise.reject(`Failed Putio adding torrent ${JSON.stringify(error.data || error)}`); + }); +} + +async function _resolve(Putio, infoHash, cachedEntryInfo, fileIndex) { + if (infoHash === 'null') { + return _unrestrictVideo(Putio, fileIndex); + } + const torrent = await _createOrFindTorrent(Putio, infoHash); + if (torrent && statusReady(torrent.status)) { + return _unrestrictLink(Putio, torrent, cachedEntryInfo, fileIndex); + } else if (torrent && statusDownloading(torrent.status)) { + console.log(`Downloading to Putio ${infoHash} [${fileIndex}]...`); + return StaticResponse.DOWNLOADING; + } else if (torrent && statusError(torrent.status)) { + console.log(`Retrying downloading to Putio ${infoHash} [${fileIndex}]...`); + return _retryCreateTorrent(Putio, infoHash, cachedEntryInfo, fileIndex); + } + return Promise.reject("Failed Putio adding torrent"); +} + +async function _createOrFindTorrent(Putio, infoHash) { + return _findTorrent(Putio, infoHash) + .catch(() => _createTorrent(Putio, infoHash)); +} + +async function _retryCreateTorrent(Putio, infoHash, encodedFileName, fileIndex) { + const newTorrent = await _createTorrent(Putio, infoHash); + return newTorrent && statusReady(newTorrent.status) + ? _unrestrictLink(Putio, newTorrent, encodedFileName, fileIndex) + : StaticResponse.FAILED_DOWNLOAD; +} + +async function _findTorrent(Putio, infoHash) { + const torrents = await Putio.Transfers.Query().then(response => response.data.transfers); + const foundTorrents = torrents.filter(torrent => torrent.source.toLowerCase().includes(infoHash)); + const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent.status)); + const foundTorrent = nonFailedTorrent || foundTorrents[0]; + if (foundTorrents && !foundTorrents.userfile_exists) { + return await Putio.Transfers.Cancel(foundTorrents.id).then(() => Promise.reject()) + } + return foundTorrent || Promise.reject('No recent torrent found in Putio'); +} + +async function _findInfoHash(Putio, fileId) { + const torrents = await Putio.Transfers.Query().then(response => response?.data?.transfers); + const foundTorrent = torrents.find(torrent => `${torrent.file_id}` === fileId); + return foundTorrent?.source ? decode(foundTorrent.source).infoHash : undefined; +} + +async function _createTorrent(Putio, infoHash) { + const magnetLink = await getMagnetLink(infoHash); + // Add the torrent and then delay for 3 secs for putio to process it and then check it's status. + return Putio.Transfers.Add({ url: magnetLink }) + .then(response => _getNewTorrent(Putio, response.data.transfer.id)); +} + +async function _getNewTorrent(Putio, torrentId, pollCounter = 0, pollRate = 2000, maxPollNumber = 15) { + return Putio.Transfers.Get(torrentId) + .then(response => response.data.transfer) + .then(torrent => statusProcessing(torrent.status) && pollCounter < maxPollNumber + ? delay(pollRate).then(() => _getNewTorrent(Putio, torrentId, pollCounter + 1)) + : torrent); +} + +async function _unrestrictLink(Putio, torrent, encodedFileName, fileIndex) { + const targetVideo = await _getTargetFile(Putio, torrent, encodedFileName, fileIndex); + return _unrestrictVideo(Putio, targetVideo.id); +} + +async function _unrestrictVideo(Putio, videoId) { + const response = await Putio.File.GetStorageURL(videoId); + const downloadUrl = response.data.url + console.log(`Unrestricted Putio [${videoId}] to ${downloadUrl}`); + return downloadUrl; +} + +async function _getTargetFile(Putio, torrent, encodedFileName, fileIndex) { + const targetFileName = decodeURIComponent(encodedFileName); + let targetFile; + let files = await _getFiles(Putio, torrent.file_id); + let videos = []; + + while (!targetFile && files.length) { + const folders = files.filter(file => file.file_type === 'FOLDER'); + videos = videos.concat(files.filter(file => isVideo(file.name))).sort((a, b) => b.size - a.size); + // when specific file index is defined search by filename + // when it's not defined find all videos and take the largest one + targetFile = Number.isInteger(fileIndex) + && videos.find(video => sameFilename(targetFileName, video.name)) + || !folders.length && videos[0]; + files = !targetFile + ? await Promise.all(folders.map(folder => _getFiles(Putio, folder.id))) + .then(results => results.reduce((a, b) => a.concat(b), [])) + : []; + } + return targetFile || Promise.reject(`No target file found for Putio [${torrent.hash}] ${targetFileName}`); +} + +async function _getFiles(Putio, fileId) { + const response = await Putio.Files.Query(fileId) + .catch(error => Promise.reject({ ...error.data, path: error.request.path })); + return response.data.files.length + ? response.data.files + : [response.data.parent]; +} + +function createPutioAPI(apiKey) { + const clientId = apiKey.replace(/@.*/, ''); + const token = apiKey.replace(/.*@/, ''); + const Putio = new PutioAPI({ clientID: clientId }); + Putio.setToken(token); + return Putio; +} + +export function toCommonError(error) { + return undefined; +} + +function statusError(status) { + return ['ERROR'].includes(status); +} + +function statusDownloading(status) { + return ['WAITING', 'IN_QUEUE', 'DOWNLOADING'].includes(status); +} + +function statusProcessing(status) { + return ['WAITING', 'IN_QUEUE', 'COMPLETING'].includes(status); +} + +function statusReady(status) { + return ['COMPLETED', 'SEEDING'].includes(status); +} diff --git a/src/lib/addon/moch/realdebrid.ts b/src/lib/addon/moch/realdebrid.ts new file mode 100644 index 0000000..4e16b9b --- /dev/null +++ b/src/lib/addon/moch/realdebrid.ts @@ -0,0 +1,366 @@ +import RealDebridClient from 'real-debrid-api'; +import { Type } from '../lib/types.js'; +import { isVideo, isArchive } from '../lib/extension.js'; +import { delay } from '../lib/promises.js'; +import { cacheAvailabilityResults, getCachedAvailabilityResults, removeAvailabilityResults } from '../lib/cache.js'; +import StaticResponse from './static.js'; +import { getMagnetLink } from '../lib/magnetHelper.js'; +import { BadTokenError, AccessDeniedError } from './mochHelper.js'; + +const MIN_SIZE = 5 * 1024 * 1024; // 5 MB +const CATALOG_MAX_PAGE = 1; +const CATALOG_PAGE_SIZE = 100; +const KEY = 'realdebrid'; +const DEBRID_DOWNLOADS = 'Downloads'; + +export async function getCachedStreams(streams, apiKey) { + const hashes = streams.map(stream => stream.infoHash); + const available = await getCachedAvailabilityResults(hashes); + return available && streams + .reduce((mochStreams, stream) => { + const cachedEntry = available[stream.infoHash]; + const cachedIds = _getCachedFileIds(stream.fileIdx, cachedEntry); + mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = { + url: `${apiKey}/${stream.infoHash}/null/${stream.fileIdx}`, + cached: !!cachedIds.length + }; + return mochStreams; + }, {}) +} + +function _getCachedFileIds(fileIndex, cachedResults) { + if (!cachedResults || !Array.isArray(cachedResults)) { + return []; + } + + const cachedIds = Number.isInteger(fileIndex) + ? cachedResults.find(ids => Array.isArray(ids) && ids.includes(fileIndex + 1)) + : cachedResults[0]; + return cachedIds || []; +} + +export async function getCatalog(apiKey, catalogId, config) { + const options = await getDefaultOptions(config.ip); + const RD = new RealDebridClient(apiKey, options); + const page = Math.floor((config.skip || 0) / 100) + 1; + const downloadsMeta = page === 1 ? [{ + id: `${KEY}:${DEBRID_DOWNLOADS}`, + type: Type.OTHER, + name: DEBRID_DOWNLOADS + }] : []; + const torrentMetas = await _getAllTorrents(RD, page) + .then(torrents => Array.isArray(torrents) ? torrents : []) + .then(torrents => torrents + .filter(torrent => torrent && statusReady(torrent.status)) + .map(torrent => ({ + id: `${KEY}:${torrent.id}`, + type: Type.OTHER, + name: torrent.filename + }))); + return downloadsMeta.concat(torrentMetas) +} + +export async function getItemMeta(itemId, apiKey, ip) { + const options = await getDefaultOptions(ip); + const RD = new RealDebridClient(apiKey, options); + if (itemId === DEBRID_DOWNLOADS) { + const videos = await _getAllDownloads(RD) + .then(downloads => downloads + .map(download => ({ + id: `${KEY}:${DEBRID_DOWNLOADS}:${download.id}`, + // infoHash: allTorrents + // .filter(torrent => (torrent.links || []).find(link => link === download.link)) + // .map(torrent => torrent.hash.toLowerCase())[0], + title: download.filename, + released: new Date(download.generated).toISOString(), + streams: [{ url: download.download }] + }))); + return { + id: `${KEY}:${DEBRID_DOWNLOADS}`, + type: Type.OTHER, + name: DEBRID_DOWNLOADS, + videos: videos + }; + } + return _getTorrentInfo(RD, itemId) + .then(torrent => ({ + id: `${KEY}:${torrent.id}`, + type: Type.OTHER, + name: torrent.filename, + infoHash: torrent.hash.toLowerCase(), + videos: torrent.files + .filter(file => file.selected) + .filter(file => isVideo(file.path)) + .map((file, index) => ({ + id: `${KEY}:${torrent.id}:${file.id}`, + title: file.path, + released: new Date(new Date(torrent.added).getTime() - index).toISOString(), + streams: [{ url: `${apiKey}/${torrent.hash.toLowerCase()}/null/${file.id - 1}` }] + })) + })) +} + +async function _getAllTorrents(RD, page = 1) { + return RD.torrents.get(page - 1, page, CATALOG_PAGE_SIZE) + .then(torrents => torrents && torrents.length === CATALOG_PAGE_SIZE && page < CATALOG_MAX_PAGE + ? _getAllTorrents(RD, page + 1) + .then(nextTorrents => torrents.concat(nextTorrents)) + .catch(() => torrents) + : torrents) +} + +async function _getAllDownloads(RD, page = 1) { + return RD.downloads.get(page - 1, page, CATALOG_PAGE_SIZE); +} + +export async function resolve({ ip, isBrowser, apiKey, infoHash, fileIndex }) { + console.log(`Unrestricting RealDebrid ${infoHash} [${fileIndex}]`); + const options = await getDefaultOptions(ip); + const RD = new RealDebridClient(apiKey, options); + + return _resolve(RD, infoHash, fileIndex, isBrowser) + .catch(error => { + if (isAccessDeniedError(error)) { + console.log(`Access denied to RealDebrid ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_ACCESS; + } + if (isInfringingFileError(error)) { + console.log(`Infringing file removed from RealDebrid ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_INFRINGEMENT; + } + if (isLimitExceededError(error)) { + console.log(`Limits exceeded in RealDebrid ${infoHash} [${fileIndex}]`); + return StaticResponse.LIMITS_EXCEEDED; + } + if (isTorrentTooBigError(error)) { + console.log(`Torrent too big for RealDebrid ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_TOO_BIG; + } + return Promise.reject(`Failed RealDebrid adding torrent ${JSON.stringify(error)}`); + }); +} + +async function _resolveCachedFileIds(infoHash, fileIndex) { + const available = await getCachedAvailabilityResults([infoHash]); + const cachedEntry = available?.[infoHash]; + const cachedIds = _getCachedFileIds(fileIndex, cachedEntry); + return cachedIds?.join(','); +} + +async function _resolve(RD, infoHash, fileIndex, isBrowser) { + const torrentId = await _createOrFindTorrentId(RD, infoHash, fileIndex); + const torrent = await _getTorrentInfo(RD, torrentId); + if (torrent && statusReady(torrent.status)) { + return _unrestrictLink(RD, torrent, fileIndex, isBrowser); + } else if (torrent && statusDownloading(torrent.status)) { + console.log(`Downloading to RealDebrid ${infoHash} [${fileIndex}]...`); + const cachedFileIds = torrent.files.filter(file => file.selected).map(file => file.id); + removeAvailabilityResults(infoHash, cachedFileIds); + return StaticResponse.DOWNLOADING; + } else if (torrent && statusMagnetError(torrent.status)) { + console.log(`Failed RealDebrid opening torrent ${infoHash} [${fileIndex}] due to magnet error`); + return StaticResponse.FAILED_OPENING; + } else if (torrent && statusError(torrent.status)) { + return _retryCreateTorrent(RD, infoHash, fileIndex); + } else if (torrent && (statusWaitingSelection(torrent.status) || statusOpening(torrent.status))) { + console.log(`Trying to select files on RealDebrid ${infoHash} [${fileIndex}]...`); + return _selectTorrentFiles(RD, torrent) + .then(() => { + console.log(`Downloading to RealDebrid ${infoHash} [${fileIndex}]...`); + return StaticResponse.DOWNLOADING + }) + .catch(error => { + console.log(`Failed RealDebrid opening torrent ${infoHash} [${fileIndex}]:`, error); + return StaticResponse.FAILED_OPENING; + }); + } + return Promise.reject(`Failed RealDebrid adding torrent ${JSON.stringify(torrent)}`); +} + +async function _createOrFindTorrentId(RD, infoHash, fileIndex) { + return _findTorrent(RD, infoHash, fileIndex) + .catch(() => _createTorrentId(RD, infoHash, fileIndex)); +} + +async function _findTorrent(RD, infoHash, fileIndex) { + const torrents = await RD.torrents.get(0, 1) || []; + const foundTorrents = torrents + .filter(torrent => torrent.hash.toLowerCase() === infoHash) + .filter(torrent => !statusError(torrent.status)); + const foundTorrent = await _findBestFitTorrent(RD, foundTorrents, fileIndex); + return foundTorrent?.id || Promise.reject('No recent torrent found'); +} + +async function _findBestFitTorrent(RD, torrents, fileIndex) { + if (torrents.length === 1) { + return torrents[0]; + } + const torrentInfos = await Promise.all(torrents.map(torrent => _getTorrentInfo(RD, torrent.id))); + const bestFitTorrents = torrentInfos + .filter(torrent => torrent.files.find(f => f.id === fileIndex + 1 && f.selected)) + .sort((a, b) => b.links.length - a.links.length); + return bestFitTorrents[0] || torrents[0]; +} + +async function _getTorrentInfo(RD, torrentId) { + if (!torrentId || typeof torrentId === 'object') { + return torrentId || Promise.reject('No RealDebrid torrentId provided') + } + return RD.torrents.info(torrentId); +} + +async function _createTorrentId(RD, infoHash, fileIndex, force = false) { + const magnetLink = await getMagnetLink(infoHash); + const addedMagnet = await RD.torrents.addMagnet(magnetLink); + const cachedFileIds = !force && await _resolveCachedFileIds(infoHash, fileIndex); + if (cachedFileIds && !['null', 'undefined'].includes(cachedFileIds)) { + await RD.torrents.selectFiles(addedMagnet.id, cachedFileIds); + } else if (!force) { + await _selectTorrentFiles(RD, { id: addedMagnet.id }); + } + return addedMagnet.id; +} + +async function _recreateTorrentId(RD, infoHash, fileIndex, force = false) { + const newTorrentId = await _createTorrentId(RD, infoHash, fileIndex, force); + await _selectTorrentFiles(RD, { id: newTorrentId }, fileIndex); + return newTorrentId; +} + +async function _retryCreateTorrent(RD, infoHash, fileIndex, shouldRetry = false) { + console.log(`Retry failed download in RealDebrid ${infoHash} [${fileIndex}]...`); + const newTorrentId = await _recreateTorrentId(RD, infoHash, fileIndex, true); + const newTorrent = await _getTorrentInfo(RD, newTorrentId); + return newTorrent && statusReady(newTorrent.status) + ? _unrestrictLink(RD, newTorrent, fileIndex, false, shouldRetry) + : StaticResponse.FAILED_DOWNLOAD; +} + +async function _selectTorrentFiles(RD, torrent, fileIndex) { + torrent = statusWaitingSelection(torrent.status) ? torrent : await _openTorrent(RD, torrent.id); + if (torrent?.files && statusWaitingSelection(torrent.status)) { + const videoFileIds = Number.isInteger(fileIndex) ? `${fileIndex + 1}` : torrent.files + .filter(file => isVideo(file.path)) + .filter(file => file.bytes > MIN_SIZE) + .map(file => file.id) + .join(','); + return RD.torrents.selectFiles(torrent.id, videoFileIds); + } else if (statusReady(torrent.status) || statusDownloading(torrent.status)) { + return torrent; + } + return Promise.reject('Failed RealDebrid torrent file selection') +} + +async function _openTorrent(RD, torrentId, pollCounter = 0, pollRate = 2000, maxPollNumber = 15) { + return _getTorrentInfo(RD, torrentId) + .then(torrent => torrent && statusOpening(torrent.status) && pollCounter < maxPollNumber + ? delay(pollRate).then(() => _openTorrent(RD, torrentId, pollCounter + 1)) + : torrent); +} + +async function _unrestrictLink(RD, torrent, fileIndex, isBrowser, shouldRetry = true) { + const targetFile = torrent.files.find(file => file.id === fileIndex + 1) + || torrent.files.filter(file => file.selected).sort((a, b) => b.bytes - a.bytes)[0]; + if (!targetFile.selected) { + console.log(`Target RealDebrid file is not downloaded: ${JSON.stringify(targetFile)}`); + await _recreateTorrentId(RD, torrent.hash.toLowerCase(), fileIndex); + return StaticResponse.DOWNLOADING; + } + + const selectedFiles = torrent.files.filter(file => file.selected); + const fileLink = torrent.links.length === 1 + ? torrent.links[0] + : torrent.links[selectedFiles.indexOf(targetFile)]; + + if (shouldRetry && !fileLink?.length) { + console.log(`No RealDebrid links found for ${torrent.hash} [${fileIndex}]`); + return _retryCreateTorrent(RD, torrent.hash, fileIndex) + } + + return _unrestrictFileLink(RD, fileLink, torrent, fileIndex, isBrowser, shouldRetry); +} + +async function _unrestrictFileLink(RD, fileLink, torrent, fileIndex, isBrowser, shouldRetry) { + return RD.unrestrict.link(fileLink) + .then(response => { + if (isArchive(response.download)) { + if (shouldRetry && Number.isInteger(fileIndex) && torrent.files.filter(file => file.selected).length > 1) { + console.log(`Only archive is available, try to download single file for ${torrent.hash} [${fileIndex}]`); + return _retryCreateTorrent(RD, torrent.hash, fileIndex) + } + return StaticResponse.FAILED_RAR; + } + // if (isBrowser && response.streamable) { + // return RD.streaming.transcode(response.id) + // .then(streamResponse => streamResponse.apple.full) + // } + return response.download; + }) + .then(unrestrictedLink => { + console.log(`Unrestricted RealDebrid ${torrent.hash} [${fileIndex}] to ${unrestrictedLink}`); + const cachedFileIds = torrent.files.filter(file => file.selected).map(file => file.id); + cacheAvailabilityResults(torrent.hash.toLowerCase(), cachedFileIds); // no need to await can happen async + return unrestrictedLink; + }) + .catch(error => { + if (shouldRetry && error.code === 19) { + console.log(`Retry download as hoster is unavailable for ${torrent.hash} [${fileIndex}]`); + return _retryCreateTorrent(RD, torrent.hash.toLowerCase(), fileIndex); + } + return Promise.reject(error); + }); +} + +export function toCommonError(error) { + if (error && error.code === 8) { + return BadTokenError; + } + if (error && isAccessDeniedError(error)) { + return AccessDeniedError; + } + return undefined; +} + +function statusError(status) { + return ['error', 'magnet_error'].includes(status); +} + +function statusMagnetError(status) { + return status === 'magnet_error'; +} + +function statusOpening(status) { + return status === 'magnet_conversion'; +} + +function statusWaitingSelection(status) { + return status === 'waiting_files_selection'; +} + +function statusDownloading(status) { + return ['downloading', 'uploading', 'queued'].includes(status); +} + +function statusReady(status) { + return ['downloaded', 'dead'].includes(status); +} + +function isAccessDeniedError(error) { + return [8, 9, 20].includes(error?.code); +} + +function isInfringingFileError(error) { + return [35].includes(error?.code); +} + +function isLimitExceededError(error) { + return [21, 23, 26, 36].includes(error?.code); +} + +function isTorrentTooBigError(error) { + return [29].includes(error?.code); +} + +async function getDefaultOptions(ip) { + return { ip, timeout: 15000 }; +} diff --git a/src/lib/addon/moch/static.ts b/src/lib/addon/moch/static.ts new file mode 100644 index 0000000..6e85015 --- /dev/null +++ b/src/lib/addon/moch/static.ts @@ -0,0 +1,19 @@ +const staticVideoUrls = { + DOWNLOADING: `videos/downloading_v2.mp4`, + FAILED_DOWNLOAD: `videos/download_failed_v2.mp4`, + FAILED_ACCESS: `videos/failed_access_v2.mp4`, + FAILED_RAR: `videos/failed_rar_v2.mp4`, + FAILED_TOO_BIG: 'failed_too_big_v1.mp4', + FAILED_OPENING: `videos/failed_opening_v2.mp4`, + FAILED_UNEXPECTED: `videos/failed_unexpected_v2.mp4`, + FAILED_INFRINGEMENT: `videos/failed_infringement_v2.mp4`, + LIMITS_EXCEEDED: `videos/limits_exceeded_v1.mp4`, + BLOCKED_ACCESS: `videos/blocked_access_v1.mp4`, +} + + +export function isStaticUrl(url) { + return Object.values(staticVideoUrls).some(videoUrl => url?.endsWith(videoUrl)); +} + +export default staticVideoUrls \ No newline at end of file diff --git a/src/lib/addon/moch/torbox.ts b/src/lib/addon/moch/torbox.ts new file mode 100644 index 0000000..e4976fe --- /dev/null +++ b/src/lib/addon/moch/torbox.ts @@ -0,0 +1,298 @@ +import axios from 'axios'; +import { Type } from '../lib/types.js'; +import { isVideo } from '../lib/extension.js'; +import StaticResponse from './static.js'; +import { getMagnetLink } from '../lib/magnetHelper.js'; +import { chunkArray, BadTokenError, sameFilename, streamFilename } from './mochHelper.js'; + +const KEY = 'torbox'; +const timeout = 30000; +const baseUrl = 'https://api.torbox.app/v1' + +export async function getCachedStreams(streams, apiKey, ip) { + const hashBatches = chunkArray(streams.map(stream => stream.infoHash), 150) + .map(hashes => getAvailabilityResponse(apiKey, hashes)); + const available = await Promise.all(hashBatches) + .then(results => results + .map(data => data.map(entry => entry.hash)) + .reduce((all, result) => all.concat(result), [])) + .catch(error => { + if (toCommonError(error)) { + return Promise.reject(error); + } + const message = error.message || error; + console.warn('Failed TorBox cached torrent availability request:', message); + return undefined; + }); + return available && streams + .reduce((mochStreams, stream) => { + const isCached = available.includes(stream.infoHash); + const fileName = streamFilename(stream); + mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = { + url: `${apiKey}/${stream.infoHash}/${fileName}/${stream.fileIdx}`, + cached: isCached + }; + return mochStreams; + }, {}) +} + +export async function getCatalog(apiKey, type, config) { + return getItemList(apiKey, type, null, config.skip) + .then(items => (items || []) + .filter(item => statusReady(item)) + .map(item => ({ + id: `${KEY}:${type}-${item.id}`, + type: Type.OTHER, + name: item.name + }))); +} + +export async function getItemMeta(itemId, apiKey) { + const [type, id] = itemId.split('-'); + const item = await getItemList(apiKey, type, id); + const createDate = item ? new Date(item.created_at) : new Date(); + return { + id: `${KEY}:${itemId}`, + type: Type.OTHER, + name: item.name, + infoHash: item.hash, + videos: item.files + .filter(file => isVideo(file.short_name)) + .map((file, index) => ({ + id: `${KEY}:${itemId}:${file.id}`, + title: file.name, + released: new Date(createDate.getTime() - index).toISOString(), + streams: [{ url: `${apiKey}/${itemId}-${file.id}/null/null` }] + })) + } +} + +export async function resolve({ ip, apiKey, infoHash, cachedEntryInfo, fileIndex }) { + console.log(`Unrestricting TorBox ${infoHash} [${fileIndex}]`); + return _resolve(apiKey, infoHash, cachedEntryInfo, fileIndex, ip) + .catch(error => { + if (isAccessDeniedError(error)) { + console.log(`Access denied to TorBox ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_ACCESS; + } + if (isLimitExceededError(error)) { + console.log(`Limits exceeded to TorBox ${infoHash} [${fileIndex}]`); + return StaticResponse.LIMITS_EXCEEDED; + } + if (isTorrentTooBigError(error)) { + console.log(`Torrent too big for TorBox ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_TOO_BIG; + } + return Promise.reject(`Failed TorBox adding torrent: ${JSON.stringify(error.message || error)}`); + }); +} + +async function _resolve(apiKey, infoHash, cachedEntryInfo, fileIndex, ip) { + if (infoHash?.includes('-')) { + const [type, rootId, fileId] = infoHash.split('-'); + return getDownloadLink(apiKey, type, rootId, fileId, ip); + } + const torrent = await _createOrFindTorrent(apiKey, infoHash); + if (torrent && statusReady(torrent)) { + return _unrestrictLink(apiKey, infoHash, torrent, cachedEntryInfo, fileIndex, ip); + } else if (torrent && statusDownloading(torrent)) { + console.log(`Downloading to TorBox ${infoHash} [${fileIndex}]...`); + return StaticResponse.DOWNLOADING; + } else if (torrent && statusError(torrent)) { + console.log(`Retry failed download in TorBox ${infoHash} [${fileIndex}]...`); + return controlTorrent(apiKey, torrent.id, 'delete') + .then(() => _retryCreateTorrent(apiKey, infoHash, cachedEntryInfo, fileIndex)); + } + + return Promise.reject(`Failed TorBox adding torrent ${JSON.stringify(torrent)}`); +} + +async function _createOrFindTorrent(apiKey, infoHash) { + return _findTorrent(apiKey, infoHash) + .catch(() => _createTorrent(apiKey, infoHash)); +} + +async function _findTorrent(apiKey, infoHash) { + const torrents = await getTorrentList(apiKey); + const foundTorrents = torrents.filter(torrent => torrent.hash === infoHash); + const nonFailedTorrent = foundTorrents.find(torrent => !statusError(torrent)); + const foundTorrent = nonFailedTorrent || foundTorrents[0]; + return foundTorrent || Promise.reject('No recent torrent found'); +} + +async function _createTorrent(apiKey, infoHash, attempts = 1) { + const magnetLink = await getMagnetLink(infoHash); + return createTorrent(apiKey, magnetLink) + .then(data => { + if (data.torrent_id) { + return getTorrentList(apiKey, data.torrent_id); + } + if (data.queued_id) { + return Promise.resolve({ ...data, download_state: 'metaDL' }) + } + if (data?.error === 'ACTIVE_LIMIT' && attempts > 0) { + return freeLastActiveTorrent(apiKey) + .then(() => _createTorrent(apiKey, infoHash, attempts - 1)); + } + return Promise.reject(`Unexpected create data: ${JSON.stringify(data)}`); + }); +} + +async function _retryCreateTorrent(apiKey, infoHash, cachedEntryInfo, fileIndex) { + const newTorrent = await _createTorrent(apiKey, infoHash); + return newTorrent && statusReady(newTorrent) + ? _unrestrictLink(apiKey, infoHash, newTorrent, cachedEntryInfo, fileIndex) + : StaticResponse.FAILED_DOWNLOAD; +} + +async function freeLastActiveTorrent(apiKey) { + const torrents = await getTorrentList(apiKey); + const seedingTorrent = torrents.filter(statusSeeding).pop(); + if (seedingTorrent) { + console.log(`Stopping seeded item in TorBox to make space...`); + return controlTorrent(apiKey, seedingTorrent.id, 'stop_seeding'); + } + const downloadingTorrent = torrents.filter(statusDownloading).pop(); + if (downloadingTorrent) { + console.log(`Deleting downloading item in TorBox to make space...`); + return controlTorrent(apiKey, downloadingTorrent.id, 'delete'); + } + return Promise.reject({ detail: 'No torrent to pause found' }); +} + +async function _unrestrictLink(apiKey, infoHash, torrent, cachedEntryInfo, fileIndex, ip) { + const targetFileName = decodeURIComponent(cachedEntryInfo); + const videos = torrent.files + .filter(file => isVideo(file.short_name)) + .sort((a, b) => b.size - a.size); + const targetVideo = Number.isInteger(fileIndex) + && videos.find(video => sameFilename(video.name, targetFileName)) + || videos[0]; + + if (!targetVideo) { + if (torrent.files.every(file => file.zipped)) { + return StaticResponse.FAILED_RAR; + } + return Promise.reject(`No TorBox file found for index ${fileIndex} in: ${JSON.stringify(torrent)}`); + } + return getDownloadLink(apiKey, 'torrents', torrent.id, targetVideo.id, ip); +} + +async function getAvailabilityResponse(apiKey, hashes) { + const url = `${baseUrl}/api/torrents/checkcached`; + const headers = getHeaders(apiKey); + const params = { hash: hashes.join(','), format: 'list' }; + return axios.get(url, { params, headers, timeout }) + .then(response => { + if (response.data?.success) { + return Promise.resolve(response.data.data || []); + } + return Promise.reject(response.data); + }) + .catch(error => Promise.reject(error.response?.data || error)); +} + +async function createTorrent(apiKey, magnetLink){ + const url = `${baseUrl}/api/torrents/createtorrent` + const headers = getHeaders(apiKey); + const data = new URLSearchParams(); + data.append('magnet', magnetLink); + data.append('allow_zip', 'false'); + return axios.post(url, data, { headers, timeout }) + .then(response => { + if (response.data?.success) { + return Promise.resolve(response.data.data); + } + return Promise.reject(response.data); + }) + .catch(error => Promise.reject(error.response?.data || error)); +} + +async function controlTorrent(apiKey, torrent_id, operation){ + const url = `${baseUrl}/api/torrents/controltorrent` + const headers = getHeaders(apiKey); + const data = { torrent_id, operation} + return axios.post(url, data, { headers, timeout }) + .then(response => { + if (response.data?.success) { + return Promise.resolve(response.data.data); + } + return Promise.reject(response.data); + }) + .catch(error => Promise.reject(error.response?.data || error)); +} + +async function getTorrentList(apiKey, id = undefined, offset = 0) { + return getItemList(apiKey, 'torrents', id, offset); +} + +async function getItemList(apiKey, type, id = undefined, offset = 0) { + const url = `${baseUrl}/api/${type}/mylist`; + const headers = getHeaders(apiKey); + const params = { id, offset }; + return axios.get(url, { params, headers, timeout }) + .then(response => { + if (response.data?.success) { + if (Array.isArray(response.data.data)) { + response.data.data.sort((a, b) => b.id - a.id); + } + return Promise.resolve(response.data.data); + } + return Promise.reject(response.data); + }) + .catch(error => Promise.reject(error.response?.data || error)); +} + +async function getDownloadLink(token, type, rootId, file_id, user_ip) { + const url = `${baseUrl}/api/${type}/requestdl`; + const params = { token, torrent_id: rootId, usenet_id: rootId, web_id: rootId, file_id, user_ip }; + return axios.get(url, { params, timeout }) + .then(response => { + if (response.data?.success) { + console.log(`Unrestricted TorBox ${type} [${rootId}] to ${response.data.data}`); + return Promise.resolve(response.data.data); + } + return Promise.reject(response.data); + }) + .catch(error => Promise.reject(error.response?.data || error)); +} + +function getHeaders(apiKey) { + return { Authorization: `Bearer ${apiKey}` }; +} + +export function toCommonError(data) { + const error = data?.response?.data || data; + if (['AUTH_ERROR', 'BAD_TOKEN'].includes(error?.error)) { + return BadTokenError; + } + return undefined; +} + +function statusDownloading(torrent) { + return !statusReady(torrent) && !statusError(torrent); +} + +function statusError(torrent) { + return (!torrent?.active && !torrent?.download_finished) || torrent?.download_state === 'error'; +} + +function statusReady(torrent) { + return torrent?.download_present; +} + +function statusSeeding(torrent) { + return ['seeding', 'uploading', 'uploading (no peers)'].includes(torrent?.download_state); +} + +function isAccessDeniedError(error) { + return ['AUTH_ERROR', 'BAD_TOKEN', 'PLAN_RESTRICTED_FEATURE'].includes(error?.error); +} + +function isLimitExceededError(error) { + return ['MONTHLY_LIMIT', 'COOLDOWN_LIMIT', 'ACTIVE_LIMIT'].includes(error?.error); +} + +function isTorrentTooBigError(error) { + return ['DOWNLOAD_TOO_LARGE'].includes(error?.error); +} diff --git a/src/lib/addon/serverless.ts b/src/lib/addon/serverless.ts new file mode 100644 index 0000000..21166db --- /dev/null +++ b/src/lib/addon/serverless.ts @@ -0,0 +1,113 @@ +import Router from 'router'; +import cors from 'cors'; +import rateLimit from "express-rate-limit"; +import requestIp from 'request-ip'; +import userAgentParser from 'ua-parser-js'; +import addonInterface from '@/addon'; +import qs from 'querystring'; +import { manifest } from '@/lib/manifest'; +import { parseConfiguration, PreConfigurations } from '@/lib/configuration'; +import landingTemplate from '@/lib/landingTemplate'; +import * as moch from '@/moch/moch'; +import { NextFunction, Request, Response } from 'express'; + +const router = new Router(); +const limiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 300, // limit each IP to 300 requests per windowMs + headers: false, + keyGenerator: (req) => requestIp.getClientIp(req) +}) + +router.use(cors()) +router.get('/', (_, res) => { + res.redirect('/configure') + res.end(); +}); + +router.get(`/:preconfiguration(${Object.keys(PreConfigurations).join('|')})`, (req: Request, res: Response) => { + res.redirect(`/${req.params.preconfiguration}/configure`) + res.end(); +}); + +router.get('/:configuration?/configure', (req: Request, res: Response) => { + const configValues = parseConfiguration(req.params.configuration || ''); + const landingHTML = landingTemplate(manifest(configValues), configValues); + res.setHeader('content-type', 'text/html'); + res.end(landingHTML); +}); + +router.get('/:configuration?/manifeston', (req: Request, res: Response) => { + const configValues = parseConfiguration(req.params.configuration || ''); + const manifestBuf = JSON.stringify(manifest(configValues)); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(manifestBuf) +}); + +router.get('/:configuration?/:resource/:type/:id/:extra?on', limiter, (req: Request, res: Response, next: NextFunction) => { + const { configuration, resource, type, id } = req.params; + const extra = req.params.extra ? qs.parse(req.url.split('/').pop().slice(0, -5)) : {} + const ip = requestIp.getClientIp(req); + const host = `${req.protocol}://${req.headers.host}`; + const configValues = { ...extra, ...parseConfiguration(configuration), id, type, ip, host }; + addonInterface.get(resource, type, id, configValues) + .then(resp => { + const cacheHeaders = { + cacheMaxAge: 'max-age', + staleRevalidate: 'stale-while-revalidate', + staleError: 'stale-if-error' + }; + const cacheControl = Object.keys(cacheHeaders) + .map(prop => Number.isInteger(resp[prop]) && cacheHeaders[prop] + '=' + resp[prop]) + .filter(val => !!val).join(', '); + + res.setHeader('Cache-Control', `${cacheControl}, public`); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(resp)); + }) + .catch(err => { + if (err.noHandler) { + if (next) { + next() + } else { + res.writeHead(404); + res.end(JSON.stringify({ err: 'not found' })); + } + } else { + console.error(err); + res.writeHead(500); + res.end(JSON.stringify({ err: 'handler error' })); + } + }); +}); + +router.get('/:moch/:apiKey/:infoHash/:cachedEntryInfo/:fileIndex/:filename?', (req: Request, res: Response) => { + const userAgent = req.headers['user-agent'] || ''; + const parameters = { + mochKey: req.params.moch, + apiKey: req.params.apiKey, + infoHash: req.params.infoHash.toLowerCase(), + fileIndex: isNaN(req.params.fileIndex) ? undefined : parseInt(req.params.fileIndex), + cachedEntryInfo: req.params.cachedEntryInfo, + ip: requestIp.getClientIp(req), + host: `${req.protocol}://${req.headers.host}`, + isBrowser: !userAgent.includes('Stremio') && !!userAgentParser(userAgent).browser.name + } + moch.resolve(parameters) + .then(url => { + res.writeHead(302, { Location: url }); + res.end(); + }) + .catch(error => { + console.log(error); + res.statusCode = 404; + res.end(); + }); +}); + +export default function (req: Request, res: Response) { + router(req, res, function () { + res.statusCode = 404; + res.end(); + }); +}; diff --git a/src/lib/addon/static/videos/blocked_access_v1.mp4 b/src/lib/addon/static/videos/blocked_access_v1.mp4 new file mode 100644 index 0000000..ed72c9a Binary files /dev/null and b/src/lib/addon/static/videos/blocked_access_v1.mp4 differ diff --git a/src/lib/addon/static/videos/download_failed_v2.mp4 b/src/lib/addon/static/videos/download_failed_v2.mp4 new file mode 100644 index 0000000..0240e64 Binary files /dev/null and b/src/lib/addon/static/videos/download_failed_v2.mp4 differ diff --git a/src/lib/addon/static/videos/downloading_v2.mp4 b/src/lib/addon/static/videos/downloading_v2.mp4 new file mode 100644 index 0000000..c0e29e8 Binary files /dev/null and b/src/lib/addon/static/videos/downloading_v2.mp4 differ diff --git a/src/lib/addon/static/videos/failed_access_v2.mp4 b/src/lib/addon/static/videos/failed_access_v2.mp4 new file mode 100644 index 0000000..f5b3c28 Binary files /dev/null and b/src/lib/addon/static/videos/failed_access_v2.mp4 differ diff --git a/src/lib/addon/static/videos/failed_infringement_v2.mp4 b/src/lib/addon/static/videos/failed_infringement_v2.mp4 new file mode 100644 index 0000000..c538fc0 Binary files /dev/null and b/src/lib/addon/static/videos/failed_infringement_v2.mp4 differ diff --git a/src/lib/addon/static/videos/failed_opening_v2.mp4 b/src/lib/addon/static/videos/failed_opening_v2.mp4 new file mode 100644 index 0000000..d12247e Binary files /dev/null and b/src/lib/addon/static/videos/failed_opening_v2.mp4 differ diff --git a/src/lib/addon/static/videos/failed_rar_v2.mp4 b/src/lib/addon/static/videos/failed_rar_v2.mp4 new file mode 100644 index 0000000..8bd04cb Binary files /dev/null and b/src/lib/addon/static/videos/failed_rar_v2.mp4 differ diff --git a/src/lib/addon/static/videos/failed_too_big_v1.mp4 b/src/lib/addon/static/videos/failed_too_big_v1.mp4 new file mode 100644 index 0000000..a479712 Binary files /dev/null and b/src/lib/addon/static/videos/failed_too_big_v1.mp4 differ diff --git a/src/lib/addon/static/videos/failed_unexpected_v2.mp4 b/src/lib/addon/static/videos/failed_unexpected_v2.mp4 new file mode 100644 index 0000000..3ca2b3a Binary files /dev/null and b/src/lib/addon/static/videos/failed_unexpected_v2.mp4 differ diff --git a/src/lib/addon/static/videos/limits_exceeded_v1.mp4 b/src/lib/addon/static/videos/limits_exceeded_v1.mp4 new file mode 100644 index 0000000..3e80a1e Binary files /dev/null and b/src/lib/addon/static/videos/limits_exceeded_v1.mp4 differ diff --git a/src/lib/catalogs/.dockerignore b/src/lib/catalogs/.dockerignore new file mode 100644 index 0000000..5538a38 --- /dev/null +++ b/src/lib/catalogs/.dockerignore @@ -0,0 +1,3 @@ +**/node_modules +**/npm-debug.log +**/.env \ No newline at end of file diff --git a/src/lib/catalogs/Dockerfile b/src/lib/catalogs/Dockerfile new file mode 100644 index 0000000..57cb66b --- /dev/null +++ b/src/lib/catalogs/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +RUN apk update && apk upgrade && \ + apk add --no-cache git + +WORKDIR /home/node/app + +COPY ./catalogs . +COPY ./addon ../addon +RUN npm ci --only-production + +CMD [ "node", "index.js" ] diff --git a/src/lib/catalogs/addon.ts b/src/lib/catalogs/addon.ts new file mode 100644 index 0000000..e9e45be --- /dev/null +++ b/src/lib/catalogs/addon.ts @@ -0,0 +1,249 @@ +import Bottleneck from 'bottleneck'; +import moment from 'moment'; +import { addonBuilder, Manifest, ManifestCatalog } from 'stremio-addon-sdk'; +import { Providers } from '../addon/lib/filter.js'; +import { createManifest, genres } from './lib/manifest.js'; +import { getMetas } from './lib/metadata.js'; +import { cacheWrapCatalog, cacheWrapIds } from './lib/cache.js'; +import * as repository from './lib/repository.js'; +import { getCacheMaxAge } from '../addon/lib/cache.js' + +const CACHE_MAX_AGE = getCacheMaxAge(); // 4 hours in seconds +const STALE_REVALIDATE_AGE = 4 * 60 * 60; // 4 hours +const STALE_ERROR_AGE = 7 * 24 * 60 * 60; // 7 days + +/** + * Returns the max concurrency for the Bottleneck limiter. + * This is the max number of concurrent requests that the limiter will allow. + * If the LIMIT_MAX_CONCURRENT environment variable is set, it will be used. + * Otherwise, the default value is 20. + * @returns {number} The max concurrency. + */ +function getConcurrency(): number { + const val = process.env.LIMIT_MAX_CONCURRENT; + + if (val) { + return parseInt(val, 10); + } + + return 20; +} + +/** + * Returns the queue size for the Bottleneck limiter. + * Defaults to 50 if LIMIT_QUEUE_SIZE environment variable is not set. + * @returns {number} The queue size. + */ +function getQueueSize(): number { + const val = process.env.LIMIT_QUEUE_SIZE; + + if (val) { + return parseInt(val, 10); + } + + return 50; +} + +interface ICreateCacheKey { + catalogId: string; + providers: string[]; + genre: string; + offset: number +} + +/** + * Creates a unique cache key for a given catalog and its options. + * @param {ICreateCacheKey} params - The id of the catalog. + * @param {string} params.catalogId - The id of the catalog. + * @param {MediaProvider[]} params.providers - The list of providers. + * @param {string} params.genre - The genre or category of the catalog. + * @param {number} params.offset - The offset of the current page. + * @returns {string} A unique cache key. + */ +function createCacheKey({ + catalogId, + providers, + genre, + offset +}: ICreateCacheKey): string { + const dateKey = moment().format('YYYY-MM-DD'); + return [catalogId, providers.join(','), genre, dateKey, offset].filter(x => x !== undefined).join('|'); +} + +const manifest: Manifest = createManifest(); +const builder = new addonBuilder(manifest); +const limiter = new Bottleneck({ + maxConcurrent: getConcurrency(), + highWater: getQueueSize(), + strategy: Bottleneck.strategy.OVERFLOW +}); +const defaultProviders = Providers.options + .filter(provider => !provider.foreign) + .map(provider => provider.label) + .sort(); + +interface IGetCatalog { + catalog: ManifestCatalog; + providers: string[]; + genre: string; + offset: number; +} + +/** + * Retrieves the cursor for a given catalog, providers, genre, and offset. + * The cursor is used to track the last item retrieved from the previous page. + * Returns undefined if the offset is 0, indicating the first page. + * + * @param {Catalog} catalog - The catalog to retrieve the cursor for. + * @param {MediaProvider[]} providers - The list of media providers. + * @param {string} genre - The genre or category. + * @param {number} offset - The offset of the current page. + * @returns {Promise} A promise resolving to the cursor ID or undefined. + */ +function getCursor({ catalog, providers, genre, offset }: IGetCatalog) { + if (offset === 0) { + return undefined; + } + const previousCacheKey = createCacheKey({ + catalogId: catalog.id, + providers, + genre, + offset + }); + return cacheWrapCatalog({ + key: previousCacheKey, + method: () => Promise.reject(new Error("cursor not found")) + }) + .then((metas) => metas[metas.length - 1]) + .then((meta) => meta.id.replace('kitsu:', '')) +} + + + +/** + * Returns the start date of a given genre or category. + * @param {string} genre - The genre or category. + * @returns {moment.Moment | undefined} The start date of the given genre or undefined if not found. + */ +function getStartDate(genre: string): moment.Moment | undefined { + switch (genre) { + case genres[0]: return moment().utc().subtract(1, 'day').startOf('day'); + case genres[1]: return moment().utc().startOf('isoWeek'); + case genres[2]: return moment().utc().subtract(7, 'day').startOf('isoWeek'); + case genres[3]: return moment().utc().startOf('month'); + case genres[4]: return moment().utc().subtract(30, 'day').startOf('month'); + case genres[5]: return undefined; + default: return moment().utc().subtract(30, 'day').startOf('day'); + } +} + +/** + * Returns the end date of a given genre or category. + * @param {string} genre - The genre or category. + * @returns {moment.Moment | undefined} The end date of the given genre or undefined if not found. + */ + +function getEndDate(genre: string): moment.Moment | undefined { + switch (genre) { + case genres[0]: return moment().utc().subtract(1, 'day').endOf('day'); + case genres[1]: return moment().utc().endOf('isoWeek'); + case genres[2]: return moment().utc().subtract(7, 'day').endOf('isoWeek'); + case genres[3]: return moment().utc().endOf('month'); + case genres[4]: return moment().utc().subtract(30, 'day').endOf('month'); + case genres[5]: return undefined; + default: return moment().utc().subtract(1, 'day').endOf('day'); + } +} + + + + +/** + * Retrieves a page of metas for a given catalog and genre. + * @param {Catalog} catalog - The catalog to retrieve. + * @param {MediaProvider[]} providers - The list of providers. + * @param {string} genre - The genre or category. + * @param {number} offset - The offset of the current page. + * @returns {Promise} A promise resolving to an array of Stream objects. + */ +async function getCatalog({ + catalog, + providers, + genre, + offset +}: IGetCatalog): Promise { + const cursor = await getCursor({ + catalog, + providers, + genre, + offset + }); + + const startDate = getStartDate(genre)?.toISOString(); + const endDate = getEndDate(genre)?.toISOString(); + + const cacheKey = createCacheKey({ + catalogId: catalog.id, + providers, + genre, + offset + }); + + return cacheWrapIds({ + key: cacheKey, + method: () => repository.getIds(providers, catalog.type, startDate, endDate) + }) + .then((ids) => ids.slice(ids.indexOf(cursor) + 1)) + .then((ids) => getMetas(ids, catalog.type)); +} + +builder.defineCatalogHandler(async (args) => { + const offset = parseInt(args.extra.skip.toString() || '0', 10); + const genre = args.extra.genre || 'default'; + const catalog = manifest.catalogs.find(c => c.id === args.id); + const providers = defaultProviders; + + console.log(`Incoming catalog ${args.id} request with genre=${genre} and skip=${offset}`); + + if (!catalog) { + return Promise.reject(new Error(`No catalog found for with id: ${args.id}`)); + } + + const cacheKey = createCacheKey({ + catalogId: catalog.id, + providers, + genre, + offset + }); + + + try { + const metas = await limiter.schedule(() => cacheWrapCatalog({ + key: cacheKey, + method: () => getCatalog({ + catalog, + providers, + genre, + offset + }) + })); + + return ({ + metas, + cacheMaxAge: CACHE_MAX_AGE, + staleRevalidate: STALE_REVALIDATE_AGE, + staleError: STALE_ERROR_AGE + }); + + } catch (error) { + if (error instanceof Error) { + return await Promise.reject(new Error(`Failed retrieving catalog ${args.id}: ${error.message}`)); + } + + return await Promise.reject(new Error(`Failed retrieving catalog ${args.id}: ${error}`)); + } +}) + + + +export default builder.getInterface(); diff --git a/src/lib/catalogs/eslint.config.mjs b/src/lib/catalogs/eslint.config.mjs new file mode 100644 index 0000000..502ada2 --- /dev/null +++ b/src/lib/catalogs/eslint.config.mjs @@ -0,0 +1,62 @@ +import globals from 'globals'; +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import tseslint from 'typescript-eslint'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + js.configs.all, + ...tseslint.configs.recommended, + prettier, + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + } + }, + { + files: ['**/*.ts'], + languageOptions: { + parser: tseslint.parser + }, + plugins: { + ...tseslint.configs.recommended, + } + }, + { + rules: { + camelcase: 'off', + 'max-lines-per-function': 'off', + 'max-statements': 'off', + 'new-cap': 'off', + 'no-magic-numbers': 'off', + 'one-var': 'off', + 'id-length': 'off', + 'sort-vars': 'off', + 'max-classes-per-file': 'off', + 'func-style': 'off', + 'no-ternary': 'off', + 'sort-imports': 'off', + 'sort-keys': 'off', + 'max-lines': 'off', + 'no-undefined': 'off', + complexity: 'warn', + 'no-plusplus': 'off', + 'prefer-const': 'off', + 'prefer-destructuring': 'off', + 'require-atomic-updates': 'off', + 'capitalized-comments': 'off', + 'no-await-in-loop': 'off', + 'no-undef-init': 'off', + 'init-declarations': 'off', + 'dot-notation': 'off', + 'no-console': 'off', + 'no-inline-comments': 'off' + } + }, + { + ignores: ['build/', 'dist/', 'express/dist', 'node_modules/', 'src/lib/api-spec.ts'] + } +]; diff --git a/src/lib/catalogs/index.ts b/src/lib/catalogs/index.ts new file mode 100644 index 0000000..ad7c20a --- /dev/null +++ b/src/lib/catalogs/index.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import serverless from './serverless.js'; + +const app = express(); + +app.use((req, res) => serverless(req, res)); +app.listen(process.env.PORT ?? 7000, () => { + console.log(`Started addon at: http://localhost:${process.env.PORT || 7000}`); +}); diff --git a/src/lib/catalogs/lib/cache.ts b/src/lib/catalogs/lib/cache.ts new file mode 100644 index 0000000..1ea9e30 --- /dev/null +++ b/src/lib/catalogs/lib/cache.ts @@ -0,0 +1,95 @@ +import KeyvMongo from "@keyv/mongo"; + +const CATALOG_TTL = 24 * 60 * 60 * 1000; // 24 hours + +const MONGO_URI = process.env.MONGODB_URI; + +/** + * Returns the remote cache instance. + * If the MONGO_URI environment variable is not set, will return undefined. + * @returns {KeyvMongo | undefined} + */ +function getRemoteCache(): KeyvMongo | undefined { + if (MONGO_URI) { + return new KeyvMongo(MONGO_URI, { collection: 'torrentio_catalog_collection' }); + } +} + +type Method = () => Promise; + +interface ICacheWrap { + key: string; + method: Method; + ttl: number; +} + +/** + * Wraps a method with caching. If the cache is not set up, will simply call the method. + * Otherwise, will check the cache for the given key. If the key is not present, will call the method, + * cache the result, and return it. If the key is present, will return the cached value. + * + * @param {ICacheWrap} - An object with the following properties: + * - key: The key to use for caching. + * - method: The method to call if the cache doesn't have the key. + * - ttl: The time to live for the cache entry. + * @returns {Promise} The cached or newly retrieved value. + */ +async function cacheWrap({ key, method, ttl }: ICacheWrap): Promise { + const cache = getRemoteCache(); + + if (!cache) { + return method(); + } + + const value = await cache.get(key); + if (value !== undefined) { + return value; + } + + const result = await method(); + + await cache.set(key, result, ttl); + + return method(); +} + +/** + * Wraps a method with caching. If the cache is not set up, will simply call the method. + * Otherwise, will check the cache for the given key. If the key is not present, will call the method, + * cache the result, and return it. If the key is present, will return the cached value. + * + * @param {string} key - The key to use for caching. + * @param {Method} method - The method to call if the cache doesn't have the key. + * @returns {Promise} The cached or newly retrieved value. + */ +export function cacheWrapCatalog({ key, method }: { + key: string; + method: Method; +}): Promise { + return cacheWrap({ + key, + method, + ttl: CATALOG_TTL + }); +} + +/** + * Wraps a method with caching, like cacheWrapCatalog, but prefixes the cache key with 'ids|'. + * This is used to cache the list of IDs for a given catalog, so that we can avoid calling the + * repository's getIds method over and over again. + * + * @param {string} key - The key to use for caching. + * @param {Method} method - The method to call if the cache doesn't have the key. + * @returns {Promise} The cached or newly retrieved value. + */ +export function cacheWrapIds({ key, method }: { + key: string; + method: Method; +}): Promise { + + return cacheWrap({ + key: `ids|${key}`, + method, + ttl: CATALOG_TTL + }); +} diff --git a/src/lib/catalogs/lib/landingTemplate.ts b/src/lib/catalogs/lib/landingTemplate.ts new file mode 100644 index 0000000..14d7953 --- /dev/null +++ b/src/lib/catalogs/lib/landingTemplate.ts @@ -0,0 +1,274 @@ +const STYLESHEET = ` +* { + box-sizing: border-box; +} + +body, +html { + margin: 0; + padding: 0; + width: 100%; + height: 100% +} + +html { + background-size: auto 100%; + background-size: cover; + background-position: center center; + background-repeat: repeat-y; +} + +body { + display: flex; + background-color: transparent; + font-family: 'Open Sans', Arial, sans-serif; + color: white; +} + +h1 { + font-size: 4.5vh; + font-weight: 700; +} + +h2 { + font-size: 2.2vh; + font-weight: normal; + font-style: italic; + opacity: 0.8; +} + +h3 { + font-size: 2.2vh; +} + +h1, +h2, +h3, +p, +label { + margin: 0; + text-shadow: 0 0 1vh rgba(0, 0, 0, 0.15); +} + +p { + font-size: 1.75vh; +} + +ul { + font-size: 1.75vh; + margin: 0; + margin-top: 1vh; + padding-left: 3vh; +} + +a { + color: green +} + +a.install-link { + text-decoration: none +} + +button { + border: 0; + outline: 0; + color: white; + background: #8A5AAB; + padding: 1.2vh 3.5vh; + margin: auto; + text-align: center; + font-family: 'Open Sans', Arial, sans-serif; + font-size: 2.2vh; + font-weight: 600; + cursor: pointer; + display: block; + box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2); + transition: box-shadow 0.1s ease-in-out; +} + +button:hover { + box-shadow: none; +} + +button:active { + box-shadow: 0 0 0 0.5vh white inset; +} + +#addon { + width: 90vh; + margin: auto; + padding-left: 10%; + padding-right: 10%; + background: rgba(0, 0, 0, 0.60); +} + +.logo { + height: 14vh; + width: 14vh; + margin: auto; + margin-bottom: 3vh; +} + +.logo img { + width: 100%; +} + +.name, .version { + display: inline-block; + vertical-align: top; +} + +.name { + line-height: 5vh; +} + +.version { + position: absolute; + line-height: 5vh; + margin-left: 1vh; + opacity: 0.8; +} + +.contact { + position: absolute; + left: 0; + bottom: 4vh; + width: 100%; + text-align: center; +} + +.contact a { + font-size: 1.4vh; + font-style: italic; +} + +.separator { + margin-bottom: 4vh; +} + +.label { + font-size: 2.2vh; + font-weight: 600; + padding: 0; + line-height: inherit; +} + +.btn-group, .multiselect-container { + width: 100%; +} + +.btn { + text-align: left; +} + +.multiselect-container { + border: 0; + border-radius: 0; +} + +.input, .btn { + height: 3.8vh; + width: 100%; + margin: auto; + margin-bottom: 10px; + padding: 6px 12px; + border: 0; + border-radius: 0; + outline: 0; + color: #333; + background-color: rgb(255, 255, 255); + box-shadow: 0 0.5vh 1vh rgba(0, 0, 0, 0.2); +} +`; +import { Providers } from '../../addon/lib/filter.js'; + +export default function landingTemplate(manifest: { background: string; logo: string; contactEmail: any; name: any; types: any[]; version: any; description: any; }, config: { providers: never[]; }) { + const providers = config.providers || []; + + const background = manifest.background || 'https://dl.strem.io/addon-background.jpg'; + const logo = manifest.logo || 'https://dl.strem.io/addon-logo.png'; + const contactHTML = manifest.contactEmail ? + `
+

Contact ${manifest.name} creator:

+ ${manifest.contactEmail} +
` : '
'; + const providersHTML = Providers.options + .map(provider => ``) + .join('\n'); + const stylizedTypes = manifest.types + .map(t => t[0].toUpperCase() + t.slice(1) + (t !== 'series' ? 's' : '')); + + return ` + + + + + + ${manifest.name} - Stremio Addon + + + + + + + + + + + + +
+ +

${manifest.name}

+

${manifest.version || '0.0.0'}

+

${manifest.description || ''}

+ +
+ +

This addon has more :

+
    + ${stylizedTypes.map(t => `
  • ${t}
  • `).join('')} +
+ +
+ + + + +
+ + + + + ${contactHTML} +
+ + + + ` +} diff --git a/src/lib/catalogs/lib/manifest.ts b/src/lib/catalogs/lib/manifest.ts new file mode 100644 index 0000000..a444b66 --- /dev/null +++ b/src/lib/catalogs/lib/manifest.ts @@ -0,0 +1,45 @@ +import { Manifest } from 'stremio-addon-sdk'; +import { Type } from '../../addon/lib/types.js'; + +export const genres = [ + 'Yesterday', + 'This Week', + 'Last Week', + 'This Month', + 'Last Month', + 'All Time' +] + +export function createManifest(config?: Manifest): Manifest { + return { + id: config?.id ?? 'com.stremio.torrentio.catalog.addon', + version: config?.version ?? '1.0.2', + name: config?.name ?? 'Torrent Catalogs', + description: config?.description ?? 'Provides catalogs for movies/series/anime based on top seeded torrents. Requires Kitsu addon for anime.', + logo: config?.logo ?? `https://i.ibb.co/w4BnkC9/GwxAcDV.png`, + background: config?.background ?? `https://i.ibb.co/VtSfFP9/t8wVwcg.jpg`, + types: config?.types ?? [Type.MOVIE, Type.SERIES], + resources: config?.resources ?? ['catalog'], + catalogs: config?.catalogs ?? [ + { + id: 'top-movies', + type: Type.MOVIE, + name: "Top seeded", + extra: [{ name: 'genre', options: genres }, { name: 'skip' }], + genres + }, + { + id: 'top-series', + type: Type.SERIES, + name: "Top seeded", + extra: [{ name: 'genre', options: genres }, { name: 'skip' }], + genres + } + ], + behaviorHints: config?.behaviorHints ?? { + // @TODO might enable configuration to configure providers + configurable: false, + configurationRequired: false + } + }; +} diff --git a/src/lib/catalogs/lib/metadata.ts b/src/lib/catalogs/lib/metadata.ts new file mode 100644 index 0000000..d02b27e --- /dev/null +++ b/src/lib/catalogs/lib/metadata.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; +import { Type } from '../../addon/lib/types.js'; + +const CINEMETA_URL = 'https://v3-cinemeta.strem.io'; +const KITSU_URL = 'https://anime-kitsu.strem.fun'; +const TIMEOUT = 30000; +const MAX_SIZE = 40; + +export async function getMetas(ids: string[], type: any) { + if (!ids.length || !type) { + return []; + } + + return _requestMetadata(ids, type) + .catch((error) => { + throw new Error(`failed metadata ${type} query due: ${error.message}`); + }); +} + +async function _requestMetadata(ids: string[], type: any) { + const url = _getUrl(ids, type); + const response = await axios.get(url, { timeout: TIMEOUT }); + const metas = response?.data?.metas || response?.data?.metasDetailed || []; + const metas_1 = metas.filter((meta: any) => meta); + return metas_1.map((meta_1: { videos: any; credits_cast: any; credits_crew: any; }) => _sanitizeMeta(meta_1)); +} + +function _getUrl(ids: string[], type: string) { + const joinedIds = ids.slice(0, MAX_SIZE).join(','); + if (type === Type.ANIME) { + return `${KITSU_URL}/catalog/${type}/kitsu-anime-list/lastVideosIds=${joinedIds}.json`; + } + return `${CINEMETA_URL}/catalog/${type}/last-videos/lastVideosIds=${joinedIds}.json`; +} + +function _sanitizeMeta(meta: { videos: any; credits_cast: any; credits_crew: any; }) { + delete meta.videos; + delete meta.credits_cast; + delete meta.credits_crew; + return meta; +} diff --git a/src/lib/catalogs/lib/repository.ts b/src/lib/catalogs/lib/repository.ts new file mode 100644 index 0000000..edfd4bd --- /dev/null +++ b/src/lib/catalogs/lib/repository.ts @@ -0,0 +1,59 @@ +import { Sequelize, QueryTypes } from 'sequelize'; +import { Type } from '../../addon/lib/types.js'; +import { ContentType } from 'stremio-addon-sdk'; + +const DATABASE_URI = process.env.DATABASE_URI; + +if (!DATABASE_URI) { + throw new Error('Missing database URI'); +} + +const database = new Sequelize(DATABASE_URI, { logging: false }); + +/** + * Retrieves a list of unique identifiers (IDs) for media content based on specified criteria. + * The function queries a database to find IDs of files associated with torrents that meet the given + * conditions such as content type, providers, date range, and other filters. + * + * @param {string[]} providers - An array of provider names to filter the results by. + * @param {ContentType} type - The type of content to filter (e.g., movie, series). + * @param {string} [startDate] - The start date for filtering based on the upload date of the torrents. + * @param {string} [endDate] - The end date for filtering based on the upload date of the torrents. + * @returns {Promise} A promise that resolves to an array of IDs that match the query conditions. + */ + +export async function getIds(providers: string[], type: ContentType, startDate?: string, endDate?: string): Promise { + const idName = 'imdbId'; + + const episodeCondition = type === Type.SERIES + ? 'AND files."imdbSeason" IS NOT NULL AND files."imdbEpisode" IS NOT NULL' + : ''; + + const dateCondition = startDate && endDate + ? `AND "uploadDate" BETWEEN '${startDate}' AND '${endDate}'` + : ''; + + const providersCondition = providers && providers.length + ? `AND provider in (${providers.map(it => `'${it}'`).join(',')})` + : ''; + + const titleCondition = type === Type.MOVIE + ? 'AND torrents.title NOT LIKE \'%[Erotic]%\'' + : ''; + + const sortCondition = type === Type.MOVIE ? 'sum(torrents.seeders)' : 'max(torrents.seeders)'; + + const query = `SELECT files."${idName}" + FROM (SELECT torrents."infoHash", torrents.seeders FROM torrents + WHERE seeders > 0 AND type = '${type}' ${providersCondition} ${dateCondition} ${titleCondition} + ) as torrents + JOIN files ON torrents."infoHash" = files."infoHash" + WHERE files."${idName}" IS NOT NULL ${episodeCondition} + GROUP BY files."${idName}" + ORDER BY ${sortCondition} DESC + LIMIT 5000` + + const results = await database.query(query, { type: QueryTypes.SELECT }); + + return results.map(result => `${result.imdbId}`); +} diff --git a/src/lib/catalogs/serverless.ts b/src/lib/catalogs/serverless.ts new file mode 100644 index 0000000..4be75bd --- /dev/null +++ b/src/lib/catalogs/serverless.ts @@ -0,0 +1,85 @@ +import { getRouter } from 'stremio-addon-sdk'; +import addonInterface from './addon'; +import qs from 'querystring'; +import { parseConfiguration } from '../addon/lib/configuration'; +import { createManifest } from './lib/manifest'; +import { NextFunction, Request, Response } from 'express'; + +const router = getRouter(addonInterface); + +router.get('/:configuration?/manifest.json', (req: Request, res: Response) => { + const configValues = parseConfiguration(req.params.configuration || ''); + const manifestBuf = JSON.stringify(createManifest(configValues)); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(manifestBuf) +}); + +router.get('/:configuration/:resource/:type/:id/:extra?.json', (req: Request, res: Response, next: NextFunction) => { + const { configuration, resource, type, id } = req.params; + + const reqUrl = req.url; + + if (!reqUrl) { + throw new Error('No request URL found.'); + } + + const reqUrls = reqUrl.split('/'); + + if (reqUrls.length === 0) { + throw new Error('No request URL found.') + } + + const popedUrls = reqUrls.pop(); + + if (!popedUrls) { + throw new Error('No request URL found.'); + } + + const extra = req.params.extra ? qs.parse(popedUrls.slice(0, -5)) : {}; + + const configValues = { ...extra, ...parseConfiguration(configuration) }; + + addonInterface.get(resource, type, id, configValues) + .then(resp => { + const cacheHeaders = { + cacheMaxAge: 'max-age', + staleRevalidate: 'stale-while-revalidate', + staleError: 'stale-if-error' + }; + + const cacheControl = Object.keys(cacheHeaders) + .map((prop) => Number.isInteger(resp[prop]) && cacheHeaders[prop] + '=' + resp[prop]) + .filter((val) => Boolean(val)).join(', '); + + res.setHeader('Cache-Control', `${cacheControl}, public`); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(resp)); + }) + .catch(err => { + if (err.noHandler) { + if (next) { + next() + } else { + res.writeHead(404); + res.end(JSON.stringify({ err: 'not found' })); + } + } else { + console.error(err); + res.writeHead(500); + res.end(JSON.stringify({ err: 'handler error' })); + } + }); +}); + +/** + * Express route handler for the serverless catalog addon. + * + * @param {Request} req Express request object + * @param {Response} res Express response object + */ +export default function run(req: Request, res: Response) { + router(req, res, () => { + res.statusCode = 404; + res.end(); + }); +};