diff --git a/addon/lib/configuration.js b/addon/lib/configuration.js index d646297..6e46eb1 100644 --- a/addon/lib/configuration.js +++ b/addon/lib/configuration.js @@ -28,6 +28,22 @@ export const PreConfigurations = { const keysToSplit = [Providers.key, LanguageOptions.key, QualityFilter.key, SizeFilter.key, DebridOptions.key]; const keysToUppercase = [SizeFilter.key]; +const keysParser = { + stremthru: (val) => { + const config = { url: '' , auth: '' }; + if (val) { + const [auth, url] = decodeURIComponent(val).split('@'); + config.url = url; + if (auth.includes(':')) { + const [store, token] = auth.split(':'); + config.auth = { store, token }; + } else { + config.auth = auth; + } + } + return config; + } +} export function parseConfiguration(configuration) { if (!configuration) { @@ -48,6 +64,11 @@ export function parseConfiguration(configuration) { .filter(key => configValues[key]) .forEach(key => configValues[key] = configValues[key].split(',') .map(value => keysToUppercase.includes(key) ? value.toUpperCase() : value.toLowerCase())) + Object.keys(keysParser).forEach((key) => { + if (configValues[key]) { + configValues[key] = keysParser[key](configValues[key]); + } + }) return configValues; } diff --git a/addon/lib/landingTemplate.js b/addon/lib/landingTemplate.js index 59206d7..12134b1 100644 --- a/addon/lib/landingTemplate.js +++ b/addon/lib/landingTemplate.js @@ -210,6 +210,7 @@ export default function landingTemplate(manifest, config = {}) { const putioKey = config[MochOptions.putio.key] || ''; const putioClientId = putioKey.replace(/@.*/, ''); const putioToken = putioKey.replace(/.*@/, ''); + const stremthru = config[MochOptions.stremthru.key] || { url: '', auth: '' }; const background = manifest.background || 'https://dl.strem.io/addon-background.jpg'; const logo = manifest.logo || 'https://dl.strem.io/addon-logo.png'; @@ -341,6 +342,12 @@ export default function landingTemplate(manifest, config = {}) { + +
+ + + +
@@ -405,6 +412,8 @@ export default function landingTemplate(manifest, config = {}) { $('#iTorbox').val("${torboxApiKey}"); $('#iPutioClientId').val("${putioClientId}"); $('#iPutioToken').val("${putioToken}"); + $('#iStremThruUrl').val("${stremthru.url}"); + $('#iStremThruAuth').val("${stremthru.auth}"); $('#iSort').val("${sort}"); $('#iLimit').val("${limit}"); $('#iSizeFilter').val("${sizeFilter}"); @@ -431,6 +440,7 @@ export default function landingTemplate(manifest, config = {}) { $('#dOffcloud').toggle(provider === '${MochOptions.offcloud.key}'); $('#dTorbox').toggle(provider === '${MochOptions.torbox.key}'); $('#dPutio').toggle(provider === '${MochOptions.putio.key}'); + $('#dStremThru').toggle(provider === '${MochOptions.stremthru.key}'); } function generateInstallLink() { @@ -451,6 +461,8 @@ export default function landingTemplate(manifest, config = {}) { const torboxValue = $('#iTorbox').val() || ''; const putioClientIdValue = $('#iPutioClientId').val() || ''; const putioTokenValue = $('#iPutioToken').val() || ''; + const stremthruUrl = $('#iStremThruUrl').val() || ''; + const stremthruAuth = $('#iStremThruAuth').val() || ''; const providers = providersList.length && providersList.length < ${Providers.options.length} && providersValue; @@ -468,6 +480,9 @@ export default function landingTemplate(manifest, config = {}) { const offcloud = offcloudValue.length && offcloudValue.trim(); const torbox = torboxValue.length && torboxValue.trim(); const putio = putioClientIdValue.length && putioTokenValue.length && putioClientIdValue.trim() + '@' + putioTokenValue.trim(); + let stremthru = stremthruUrl.trim().length + ? encodeURIComponent(stremthruAuth.trim() + '@' + stremthruUrl.trim()) + : ''; const preConfigurations = { ${preConfigurationObject} @@ -486,7 +501,8 @@ export default function landingTemplate(manifest, config = {}) { ['${MochOptions.debridlink.key}', debridLink], ['${MochOptions.offcloud.key}', offcloud], ['${MochOptions.torbox.key}', torbox], - ['${MochOptions.putio.key}', putio] + ['${MochOptions.putio.key}', putio], + ['${MochOptions.stremthru.key}', stremthru] ].filter(([_, value]) => value.length).map(([key, value]) => key + '=' + value).join('|'); configurationValue = Object.entries(preConfigurations) .filter(([key, value]) => value === configurationValue) diff --git a/addon/moch/moch.js b/addon/moch/moch.js index f826843..3b49f6e 100644 --- a/addon/moch/moch.js +++ b/addon/moch/moch.js @@ -6,6 +6,7 @@ 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 * as stremthru from './stremthru.js'; import StaticResponse, { isStaticUrl } from './static.js'; import { cacheWrapResolvedUrl } from '../lib/cache.js'; import { timeout } from '../lib/promises.js'; @@ -64,6 +65,13 @@ export const MochOptions = { name: 'Put.io', shortName: 'Putio', catalogs: [''] + }, + stremthru: { + key: 'stremthru', + instance: stremthru, + name: 'StremThru', + shortName: 'ST', + catalogs: [''] } }; diff --git a/addon/moch/stremthru.js b/addon/moch/stremthru.js new file mode 100644 index 0000000..8f5ea55 --- /dev/null +++ b/addon/moch/stremthru.js @@ -0,0 +1,338 @@ +import { StremThru, StremThruError } from "stremthru"; +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 { + AccessDeniedError, + BadTokenError, + streamFilename, +} from "./mochHelper.js"; + +const KEY = "stremthru"; +const AGENT = "torrentio"; + +/** + * @typedef {{ url: string, auth: string | { store: string, token: string }}} Conf + * @typedef {`${string}@${string}`} EncodedConf + */ + +/** + * @param {Conf} conf + * @returns {EncodedConf} + */ +function encodeConf(conf) { + let auth = conf.auth; + if (typeof auth === "object") { + auth = `${auth.store}:${auth.token}`; + } + return Buffer.from(`${auth}@${conf.url}`).toString("base64"); +} + +/** + * @param {EncodedConf} encoded + * @return {Conf} + */ +function decodeConf(encoded) { + const decoded = Buffer.from(encoded, "base64").toString("ascii"); + const parts = decoded.split("@"); + let auth = parts[0]; + if (auth.includes(":")) { + const [store, token] = auth.split(":"); + auth = { store, token }; + } + return { url: parts.slice(1).join("@"), auth }; +} + +/** + * @param {Conf} conf + * @param {string} ip + */ +export async function getCachedStreams(streams, conf, ip) { + const options = getDefaultOptions(ip); + const ST = new StremThru({ ...options, baseUrl: conf.url, auth: conf.auth }); + const hashes = streams.map((stream) => stream.infoHash); + const available = await ST.store + .checkMagnet({ magnet: hashes }) + .catch((error) => { + if (toCommonError(error)) { + return Promise.reject(error); + } + console.warn( + `Failed StremThru cached [${hashes[0]}] torrent availability request:`, + error, + ); + return undefined; + }); + const apiKey = encodeConf(conf); + return ( + available && + streams.reduce((mochStreams, stream) => { + const cachedEntry = available.data.items.find( + (magnet) => stream.infoHash === magnet.hash, + ); + const fileName = streamFilename(stream); + mochStreams[`${stream.infoHash}@${stream.fileIdx}`] = { + url: `${apiKey}/${stream.infoHash}/${fileName}/${stream.fileIdx}`, + cached: cachedEntry?.status === "cached", + }; + return mochStreams; + }, {}) + ); +} + +/** + * @param {Conf} conf + * @param {number} [offset] + * @param {string} ip + */ +export async function getCatalog(conf, offset = 0, ip) { + if (offset > 0) { + return []; + } + const options = getDefaultOptions(ip); + const ST = new StremThru({ ...options, baseUrl: conf.url, auth: conf.auth }); + return ST.store.listMagnets().then((response) => + response.data.items + .filter((torrent) => statusDownloaded(torrent.status)) + .map((torrent) => ({ + id: `${KEY}:${torrent.id}`, + type: Type.OTHER, + name: torrent.name, + })), + ); +} + +/** + * @param {string} itemId + * @param {Conf} conf + * @param {string} ip + */ +export async function getItemMeta(itemId, conf, ip) { + const options = getDefaultOptions(ip); + const ST = new StremThru({ ...options, baseUrl: conf.url, auth: conf.auth }); + const apiKey = encodeConf(conf); + return ST.store + .getMagnet(itemId) + .then((response) => response.data) + .then((torrent) => ({ + id: `${KEY}:${torrent.id}`, + type: Type.OTHER, + name: torrent.name, + infoHash: torrent.hash, + videos: torrent.files + .filter((file) => isVideo(file.name) && file.link) + .map((file) => ({ + id: `${KEY}:${torrent.id}:${file.index}`, + title: file.name, + released: torrent.added_at, + streams: [ + { + url: `${apiKey}/${torrent.hash}/${encodeURIComponent(file.name)}/${file.index}`, + }, + ], + })), + })); +} + +export async function resolve({ + apiKey, + infoHash, + cachedEntryInfo, + fileIndex, + ip, +}) { + console.log(`Unrestricting StremThru ${infoHash} [${fileIndex}]`); + const conf = decodeConf(apiKey); + const fileName = decodeURIComponent(cachedEntryInfo); + const options = getDefaultOptions(ip); + const ST = new StremThru({ ...options, baseUrl: conf.url, auth: conf.auth }); + + return _resolve(ST, infoHash, fileName, fileIndex).catch((error) => { + if (errorFailedAccessError(error)) { + console.log(`Access denied to StremThru ${infoHash} [${fileIndex}]`); + return StaticResponse.FAILED_ACCESS; + } else if (error.code === "STORE_LIMIT_EXCEEDED") { + console.log( + `Deleting and retrying adding to StremThru ${infoHash} [${fileIndex}]...`, + ); + return _deleteAndRetry(ST, infoHash, fileName, fileIndex); + } + return Promise.reject( + `Failed StremThru adding torrent ${JSON.stringify(error)}`, + ); + }); +} + +async function _resolve(ST, infoHash, fileName, fileIndex) { + const torrent = await _createOrFindTorrent(ST, infoHash); + if (torrent && statusDownloaded(torrent.status)) { + return _unrestrictLink(ST, torrent, fileName, fileIndex); + } else if (torrent && statusDownloading(torrent.status)) { + console.log(`Downloading to StremThru ${infoHash} [${fileIndex}]...`); + return StaticResponse.DOWNLOADING; + } else if (torrent && torrent.status === "invalid") { + console.log( + `Failed StremThru opening torrent ${infoHash} [${fileIndex}] due to magnet error`, + ); + return StaticResponse.FAILED_OPENING; + } + + return Promise.reject( + `Failed StremThru adding torrent ${JSON.stringify(torrent)}`, + ); +} + +/** + * @param {import('stremthru').StremThru} ST + */ +async function _createOrFindTorrent(ST, infoHash) { + return _findTorrent(ST, infoHash).catch(() => _createTorrent(ST, infoHash)); +} + +/** + * @param {import('stremthru').StremThru} ST + * @param {string} infoHash + * @param {string} fileName + * @param {number} fileIndex + */ +async function _retryCreateTorrent(ST, infoHash, fileName, fileIndex) { + const newTorrent = await _createTorrent(ST, infoHash); + return newTorrent && statusDownloaded(newTorrent.status) + ? _unrestrictLink(ST, newTorrent, fileName, fileIndex) + : StaticResponse.FAILED_DOWNLOAD; +} + +/** + * @param {import('stremthru').StremThru} ST + * @param {string} infoHash + * @param {string} fileName + * @param {number} fileIndex + */ +async function _deleteAndRetry(ST, infoHash, fileName, fileIndex) { + const torrents = await ST.store + .listMagnets() + .then((response) => + response.data.items.filter((item) => statusDownloading(item.status)), + ); + const oldestActiveTorrent = torrents[torrents.length - 1]; + return ST.store + .removeMagnet(oldestActiveTorrent.id) + .then(() => _retryCreateTorrent(ST, infoHash, fileName, fileIndex)); +} + +/** + * @param {import('stremthru').StremThru} ST + * @param {string} infoHash + */ +async function _findTorrent(ST, infoHash) { + const torrents = await ST.store + .listMagnets() + .then((response) => response.data.items); + const foundTorrents = torrents.filter((torrent) => torrent.hash === infoHash); + const nonFailedTorrent = foundTorrents.find( + (torrent) => !statusError(torrent.status), + ); + const foundTorrent = nonFailedTorrent || foundTorrents[0]; + if (foundTorrent) { + return ST.store.getMagnet(foundTorrent.id).then((res) => res.data); + } + return Promise.reject("No recent torrent found"); +} + +/** + * @param {import('stremthru').StremThru} ST + * @param {string} infoHash + */ +async function _createTorrent(ST, infoHash) { + const magnetLink = await getMagnetLink(infoHash); + const uploadResponse = await ST.store.addMagnet({ magnet: magnetLink }); + const torrentId = uploadResponse.data.id; + return ST.store + .getMagnet(torrentId) + .then((statusResponse) => statusResponse.data); +} + +/** + * @param {import('stremthru').StremThru} ST + * @param {Awaited>['data']} torrent + * @param {string} fileName + * @param {number} fileIndex + */ +async function _unrestrictLink(ST, torrent, fileName, fileIndex) { + const targetFile = torrent.files.find((file) => + isVideo(file.name) && file.index === -1 + ? file.name === fileName + : file.index === fileIndex, + ); + if (!targetFile && torrent.files.every((file) => isArchive(file.name))) { + console.log( + `Only StremThru archive is available for ${torrent.hash} [${fileIndex}]`, + ); + return StaticResponse.FAILED_RAR; + } + if (!targetFile || !targetFile.link || !targetFile.link.length) { + return Promise.reject( + `No StremThru links found for ${torrent.hash} [${fileIndex}]`, + ); + } + const unrestrictedLink = await ST.store + .generateLink({ link: targetFile.link }) + .then((response) => response.data.link); + console.log( + `Unrestricted StremThru ${torrent.hash} [${fileIndex}] to ${unrestrictedLink}`, + ); + return unrestrictedLink; +} + +/** + * @param {string} clientIp + */ +function getDefaultOptions(clientIp) { + return { userAgent: AGENT, timeout: 10000, clientIp }; +} + +export function toCommonError(error) { + if (error instanceof StremThruError) { + if (error.code === "UNAUTHORIZED") { + return BadTokenError; + } + if (error.code === "FORBIDDEN") { + return AccessDeniedError; + } + } + return undefined; +} + +/** + * @param {import('stremthru').StoreMagnetStatus} status + */ +function statusError(status) { + return status === "invalid" || status === "failed"; +} + +/** + * @param {import('stremthru').StoreMagnetStatus} status + */ +function statusDownloading(status) { + return ( + status === "queued" || status === "downloading" || status === "processing" + ); +} + +/** + * @param {import('stremthru').StoreMagnetStatus} status + */ +function statusDownloaded(status) { + return status === "downloaded"; +} + +/** + * @param {import('stremthru').StremThruError} error + */ +function errorFailedAccessError(error) { + return ( + error instanceof StremThruError && + ["FORBIDDEN", "UNAUTHORIZED", "PAYMENT_REQUIRED"].includes(error.code) + ); +} diff --git a/addon/package-lock.json b/addon/package-lock.json index 175ffcd..553fc4e 100644 --- a/addon/package-lock.json +++ b/addon/package-lock.json @@ -31,6 +31,7 @@ "router": "^1.3.8", "sequelize": "^6.29.0", "stremio-addon-sdk": "^1.6.10", + "stremthru": "^0.6.0", "swagger-stats": "^0.99.7", "ua-parser-js": "^1.0.36", "user-agents": "^1.0.1444" @@ -161,6 +162,30 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.0.0.tgz", "integrity": "sha512-cHlGmko4gWLVI27cGJntjs/Sj8th9aYwplmZFwmmgYQQvL5NUsgVJG7OddLvNfLqYS31KFN0s3qlaD9qCaxACA==" }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/validator": { "version": "13.12.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", @@ -2176,6 +2201,16 @@ "addon-bootstrap": "cli/bootstrap.js" } }, + "node_modules/stremthru": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/stremthru/-/stremthru-0.6.0.tgz", + "integrity": "sha512-Bzqw6Hw6auVJUOtGjpJGkjZrXl2C03IFijGcAIux/FbCzRnaaOfpjBk+YAFh/kRse/dKFzHZKBcxau3XERxyTA==", + "license": "MIT", + "dependencies": { + "@types/node-fetch": "^2.6.11", + "node-fetch": "release-2.x" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/addon/package.json b/addon/package.json index 2a4686e..fd19957 100644 --- a/addon/package.json +++ b/addon/package.json @@ -31,6 +31,7 @@ "router": "^1.3.8", "sequelize": "^6.29.0", "stremio-addon-sdk": "^1.6.10", + "stremthru": "^0.6.0", "swagger-stats": "^0.99.7", "ua-parser-js": "^1.0.36", "user-agents": "^1.0.1444"