384 lines
12 KiB
JavaScript
384 lines
12 KiB
JavaScript
|
|
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(', ')}`);
|
|
});
|