import { BaseWrapper, getCometStreams, getDebridioStreams, getDMMCastStreams, getEasynewsPlusStreams, getEasynewsStreams, getJackettioStreams, getMediafusionStreams, getOrionStreams, getPeerflixStreams, getTorboxStreams, getTorrentioStreams, } from '@aiostreams/wrappers'; import { Stream, ParsedStream, StreamRequest, Config, ErrorStream, } from '@aiostreams/types'; import { gdriveFormat, torrentioFormat, torboxFormat, imposterFormat, } from '@aiostreams/formatters'; import { addonDetails, createProxiedMediaFlowUrl, getMediaFlowConfig, getMediaFlowPublicIp, getTimeTakenSincePoint, Settings, } from '@aiostreams/utils'; import { errorStream } from './responses'; export class AIOStreams { private config: Config; constructor(config: any) { this.config = config; } private async getRequestingIp() { let userIp = this.config.requestingIp; if (userIp === '::1') { userIp = undefined; } const mediaFlowConfig = getMediaFlowConfig(this.config); if (mediaFlowConfig.mediaFlowEnabled) { const mediaFlowIp = await getMediaFlowPublicIp( mediaFlowConfig, this.config.instanceCache ); if (mediaFlowIp) { userIp = mediaFlowIp; } } return userIp; } public async getStreams(streamRequest: StreamRequest): Promise { const streams: Stream[] = []; const startTime = new Date().getTime(); let ipRequestCount = 0; while (ipRequestCount < 3) { try { const ip = await this.getRequestingIp(); if (!ip && getMediaFlowConfig(this.config).mediaFlowEnabled) { throw new Error('No IP was found with MediaFlow enabled'); } this.config.requestingIp = ip; break; } catch (error) { console.error( `|ERR| addon > getStreams: Failed to get requesting IP: ${error}, retrying ${ipRequestCount + 1}/3` ); ipRequestCount++; } } if (ipRequestCount === 3) { console.error( '|ERR| addon > getStreams: Failed to get requesting IP after 3 attempts' ); if (this.config.mediaFlowConfig?.mediaFlowEnabled) { return [ errorStream('Aborted request after failing to get requesting IP'), ]; } } const { parsedStreams, errorStreams } = await this.getParsedStreams(streamRequest); console.log( `|INF| addon > getStreams: Got ${parsedStreams.length} parsed streams and ${errorStreams.length} error streams in ${getTimeTakenSincePoint(startTime)}` ); const filterStartTime = new Date().getTime(); const excludeRegex = this.config.excludeFilters ? new RegExp( `(? filter.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')) .map((filter) => filter.replace(/\s/g, '[ .\\-_]?')) .join('|')})(?=[ \\)\\]_.-]|$)`, 'i' ) : null; const strictIncludeRegex = this.config.strictIncludeFilters ? new RegExp( `(? filter.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')) .map((filter) => filter.replace(/\s/g, '[ .\\-_]?')) .join('|')})(?=[ \\)\\]_.-]|$)`, 'i' ) : null; excludeRegex || strictIncludeRegex ? console.log( `|INF| addon > getStreams: Created regex filters: excludeRegex: ${excludeRegex}, strictIncludeRegex: ${strictIncludeRegex}` ) : null; let filteredResults = parsedStreams.filter((parsedStream) => { const streamTypeFilter = this.config.streamTypes?.find( (streamType) => streamType[parsedStream.type] ); if (this.config.streamTypes && !streamTypeFilter) return false; const resolutionFilter = this.config.resolutions.find( (resolution) => resolution[parsedStream.resolution] ); if (!resolutionFilter) return false; const qualityFilter = this.config.qualities.find( (quality) => quality[parsedStream.quality] ); if (!qualityFilter) return false; // Check for HDR and DV tags in the parsed stream const hasHDR = parsedStream.visualTags.some((tag) => tag.startsWith('HDR') ); const hasDV = parsedStream.visualTags.includes('DV'); const hasHDRAndDV = hasHDR && hasDV; const HDRAndDVEnabled = this.config.visualTags.some( (visualTag) => visualTag['HDR+DV'] === true ); // Helper function to check if a specific tag is enabled const isTagEnabled = (tag: string) => this.config.visualTags.some((visualTag) => visualTag[tag] === true); if (hasHDRAndDV) { if (!HDRAndDVEnabled) { return false; } } else if (hasHDR) { const specificHdrTags = parsedStream.visualTags.filter((tag) => tag.startsWith('HDR') ); const disabledTags = specificHdrTags.filter( (tag) => !isTagEnabled(tag) ); if (disabledTags.length > 0) { return false; } } else if (hasDV && !isTagEnabled('DV')) { return false; } // Check other visual tags for explicit disabling for (const tag of parsedStream.visualTags) { if (tag.startsWith('HDR') || tag === 'DV') continue; if (isTagEnabled(tag) === false) { return false; } } // apply excludedLanguages filter const excludedLanguages = this.config.excludedLanguages; if (excludedLanguages && parsedStream.languages.length > 0) { if ( parsedStream.languages.every((lang) => excludedLanguages.includes(lang) ) ) { return false; } } else if ( excludedLanguages && excludedLanguages.includes('Unknown') && parsedStream.languages.length === 0 ) { return false; } const audioTagFilter = parsedStream.audioTags.find( (tag) => !this.config.audioTags.some((audioTag) => audioTag[tag]) ); if (audioTagFilter) return false; if ( parsedStream.encode && !this.config.encodes.some((encode) => encode[parsedStream.encode]) ) return false; if ( this.config.onlyShowCachedStreams && parsedStream.provider && !parsedStream.provider.cached ) return false; if ( this.config.minSize && parsedStream.size && parsedStream.size < this.config.minSize ) return false; if ( this.config.maxSize && parsedStream.size && parsedStream.size > this.config.maxSize ) return false; if ( streamRequest.type === 'movie' && this.config.maxMovieSize && parsedStream.size && parsedStream.size > this.config.maxMovieSize ) return false; if ( streamRequest.type === 'movie' && this.config.minMovieSize && parsedStream.size && parsedStream.size < this.config.minMovieSize ) return false; if ( streamRequest.type === 'series' && this.config.maxEpisodeSize && parsedStream.size && parsedStream.size > this.config.maxEpisodeSize ) return false; if ( streamRequest.type === 'series' && this.config.minEpisodeSize && parsedStream.size && parsedStream.size < this.config.minEpisodeSize ) return false; // apply keyword filters if ( this.config.excludeFilters && this.config.excludeFilters.length > 0 && parsedStream.filename && excludeRegex ) { if (excludeRegex.test(parsedStream.filename)) { return false; } } if ( this.config.strictIncludeFilters && this.config.strictIncludeFilters.length > 0 && parsedStream.filename && strictIncludeRegex ) { if (!strictIncludeRegex.test(parsedStream.filename)) { return false; } } return true; }); console.log( `|INF| addon > getStreams: Initial filter to ${filteredResults.length} streams in ${getTimeTakenSincePoint(filterStartTime)}` ); if (this.config.cleanResults) { const cleanedStreams: ParsedStream[] = []; const initialStreams = filteredResults; const normaliseFilename = (filename?: string): string | undefined => filename ?.replace( /\.(mkv|mp4|avi|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|3g2|m2ts|ts|vob|ogv|ogm|divx|xvid|rm|rmvb|asf|mxf|mka|mks|mk3d|webm|f4v|f4p|f4a|f4b)$/i, '' ) .replace(/[^\p{L}\p{N}+]/gu, '') .replace(/\s+/g, '') .toLowerCase(); const groupStreamsByKey = ( streams: ParsedStream[], keyExtractor: (stream: ParsedStream) => string | undefined ): Record => { return streams.reduce( (acc, stream) => { const key = keyExtractor(stream); if (!key) { if (!cleanedStreams.includes(stream)) { cleanedStreams.push(stream); } return acc; } acc[key] = acc[key] || []; acc[key].push(stream); return acc; }, {} as Record ); }; const cleanResultsStartTime = new Date().getTime(); // Deduplication by normalised filename const cleanResultsByFilenameStartTime = new Date().getTime(); console.log( `|INF| addon > cleaner: Received ${initialStreams.length} streams to clean` ); const streamsGroupedByFilename = groupStreamsByKey( initialStreams, (stream) => normaliseFilename(stream.filename) ); console.log( `|INF| addon > cleaner: Found ${Object.keys(streamsGroupedByFilename).length} unique filenames` ); // Process grouped streams by filename const cleanedStreamsByFilename = await this.processGroupedStreams( streamsGroupedByFilename ); console.log( `|INF| addon > cleaner: Deduplicated streams by filename to ${cleanedStreamsByFilename.length} streams in ${getTimeTakenSincePoint(cleanResultsByFilenameStartTime)}` ); // Deduplication by hash const cleanResultsByHashStartTime = new Date().getTime(); const streamsGroupedByHash = groupStreamsByKey( cleanedStreamsByFilename, (stream) => stream._infoHash ); console.log( `|INF| addon > cleaner: Found ${Object.keys(streamsGroupedByHash).length} unique hashes with ${cleanedStreamsByFilename.length - Object.values(streamsGroupedByHash).reduce((sum, group) => sum + group.length, 0)} streams not grouped` ); // Process grouped streams by hash const cleanedStreamsByHash = await this.processGroupedStreams(streamsGroupedByHash); console.log( `|INF| addon > cleaner: Deduplicated streams by hash to ${cleanedStreamsByHash.length} streams in ${getTimeTakenSincePoint(cleanResultsByHashStartTime)}` ); cleanedStreams.push(...cleanedStreamsByHash); console.log( `|INF| addon > cleaner: Deduplicated streams to ${cleanedStreams.length} streams in ${getTimeTakenSincePoint(cleanResultsStartTime)}` ); filteredResults = cleanedStreams; } // Apply sorting const sortStartTime = new Date().getTime(); // initially sort by filename to ensure consistent results filteredResults.sort((a, b) => a.filename && b.filename ? a.filename.localeCompare(b.filename) : 0 ); // then apply our this.config sorting filteredResults.sort((a, b) => { for (const sortByField of this.config.sortBy) { const field = Object.keys(sortByField)[0]; const value = sortByField[field]; if (value) { const fieldComparison = this.compareByField(a, b, field); if (fieldComparison !== 0) return fieldComparison; } } return 0; }); console.log( `|INF| addon > getStreams: Sorted results in ${getTimeTakenSincePoint(sortStartTime)}` ); // apply config.maxResultsPerResolution if (this.config.maxResultsPerResolution) { const startTime = new Date().getTime(); const resolutionCounts = new Map(); const limitedResults = filteredResults.filter((result) => { const resolution = result.resolution || 'Unknown'; const currentCount = resolutionCounts.get(resolution) || 0; if (currentCount < this.config.maxResultsPerResolution!) { resolutionCounts.set(resolution, currentCount + 1); return true; } return false; }); filteredResults = limitedResults; console.log( `|INF| addon > getStreams: Limited results to ${limitedResults.length} streams after applying maxResultsPerResolution in ${new Date().getTime() - startTime}ms` ); } // Create stream objects const streamsStartTime = new Date().getTime(); const streamObjects = await Promise.all( filteredResults.map(this.createStreamObject.bind(this)) ); streams.push(...streamObjects.filter((s) => s !== null)); // Add error streams to the end streams.push( ...errorStreams.map((e) => errorStream(e.error, e.addon.name)) ); console.log( `|INF| addon > getStreams: Created ${streams.length} stream objects in ${getTimeTakenSincePoint(streamsStartTime)}` ); console.log( `|INF| addon > getStreams: Total time taken to serve streams: ${getTimeTakenSincePoint(startTime)}` ); return streams; } private createMediaFlowStream( parsedStream: ParsedStream, name: string, description: string ): Stream { if (!parsedStream.url) { console.error( `|ERR| addon > createMediaFlowStream: Stream URL is missing, cannot proxy a stream without a URL` ); throw new Error('Stream URL is missing'); } const mediaFlowConfig = getMediaFlowConfig(this.config); const proxiedUrl = createProxiedMediaFlowUrl( parsedStream.url, mediaFlowConfig, parsedStream.stream?.behaviorHints?.proxyHeaders ); if (!proxiedUrl) { throw new Error('Could not create MediaFlow proxied URL'); } const combinedTags = [ parsedStream.resolution, parsedStream.quality, parsedStream.encode, ...parsedStream.visualTags, ...parsedStream.audioTags, ...parsedStream.languages, ]; return { url: proxiedUrl, name: this.config.addonNameInDescription ? Settings.ADDON_NAME : `🕵️ ${name}`, description: this.config.addonNameInDescription ? `🕵️ ${name}\n${description}` : description, subtitles: parsedStream.stream?.subtitles, behaviorHints: { notWebReady: parsedStream.stream?.behaviorHints?.notWebReady, filename: parsedStream.filename, videoSize: Math.floor(parsedStream.size || 0) || undefined, videoHash: parsedStream.stream?.behaviorHints?.videoHash, bingeGroup: `mfp.${Settings.ADDON_ID}|${parsedStream.addon.name}|${combinedTags.join('|')}`, }, }; } private shouldProxyStream(stream: ParsedStream): boolean { const mediaFlowConfig = getMediaFlowConfig(this.config); if (!mediaFlowConfig.mediaFlowEnabled) return false; if (!stream.url) return false; // now check if mediaFlowConfig.proxiedAddons or mediaFlowConfig.proxiedServices is not null if ( mediaFlowConfig.proxiedAddons && mediaFlowConfig.proxiedAddons.length > 0 && !mediaFlowConfig.proxiedAddons.includes(stream.addon.id) ) { return false; } if ( (mediaFlowConfig.proxiedServices && mediaFlowConfig.proxiedServices.length > 0 && stream.provider && !mediaFlowConfig.proxiedServices.includes(stream.provider.id)) || (mediaFlowConfig.proxiedServices && mediaFlowConfig.proxiedServices.length > 0 && !stream.provider && !mediaFlowConfig.proxiedServices.includes('none')) ) { return false; } return true; } private async createStreamObject( parsedStream: ParsedStream ): Promise { let name: string = ''; let description: string = ''; switch (this.config.formatter) { case 'gdrive': { const { name: _name, description: _description } = gdriveFormat(parsedStream); name = _name; description = _description; break; } case 'minimalistic-gdrive': { const { name: _name, description: _description } = gdriveFormat( parsedStream, true ); name = _name; description = _description; break; } case 'imposter': { const { name: _name, description: _description } = imposterFormat(parsedStream); name = _name; description = _description; break; } case 'torrentio': { const { name: _name, description: _description } = torrentioFormat(parsedStream); name = _name; description = _description; break; } case 'torbox': { const { name: _name, description: _description } = torboxFormat(parsedStream); name = _name; description = _description; break; } default: { throw new Error('Unsupported formatter'); } } const combinedTags = [ parsedStream.resolution, parsedStream.quality, parsedStream.encode, ...parsedStream.visualTags, ...parsedStream.audioTags, ...parsedStream.languages, ]; let stream: Stream; const shouldProxy = this.shouldProxyStream(parsedStream); if (shouldProxy) { try { const mediaFlowStream = this.createMediaFlowStream( parsedStream, name, description ); if (!mediaFlowStream) { throw new Error('Unknown error creating MediaFlow stream'); } return mediaFlowStream; } catch (error) { console.error( `|ERR| addon > createStreamObject: Failed to create MediaFlow stream URL: ${error}` ); return null; } } stream = { url: parsedStream.url, externalUrl: parsedStream.externalUrl, infoHash: parsedStream.torrent?.infoHash, fileIdx: parsedStream.torrent?.fileIdx, name: this.config.addonNameInDescription ? Settings.ADDON_NAME : Settings.SHOW_DIE ? `🎲 ${name}` : name, description: this.config.addonNameInDescription ? `🎲 ${name}\n${description}` : description, subtitles: parsedStream.stream?.subtitles, sources: parsedStream.torrent?.sources, behaviorHints: { videoSize: Math.floor(parsedStream.size || 0) || undefined, filename: parsedStream.filename, bingeGroup: `${Settings.ADDON_ID}|${parsedStream.addon.name}|${combinedTags.join('|')}`, proxyHeaders: parsedStream.stream?.behaviorHints?.proxyHeaders, notWebReady: parsedStream.stream?.behaviorHints?.notWebReady, }, }; return stream; } private compareLanguages(a: ParsedStream, b: ParsedStream) { if (this.config.prioritiseLanguage) { const aHasPrioritisedLanguage = a.languages.includes( this.config.prioritiseLanguage ); const bHasPrioritisedLanguage = b.languages.includes( this.config.prioritiseLanguage ); if (aHasPrioritisedLanguage && !bHasPrioritisedLanguage) return -1; if (!aHasPrioritisedLanguage && bHasPrioritisedLanguage) return 1; } return 0; } private compareByField(a: ParsedStream, b: ParsedStream, field: string) { if (field === 'resolution') { return ( this.config.resolutions.findIndex( (resolution) => resolution[a.resolution] ) - this.config.resolutions.findIndex( (resolution) => resolution[b.resolution] ) ); } else if (field === 'cached') { let aCanbeCached = a.provider; let bCanbeCached = b.provider; let aCached = a.provider?.cached; let bCached = b.provider?.cached; // prioritise non debrid/usenet p2p over uncached if (aCanbeCached && !bCanbeCached && !aCached) return 1; if (!aCanbeCached && bCanbeCached && !bCached) return -1; if (aCanbeCached && bCanbeCached) { if (aCached === bCached) return 0; // prioritise a false value over undefined if (aCached === false && bCached === undefined) return -1; if (aCached === undefined && bCached === false) return 1; return this.config.sortBy.find( (sort) => Object.keys(sort)[0] === 'cached' )?.direction === 'asc' ? aCached ? 1 : -1 // uncached > cached : aCached ? -1 : 1; // cached > uncached } } else if (field === 'hasProvider') { // files from a provider should be prioritised and then let aHasProvider = a.provider; let bHasProvider = b.provider; if (aHasProvider && !bHasProvider) return -1; if (!aHasProvider && bHasProvider) return 1; } else if (field === 'service') { // sort files with providers by name let aProvider = a.provider?.id; let bProvider = b.provider?.id; if (aProvider && bProvider) { const aIndex = this.config.services.findIndex( (service) => service.id === aProvider ); const bIndex = this.config.services.findIndex( (service) => service.id === bProvider ); return aIndex - bIndex; } } else if (field === 'size') { return this.config.sortBy.find((sort) => Object.keys(sort)[0] === 'size') ?.direction === 'asc' ? (a.size || 0) - (b.size || 0) : (b.size || 0) - (a.size || 0); } else if (field === 'seeders') { if ( a.torrent?.seeders !== undefined && b.torrent?.seeders !== undefined ) { return this.config.sortBy.find( (sort) => Object.keys(sort)[0] === 'seeders' )?.direction === 'asc' ? a.torrent.seeders - b.torrent.seeders : b.torrent.seeders - a.torrent.seeders; } else if ( a.torrent?.seeders !== undefined && b.torrent?.seeders === undefined ) { return -1; } else if ( a.torrent?.seeders === undefined && b.torrent?.seeders !== undefined ) { return 1; } } else if (field === 'streamType') { return ( (this.config.streamTypes?.findIndex( (streamType) => streamType[a.type] ) ?? -1) - (this.config.streamTypes?.findIndex( (streamType) => streamType[b.type] ) ?? -1) ); } else if (field === 'quality') { return ( this.config.qualities.findIndex((quality) => quality[a.quality]) - this.config.qualities.findIndex((quality) => quality[b.quality]) ); } else if (field === 'visualTag') { // Find the highest priority visual tag in each file const getIndexOfTag = (tag: string) => this.config.visualTags.findIndex((t) => t[tag]); const getHighestPriorityTagIndex = (tags: string[]) => { // Check if the file contains both any HDR tag and DV const hasHDR = tags.some((tag) => tag.startsWith('HDR')); const hasDV = tags.includes('DV'); if (hasHDR && hasDV) { // Sort according to the position of the HDR+DV tag const hdrDvIndex = this.config.visualTags.findIndex( (t) => t['HDR+DV'] ); if (hdrDvIndex !== -1) { return hdrDvIndex; } } // If the file contains multiple HDR tags, look at the HDR tag that has the highest priority const hdrTagIndices = tags .filter((tag) => tag.startsWith('HDR')) .map((tag) => getIndexOfTag(tag)); if (hdrTagIndices.length > 0) { return Math.min(...hdrTagIndices); } // Always consider the highest priority visual tag when a file has multiple visual tags return tags.reduce( (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), this.config.visualTags.length ); }; const aVisualTagIndex = getHighestPriorityTagIndex(a.visualTags); const bVisualTagIndex = getHighestPriorityTagIndex(b.visualTags); // Sort by the visual tag index return aVisualTagIndex - bVisualTagIndex; } else if (field === 'audioTag') { // Find the highest priority audio tag in each file const getIndexOfTag = (tag: string) => this.config.audioTags.findIndex((t) => t[tag]); const aAudioTagIndex = a.audioTags.reduce( (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), this.config.audioTags.length ); const bAudioTagIndex = b.audioTags.reduce( (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), this.config.audioTags.length ); // Sort by the audio tag index return aAudioTagIndex - bAudioTagIndex; } else if (field === 'encode') { return ( this.config.encodes.findIndex((encode) => encode[a.encode]) - this.config.encodes.findIndex((encode) => encode[b.encode]) ); } else if (field === 'addon') { const aAddon = a.addon.id; const bAddon = b.addon.id; const addonIds = this.config.addons.map((addon) => { return `${addon.id}-${JSON.stringify(addon.options)}`; }); return addonIds.indexOf(aAddon) - addonIds.indexOf(bAddon); } else if (field === 'language') { if (this.config.prioritiseLanguage) { return this.compareLanguages(a, b); } if (!this.config.prioritisedLanguages) { return 0; } // else, we look at the array of prioritisedLanguages. // any file with a language in the prioritisedLanguages array should be prioritised // if both files contain a prioritisedLanguage, we compare the index of the highest priority language const aHasPrioritisedLanguage = a.languages.some((lang) => this.config.prioritisedLanguages?.includes(lang) ) || (a.languages.length === 0 && this.config.prioritisedLanguages?.includes('Unknown')); const bHasPrioritisedLanguage = b.languages.some((lang) => this.config.prioritisedLanguages?.includes(lang) ) || (b.languages.length === 0 && this.config.prioritisedLanguages?.includes('Unknown')); if (aHasPrioritisedLanguage && !bHasPrioritisedLanguage) return -1; if (!aHasPrioritisedLanguage && bHasPrioritisedLanguage) return 1; if (aHasPrioritisedLanguage && bHasPrioritisedLanguage) { const getHighestPriorityLanguageIndex = (languages: string[]) => { if (languages.length === 0) { const unknownIndex = this.config.prioritisedLanguages!.indexOf('Unknown'); return unknownIndex !== -1 ? unknownIndex : this.config.prioritisedLanguages!.length; } return languages.reduce((minIndex, lang) => { const index = this.config.prioritisedLanguages?.indexOf(lang) ?? this.config.prioritisedLanguages!.length; return index !== -1 ? Math.min(minIndex, index) : minIndex; }, this.config.prioritisedLanguages!.length); }; const aHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex( a.languages ); const bHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex( b.languages ); return aHighestPriorityLanguageIndex - bHighestPriorityLanguageIndex; } } return 0; } private async getParsedStreams( streamRequest: StreamRequest ): Promise<{ parsedStreams: ParsedStream[]; errorStreams: ErrorStream[] }> { const parsedStreams: ParsedStream[] = []; const errorStreams: ErrorStream[] = []; const formatError = (error: string) => error .replace(/- |: /g, '\n') .split('\n') .map((line: string) => line.trim()) .join('\n') .trim(); const addonPromises = this.config.addons.map(async (addon) => { const addonName = addon.options.name || addon.options.overrideName || addonDetails.find((addonDetail) => addonDetail.id === addon.id)?.name || addon.id; const addonId = `${addon.id}-${JSON.stringify(addon.options)}`; try { const startTime = new Date().getTime(); const { addonStreams, addonErrors } = await this.getStreamsFromAddon( addon, addonId, streamRequest ); parsedStreams.push(...addonStreams); errorStreams.push( ...[...new Set(addonErrors)].map((error) => ({ error: formatError(error), addon: { id: addonId, name: addonName }, })) ); console.log( `|INF| addon > getParsedStreams: Got ${addonStreams.length} streams ${addonErrors.length > 0 ? `and ${addonErrors.length} errors ` : ''}from addon ${addonName} in ${getTimeTakenSincePoint(startTime)}` ); } catch (error: any) { console.error( `|ERR| addon > getParsedStreams: Failed to get streams from ${addonName}: ${error}` ); errorStreams.push({ error: formatError(error.message), addon: { id: addonId, name: addonName, }, }); } }); await Promise.all(addonPromises); return { parsedStreams, errorStreams }; } private async getStreamsFromAddon( addon: Config['addons'][0], addonId: string, streamRequest: StreamRequest ): Promise<{ addonStreams: ParsedStream[]; addonErrors: string[] }> { switch (addon.id) { case 'torbox': { return await getTorboxStreams( this.config, addon.options, streamRequest, addonId ); } case 'torrentio': { return await getTorrentioStreams( this.config, addon.options, streamRequest, addonId ); } case 'comet': { return await getCometStreams( this.config, addon.options, streamRequest, addonId ); } case 'mediafusion': { return await getMediafusionStreams( this.config, addon.options, streamRequest, addonId ); } case 'jackettio': { return await getJackettioStreams( this.config, addon.options, streamRequest, addonId ); } case 'orion-stremio-addon': { return await getOrionStreams( this.config, addon.options, streamRequest, addonId ); } case 'easynews': { return await getEasynewsStreams( this.config, addon.options, streamRequest, addonId ); } case 'easynews-plus': { return await getEasynewsPlusStreams( this.config, addon.options, streamRequest, addonId ); } case 'debridio': { return await getDebridioStreams( this.config, addon.options, streamRequest, addonId ); } case 'peerflix': { return await getPeerflixStreams( this.config, addon.options, streamRequest, addonId ); } case 'dmm-cast': { return await getDMMCastStreams( this.config, addon.options, streamRequest, addonId ); } case 'gdrive': { if (!addon.options.addonUrl) { throw new Error('The addon URL was undefined for GDrive'); } const wrapper = new BaseWrapper( addon.options.overrideName || 'GDrive', addon.options.addonUrl, addonId, this.config, addon.options.indexerTimeout ? parseInt(addon.options.indexerTimeout) : Settings.DEFAULT_GDRIVE_TIMEOUT ); return await wrapper.getParsedStreams(streamRequest); } default: { if (!addon.options.url) { throw new Error( `The addon URL was undefined for ${addon.options.name}` ); } const wrapper = new BaseWrapper( addon.options.name || 'Custom', addon.options.url.trim(), addonId, this.config, addon.options.indexerTimeout ? parseInt(addon.options.indexerTimeout) : undefined ); return wrapper.getParsedStreams(streamRequest); } } } private async processGroupedStreams( groupedStreams: Record ) { const uniqueStreams: ParsedStream[] = []; Object.values(groupedStreams).forEach((groupedStreams) => { if (groupedStreams.length === 1) { uniqueStreams.push(groupedStreams[0]); return; } /*console.log( `==================\nDetermining unique streams for ${groupedStreams[0].filename} from ${groupedStreams.length} total duplicates` ); console.log( groupedStreams.map( (stream) => `Addon ID: ${stream.addon.id}, Provider ID: ${stream.provider?.id}, Provider Cached: ${stream.provider?.cached}, type: ${stream.torrent ? 'torrent' : 'usenet'}` ) ); console.log('==================');*/ // Separate streams into categories const cachedStreams = groupedStreams.filter( (stream) => stream.provider?.cached || (!stream.provider && stream.url) ); const uncachedStreams = groupedStreams.filter( (stream) => stream.provider && !stream.provider.cached ); const noProviderStreams = groupedStreams.filter( (stream) => !stream.provider && stream.torrent?.infoHash ); // Select uncached streams by addon priority (one per provider) const selectedUncachedStreams = Object.values( uncachedStreams.reduce( (acc, stream) => { acc[stream.provider!.id] = acc[stream.provider!.id] || []; acc[stream.provider!.id].push(stream); return acc; }, {} as Record ) ).map((providerGroup) => { return providerGroup.sort((a, b) => { const aIndex = this.config.addons.findIndex( (addon) => `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id ); const bIndex = this.config.addons.findIndex( (addon) => `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id ); return aIndex - bIndex; })[0]; }); //selectedUncachedStreams.forEach(stream => console.log(`Selected uncached stream for provider ${stream.provider!.id}: Addon ID: ${stream.addon.id}`)); // Select cached streams by provider and addon priority const selectedCachedStream = cachedStreams.sort((a, b) => { const aProviderIndex = this.config.services.findIndex( (service) => service.id === a.provider!.id ); const bProviderIndex = this.config.services.findIndex( (service) => service.id === b.provider!.id ); if (aProviderIndex !== bProviderIndex) { return aProviderIndex - bProviderIndex; } const aAddonIndex = this.config.addons.findIndex( (addon) => `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id ); const bAddonIndex = this.config.addons.findIndex( (addon) => `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id ); if (aAddonIndex !== bAddonIndex) { return aAddonIndex - bAddonIndex; } // now look at the type of stream. prefer usenet over torrents if (a.torrent?.seeders && !b.torrent?.seeders) return 1; if (!a.torrent?.seeders && b.torrent?.seeders) return -1; return 0; })[0]; // Select one non-provider stream (highest addon priority) const selectedNoProviderStream = noProviderStreams.sort((a, b) => { const aIndex = this.config.addons.findIndex( (addon) => `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id ); const bIndex = this.config.addons.findIndex( (addon) => `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id ); if (aIndex !== bIndex) { return aIndex - bIndex; } // now look at the type of stream. prefer usenet over torrents if (a.torrent?.seeders && !b.torrent?.seeders) return 1; if (!a.torrent?.seeders && b.torrent?.seeders) return -1; return 0; })[0]; // Combine selected streams for this group if (selectedNoProviderStream) { //console.log(`Selected no provider stream: Addon ID: ${selectedNoProviderStream.addon.id}`); uniqueStreams.push(selectedNoProviderStream); } if (selectedCachedStream) { //console.log(`Selected cached stream for provider ${selectedCachedStream.provider!.id} from Addon ID: ${selectedCachedStream.addon.id}`); uniqueStreams.push(selectedCachedStream); } uniqueStreams.push(...selectedUncachedStreams); }); return uniqueStreams; } }