439 lines
14 KiB
HTML
439 lines
14 KiB
HTML
<!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) => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
}[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>
|