feat: optimise stream generation and use new mediaflow endpoints with filename support

This commit is contained in:
Viren070
2025-04-26 21:01:53 +01:00
parent 64fb0c17b6
commit ebfccedf57
2 changed files with 176 additions and 231 deletions
+110 -136
View File
@@ -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<Stream> {
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<Stream | null> {
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<Stream[]> {
// 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) {
+66 -95
View File
@@ -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<string, string>;
response?: Record<string, string>;
}
) {
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<string, string> = {
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<string, string>,
requestHeaders: Record<string, string>
) {
streams: {
url: string;
filename?: string;
headers?: {
request?: Record<string, string>;
response?: Record<string, string>;
};
}[]
): Promise<string[] | null> {
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;
}
}