mirror of
https://gitlab.com/vyla-entertainment/TV.git
synced 2026-11-09 17:47:20 -05:00
348 lines
13 KiB
JavaScript
348 lines
13 KiB
JavaScript
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}`);
|
|
}); |