diff --git a/.env.example b/.env.example index 0dfbab5..a3c9834 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ NODE_ENV=development # 'development' | 'production'. Development debuggs, produc STREMIO_ADDON=false # Set to 'true' to enable Stremio addon functionality. Usage: host:port/stremio/manifest.json CORS_ORIGIN="*" # CORS origin for the server. Set to '*' to allow all origins, or a specific origin (e.g., 'http://example.com') MCP_ENABLED=false # Set to 'true' to enable MCP functionality +INTERNAL_DEBUG=false # Set to 'true' to get non-playable sources too. recommended: false # TMDB Configuration TMDB_API_KEY=your_tmdb_api_key_here diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9c3a6b6..692dd94 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -79,9 +79,11 @@ const resp = await prov.getMovieSources(mediaObj) console.log(resp) ``` -Then run it with `npx tsx src/providers/example/test.ts`. +Then run it with `npx tsx src/providers/example/test.ts`. A full testing suite will be added in the future, but for now, this is the best way to test a single provider without having to run the entire server and make API calls to it. +When testing the whole setup, make sure to set `INTERNAL_DEBUG` to `true` in your `.env` file to get ALL sources (also non-playable ones) and detailed diagnostics in the response. This will help you identify any issues with your provider. + 3. **Test your changes**: - Test with multiple TMDB IDs (movies and TV shows) - Verify error handling works correctly diff --git a/docs/images/img.png b/docs/images/img.png deleted file mode 100644 index fe86b36..0000000 Binary files a/docs/images/img.png and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 2d7e755..9a9adb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -601,9 +601,9 @@ "license": "MIT" }, "node_modules/@omss/framework": { - "version": "1.1.24", - "resolved": "https://registry.npmjs.org/@omss/framework/-/framework-1.1.24.tgz", - "integrity": "sha512-Xr+uhh8LMimF/J+31GDf4az3BZyzKLr1NCepJW2RHF6BzOuheoGDhxh4gAyjXcpr32HUcWKhkrpIXlh0c79d8Q==", + "version": "1.1.26", + "resolved": "https://registry.npmjs.org/@omss/framework/-/framework-1.1.26.tgz", + "integrity": "sha512-LiUjydmOmTnrlUKfJmOS8WNzV9quz/MAPg42nobeLdnjmxgCYsvL7E4CrEfCobV9Cu/BNKwXw9yDx3fvWpoTxw==", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", @@ -1548,9 +1548,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/src/providers/tulnex/decrypt.ts b/src/providers/tulnex/decrypt.ts new file mode 100644 index 0000000..6b0fb8c --- /dev/null +++ b/src/providers/tulnex/decrypt.ts @@ -0,0 +1,136 @@ +/* + * Credit where credit is due. Decrypt logic was taken from: https://github.com/vyla-entertainment/stream-api/blob/main/sources/cinezo.js + * with permission: https://github.com/orgs/cinepro-org/discussions/1#discussioncomment-16937840 + */ +const L1_KEY = 'U24wMHBEMGcjTDFfWDBSX000c3QzckszeSEyMDI2c2V4'; +const L1_SALT = 'eEs5IW1SMkBwTDUjblE4c2V4'; +const L3_KEY = 'U24wMHBEMGcjTDNfQUVTX1MzY3VyM0szeUAyMDI2JHNleA=='; +const L4_KEY = 'U24wMHBEMGcjTDRfSE1BQ19GMW40bFc0bGwjMjAyNiFzZXg='; + +function base64ToBuffer(b64: string) { + const bin = atob(b64); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + return buf.buffer; +} + +function bufferToHex(buf: ArrayBuffer) { + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function strToBuffer(str: string) { + return new TextEncoder().encode(str).buffer; +} +function bufferToStr(buf: ArrayBuffer) { + return new TextDecoder().decode(buf); +} + +function hexToUint8(hex: string) { + const arr = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) + arr[i / 2] = parseInt(hex.substr(i, 2), 16); + return arr; +} + +async function pbkdf2( + pass: string, + salt: string, + iterations: number, + keyLen: number, + hash: string +) { + const keyMat = await crypto.subtle.importKey( + 'raw', + strToBuffer(pass), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ); + const derived = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: strToBuffer(salt), iterations, hash }, + keyMat, + { name: 'AES-GCM', length: keyLen * 8 }, + true, + ['encrypt', 'decrypt'] + ); + return new Uint8Array(await crypto.subtle.exportKey('raw', derived)); +} + +function xorDecrypt(hexStr: string, keyBytes: Uint8Array) { + const src = hexToUint8(hexStr); + const out = new Uint8Array(src.length); + for (let i = 0; i < src.length; i++) out[i] = src[i] ^ keyBytes[i % 32]; + return bufferToStr(out.buffer); +} + +function binaryDecode(encoded: string) { + return atob(encoded) + .split(' ') + .map((s) => String.fromCharCode(parseInt(s, 2))) + .join(''); +} + +async function decodeL3(data: string) { + const parts = data.split('.'); + if (parts.length !== 3) throw new Error('L3 invalid'); + const [ivB64, saltB64, ctB64] = parts; + const salt = atob(saltB64); + const keyBytes = await pbkdf2( + Buffer.from(L3_KEY, 'base64').toString(), + salt, + 100000, + 32, + 'SHA-512' + ); + const aesKey = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-CBC' }, + false, + ['decrypt'] + ); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-CBC', iv: new Uint8Array(base64ToBuffer(ivB64)) }, + aesKey, + base64ToBuffer(ctB64) + ); + return bufferToStr(decrypted); +} + +async function decodeL4(data: string) { + const sep = data.indexOf('|'); + if (sep === -1) throw new Error('L4 no separator'); + const receivedHmac = data.slice(0, sep); + const payload = data.slice(sep + 1); + const payloadStr = bufferToStr(base64ToBuffer(payload)); + const hmacKey = await crypto.subtle.importKey( + 'raw', + strToBuffer(Buffer.from(L4_KEY, 'base64').toString()), + { name: 'HMAC', hash: 'SHA-512' }, + false, + ['sign'] + ); + const sig = await crypto.subtle.sign( + 'HMAC', + hmacKey, + new TextEncoder().encode(payloadStr) + ); + if (receivedHmac !== bufferToHex(sig)) throw new Error('L4 HMAC mismatch'); + return payloadStr; +} + +export async function decryptPayload(payload: string) { + const xorKey = await pbkdf2( + Buffer.from(L1_KEY, 'base64').toString(), + Buffer.from(L1_SALT, 'base64').toString(), + 50000, + 32, + 'SHA-256' + ); + const l4out = await decodeL4(payload); + const l3out = await decodeL3(l4out); + const l2out = binaryDecode(l3out); + return JSON.parse(xorDecrypt(l2out, xorKey)); +} diff --git a/src/providers/tulnex/tulnex.mapper.ts b/src/providers/tulnex/tulnex.mapper.ts new file mode 100644 index 0000000..a38c8a1 --- /dev/null +++ b/src/providers/tulnex/tulnex.mapper.ts @@ -0,0 +1,158 @@ +import { ExtractedStream } from './tulnex.types.js'; + +export function extractUrl(data: any): ExtractedStream | null { + if (!data) return null; + + const wrap = ( + url: unknown, + headers: Record | null = null + ): ExtractedStream | null => { + if (!url || typeof url !== 'string' || !url.includes('http')) + return null; + return { url, headers }; + }; + + if (typeof data === 'string' && data.includes('http')) return wrap(data); + + const d = data as Record; + const headers = (d.headers as Record) ?? null; + + if (typeof d.url === 'string' && d.url.includes('http')) + return wrap(d.url, headers); + if (typeof d.stream === 'string' && d.stream.includes('http')) + return wrap(d.stream, headers); + if (typeof d.playlist === 'string' && d.playlist.includes('http')) + return wrap(d.playlist, headers); + if (typeof d.streamUrl === 'string' && d.streamUrl.includes('http')) + return wrap(d.streamUrl, headers); + if (typeof d.stream_url === 'string' && d.stream_url.includes('http')) + return wrap(d.stream_url, headers); + if (typeof d.streaming_url === 'string' && d.streaming_url.includes('http')) + return wrap(d.streaming_url, headers); + if (typeof d.video_url === 'string' && d.video_url.includes('http')) + return wrap(d.video_url, headers); + if (typeof d.m3u8 === 'string' && d.m3u8.includes('http')) + return wrap(d.m3u8, headers); + + const srcsPrimary = (d.sources as Record)?.primary as + | Record + | undefined; + if (srcsPrimary?.url) + return wrap( + srcsPrimary.url, + (srcsPrimary.headers as Record) ?? headers + ); + + if (Array.isArray(d.sources) && d.sources.length > 0) { + const sorted = (d.sources as Record[]) + .filter( + (s) => + typeof s.url === 'string' && + (s.url as string).includes('http') + ) + .sort((a, b) => { + const qa = parseInt( + ((a.quality as string) ?? '').replace('p', '') || '0' + ); + const qb = parseInt( + ((b.quality as string) ?? '').replace('p', '') || '0' + ); + return qb - qa; + }); + if (sorted.length > 0) + return wrap( + sorted[0].url, + (sorted[0].headers as Record) ?? headers + ); + } + + if (Array.isArray(d.languages)) { + const orig = (d.languages as Record[]).find( + (l) => + l.original === true && + Array.isArray(l.sources) && + (l.sources as unknown[]).length > 0 + ); + if (orig) { + const sorted = [ + ...(orig.sources as Record[]) + ].sort( + (a, b) => + parseInt( + ((b.quality as string) ?? '').replace('p', '') || '0' + ) - + parseInt( + ((a.quality as string) ?? '').replace('p', '') || '0' + ) + ); + return wrap( + sorted[0].url ?? sorted[0].file, + (sorted[0].headers as Record) ?? + (orig.headers as Record) ?? + headers + ); + } + } + + if (Array.isArray(d.links) && d.links.length > 0) { + const link = (d.links as Record[]).find( + (l) => + typeof l.url === 'string' && (l.url as string).includes('http') + ); + if (link) return wrap(link.url, headers); + } + + const nestedData = d.data as Record | undefined; + if ( + nestedData?.data && + (nestedData.data as Record)?.stream + ) + return wrap( + ( + (nestedData.data as Record).stream as Record< + string, + unknown + > + )?.playlist, + headers + ); + if (nestedData?.stream) + return wrap( + (nestedData.stream as Record)?.playlist, + headers + ); + if (typeof nestedData?.url === 'string' && nestedData.url.includes('http')) + return wrap( + nestedData.url, + (nestedData.headers as Record) ?? headers + ); + + if (Array.isArray(nestedData?.sources)) { + const src = (nestedData!.sources as Record[]).find( + (s) => + typeof s.url === 'string' && (s.url as string).includes('http') + ); + if (src) + return wrap( + src.url, + (src.headers as Record) ?? headers + ); + } + + if (Array.isArray(d.streams)) { + const src = (d.streams as Record[]).find( + (s) => + (typeof s.url === 'string' && + (s.url as string).includes('http')) || + (typeof s.link === 'string' && + (s.link as string).includes('http')) + ); + if (src) + return wrap( + src.url ?? src.link, + (src.headers as Record) ?? headers + ); + } + + return null; +} diff --git a/src/providers/tulnex/tulnex.ts b/src/providers/tulnex/tulnex.ts new file mode 100644 index 0000000..a7007d2 --- /dev/null +++ b/src/providers/tulnex/tulnex.ts @@ -0,0 +1,163 @@ +import { BaseProvider } from '@omss/framework'; +import type { + ProviderCapabilities, + ProviderMediaObject, + ProviderResult +} from '@omss/framework'; +import { generateRandomUserAgent } from '../../utils/ua.js'; +import { TulnexApiResponse } from './tulnex.types.js'; +import { decryptPayload } from './decrypt.js'; +import { extractUrl } from './tulnex.mapper.js'; + +export class TulnexProvider extends BaseProvider { + readonly id = 'tulnex'; + readonly name = 'Tulnex'; + readonly enabled = true; + + readonly BASE_URL = 'https://api.tulnex.com'; + readonly HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'accept-language': 'en-US,en;q=0.9', + 'cache-control': 'no-cache' + }; + + readonly SERVERS = [ + `onion`, + `vidzee`, + `icefy`, + `tik`, + `vaplayer`, + `vidfast-alpha`, + `uniquestream`, + `vidfast-mega`, + `vidfast-vrapid`, + `allmovies`, + `vidlink`, + `vidfast-vedge`, + `vidfast-vfast`, + `moviebox` + ]; + + readonly capabilities: ProviderCapabilities = { + supportedContentTypes: ['movies', 'tv'] + }; + + async getMovieSources(media: ProviderMediaObject): Promise { + return await this.getSources(media); + } + + async getTVSources(media: ProviderMediaObject): Promise { + return await this.getSources(media); + } + + private async getSources( + media: ProviderMediaObject + ): Promise { + try { + const results = await Promise.allSettled( + this.SERVERS.map((server) => this.doScrape(server, media)) + ); + + const successful = results + .filter( + ( + r + ): r is PromiseFulfilledResult< + Awaited> + > => r.status === 'fulfilled' && r.value != null + ) + .map((r) => r.value); + + return { + sources: successful + .filter((r) => r !== null) + .map((r) => ({ + url: this.createProxyUrl( + r.url, + r.headers ? r.headers : {} + ), + type: + r.url.includes('mkv') || r.url.includes('mp4') + ? 'mp4' + : 'hls', + audioTracks: [ + { + label: 'Original', + language: 'Original' + } + ], + quality: 'Auto', + provider: { + name: this.name, + id: this.id + } + })), + subtitles: [], + diagnostics: [] + }; + } catch (e) { + return this.emptyResult( + e instanceof Error ? e.message : 'Unknown provider error' + ); + } + } + + private async doScrape(serverName: string, media: ProviderMediaObject) { + const url = + media.type === 'movie' + ? this.BASE_URL + '/' + serverName + '/movie/' + media.tmdbId + : this.BASE_URL + + '/' + + serverName + + '/tv/' + + media.tmdbId + + '/' + + media.s + + '/' + + media.e; + const req = await fetch(url, { + headers: { ...this.HEADERS, Accept: 'application/json, */*' } + }); + if (!req.ok) { + return null; + } + const data = (await req.json()) as unknown as TulnexApiResponse; + if (data.payload === undefined) { + return null; + } + const decrypted = await decryptPayload(data.payload); + if (!decrypted) { + return null; + } + return extractUrl(decrypted); + } + + private emptyResult(message: string): ProviderResult { + return { + sources: [], + subtitles: [], + diagnostics: [ + { + code: 'PROVIDER_ERROR', + message: `${this.name}: ${message}`, + field: '', + severity: 'error' + } + ] + }; + } + + async healthCheck(): Promise { + try { + const response = await fetch(this.BASE_URL, { + method: 'HEAD', + headers: this.HEADERS + }); + return response.status === 200; + } catch { + return false; + } + } +} diff --git a/src/providers/tulnex/tulnex.types.ts b/src/providers/tulnex/tulnex.types.ts new file mode 100644 index 0000000..0b7ddd8 --- /dev/null +++ b/src/providers/tulnex/tulnex.types.ts @@ -0,0 +1,9 @@ +export type TulnexApiResponse = { + v: string; + payload: string; +}; + +export interface ExtractedStream { + url: string; + headers: Record | null; +} diff --git a/src/providers/vidapi/vidapi.ts b/src/providers/vidapi/vidapi.ts index 959b8cd..b1f671c 100644 --- a/src/providers/vidapi/vidapi.ts +++ b/src/providers/vidapi/vidapi.ts @@ -18,9 +18,11 @@ export class VidApiProvider extends BaseProvider { readonly IFRAME_URL = 'https://brightpathsignals.com'; readonly API_URL = 'https://streamdata.vaplayer.ru/api.php'; readonly HEADERS = { - 'User-Agent': generateRandomUserAgent(), - referer: `${this.IFRAME_URL}/`, - origin: this.IFRAME_URL + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + Referer: `${this.IFRAME_URL}/`, + Origin: this.IFRAME_URL, + Accept: '*/*' }; readonly capabilities: ProviderCapabilities = { @@ -71,12 +73,8 @@ export class VidApiProvider extends BaseProvider { const data = json.data; const diagnostics: ProviderResult['diagnostics'] = []; - const sources: Source[] = (data.stream_urls ?? []) - .filter( - (streamUrl: string) => - !streamUrl.includes('strategicgrowthpartners') - ) - .map((streamUrl: string): Source => { + const sources: Source[] = (data.stream_urls ?? []).map( + (streamUrl: string): Source => { const sourceType: SourceType = streamUrl.includes('mp4') || streamUrl.includes('mkv') ? 'mp4' @@ -97,7 +95,8 @@ export class VidApiProvider extends BaseProvider { name: this.name } }; - }); + } + ); const subtitles: Subtitle[] = (json.default_subs ?? []).map( (sub: { lang: string; diff --git a/src/providers/vidnest/vidnest.ts b/src/providers/vidnest/vidnest.ts index f4c2c00..7232284 100644 --- a/src/providers/vidnest/vidnest.ts +++ b/src/providers/vidnest/vidnest.ts @@ -168,7 +168,7 @@ export class VidNestProvider extends BaseProvider { root.sources.map((s) => ({ url: this.createProxyUrl(s.url), type: this.inferSourceType(s.format, s.url), - quality: s.name, + quality: this.inferQuality(s.name), audioTracks: [{ language: 'French', label: 'fr' }], provider: { id: this.id, name: this.name } })), diff --git a/src/providers/vixsrc/vixsrc.ts b/src/providers/vixsrc/vixsrc.ts index 3711022..e4c6967 100644 --- a/src/providers/vixsrc/vixsrc.ts +++ b/src/providers/vixsrc/vixsrc.ts @@ -299,6 +299,9 @@ export class VixSrcProvider extends BaseProvider { */ private parseSubtitles(content: string, pageUrl: string): Subtitle[] { const subtitles: Subtitle[] = []; + + /* Doesn't work.. + // TODO: Fix subtitles for vixsrc const lines = content.split('\n'); for (const line of lines) { @@ -318,6 +321,7 @@ export class VixSrcProvider extends BaseProvider { format: 'vtt' }); } + */ return subtitles; } diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..fcedef9 --- /dev/null +++ b/vercel.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "builds": [ + { + "src": "src/index.ts", + "use": "@vercel/node", + "config": { + "includeFiles": ["dist/**"] + } + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "src/index.ts" + } + ], + "functions": { + "src/index.ts": { + "runtime": "nodejs20.x", + "maxDuration": 10 + } + }, + "env": { + "NODE_ENV": "production" + }, + "build": { + "env": { + "NODE_ENV": "production" + } + }, + "cleanUrls": true, + "trailingSlash": false +} \ No newline at end of file