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}` : '', backdrop: m.backdrop_path ? `https://image.tmdb.org/t/p/w1280${m.backdrop_path}` : '', rating: m.vote_average ? Number(m.vote_average).toFixed(1) : '', 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 || '', still: e.still_path ? `https://image.tmdb.org/t/p/w300${e.still_path}` : '', })), }); } 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=' }); 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=&episode= (see /tv//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(', ')}`); });