Files
TV/server.js
2026-10-04 20:01:41 -07:00

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}`);
});