Spaces:
Paused
Paused
| 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<Stream[]> { | |
| 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( | |
| `(?<![^ [(_\\-.])(${this.config.excludeFilters | |
| .map((filter) => filter.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')) | |
| .map((filter) => filter.replace(/\s/g, '[ .\\-_]?')) | |
| .join('|')})(?=[ \\)\\]_.-]|$)`, | |
| 'i' | |
| ) | |
| : null; | |
| const strictIncludeRegex = this.config.strictIncludeFilters | |
| ? new RegExp( | |
| `(?<![^ [(_\\-.])(${this.config.strictIncludeFilters | |
| .map((filter) => 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<string, ParsedStream[]> => { | |
| 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<string, ParsedStream[]> | |
| ); | |
| }; | |
| 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<Stream | null> { | |
| 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<string, ParsedStream[]> | |
| ) { | |
| 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<string, ParsedStream[]> | |
| ) | |
| ).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; | |
| } | |
| } | |