diff --git a/packages/addon/src/addon.ts b/packages/addon/src/addon.ts index c8182da8..d6b54edd 100644 --- a/packages/addon/src/addon.ts +++ b/packages/addon/src/addon.ts @@ -28,12 +28,12 @@ import { } from '@aiostreams/formatters'; import { addonDetails, - createProxiedMediaFlowUrl, getMediaFlowConfig, getMediaFlowPublicIp, getTimeTakenSincePoint, Settings, createLogger, + generateMediaFlowStreams, } from '@aiostreams/utils'; import { errorStream } from './responses'; @@ -508,9 +508,7 @@ export class AIOStreams { // Create stream objects const streamsStartTime = new Date().getTime(); - const streamObjects = await Promise.all( - filteredResults.map(this.createStreamObject.bind(this)) - ); + const streamObjects = await this.createStreamObjects(filteredResults); streams.push(...streamObjects.filter((s) => s !== null)); // Add error streams to the end @@ -527,56 +525,6 @@ export class AIOStreams { return streams; } - private async createMediaFlowStream( - parsedStream: ParsedStream, - name: string, - description: string - ): Promise { - if (!parsedStream.url) { - logger.error( - `Stream URL is missing, cannot proxy a stream without a URL`, - { func: 'createMediaFlowStream' } - ); - throw new Error('Stream URL is missing'); - } - - const mediaFlowConfig = getMediaFlowConfig(this.config); - const proxiedUrl = await 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.split('\n').join(' ')}\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; @@ -608,107 +556,133 @@ export class AIOStreams { return true; } - private async createStreamObject( - parsedStream: ParsedStream - ): Promise { - let name: string = ''; - let description: string = ''; + private getFormattedText(parsedStream: ParsedStream): { + name: string; + description: string; + } { switch (this.config.formatter) { case 'gdrive': { - const { name: _name, description: _description } = - gdriveFormat(parsedStream); - name = _name; - description = _description; - break; + return gdriveFormat(parsedStream, false); } case 'minimalistic-gdrive': { - const { name: _name, description: _description } = gdriveFormat( - parsedStream, - true - ); - name = _name; - description = _description; - break; + return gdriveFormat(parsedStream, true); } case 'imposter': { - const { name: _name, description: _description } = - imposterFormat(parsedStream); - name = _name; - description = _description; - break; + return imposterFormat(parsedStream); } case 'torrentio': { - const { name: _name, description: _description } = - torrentioFormat(parsedStream); - name = _name; - description = _description; - break; + return torrentioFormat(parsedStream); } case 'torbox': { - const { name: _name, description: _description } = - torboxFormat(parsedStream); - name = _name; - description = _description; - break; + return torboxFormat(parsedStream); } default: { throw new Error('Unsupported formatter'); } } + } - const combinedTags = [ - parsedStream.resolution, - parsedStream.quality, - parsedStream.encode, - ...parsedStream.visualTags, - ...parsedStream.audioTags, - ...parsedStream.languages, - ]; + private async createStreamObjects( + parsedStreams: ParsedStream[] + ): Promise { + // Step 1: Format all stream metadata + let streamObjects: Stream[] = await Promise.all( + parsedStreams.map(async (parsedStream) => { + const { name, description } = this.getFormattedText(parsedStream); - let stream: Stream; - const shouldProxy = this.shouldProxyStream(parsedStream); - if (shouldProxy) { - try { - const mediaFlowStream = await this.createMediaFlowStream( - parsedStream, - name, - description - ); - if (!mediaFlowStream) { - throw new Error('Unknown error creating MediaFlow stream'); - } - return mediaFlowStream; - } catch (error) { - logger.error(`Failed to create MediaFlow stream URL: ${error}`); - return null; - } + const combinedTags = [ + parsedStream.resolution, + parsedStream.quality, + parsedStream.encode, + ...parsedStream.visualTags, + ...parsedStream.audioTags, + ...parsedStream.languages, + ]; + + return { + 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.split('\n').join(' ')}\n${description}` + : description, + subtitles: parsedStream.stream?.subtitles, + sources: parsedStream.torrent?.sources, + behaviorHints: { + videoSize: parsedStream.size + ? Math.floor(parsedStream.size) + : undefined, + filename: parsedStream.filename, + bingeGroup: `${Settings.ADDON_ID}|${parsedStream.addon.name}|${combinedTags.join('|')}`, + proxyHeaders: parsedStream.stream?.behaviorHints?.proxyHeaders, + notWebReady: parsedStream.stream?.behaviorHints?.notWebReady, + }, + }; + }) + ); + + // Determine which streams need proxying and remember their indexes + const streamsToProxy = parsedStreams + .map((stream, index) => ({ stream, index })) + .filter(({ stream }) => stream.url !== undefined) // cannot proxy a stream without a URL + .filter(({ stream }) => this.shouldProxyStream(stream)); + + // Generate proxied URLs + const proxiedUrls = + streamsToProxy.length > 0 + ? await generateMediaFlowStreams( + getMediaFlowConfig(this.config), + streamsToProxy.map(({ stream }) => ({ + url: stream.url!, + filename: stream.filename, + headers: stream.stream?.behaviorHints?.proxyHeaders, + })) + ) + : null; + + if ( + streamsToProxy && + proxiedUrls && + proxiedUrls.length !== streamsToProxy.length + ) { + logger.error( + `Proxied URLs length (${proxiedUrls.length}) does not match streamsToProxy length (${streamsToProxy.length})` + ); + return streamObjects; + } else if (streamsToProxy.length > 0 && !proxiedUrls) { + logger.error( + `Proxied URLs is null, but streamsToProxy length is ${streamsToProxy.length}, filtering out streams that needed proxying` + ); + streamObjects = streamObjects.filter( + (_, index) => !streamsToProxy.some((s) => s.index === index) + ); + } else if (proxiedUrls) { + // inject proxied URLs back into their original positions + streamsToProxy.forEach(({ index }, i) => { + streamObjects[index].url = proxiedUrls[i] || streamObjects[index].url; + + streamObjects[index].name = this.config.addonNameInDescription + ? Settings.ADDON_NAME + : `🕵️ ${streamObjects[index].name}`; + + streamObjects[index].description = this.config.addonNameInDescription + ? `🕵️ ${streamObjects[index].name.split('\n').join(' ')}\n${streamObjects[index].description}` + : streamObjects[index].description; + + streamObjects[index].behaviorHints = { + ...streamObjects[index].behaviorHints, + bingeGroup: `mfp.${streamObjects[index].behaviorHints?.bingeGroup}`, + }; + }); } - 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.split('\n').join(' ')}\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; + return streamObjects; } private compareLanguages(a: ParsedStream, b: ParsedStream) { diff --git a/packages/utils/src/mediaflow.ts b/packages/utils/src/mediaflow.ts index 3f516cfb..b1821ffd 100644 --- a/packages/utils/src/mediaflow.ts +++ b/packages/utils/src/mediaflow.ts @@ -3,115 +3,86 @@ import path from 'path'; import { Settings } from './settings'; import { getTextHash } from './crypto'; import { Cache } from './cache'; -import { createLogger } from './logger'; +import { createLogger, maskSensitiveInfo } from './logger'; const logger = createLogger('mediaflow'); const PRIVATE_CIDR = /^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/; -export async function createProxiedMediaFlowUrl( - url: string, +export async function generateMediaFlowStreams( mediaFlowConfig: Config['mediaFlowConfig'], - headers?: { - request?: Record; - response?: Record; - } -) { - if (!url) { - logger.error('streamUrl is missing, could not create proxied URL'); - throw new Error('Stream URL is missing'); - } - if (!mediaFlowConfig) { - logger.error('mediaFlowConfig is missing'); - throw new Error('MediaFlow configuration is missing'); - } - if (!mediaFlowConfig?.proxyUrl || !mediaFlowConfig?.apiPassword) { - logger.error('mediaFlowUrl or API password is missing'); - throw new Error('MediaFlow URL or API password is missing'); - } - - const queryParams: Record = { - api_password: mediaFlowConfig.apiPassword, - }; - queryParams.d = url; - - const responseHeaders = headers?.response || { - 'Content-Disposition': `attachment; filename=${path.basename(url)}`, - }; - const requestHeaders = headers?.request || {}; - - if (Settings.ENCRYPT_MEDIAFLOW_URLS) { - const encryptedUrl = await encryptMediaFlowUrl( - url, - mediaFlowConfig, - responseHeaders, - requestHeaders - ); - return encryptedUrl; - } - - if (requestHeaders) { - Object.entries(requestHeaders).forEach(([key, value]) => { - queryParams[`h_${key}`] = value; - }); - } - - if (responseHeaders) { - Object.entries(responseHeaders).forEach(([key, value]) => { - queryParams[`r_${key}`] = value; - }); - } - - const encodedParams = new URLSearchParams(queryParams).toString(); - const proxiedUrl = new URL(mediaFlowConfig.proxyUrl.replace(/\/$/, '')); - const proxyEndpoint = '/proxy/stream'; - proxiedUrl.pathname = `${proxiedUrl.pathname === '/' ? '' : proxiedUrl.pathname}${proxyEndpoint}`; - proxiedUrl.search = encodedParams; - return proxiedUrl.toString(); -} - -async function encryptMediaFlowUrl( - url: string, - mediaFlowConfig: Config['mediaFlowConfig'], - responseHeaders: Record, - requestHeaders: Record -) { + streams: { + url: string; + filename?: string; + headers?: { + request?: Record; + response?: Record; + }; + }[] +): Promise { if (!mediaFlowConfig) { throw new Error('MediaFlow configuration is missing'); } const proxyUrl = new URL(mediaFlowConfig.proxyUrl.replace(/\/$/, '')); - const generateEncryptedUrlEndpoint = '/generate_encrypted_or_encoded_url'; - proxyUrl.pathname = `${proxyUrl.pathname === '/' ? '' : proxyUrl.pathname}${generateEncryptedUrlEndpoint}`; + const generateUrlsEndpoint = '/generate_urls'; + proxyUrl.pathname = `${proxyUrl.pathname === '/' ? '' : proxyUrl.pathname}${generateUrlsEndpoint}`; const data = { mediaflow_proxy_url: mediaFlowConfig.proxyUrl.replace(/\/$/, ''), - endpoint: '/proxy/stream', - destination_url: url, - request_headers: requestHeaders, - response_headers: responseHeaders, - expiration: 3600 * 24, // URL will expire in 24 hours - api_password: mediaFlowConfig.apiPassword, + api_password: Settings.ENCRYPT_MEDIAFLOW_URLS + ? mediaFlowConfig.apiPassword + : undefined, + urls: streams.map((stream) => { + return { + endpoint: '/proxy/stream', + filename: stream.filename || path.basename(stream.url), + query_params: Settings.ENCRYPT_MEDIAFLOW_URLS + ? undefined + : { + api_password: mediaFlowConfig.apiPassword, + }, + destination_url: stream.url, + request_headers: stream.headers?.request, + response_headers: stream.headers?.response, + }; + }), }; - const response = await fetch(proxyUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - signal: AbortSignal.timeout(Settings.MEDIAFLOW_IP_TIMEOUT), - }); - if (!response.ok) { - throw new Error(`${response.status}: ${response.statusText}`); - } - const responseData = await response.json(); - if (responseData.error) { - throw new Error(responseData.error); - } - if (responseData.encoded_url) { - return responseData.encoded_url; - } else { - throw new Error('No encrypted or encoded URL returned'); + try { + if (Settings.LOG_SENSITIVE_INFO) { + logger.debug(`POST ${proxyUrl.toString()}`); + } else { + logger.debug( + `POST ${proxyUrl.protocol}://${maskSensitiveInfo(proxyUrl.hostname)}${proxyUrl.port ? `:${proxyUrl.port}` : ''}/${generateUrlsEndpoint}` + ); + } + const response = await fetch(proxyUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + signal: AbortSignal.timeout(Settings.MEDIAFLOW_IP_TIMEOUT), + }); + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + + const responseData = await response.json(); + + if (responseData.error) { + throw new Error(responseData.error); + } + if (responseData.urls) { + return responseData.urls; + } else { + throw new Error('No encrypted or encoded URL returned'); + } + } catch (error) { + logger.error( + `Failed to encrypt MediaFlow URL using request to ${maskSensitiveInfo(proxyUrl.toString())}: ${error}` + ); + return null; } } @@ -184,7 +155,7 @@ export async function getMediaFlowPublicIp( } return publicIp; } catch (error: any) { - logger.error(`${error.message}`); + logger.error(`Failed to get MediaFlow public IP: ${error.message}`); return null; } }