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
+438
View File
@@ -0,0 +1,438 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bug.tv reverse demo</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 2rem;
background: #111;
color: #eee;
}
h1 { margin-top: 0; }
#searchForm {
display: flex;
gap: .5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
#apiBase { width: 240px; }
#searchInput { flex: 1; min-width: 220px; }
#proxyForm { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; }
#proxyForm label { display: flex; align-items: center; gap: .35rem; }
#proxyBase { width: 320px; }
#proxyToken { width: 200px; }
input, select, button {
padding: .55rem .7rem;
border-radius: 6px;
border: 1px solid #444;
background: #1c1c1c;
color: #eee;
font: inherit;
}
button { cursor: pointer; }
button:hover { background: #2a2a2a; }
#providers {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: .35rem;
max-height: 170px;
overflow: auto;
padding: .6rem;
border: 1px solid #333;
border-radius: 6px;
margin-bottom: 1rem;
}
#providers label { font-size: .9rem; white-space: nowrap; }
#results button, #streams button {
display: block;
width: 100%;
text-align: left;
margin: .35rem 0;
}
.type {
display: inline-block;
background: #333;
border-radius: 4px;
padding: .1rem .4rem;
font-size: .75rem;
margin-right: .4rem;
}
.year { color: #aaa; margin-left: .4rem; }
video {
width: 100%;
max-width: 960px;
background: #000;
border-radius: 8px;
}
#status {
min-height: 1.4em;
margin: .6rem 0;
color: #8cf;
word-break: break-word;
}
#status.error { color: #f88; }
#tvBox label { margin-right: 1rem; }
</style>
</head>
<body>
<h1>bug.tv reverse demo</h1>
<form id="searchForm">
<input id="apiBase" type="text" value="https://bug-api-test.tuns.sh" spellcheck="false">
<input id="searchInput" type="text" placeholder="Search movies or TV" autocomplete="off">
<button type="submit">Search</button>
</form>
<form id="proxyForm">
<label>Proxy <input id="proxyBase" type="text" value="https://bug-api-test.tuns.sh" spellcheck="false"></label>
<label>Token <input id="proxyToken" type="password" placeholder="optional" autocomplete="off"></label>
</form>
<h2>Providers</h2>
<div id="providers">loading…</div>
<h2>Results</h2>
<div id="results"></div>
<div id="tvBox" hidden>
<label>Season <select id="season"></select></label>
<label>Episode <select id="episode"></select></label>
</div>
<h2>Streams</h2>
<div id="streams"></div>
<video id="player" controls></video>
<div id="status"></div>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
<script>
const searchForm = document.getElementById('searchForm');
const apiBaseInput = document.getElementById('apiBase');
const proxyBaseInput = document.getElementById('proxyBase');
const proxyTokenInput = document.getElementById('proxyToken');
const searchInput = document.getElementById('searchInput');
const providerBox = document.getElementById('providers');
const resultsBox = document.getElementById('results');
const tvBox = document.getElementById('tvBox');
const seasonSelect = document.getElementById('season');
const episodeSelect = document.getElementById('episode');
const streamBox = document.getElementById('streams');
const player = document.getElementById('player');
const statusBox = document.getElementById('status');
let hls = null;
let currentMedia = null;
let lastStreamParams = null;
function apiBase() {
return apiBaseInput.value.trim().replace(/\/+$/, '') || 'http://bug-api-test.tuns.sh';
}
function endpointFor(base) {
const root = (base || '').trim().replace(/\/+$/, '');
if (!root) return '';
return root.endsWith('/proxy/media') ? root : `${root}/proxy/media`;
}
function apiProxyEndpoint() {
return endpointFor(apiBase());
}
function selectedProxyEndpoint() {
return endpointFor(proxyBaseInput.value.trim() || apiBase());
}
function proxyEndpoints() {
const list = [selectedProxyEndpoint()].filter(Boolean);
const api = apiProxyEndpoint();
if (api && !list.includes(api)) list.push(api);
return list;
}
function proxyFor(targetUrl, headers, endpoint) {
const query = new URLSearchParams({
url: targetUrl,
h: JSON.stringify(headers || {}),
});
const token = proxyTokenInput.value.trim();
if (token) query.set('token', token);
return `${endpoint || selectedProxyEndpoint()}?${query.toString()}`;
}
function setStatus(message, isError = false) {
statusBox.textContent = message || '';
statusBox.classList.toggle('error', !!isError);
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
}
async function api(path) {
const res = await fetch(apiBase() + path, { headers: { Accept: 'application/json' } });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { error: text.slice(0, 300) };
}
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
function clearPlayer() {
if (hls) {
hls.destroy();
hls = null;
}
player.querySelectorAll('track').forEach((t) => t.remove());
player.removeAttribute('src');
player.load();
}
async function initProviders() {
const data = await api('/providers');
providerBox.innerHTML = '';
for (const name of data.all) {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = name;
checkbox.checked = data.enabled.includes(name);
label.append(checkbox, document.createTextNode(` ${name}`));
providerBox.append(label);
}
}
providerBox.addEventListener('change', () => {
if (lastStreamParams) loadStreams(lastStreamParams);
});
function selectedProviders() {
return [...providerBox.querySelectorAll('input:checked')].map((i) => i.value);
}
searchForm.addEventListener('submit', (e) => {
e.preventDefault();
doSearch();
});
async function doSearch() {
const q = searchInput.value.trim();
if (!q) return;
currentMedia = null;
lastStreamParams = null;
clearPlayer();
tvBox.hidden = true;
resultsBox.innerHTML = '';
streamBox.innerHTML = '';
setStatus('Searching…');
try {
const data = await api(`/search?q=${encodeURIComponent(q)}`);
if (!data.results.length) {
setStatus('No results found.');
return;
}
setStatus(`${data.count} result${data.count === 1 ? '' : 's'}`);
for (const r of data.results) {
const btn = document.createElement('button');
btn.innerHTML = `<span class="type">${r.type === 'tv' ? 'TV' : 'MOVIE'}</span>${escapeHtml(r.name)}<span class="year">${escapeHtml(r.year)}</span>`;
btn.addEventListener('click', () => selectResult(r));
resultsBox.append(btn);
}
} catch (e) {
setStatus(e.message, true);
}
}
async function selectResult(result) {
currentMedia = result;
lastStreamParams = null;
clearPlayer();
streamBox.innerHTML = '';
if (result.type === 'movie') {
tvBox.hidden = true;
await loadStreams({ tmdb: result.tmdb_id, type: 'movie' });
return;
}
tvBox.hidden = false;
seasonSelect.innerHTML = '<option value="">loading…</option>';
episodeSelect.innerHTML = '';
try {
const data = await api(`/tv/${result.tmdb_id}/seasons`);
seasonSelect.innerHTML = '';
if (!data.seasons.length) {
setStatus('No seasons found.');
return;
}
for (const s of data.seasons) {
const opt = document.createElement('option');
opt.value = s.season_number;
opt.textContent = `${s.name} (${s.episode_count})`;
seasonSelect.append(opt);
}
setStatus('Choose a season, then an episode.');
} catch (e) {
setStatus(e.message, true);
}
}
seasonSelect.addEventListener('change', async () => {
if (!currentMedia || !seasonSelect.value) return;
episodeSelect.innerHTML = '<option value="">loading…</option>';
try {
const data = await api(`/tv/${currentMedia.tmdb_id}/seasons/${seasonSelect.value}`);
episodeSelect.innerHTML = '';
if (!data.episodes.length) {
setStatus('No episodes found.');
return;
}
for (const ep of data.episodes) {
const opt = document.createElement('option');
opt.value = ep.episode_number;
opt.textContent = `Episode ${ep.episode_number}${ep.name ? `${ep.name}` : ''}`;
episodeSelect.append(opt);
}
setStatus('Choose an episode.');
} catch (e) {
setStatus(e.message, true);
}
});
episodeSelect.addEventListener('change', () => {
if (!currentMedia || !episodeSelect.value) return;
loadStreams({
tmdb: currentMedia.tmdb_id,
type: 'tv',
season: seasonSelect.value,
episode: episodeSelect.value
});
});
async function loadStreams(params) {
const providers = selectedProviders();
if (!providers.length) {
setStatus('Enable at least one provider.', true);
return;
}
lastStreamParams = params;
const query = new URLSearchParams({
tmdb: params.tmdb,
type: params.type,
providers: providers.join(',')
});
if (params.season) query.set('season', params.season);
if (params.episode) query.set('episode', params.episode);
setStatus('Fetching streams…');
try {
const data = await api(`/streams?${query.toString()}`);
streamBox.innerHTML = '';
if (!data.streams.length) {
setStatus('No streams found.', true);
return;
}
setStatus(`${data.matched} of ${data.total} streams`);
data.streams.forEach((s, i) => {
const btn = document.createElement('button');
const quality = s.quality || (s.pixels ? `${s.pixels}p` : 'unknown');
btn.innerHTML = `${i + 1}. [${escapeHtml(s.provider)}] ${escapeHtml(quality)}${s.audio ? ` · ${escapeHtml(s.audio)}` : ''}${escapeHtml(s.name)}`;
btn.addEventListener('click', () => playStream(s).catch((e) => setStatus(e.message, true)));
streamBox.append(btn);
});
} catch (e) {
setStatus(e.message, true);
}
}
async function playStream(stream) {
clearPlayer();
const url = stream.url;
const headers = stream.headers || {};
const isHls = /\.m3u8($|\?)/i.test(url) || stream.type === 'm3u8';
const isDash = /\.mpd($|\?)/i.test(url) || stream.type === 'dash';
if (isDash) {
setStatus('DASH streams are not supported by this demo player.', true);
return;
}
let endpoint = selectedProxyEndpoint();
if (!endpoint) {
setStatus('Set an API base or proxy endpoint.', true);
return;
}
setStatus('Checking stream proxy…');
for (const candidate of proxyEndpoints()) {
const testUrl = proxyFor(url, headers, candidate);
try {
const res = await fetch(testUrl, { cache: 'no-store' });
if (res.ok) {
endpoint = candidate;
break;
}
if (res.body) res.body.cancel();
} catch {}
}
const proxiedUrl = proxyFor(url, headers, endpoint);
for (const sub of stream.subtitles || []) {
let resolved = sub.url;
try {
resolved = new URL(sub.url, url).toString();
} catch {}
const track = document.createElement('track');
track.kind = 'subtitles';
track.src = proxyFor(resolved, headers, endpoint);
track.srclang = sub.language || 'en';
track.label = sub.name || sub.language || 'Subtitles';
player.append(track);
}
if (isHls && window.Hls && Hls.isSupported()) {
setStatus('Loading HLS stream…');
hls = new Hls();
hls.loadSource(proxiedUrl);
hls.attachMedia(player);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
player.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) setStatus(`HLS ${data.type} error: ${data.details}`, true);
});
return;
}
if (isHls && player.canPlayType('application/vnd.apple.mpegurl')) {
setStatus('Loading native HLS stream…');
player.src = proxiedUrl;
player.play().catch(() => {});
return;
}
if (isHls) {
setStatus('HLS.js is unavailable and this browser does not play HLS natively.', true);
return;
}
setStatus('Playing proxied stream…');
player.src = proxiedUrl;
player.play().catch(() => {});
}
initProviders().catch((e) => setStatus(`Failed to load providers: ${e.message}`, true));
</script>
</body>
</html>