diff --git a/packages/addon/src/addon.ts b/packages/addon/src/addon.ts index 70dddd00..49d0528e 100644 --- a/packages/addon/src/addon.ts +++ b/packages/addon/src/addon.ts @@ -11,6 +11,7 @@ import { getOrionStreams, getPeerflixStreams, getStremioJackettStreams, + getStremThruStoreStreams, getTorboxStreams, getTorrentioStreams, } from '@aiostreams/wrappers'; @@ -1080,6 +1081,14 @@ export class AIOStreams { addonId ); } + case 'stremthru-store': { + return await getStremThruStoreStreams( + this.config, + addon.options, + streamRequest, + addonId + ); + } case 'dmm-cast': { return await getDMMCastStreams( this.config, diff --git a/packages/utils/src/details.ts b/packages/utils/src/details.ts index 8a7dd185..acdedd29 100644 --- a/packages/utils/src/details.ts +++ b/packages/utils/src/details.ts @@ -687,6 +687,61 @@ export const addonDetails: AddonDetail[] = [ }, ], }, + { + name: 'StremThru Store', + id: 'stremthru-store', + requiresService: true, + supportedServices: [ + 'torbox', + 'easydebrid', + 'realdebrid', + 'debridlink', + 'alldebrid', + 'premiumize', + 'offcloud', + 'pikpak', + ], + options: [ + { + id: 'prioritiseDebrid', + required: false, + label: 'Prioritise Debrid Service', + description: + 'Prioritise a specific debrid service when fetching streams. This option is useful when you want to use a specific debrid service for fetching streams. By default, the addon will make a separate request for each debrid service. I highly recommend provding a value for this option as it will speed up the fetching process and remove redundant results.', + type: 'select', + options: [ + { value: 'torbox', label: 'Torbox' }, + { value: 'easydebrid', label: 'EasyDebrid' }, + { value: 'realdebrid', label: 'Real Debrid' }, + { value: 'debridlink', label: 'Debrid Link' }, + { value: 'alldebrid', label: 'All Debrid' }, + { value: 'premiumize', label: 'Premiumize' }, + { value: 'offcloud', label: 'Offcloud' }, + { value: 'pikpak', label: 'PikPak' }, + ], + }, + { + id: 'overrideName', + required: false, + label: 'Override Addon Name', + description: + "Override the name of the addon that shows up in the results. Leave it empty to use the default name of 'StremThru Store'.", + type: 'text', + }, + { + id: 'indexerTimeout', + required: false, + label: 'Override Indexer Timeout', + description: + 'The timeout for fetching streams from the StremThru Store addon in milliseconds. This is the time in milliseconds that the addon will wait for a response from StremThru Store before timing out. Leave it empty to use the recommended timeout.', + type: 'number', + constraints: { + min: Settings.MIN_TIMEOUT, + max: Settings.MAX_TIMEOUT, + }, + }, + ], + }, { name: 'DMM Cast', id: 'dmm-cast', diff --git a/packages/utils/src/settings.ts b/packages/utils/src/settings.ts index 1b1ca233..01ef0396 100644 --- a/packages/utils/src/settings.ts +++ b/packages/utils/src/settings.ts @@ -237,6 +237,14 @@ export class Settings { ? parseInt(process.env.DEFAULT_DEBRIDIO_TIMEOUT) : undefined; + public static readonly STREMTHRU_STORE_URL = + process.env.STREMTHRU_STORE_URL || + 'https://stremthru.elfhosted.com/stremio/store/'; + public static readonly DEFAULT_STREMTHRU_STORE_TIMEOUT = process.env + .DEFAULT_STREMTHRU_STORE_TIMEOUT + ? parseInt(process.env.DEFAULT_STREMTHRU_STORE_TIMEOUT) + : undefined; + public static readonly DEFAULT_DMM_CAST_TIMEOUT = process.env .DEFAULT_DMM_CAST_TIMEOUT ? parseInt(process.env.DEFAULT_DMM_CAST_TIMEOUT) diff --git a/packages/wrappers/src/index.ts b/packages/wrappers/src/index.ts index 2317e369..d05bab96 100644 --- a/packages/wrappers/src/index.ts +++ b/packages/wrappers/src/index.ts @@ -12,3 +12,4 @@ export * from './orion'; export * from './peerflix'; export * from './dmmCast'; export * from './stremio-jackett'; +export * from './stremthruStore'; diff --git a/packages/wrappers/src/stremthruStore.ts b/packages/wrappers/src/stremthruStore.ts new file mode 100644 index 00000000..97e33c46 --- /dev/null +++ b/packages/wrappers/src/stremthruStore.ts @@ -0,0 +1,189 @@ +import { AddonDetail, ParseResult, StreamRequest } from '@aiostreams/types'; +import { ParsedStream, Config } from '@aiostreams/types'; +import { BaseWrapper } from './base'; +import { addonDetails, createLogger } from '@aiostreams/utils'; +import { Settings } from '@aiostreams/utils'; +import { Stream } from 'stream'; + +const logger = createLogger('wrappers'); + +export class StremThruStore extends BaseWrapper { + constructor( + configString: string | null, + overrideUrl: string | null, + addonName: string = 'ST Store', + addonId: string, + userConfig: Config, + indexerTimeout?: number + ) { + let url = overrideUrl + ? overrideUrl + : Settings.STREMTHRU_STORE_URL + (configString ? configString + '/' : ''); + + super( + addonName, + url, + addonId, + userConfig, + indexerTimeout || Settings.DEFAULT_STREMTHRU_STORE_TIMEOUT + ); + } + + protected parseStream(stream: { [key: string]: string }): ParseResult { + const parsedResult = super.parseStream(stream); + if (parsedResult.type === 'stream' && parsedResult.result.provider?.id) { + parsedResult.result.provider = { + ...parsedResult.result.provider, + cached: true, + }; + + // all st store results are "personal" streams. + parsedResult.result.personal = true; + // ST store results use a cogwheel emoji (⚙️) for the release group, this is mistakenly identified as an indexer. + // remove it (personal results don't have an indexer anyway) + parsedResult.result.indexers = undefined; + } + return parsedResult; + } +} +export async function getStremThruStoreStreams( + config: Config, + stremthruStoreOptions: { + prioritiseDebrid?: string; + overrideUrl?: string; + indexerTimeout?: string; + overrideName?: string; + }, + streamRequest: StreamRequest, + addonId: string +): Promise<{ addonStreams: ParsedStream[]; addonErrors: string[] }> { + const supportedServices: string[] = + addonDetails.find((addon: AddonDetail) => addon.id === 'stremthru-store') + ?.supportedServices || []; + const parsedStreams: ParsedStream[] = []; + const indexerTimeout = stremthruStoreOptions.indexerTimeout + ? parseInt(stremthruStoreOptions.indexerTimeout) + : undefined; + + // If overrideUrl is provided, use it to get streams and skip all other steps + if (stremthruStoreOptions.overrideUrl) { + const stremthruStore = new StremThruStore( + null, + stremthruStoreOptions.overrideUrl as string, + stremthruStoreOptions.overrideName, + addonId, + config, + indexerTimeout + ); + return await stremthruStore.getParsedStreams(streamRequest); + } + + // find all usable and enabled services + const usableServices = config.services.filter( + (service) => supportedServices.includes(service.id) && service.enabled + ); + + // if no usable services found, raise error + if (usableServices.length < 1) { + throw new Error('No supported service(s) enabled'); + } + + // otherwise, depending on the configuration, create multiple instances of StremThru Store or use a single instance with the prioritised service + + if ( + stremthruStoreOptions.prioritiseDebrid && + !supportedServices.includes(stremthruStoreOptions.prioritiseDebrid) + ) { + throw new Error('Invalid debrid service'); + } + + const formServiceCredentialsString = ( + service: string, + credentials: { [key: string]: string } + ) => { + if (service === 'pikpak' || service === 'offcloud') { + if (!credentials.email || !credentials.password) { + throw new Error( + `Credentials for ${service} are not valid. Please check your configuration. Email and password are required.` + ); + } + return `${credentials.email}:${credentials.password}`; + } + if (!credentials.apiKey) { + throw new Error(`API Key is missing for ${service}`); + } + return credentials.apiKey; + }; + + if (stremthruStoreOptions.prioritiseDebrid) { + const debridService = usableServices.find( + (service) => service.id === stremthruStoreOptions.prioritiseDebrid + ); + if (!debridService) { + throw new Error( + 'Debrid service not found for ' + stremthruStoreOptions.prioritiseDebrid + ); + } + const storeToken = formServiceCredentialsString( + debridService.id, + debridService.credentials + ); + + const stremthruStore = new StremThruStore( + getConfigString(stremthruStoreOptions.prioritiseDebrid, storeToken), + null, + stremthruStoreOptions.overrideName, + addonId, + config, + indexerTimeout + ); + + return await stremthruStore.getParsedStreams(streamRequest); + } + + // if no prioritised service is provided, create a stremthru instance for each service + const servicesToUse = usableServices.filter((service) => service.enabled); + if (servicesToUse.length < 1) { + throw new Error('No supported service(s) enabled'); + } + const errorMessages: string[] = []; + const streamPromises = servicesToUse.map(async (service) => { + logger.info(`Getting StremThru Store streams for ${service.id}`, { + func: 'stremthru-store', + }); + const stremthruStore = new StremThruStore( + getConfigString( + service.id, + formServiceCredentialsString(service.id, service.credentials) + ), + null, + stremthruStoreOptions.overrideName, + addonId, + config, + indexerTimeout + ); + return stremthruStore.getParsedStreams(streamRequest); + }); + + const results = await Promise.allSettled(streamPromises); + results.forEach((result) => { + if (result.status === 'fulfilled') { + const streams = result.value; + parsedStreams.push(...streams.addonStreams); + errorMessages.push(...streams.addonErrors); + } else { + errorMessages.push(result.reason.message); + } + }); + + return { addonStreams: parsedStreams, addonErrors: errorMessages }; +} + +function getConfigString(storeName: string, storeToken: string) { + return Buffer.from( + JSON.stringify({ + store_name: storeName, + store_token: storeToken, + }) + ).toString('base64'); +}