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"