import express from 'express'; import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; import VylaSDK from '@vyla-entertainment/sdk'; dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3000; const sdk = new VylaSDK({ tmdbApiKey: process.env.TMDB_API_KEY || '3f6703f3e2a0b2b40fe4fbb3e1546244' }); app.use(express.static(__dirname)); function getCleanClientIp(req) { let ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || ''; if (ip.includes(',')) ip = ip.split(',')[0].trim(); if (ip.includes('::ffff:')) ip = ip.replace('::ffff:', ''); if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || !ip) { return undefined; } return ip; } app.get('/api/sources', (req, res) => { try { const sources = sdk.getSources(true); res.json({ sources }); } catch (err) { res.status(500).json({ error: err.message }); } }); async function isStreamPlayable(streamUrl, headers = {}) { try { const fetchOpts = { method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 'Accept': '*/*', 'Accept-Language': 'en-US,en;q=0.9', ...headers, 'Range': 'bytes=0-1024' }, redirect: 'follow', signal: AbortSignal.timeout(6000) }; const res = await fetch(streamUrl, fetchOpts); if (![200, 206, 304].includes(res.status)) return false; const ct = (res.headers.get('content-type') || '').toLowerCase(); const isM3U8 = streamUrl.includes('.m3u8') || ct.includes('mpegurl') || ct.includes('m3u8'); if (isM3U8) { const text = await res.text(); const lines = text.split('\n'); const baseUrl = streamUrl.substring(0, streamUrl.lastIndexOf('/') + 1); const originBase = new URL(streamUrl).origin; let firstTarget = null; for (let i = 0; i < lines.length; i++) { const t = lines[i].trim(); if (t && !t.startsWith('#')) { firstTarget = t.startsWith('http') ? t : t.startsWith('/') ? originBase + t : baseUrl + t; break; } } if (!firstTarget) return false; if (firstTarget.includes('.m3u8') || firstTarget.includes('m3u8')) { return await isStreamPlayable(firstTarget, headers); } const chunkRes = await fetch(firstTarget, { method: 'GET', headers: { 'User-Agent': fetchOpts.headers['User-Agent'], 'Accept': '*/*', 'Accept-Language': 'en-US,en;q=0.9', ...headers, 'Range': 'bytes=0-1024' }, redirect: 'follow', signal: AbortSignal.timeout(6000) }); return [200, 206, 304].includes(chunkRes.status); } return true; } catch (_) { return false; } } app.get('/api/stream', async (req, res) => { const { provider, tmdbId, season, episode } = req.query; if (!provider || !tmdbId) { return res.status(400).json({ error: 'Missing provider or tmdbId parameter' }); } const clientIP = getCleanClientIp(req); try { const s = season && season !== 'null' && season !== 'undefined' ? season : undefined; const e = episode && episode !== 'null' && episode !== 'undefined' ? episode : undefined; const streamData = await sdk.getStream( String(provider), String(tmdbId), s, e, clientIP ); if (!streamData) return res.status(404).json({ error: 'Stream not found' }); const customHeaders = streamData.headers || {}; if (Array.isArray(streamData.allUrls) && streamData.allUrls.length > 0) { const checks = await Promise.all( streamData.allUrls.map(async (u) => { const ok = u && u.url ? await isStreamPlayable(u.url, customHeaders) : false; return ok ? u : null; }) ); streamData.allUrls = checks.filter(Boolean); if (streamData.allUrls.length > 0) { if (!streamData.url || !streamData.allUrls.some(u => u.url === streamData.url)) { streamData.url = streamData.allUrls[0].url; } } else { streamData.url = null; } } else if (streamData.url) { const ok = await isStreamPlayable(streamData.url, customHeaders); if (!ok) streamData.url = null; } if (!streamData.url) { return res.status(404).json({ error: 'No playable stream found' }); } res.json({ success: true, provider, stream: streamData }); } catch (err) { res.status(404).json({ error: err.message }); } }); app.get('/api/subtitles', async (req, res) => { const { tmdbId, season, episode } = req.query; if (!tmdbId) return res.json({ subtitles: [] }); try { const s = season && season !== 'null' && season !== 'undefined' ? season : undefined; const e = episode && episode !== 'null' && episode !== 'undefined' ? episode : undefined; const subtitles = await sdk.getSubtitles(String(tmdbId), s, e); res.json({ subtitles: subtitles || [] }); } catch (err) { res.json({ subtitles: [] }); } }); app.get('/api/timestamps', async (req, res) => { const { tmdb_id, season, episode, duration_ms } = req.query; if (!tmdb_id) return res.status(400).json({ error: 'Missing tmdb_id' }); let u = `https://api.theintrodb.org/v3/media?tmdb_id=${tmdb_id}`; if (season) u += `&season=${season}`; if (episode) u += `&episode=${episode}`; if (duration_ms) u += `&duration_ms=${duration_ms}`; try { const upstream = await fetch(u); if (!upstream.ok) return res.json({ segments: [] }); const data = await upstream.json(); const segments = []; ['recap', 'intro', 'preview', 'credits'].forEach(type => { if (Array.isArray(data[type])) { data[type].forEach(seg => { segments.push({ type, label: type === 'credits' ? 'Skip Credits' : type === 'recap' ? 'Skip Recap' : type === 'preview' ? 'Skip Preview' : 'Skip Intro', start: seg.start_ms != null ? seg.start_ms / 1000 : 0, end: seg.end_ms != null ? seg.end_ms / 1000 : null }); }); } }); res.json({ segments }); } catch (_) { res.json({ segments: [] }); } }); app.all('/api/proxy', async (req, res) => { const targetUrl = req.query.url; if (!targetUrl) return res.status(400).send('Missing url parameter'); let customHeaders = {}; if (req.query.headers) { try { customHeaders = JSON.parse(req.query.headers); } catch (_) { } } delete customHeaders['Host']; delete customHeaders['host']; const isStrip = req.query.strip === '1' || req.query.tt === '1' || /tiktokcdn\.com|ibyteimg\.com|seg\.html|enproxy|letsgocdn\d+\.shop/i.test(targetUrl); try { const fetchOpts = { method: req.method === 'HEAD' ? 'HEAD' : 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 'Accept': '*/*', 'Accept-Language': 'en-US,en;q=0.9', ...customHeaders }, redirect: 'follow' }; if (req.headers.range) fetchOpts.headers['Range'] = req.headers.range; let response; let lastErr; for (let i = 0; i < 3; i++) { try { response = await fetch(targetUrl, fetchOpts); if (response.ok || response.status === 206 || response.status === 404) break; if (response.status === 403) { if (i === 2) break; await new Promise(r => setTimeout(r, 1000)); continue; } } catch (err) { lastErr = err; if (i === 2) break; await new Promise(r => setTimeout(r, 500 * (i + 1))); } } if (!response) { return res.status(502).send(lastErr ? lastErr.message : 'Bad Gateway'); } res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': '*', 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges' }); if (req.method === 'OPTIONS') return res.sendStatus(204); if (!response.ok && response.status !== 206) { return res.status(response.status).send(`Upstream error: ${response.status}`); } const ct = (response.headers.get('content-type') || '').toLowerCase(); const isM3U8 = targetUrl.includes('.m3u8') || ct.includes('mpegurl') || ct.includes('m3u8'); if (targetUrl.includes('.srt') || ct.includes('subrip')) { const raw = await response.text(); let vtt = 'WEBVTT\n\n' + raw .replace(/\r\n|\r/g, '\n') .replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, '$1.$2'); res.set('Content-Type', 'text/vtt; charset=utf-8'); return res.send(vtt); } if (isM3U8) { const text = await response.text(); const baseUrl = targetUrl.substring(0, targetUrl.lastIndexOf('/') + 1); const originBase = new URL(targetUrl).origin; const headersParam = Object.keys(customHeaders).length ? `&headers=${encodeURIComponent(JSON.stringify(customHeaders))}` : ''; const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { const t = lines[i].trim(); if (!t) continue; if (t.startsWith('#EXT-X-KEY') || t.startsWith('#EXT-X-MAP')) { let uIdx = t.indexOf('URI="'); if (uIdx !== -1) { const end = t.indexOf('"', uIdx + 5); if (end !== -1) { const uri = t.slice(uIdx + 5, end); const abs = uri.startsWith('http') ? uri : uri.startsWith('/') ? originBase + uri : baseUrl + uri; lines[i] = t.slice(0, uIdx + 5) + `/api/proxy?url=${encodeURIComponent(abs)}${headersParam}` + '"' + t.slice(end + 1); } } } else if (!t.startsWith('#')) { const abs = t.startsWith('http') ? t : t.startsWith('/') ? originBase + t : baseUrl + t; const stripParam = isStrip || /seg\.html|enproxy|tiktokcdn|ibyteimg/i.test(abs) ? '&strip=1' : ''; lines[i] = `/api/proxy?url=${encodeURIComponent(abs)}${headersParam}${stripParam}`; } } res.set('Content-Type', 'application/vnd.apple.mpegurl'); return res.send(lines.join('\n')); } if (response.headers.has('content-type')) res.set('Content-Type', response.headers.get('content-type')); if (response.headers.has('content-length')) res.set('Content-Length', response.headers.get('content-length')); if (response.headers.has('content-range')) res.set('Content-Range', response.headers.get('content-range')); if (response.headers.has('accept-ranges')) res.set('Accept-Ranges', response.headers.get('accept-ranges')); if (req.method === 'HEAD') return res.end(); if (isStrip) { const arrayBuffer = await response.arrayBuffer(); let buffer = Buffer.from(arrayBuffer); if (buffer.length > 120 && (buffer[0] === 0x89 || buffer[0] === 0xFF || buffer[0] === 0x00)) { buffer = buffer.subarray(120); } res.set('Content-Type', 'video/MP2T'); res.removeHeader('content-length'); return res.send(buffer); } res.status(response.status); if (response.body) { const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; res.write(value); } res.end(); } else { res.end(); } } catch (err) { res.status(502).send(err.message); } }); app.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}`); });