This commit is contained in:
4DBug
2026-08-26 13:22:49 -05:00
commit 53b3c1da6c
96 changed files with 6210 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const { Readable } = require('stream');
const { loadConfig } = require('../mod/config');
const tmdb = require('../mod/tmdb');
const { listAll, loadProviders } = require('../mod/loadProviders');
const { scrapeAll } = require('../mod/scrape');
const { normalize } = require('../mod/normalize');
const { applyFilters, rankStreams } = require('../mod/filter');
const spec = require('./openapi');
const cfg = loadConfig();
const PORT = process.env.STREAM_API_PORT ? parseInt(process.env.STREAM_API_PORT, 10) : cfg.apiPort;
const HOST = process.env.STREAM_API_HOST || cfg.apiHost;
const SWAGGER_HTML = path.join(__dirname, 'public', 'index.html');
function json(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
});
res.end(body);
}
function html(res, status, text, type = 'text/html; charset=utf-8') {
res.writeHead(status, { 'Content-Type': type, 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store' });
res.end(text);
}
const PROXY_DISALLOWED = new Set([
'host',
'connection',
'content-length',
'transfer-encoding',
'upgrade',
'keep-alive',
'proxy-authorization',
'proxy-authentication',
'te',
'trailer',
'via',
'warning',
'expect',
'date',
'dnt',
'accept-encoding',
]);
function parseProxyHeaders(raw) {
if (!raw) return {};
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const out = {};
for (const [name, value] of Object.entries(parsed)) {
if (typeof value !== 'string' || !value) continue;
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) continue;
if (PROXY_DISALLOWED.has(name.toLowerCase())) continue;
out[name] = value;
}
return out;
}
function proxyTarget(target, headers) {
const query = new URLSearchParams({ url: target, h: JSON.stringify(headers || {}) });
return `/proxy/media?${query.toString()}`;
}
function rewriteM3u8(text, baseUrl, headers) {
return text
.split(/\r?\n/)
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return line;
if (trimmed.startsWith('#')) {
return line.replace(/(URI\s*=\s*)(?:"([^"]*)"|'([^']*)')/g, (match, prefix, double, single) => {
const uri = double !== undefined ? double : single;
try {
return `${prefix}"${proxyTarget(new URL(uri, baseUrl).toString(), headers)}"`;
} catch {
return match;
}
});
}
try {
return proxyTarget(new URL(trimmed, baseUrl).toString(), headers);
} catch {
return line;
}
})
.join('\n');
}
async function handleProxyMedia(req, res, url) {
const target = (url.searchParams.get('url') || '').trim();
const headers = parseProxyHeaders(url.searchParams.get('h'));
let parsed;
try {
parsed = new URL(target);
} catch {
return json(res, 400, { error: 'missing or invalid ?url=' });
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return json(res, 400, { error: 'proxy only supports http/https' });
}
const upstreamHeaders = { ...headers };
if (req.headers.range) upstreamHeaders.range = req.headers.range;
let upstream;
try {
upstream = await fetch(parsed.toString(), { headers: upstreamHeaders, redirect: 'follow' });
} catch (e) {
return json(res, 502, { error: `proxy fetch failed: ${e.message}` });
}
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
const contentType = upstream.headers.get('content-type') || '';
const looksLikeM3u8 = /mpegurl/i.test(contentType) || /\.m3u8(?:[?#].*)?$/i.test(parsed.pathname + parsed.search);
if (looksLikeM3u8 && upstream.status < 400) {
try {
const text = await upstream.text();
if (/mpegurl/i.test(contentType) || text.trimStart().startsWith('#EXTM3U')) {
const body = rewriteM3u8(text, parsed.toString(), headers);
res.writeHead(upstream.status, {
'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
return res.end(body);
}
res.writeHead(upstream.status, {
'Content-Type': contentType || 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(text),
});
return res.end(text);
} catch (e) {
if (!res.headersSent) return json(res, 502, { error: `proxy playlist failed: ${e.message}` });
return res.destroy();
}
}
res.statusCode = upstream.status;
const copyHeaders = ['content-type', 'content-range', 'accept-ranges'];
if (upstream.status === 206) copyHeaders.push('content-length');
for (const name of copyHeaders) {
const value = upstream.headers.get(name);
if (value) res.setHeader(name, value);
}
if (!upstream.body) return res.end();
const body = Readable.fromWeb(upstream.body);
body.on('error', () => {
try {
upstream.body.cancel();
} catch {}
});
body.pipe(res).on('error', () => {});
}
function searchResult(m) {
const isTv = m.media_type === 'tv';
const date = isTv ? m.first_air_date : m.release_date;
return {
tmdb_id: m.id,
type: isTv ? 'tv' : 'movie',
name: m.title || m.name,
year: date ? String(date).slice(0, 4) : '',
poster: m.poster_path ? `https://image.tmdb.org/t/p/w342${m.poster_path}` : '',
overview: m.overview || '',
};
}
async function handleSearch(req, res, url) {
const q = (url.searchParams.get('q') || '').trim();
if (!q) return json(res, 400, { error: 'missing ?q=' });
try {
const results = await tmdb.search(q, cfg.tmdbKey);
return json(res, 200, { query: q, count: results.length, results: results.map(searchResult) });
} catch (e) {
return json(res, 502, { error: `tmdb search failed: ${e.message}` });
}
}
async function handleTrending(req, res, url) {
const type = url.searchParams.get('type') || 'all';
const timeWindow = url.searchParams.get('window') || 'week';
if (!['all', 'movie', 'tv'].includes(type)) {
return json(res, 400, { error: '?type= must be all, movie or tv' });
}
if (!['day', 'week'].includes(timeWindow)) {
return json(res, 400, { error: '?window= must be day or week' });
}
try {
const results = await tmdb.trending(type, timeWindow, cfg.tmdbKey);
return json(res, 200, { type, window: timeWindow, count: results.length, results: results.map(searchResult) });
} catch (e) {
return json(res, 502, { error: `tmdb trending failed: ${e.message}` });
}
}
async function handleSeasons(req, res, url, id) {
try {
const seasons = await tmdb.getSeasons(id, cfg.tmdbKey);
return json(res, 200, {
tmdb_id: id,
count: seasons.length,
seasons: seasons.map((s) => ({
season_number: s.season_number,
name: s.name,
episode_count: s.episode_count,
air_date: s.air_date || '',
})),
});
} catch (e) {
return json(res, 502, { error: `tmdb seasons failed: ${e.message}` });
}
}
async function handleEpisodes(req, res, url, id, season) {
try {
const episodes = await tmdb.getEpisodes(id, season, cfg.tmdbKey);
return json(res, 200, {
tmdb_id: id,
season: parseInt(season, 10),
count: episodes.length,
episodes: episodes.map((e) => ({
episode_number: e.episode_number,
name: e.name,
air_date: e.air_date || '',
})),
});
} catch (e) {
return json(res, 502, { error: `tmdb episodes failed: ${e.message}` });
}
}
const TMDB_IMG = 'https://image.tmdb.org/t/p/';
function fullImage(pathname) {
return pathname ? `${TMDB_IMG}original${pathname}` : '';
}
async function handleDetails(req, res, type, id) {
try {
const d = await tmdb.details(type, id, cfg.tmdbKey);
d.poster_url = fullImage(d.poster_path);
d.backdrop_url = fullImage(d.backdrop_path);
if (d.images) {
for (const key of Object.keys(d.images)) {
d.images[key] = (d.images[key] || []).map((im) => ({ ...im, url: fullImage(im.file_path) }));
}
}
return json(res, 200, d);
} catch (e) {
const status = /HTTP 404/.test(e.message) ? 404 : 502;
return json(res, status, { error: `tmdb details failed: ${e.message}` });
}
}
async function handleStreams(req, res, url) {
const id = (url.searchParams.get('tmdb') || '').trim();
if (!/^\d+$/.test(id)) return json(res, 400, { error: 'missing or invalid ?tmdb=<id>' });
const type = url.searchParams.get('type') === 'tv' ? 'tv' : 'movie';
const season = url.searchParams.get('season');
const episode = url.searchParams.get('episode');
if (type === 'tv' && (!season || !episode)) {
return json(res, 400, { error: 'tv streams need ?season=<n>&episode=<n> (see /tv/<id>/seasons)' });
}
const providersParam = url.searchParams.get('providers');
const names = providersParam
? providersParam.split(',').map((s) => s.trim()).filter(Boolean)
: cfg.enabledProviders;
const { providers, loadErrors } = loadProviders(names);
if (!providers.length) return json(res, 500, { error: 'no providers available', loadErrors });
const media = {
id,
type,
seasonId: season ? parseInt(season, 10) : null,
episodeId: episode ? parseInt(episode, 10) : null,
};
const { streams, errors } = await scrapeAll(providers, media, cfg.timeoutMs);
const all = streams.map((s) => normalize(s, s._provider));
const filters = {
minQuality: url.searchParams.get('minQuality') || 'any',
audioCodec: url.searchParams.get('audioCodec') || 'any',
subtitles: url.searchParams.get('subtitles') || 'any',
subtitleLang: url.searchParams.get('subtitleLang') || '',
};
const ranked = rankStreams(applyFilters(all, filters));
return json(res, 200, {
tmdb_id: id,
type,
season: media.seasonId,
episode: media.episodeId,
total: all.length,
matched: ranked.length,
streams: ranked.map((s) => ({
provider: s.provider,
name: s.name,
url: s.url,
quality: s.quality,
pixels: s.pixels,
audio: s.audio,
type: s.type,
headers: s.headers,
subtitles: s.subtitles,
})),
providerErrors: errors,
loadErrors,
});
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const p = url.pathname.replace(/\/+$/, '') || '/';
try {
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
});
return res.end();
}
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed, use GET' });
// web docs
if (p === '/' || p === '/index.html') {
const data = fs.readFileSync(SWAGGER_HTML);
return html(res, 200, data);
}
if (p === '/api.json') {
return json(res, 200, spec);
}
if (p === '/proxy/media') return await handleProxyMedia(req, res, url);
// api
if (p === '/providers') return json(res, 200, { all: listAll(), enabled: cfg.enabledProviders });
if (p === '/search') return await handleSearch(req, res, url);
if (p === '/trending') return await handleTrending(req, res, url);
let m = p.match(/^\/movie\/(\d+)$/);
if (m) return await handleDetails(req, res, 'movie', m[1]);
m = p.match(/^\/tv\/(\d+)$/);
if (m) return await handleDetails(req, res, 'tv', m[1]);
m = p.match(/^\/tv\/(\d+)\/seasons$/);
if (m) return await handleSeasons(req, res, url, m[1]);
m = p.match(/^\/tv\/(\d+)\/seasons\/(\d+)$/);
if (m) return await handleEpisodes(req, res, url, m[1], m[2]);
if (p === '/streams') return await handleStreams(req, res, url);
return json(res, 404, { error: 'not found', hint: 'see GET / (Swagger UI) or GET /api.json' });
} catch (e) {
if (!res.headersSent) return json(res, 500, { error: e.message });
res.destroy();
}
});
server.listen(PORT, HOST, () => {
console.log(`bug.tv api: http://${HOST}:${PORT} (docs at /)`);
console.log(`providers: ${cfg.enabledProviders.join(', ')}`);
});