diff --git a/src/extractor/StreamUp.ts b/src/extractor/StreamUp.ts new file mode 100644 index 0000000..dccfddb --- /dev/null +++ b/src/extractor/StreamUp.ts @@ -0,0 +1,113 @@ +import crypto from 'crypto'; +import { Context, Format, Meta, UrlResult } from '../types'; +import { Extractor } from './Extractor'; +import { NotFoundError } from '../error'; + +/** + * Port of: + * https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/streamup.py + */ +export class StreamUp extends Extractor { + public readonly id = 'streamup'; + public readonly label = 'StreamUp'; + public override readonly ttl = 6 * 60 * 60 * 1000; // 6 hours + + public supports(_ctx: Context, url: URL): boolean { + return [ + 'streamup.ws', + 'streamup.cc', + 'strmup.to', + 'strmup.cc', + 'vfaststream.com', + ].includes(url.host); + } + + public override normalize(url: URL): URL { + return new URL(`/${url.pathname.split('/').at(-1)}`, url.origin); + } + + protected async extractInternal( + ctx: Context, + url: URL, + meta: Meta, + ): Promise { + const referer = `${url.origin}/`; + + const headers = { + Referer: referer, + 'User-Agent': 'Mozilla/5.0', + }; + + const html = await this.fetcher.text(ctx, url, { headers }); + + const sessionMatch = html.match(/'([a-f0-9]{32})'/); + const encryptedMatch = html.match(/'([A-Za-z0-9+/=]{200,})'/); + + let streamUrl: string | undefined; + + if (sessionMatch && encryptedMatch) { + /* encrypted flow */ + const sessionId = sessionMatch[1] as string; + const encryptedB64 = encryptedMatch[1] as string; + + const keyUrl = new URL(`/ajax/stream?session=${sessionId}`, url.origin); + const keyB64 = await this.fetcher.text(ctx, keyUrl, { headers }); + + const key = Buffer.from(keyB64, 'base64'); + const encrypted = Buffer.from(encryptedB64, 'base64'); + + const iv = encrypted.subarray(0, 16); + const ciphertext = encrypted.subarray(16); + + const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv); + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString('utf-8'); + + const data = JSON.parse(decrypted) as { + streaming_url?: string; + }; + + streamUrl = data.streaming_url; + } else { + /* fallback flow */ + const filecode = url.pathname.split('/').at(-1); + const apiUrl = new URL(`/ajax/stream?filecode=${filecode}`, url.origin); + + const jsonText = await this.fetcher.text(ctx, apiUrl, { headers }); + const data = JSON.parse(jsonText) as { + streaming_url?: string; + }; + + streamUrl = data.streaming_url; + } + + if (!streamUrl) { + throw new NotFoundError(); + } + + /* cleanup identical to Python resolver */ + streamUrl = streamUrl + .replace(/\\r\\n/g, '') + .replace(/\r|\n/g, '') + .replace(/\\\//g, '/'); + + return [ + { + url: new URL(streamUrl), + format: Format.hls, + label: this.label, + sourceId: `${this.id}_${meta.countryCodes?.join('_')}`, + ttl: this.ttl, + requestHeaders: { + Referer: referer, + Origin: url.origin, + }, + meta: { + ...meta, + }, + }, + ]; + } +}