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 { sameFilename, streamFilename, BadTokenError, AccessDeniedError } from './mochHelper.js'; import * as querystring from "node:querystring"; const KEY = 'torbox'; const timeout = 30000; const baseUrl = 'https://api.torbox.app/v1' export async function getCachedStreams(streams, apiKey) { const available = await getAvailabilityResponse(apiKey, streams.map(stream => stream.infoHash)) .then(results => new Map(results.map(result => [result.hash, result]))) .catch(error => { console.log('Failed TorBox cached torrent availability request: ', JSON.stringify(error.message || error)); if (toCommonError(error)) { return Promise.reject(error); } return undefined; }); return available && streams .reduce((mochStreams, stream) => { const cachedEntry = available.get(stream.infoHash); const fileName = streamFilename(stream); const targetFileName = decodeURIComponent(fileName); const videos = (cachedEntry?.files || []) .filter(file => isVideo(file.short_name)) .sort((a, b) => b.size - a.size); const targetVideo = Number.isInteger(stream.fileIdx) && videos.find(video => sameFilename(video.name, targetFileName)) || videos[0]; mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = { url: `${apiKey}/${stream.infoHash}/${fileName}/${stream.fileIdx}`, cached: !!cachedEntry, behaviorHints: targetVideo?.opensubtitles_hash && { videoSize: targetVideo.size, videoHash: targetVideo.opensubtitles_hash } }; 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: getDownloadLink(apiKey, type, id, file.id) }] })) } } 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); } 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 ${JSON.stringify(torrent)}...`); 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) { 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); } async function getAvailabilityResponse(apiKey, hashes) { const url = `${baseUrl}/api/torrents/checkcached`; const headers = getHeaders(apiKey); const params = { format: 'list', list_files: true }; const data = { hashes } return axios.post(url, data, { 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, bypass_cache = true) { const url = `${baseUrl}/api/${type}/mylist`; const headers = getHeaders(apiKey); const params = { id, offset, bypass_cache }; 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)); } function getDownloadLink(token, type, rootId, file_id) { const idKey = { torrents: 'torrent_id', usenet: 'usenet_id', webdl: 'web_id' }[type] const params = { token, [idKey]: rootId, file_id, redirect: true }; return `${baseUrl}/api/${type}/requestdl?${querystring.stringify(params)}`; } function getHeaders(apiKey) { return { Authorization: `Bearer ${apiKey}`, 'User-Agent': 'torrentio' }; } export function toCommonError(data) { const error = data?.response?.data || data; if (['BAD_TOKEN'].includes(error?.error)) { return BadTokenError; } if (isAccessDeniedError(error)) { return AccessDeniedError; } return undefined; } function statusDownloading(torrent) { return (!statusReady(torrent) && !statusError(torrent)) || !!torrent?.queued_id; } 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); }