diff --git a/addon/addon.js b/addon/addon.js index 6f4253b..fd4eaf3 100644 --- a/addon/addon.js +++ b/addon/addon.js @@ -38,9 +38,9 @@ builder.defineStreamHandler((args) => { }); builder.defineCatalogHandler((args) => { - const mochKey = args.id.replace("torrentio-", ''); + const [_, mochKey, catalogId] = args.id.split('-'); console.log(`Incoming catalog ${args.id} request with skip=${args.extra.skip || 0}`) - return getMochCatalog(mochKey, args.extra) + return getMochCatalog(mochKey, catalogId, args.extra) .then(metas => ({ metas: metas, cacheMaxAge: CATALOG_CACHE_MAX_AGE diff --git a/addon/lib/landingTemplate.js b/addon/lib/landingTemplate.js index ba98f9f..59206d7 100644 --- a/addon/lib/landingTemplate.js +++ b/addon/lib/landingTemplate.js @@ -206,6 +206,7 @@ export default function landingTemplate(manifest, config = {}) { 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(/.*@/, ''); @@ -330,6 +331,11 @@ export default function landingTemplate(manifest, config = {}) { +
+ + +
+
@@ -396,6 +402,7 @@ export default function landingTemplate(manifest, config = {}) { $('#iAllDebrid').val("${allDebridApiKey}"); $('#iDebridLink').val("${debridLinkApiKey}"); $('#iOffcloud').val("${offcloudApiKey}"); + $('#iTorbox').val("${torboxApiKey}"); $('#iPutioClientId').val("${putioClientId}"); $('#iPutioToken').val("${putioToken}"); $('#iSort').val("${sort}"); @@ -422,6 +429,7 @@ export default function landingTemplate(manifest, config = {}) { $('#dAllDebrid').toggle(provider === '${MochOptions.alldebrid.key}'); $('#dDebridLink').toggle(provider === '${MochOptions.debridlink.key}'); $('#dOffcloud').toggle(provider === '${MochOptions.offcloud.key}'); + $('#dTorbox').toggle(provider === '${MochOptions.torbox.key}'); $('#dPutio').toggle(provider === '${MochOptions.putio.key}'); } @@ -439,7 +447,8 @@ export default function landingTemplate(manifest, config = {}) { const allDebridValue = $('#iAllDebrid').val() || ''; const debridLinkValue = $('#iDebridLink').val() || '' const premiumizeValue = $('#iPremiumize').val() || ''; - const offcloudValue = $('#iOffcloud').val() || '' + const offcloudValue = $('#iOffcloud').val() || ''; + const torboxValue = $('#iTorbox').val() || ''; const putioClientIdValue = $('#iPutioClientId').val() || ''; const putioTokenValue = $('#iPutioToken').val() || ''; @@ -457,6 +466,7 @@ export default function landingTemplate(manifest, config = {}) { const allDebrid = allDebridValue.length && allDebridValue.trim(); const debridLink = debridLinkValue.length && debridLinkValue.trim(); const offcloud = offcloudValue.length && offcloudValue.trim(); + const torbox = torboxValue.length && torboxValue.trim(); const putio = putioClientIdValue.length && putioTokenValue.length && putioClientIdValue.trim() + '@' + putioTokenValue.trim(); const preConfigurations = { @@ -475,6 +485,7 @@ export default function landingTemplate(manifest, config = {}) { ['${MochOptions.alldebrid.key}', allDebrid], ['${MochOptions.debridlink.key}', debridLink], ['${MochOptions.offcloud.key}', offcloud], + ['${MochOptions.torbox.key}', torbox], ['${MochOptions.putio.key}', putio] ].filter(([_, value]) => value.length).map(([key, value]) => key + '=' + value).join('|'); configurationValue = Object.entries(preConfigurations) diff --git a/addon/lib/manifest.js b/addon/lib/manifest.js index c12e64b..4141f4d 100644 --- a/addon/lib/manifest.js +++ b/addon/lib/manifest.js @@ -5,7 +5,7 @@ import { getManifestOverride } from './configuration.js'; import { Type } from './types.js'; const DefaultProviders = Providers.options.map(provider => provider.key); -const CatalogMochs = Object.values(MochOptions).filter(moch => moch.catalog); +const MochProviders = Object.values(MochOptions); export function manifest(config = {}) { const overrideManifest = getManifestOverride(config); @@ -36,7 +36,7 @@ export function dummyManifest() { function getName(manifest, config) { const rootName = manifest?.name || 'Torrentio'; - const mochSuffix = Object.values(MochOptions) + const mochSuffix = MochProviders .filter(moch => config[moch.key]) .map(moch => moch.shortName) .join('/'); @@ -48,11 +48,11 @@ function getDescription(config) { const enabledProvidersDesc = Providers.options .map(provider => `${provider.label}${providersList.includes(provider.key) ? '(+)' : '(-)'}`) .join(', ') - const enabledMochs = Object.values(MochOptions) + const enabledMochs = MochProviders .filter(moch => config[moch.key]) .map(moch => moch.name) .join(' & '); - const possibleMochs = Object.values(MochOptions).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}.` @@ -60,14 +60,15 @@ function getDescription(config) { } function getCatalogs(config) { - return CatalogMochs + return MochProviders .filter(moch => showDebridCatalog(config) && config[moch.key]) - .map(moch => ({ - id: `torrentio-${moch.key}`, - name: `${moch.name}`, + .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) { @@ -79,9 +80,9 @@ function getResources(config) { const metaResource = { name: 'meta', types: [Type.OTHER], - idPrefixes: CatalogMochs.filter(moch => config[moch.key]).map(moch => moch.key) + idPrefixes: MochProviders.filter(moch => config[moch.key]).map(moch => moch.key) }; - if (showDebridCatalog(config) && CatalogMochs.filter(moch => config[moch.key]).length) { + if (showDebridCatalog(config) && MochProviders.filter(moch => config[moch.key]).length) { return [streamResource, metaResource]; } return [streamResource]; diff --git a/addon/moch/alldebrid.js b/addon/moch/alldebrid.js index ba33225..10d0e8e 100644 --- a/addon/moch/alldebrid.js +++ b/addon/moch/alldebrid.js @@ -20,11 +20,11 @@ export async function getCachedStreams(streams, apiKey, ip) { }, {}) } -export async function getCatalog(apiKey, offset = 0, ip) { - if (offset > 0) { +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { return []; } - const options = await getDefaultOptions(ip); + const options = await getDefaultOptions(config.ip); const AD = new AllDebridClient(apiKey, options); return AD.magnet.status() .then(response => response.data.magnets) diff --git a/addon/moch/debridlink.js b/addon/moch/debridlink.js index b6f1e92..9a6b852 100644 --- a/addon/moch/debridlink.js +++ b/addon/moch/debridlink.js @@ -18,8 +18,8 @@ export async function getCachedStreams(streams, apiKey) { }, {}) } -export async function getCatalog(apiKey, offset = 0) { - if (offset > 0) { +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { return []; } const options = await getDefaultOptions(); diff --git a/addon/moch/moch.js b/addon/moch/moch.js index dd82be6..e64b993 100644 --- a/addon/moch/moch.js +++ b/addon/moch/moch.js @@ -1,10 +1,10 @@ -import namedQueue from 'named-queue'; 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'; @@ -21,42 +21,49 @@ export const MochOptions = { instance: realdebrid, name: "RealDebrid", shortName: 'RD', - catalog: true + catalogs: [''] }, premiumize: { key: 'premiumize', instance: premiumize, name: 'Premiumize', shortName: 'PM', - catalog: true + catalogs: [''] }, alldebrid: { key: 'alldebrid', instance: alldebrid, name: 'AllDebrid', shortName: 'AD', - catalog: true + catalogs: [''] }, debridlink: { key: 'debridlink', instance: debridlink, name: 'DebridLink', shortName: 'DL', - catalog: true + catalogs: [''] }, offcloud: { key: 'offcloud', instance: offcloud, name: 'Offcloud', shortName: 'OC', - catalog: true + catalogs: [''] + }, + torbox: { + key: 'torbox', + instance: torbox, + name: 'TorBox', + shortName: 'TB', + catalogs: [`Torrents`, `Usenet`, `WebDL`] }, putio: { key: 'putio', instance: putio, name: 'Put.io', shortName: 'Putio', - catalog: true + catalogs: [''] } }; @@ -112,7 +119,7 @@ export async function resolve(parameters) { return unrestrictQueues[moch.key].wrap(id, method); } -export async function getMochCatalog(mochKey, config) { +export async function getMochCatalog(mochKey, catalogId, config, ) { const moch = MochOptions[mochKey]; if (!moch) { return Promise.reject(new Error(`Not a valid moch provider: ${mochKey}`)); @@ -120,7 +127,7 @@ export async function getMochCatalog(mochKey, config) { if (isInvalidToken(config[mochKey], mochKey)) { return Promise.reject(new Error(`Invalid API key for moch provider: ${mochKey}`)); } - return moch.instance.getCatalog(config[moch.key], config.skip, config.ip) + return moch.instance.getCatalog(config[moch.key], catalogId, config) .catch(rawError => { const commonError = moch.instance.toCommonError(rawError); if (commonError === BadTokenError) { diff --git a/addon/moch/offcloud.js b/addon/moch/offcloud.js index 61757cd..acb0ef6 100644 --- a/addon/moch/offcloud.js +++ b/addon/moch/offcloud.js @@ -34,8 +34,8 @@ export async function getCachedStreams(streams, apiKey) { }, {}) } -export async function getCatalog(apiKey, offset = 0) { - if (offset > 0) { +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { return []; } const options = await getDefaultOptions(); diff --git a/addon/moch/premiumize.js b/addon/moch/premiumize.js index f26d7c2..128d849 100644 --- a/addon/moch/premiumize.js +++ b/addon/moch/premiumize.js @@ -37,8 +37,8 @@ async function _getCachedStreams(PM, apiKey, streams) { }, {})); } -export async function getCatalog(apiKey, offset = 0) { - if (offset > 0) { +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { return []; } const options = await getDefaultOptions(); diff --git a/addon/moch/putio.js b/addon/moch/putio.js index 3a00575..4dc42dd 100644 --- a/addon/moch/putio.js +++ b/addon/moch/putio.js @@ -22,8 +22,8 @@ export async function getCachedStreams(streams, apiKey) { }, {}); } -export async function getCatalog(apiKey, offset = 0) { - if (offset > 0) { +export async function getCatalog(apiKey, catalogId, config) { + if (config.skip > 0) { return []; } const Putio = createPutioAPI(apiKey) diff --git a/addon/moch/realdebrid.js b/addon/moch/realdebrid.js index 6673ab5..c8213d6 100644 --- a/addon/moch/realdebrid.js +++ b/addon/moch/realdebrid.js @@ -39,11 +39,8 @@ function _getCachedFileIds(fileIndex, cachedResults) { return cachedIds || []; } -export async function getCatalog(apiKey, offset, ip) { - if (offset > 0) { - return []; - } - const options = await getDefaultOptions(ip); +export async function getCatalog(apiKey, catalogId, config) { + const options = await getDefaultOptions(config.ip); const RD = new RealDebridClient(apiKey, options); const downloadsMeta = { id: `${KEY}:${DEBRID_DOWNLOADS}`, diff --git a/addon/moch/static.js b/addon/moch/static.js index 4483e81..b0f079d 100644 --- a/addon/moch/static.js +++ b/addon/moch/static.js @@ -5,7 +5,9 @@ const staticVideoUrls = { FAILED_RAR: `videos/failed_rar_v2.mp4`, FAILED_OPENING: `videos/failed_opening_v2.mp4`, FAILED_UNEXPECTED: `videos/failed_unexpected_v2.mp4`, - FAILED_INFRINGEMENT: `videos/failed_infringement_v2.mp4` + FAILED_INFRINGEMENT: `videos/failed_infringement_v2.mp4`, + LIMITS_EXCEEDED: `videos/limits_exceeded_v1.mp4`, + BLOCKED_ACCESS: `videos/blocked_access_v1.mp4`, } diff --git a/addon/moch/torbox.js b/addon/moch/torbox.js new file mode 100644 index 0000000..9daa75f --- /dev/null +++ b/addon/moch/torbox.js @@ -0,0 +1,249 @@ +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) { + if (config.skip > 0) { + return []; + } + 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}/null/${itemId}-${file.id}/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; + } + return Promise.reject(`Failed TorBox adding torrent: ${JSON.stringify(error.message || error)}`); + }); +} + +async function _resolve(apiKey, infoHash, cachedEntryInfo, fileIndex, ip) { + if (infoHash === 'null') { + const [type, rootId, fileId] = cachedEntryInfo.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); + } 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 _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) { + 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' }) + } + 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 _unrestrictLink(apiKey, infoHash, torrent, cachedEntryInfo, fileIndex) { + 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) { + return Promise.reject(`No TorBox file found for index ${fileIndex} in: ${JSON.stringify(torrent)}`); + } + return getDownloadLink(apiKey, 'torrents', torrent.id, targetVideo.id); +} + +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 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) { + 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 ['metaDL', 'downloading', 'stalled', 'processing', 'completed'].includes(torrent?.download_state); +} + +function statusError(torrent) { + return !torrent?.active && !torrent?.download_finished; +} + +function statusReady(torrent) { + return torrent?.download_present; +} + +function isAccessDeniedError(error) { + return ['AUTH_ERROR', 'BAD_TOKEN', 'PLAN_RESTRICTED_FEATURE'].includes(error?.error); +} + +function isLimitExceededError(error) { + return ['DOWNLOAD_TOO_LARGE', 'MONTHLY_LIMIT', 'COOLDOWN_LIMIT', 'ACTIVE_LIMIT'].includes(error?.error); +} diff --git a/addon/static/videos/limits_exceeded_v1.mp4 b/addon/static/videos/limits_exceeded_v1.mp4 new file mode 100644 index 0000000..3e80a1e Binary files /dev/null and b/addon/static/videos/limits_exceeded_v1.mp4 differ