Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file removed docs/images/img.png
Binary file not shown.
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

136 changes: 136 additions & 0 deletions src/providers/tulnex/decrypt.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer>) {
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));
}
158 changes: 158 additions & 0 deletions src/providers/tulnex/tulnex.mapper.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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<string, unknown>;
const headers = (d.headers as Record<string, string>) ?? 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<string, unknown>)?.primary as
| Record<string, unknown>
| undefined;
if (srcsPrimary?.url)
return wrap(
srcsPrimary.url,
(srcsPrimary.headers as Record<string, string>) ?? headers
);

if (Array.isArray(d.sources) && d.sources.length > 0) {
const sorted = (d.sources as Record<string, unknown>[])
.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<string, string>) ?? headers
);
}

if (Array.isArray(d.languages)) {
const orig = (d.languages as Record<string, unknown>[]).find(
(l) =>
l.original === true &&
Array.isArray(l.sources) &&
(l.sources as unknown[]).length > 0
);
if (orig) {
const sorted = [
...(orig.sources as Record<string, unknown>[])
].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<string, string>) ??
(orig.headers as Record<string, string>) ??
headers
);
}
}

if (Array.isArray(d.links) && d.links.length > 0) {
const link = (d.links as Record<string, unknown>[]).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<string, unknown> | undefined;
if (
nestedData?.data &&
(nestedData.data as Record<string, unknown>)?.stream
)
return wrap(
(
(nestedData.data as Record<string, unknown>).stream as Record<
string,
unknown
>
)?.playlist,
headers
);
if (nestedData?.stream)
return wrap(
(nestedData.stream as Record<string, unknown>)?.playlist,
headers
);
if (typeof nestedData?.url === 'string' && nestedData.url.includes('http'))
return wrap(
nestedData.url,
(nestedData.headers as Record<string, string>) ?? headers
);

if (Array.isArray(nestedData?.sources)) {
const src = (nestedData!.sources as Record<string, unknown>[]).find(
(s) =>
typeof s.url === 'string' && (s.url as string).includes('http')
);
if (src)
return wrap(
src.url,
(src.headers as Record<string, string>) ?? headers
);
}

if (Array.isArray(d.streams)) {
const src = (d.streams as Record<string, unknown>[]).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<string, string>) ?? headers
);
}

return null;
}
Loading
Loading