add stremthru integration

This commit is contained in:
Munif Tanjim 2024-12-06 23:44:10 +06:00
parent 703c655620
commit 4bd70f5553
6 changed files with 420 additions and 1 deletions

View file

@ -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;
}

View file

@ -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 = {}) {
<input type="text" id="iPutioClientId" placeholder="ClientId" onchange="generateInstallLink()" class="input">
<input type="text" id="iPutioToken" placeholder="Token" onchange="generateInstallLink()" class="input">
</div>
<div id="dStremThru">
<label class="label" for="iStremThru">StremThru URL and Credential:</label>
<input type="text" id="iStremThruUrl" placeholder="URL: https://" onchange="generateInstallLink()" class="input">
<input type="text" id="iStremThruAuth" placeholder="Credential: 'store_name:store_token' / base64 encoded 'username:password'" onchange="generateInstallLink()" class="input">
</div>
<div id="dDebridOptions">
<label class="label" for="iDebridOptions">Debrid options:</label>
@ -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)

View file

@ -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: ['']
}
};

338
addon/moch/stremthru.js Normal file
View file

@ -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<ReturnType<import('stremthru').StremThru['store']['getMagnet']>>['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)
);
}

View file

@ -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",

View file

@ -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"